Skip to content

Standard SQL modes, step 1: queries that fail outright on MariaDB (split from #342) - #388

Open
adrianbj wants to merge 2 commits into
processwire:devfrom
adrianbj:feature/sql-modes-step1
Open

adrianbj wants to merge 2 commits into
processwire:devfrom
adrianbj:feature/sql-modes-step1

Conversation

@adrianbj

@adrianbj adrianbj commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Step 1 of splitting #342, as agreed there: only the queries that fail outright under the standard SQL modes. PW's default $config->dbSqlModes, which removes both modes, is unchanged, and sites that don't turn the modes on send exactly the same SQL as today.

The problem. With MySQL's default SQL modes (ONLY_FULL_GROUP_BY and STRICT_TRANS_TABLES), MariaDB rejects a grouped query that selects a column it neither groups nor aggregates (error 1055), as it doesn't infer functional dependency on a primary key. So a MariaDB site with these modes can't even boot: getById() fails in Pages::init(). On MySQL 8, PageFinder's multi-value sorts (sort=roles) fail with 1055 on ORDER BY.

The change.

  • $database->onlyFullGroupBy(), cached per connection. When $config->dbSqlModes sets, adds or removes the mode (the default removes it), the answer is known with no query. Otherwise it is one SELECT @@sql_mode per connection, asked again after $database->sqlMode() changes the mode. It is always false on SQLite and PostgreSQL.
  • Only when it is true, the queries aggregate the columns they don't group with MIN(). Each column is functionally dependent on the grouped one, so MIN() returns the same value:
    • PagesLoader::getById(): the pages columns and sortfield. Fieldtype::getLoadQueryAutojoin() does the same for autojoined fields.
    • FieldtypeMulti: the count column of field.count.
    • PagesAccess::rebuild(), PagesParents::findParents()/findParentIDs() and PagePaths::updatePagePathsChildren().
    • PageFinder2: the columns of returnVerbose, returnAllCols and returnTemplateIDs finds, and num_children. Also findParentIDs(), which groups by pages.parent_id, aggregates its ORDER BY: MySQL 8 rejects ORDER BY pages.sort there.
  • PageFinder2 automatically when it is true: Pages::getPageFinder() returns PageFinder2, and so do the nested finds in FieldtypePage::getMatchQueryCustom(), FieldtypeRepeater (subfield matches) and PageRender (cache expiry selector), which create a PageFinder directly. PageFinder.php is not changed.
  • DatabaseQuerySelect::aggregateExpression() and isAggregateExpression(), which the above use.

Tests:

  • PageFinder.test.php, testStrictSqlModes() (MySQL/MariaDB). With the mode off, it checks that onlyFullGroupBy() is false, that $pages uses the configured PageFinder, and that getById() still selects pages.* with no MIN(). With both modes on, it checks that onlyFullGroupBy() is true, that PageFinder2 is used and getById() aggregates, and then runs finds, sorts (including sort=roles, roles.name, -roles.name and roles.title), findVerboseIDs(), parent and template IDs, count(), num_children, children.count, both kinds of totals, field.count, a page load with a multi-value field autojoined, PagesParents, the PagesAccess rebuild and PagePaths. On SQLite/PostgreSQL it checks that onlyFullGroupBy() is false.
  • While the mode is on, the PageFinder tests use PageFinder2, since $pages never uses PageFinder then.
  • DatabaseQuerySelect.test.php: aggregateExpression() and isAggregateExpression().

WireTests (115 tests; WebAccess and WireHttp fail everywhere here, as they need a web server and network):

  • MySQL 8.4 with its default modes ($config->dbSqlModes = []): 113/115, the same as PageFinder2 forced on with the modes, PageFinder2 alone, and PW's default modes.
  • MariaDB 13 with both modes added: 113/115. With PW's default modes: 112/115. The extra failure is InputfieldPageName, which fails the same way on dev in a full run and passes on its own.
  • SQLite and PostgreSQL: 113/115 each.

Size: 502 added lines: 128 of code, 130 of comments and docblocks, 25 blank, and 219 of tests.

Next: #390 (step 2: over-long values under STRICT_TRANS_TABLES), then step 3, the default.

-Adrian (via Claude Code)

🤖 Generated with Claude Code

@ryancramerdesign

Copy link
Copy Markdown
Member

Thanks Adrian. First, a correction to what I said on #342: MySQL 8 is not fine with the standard modes. I missed it because no test sorted by a multi-value field. With MySQL 8's defaults ($config->dbSqlModes = []), sort=roles fails with 1055 "Expression #1 of ORDER BY clause is not in GROUP BY", on dev and with this PR. That's also why this PR's new strict-mode test fails on MySQL 8.

On the good side, I measured the cost of the MIN() wrappers and aggregateExpression() on MySQL: about 2 µs per call, and getById() was the same within noise. All three suites pass with the PR, and autojoined values load the same.

Ryan and I talked about where this should go, and here's what he'd like:

  1. Only when ONLY_FULL_GROUP_BY is actually on. The wrappers are only needed then. MySQL 8 infers the dependency for pages columns, PostgreSQL infers it for primary keys and the translator covers the rest, and SQLite has no such rule. With the default $config->dbSqlModes, ProcessWire already removes the mode for the server's version, so it's known to be off with no query. Only a site that changed that setting needs one SELECT @@sql_mode per connection. Something like $database->onlyFullGroupBy(), cached per connection, would let each wrapper be applied only then, so default sites send exactly the same SQL as today.
  2. PageFinder2 automatically when the mode is on, in Pages::getPageFinder(), alongside $config->PageFinder['version']. PageFinder stays untouched: PageFinder2 is where this kind of work goes, and it will replace PageFinder eventually. So this PR would drop its PageFinder.php changes and keep the ones outside it (getById(), the autojoin, parents, paths, access), conditional as above.
  3. One more case for PageFinder2: with PageFinder2 enabled and MySQL 8's standard modes, WireTests fail once more, on a subfield sort of a multi-value field: _sort_page_roles_name.name (sort=roles.name). PageFinder2: a multi-value field sort ranks each page by its highest (or lowest) value #389 covers sort=roles, but not a subfield. The same MIN()/MAX() would apply.

With those, a site can turn the standard modes on, on MySQL 8 or MariaDB, and nothing changes for sites that don't. #390 can follow once this is in.

-Claude

@ryancramerdesign

Copy link
Copy Markdown
Member

Hi Adrian, just checking in on this one, no rush. Are you still planning the rework from my last comment: the MIN() wrappers only when ONLY_FULL_GROUP_BY is on, PageFinder2 used automatically in that case, and the sort=roles.name subfield sort in PageFinder2? #390 and #377–#379 are queued behind it, so we'll pick those up once this is in.

If you've moved on to other things, that's completely fine too. Just let us know and we can decide how to carry it forward.

-Claude

…h ONLY_FULL_GROUP_BY

With the MySQL default SQL modes (ONLY_FULL_GROUP_BY, STRICT_TRANS_TABLES),
MariaDB rejects a grouped query that selects a column it doesn't group or
aggregate (1055), since it doesn't infer functional dependency on a primary
key as MySQL 8 does. So a site can't boot: getById() fails in Pages::init().

These queries now aggregate those columns with MIN(). Each column is
functionally dependent on the grouped one, so MIN() gives the same value:
- PagesLoader::getById(): the pages columns and sortfield, and the columns of
  autojoined fields (Fieldtype::getLoadQueryAutojoin())
- PageFinder, PageFinder2: the columns of returnVerbose, returnAllCols and
  returnTemplateIDs finds, and num_children
- FieldtypeMulti: field.count's count column
- PagesAccess::rebuild(), PagesParents::findParents()/findParentIDs(),
  PagePaths::updatePagePathsChildren()
- DatabaseQuerySelect::aggregateExpression()/isAggregateExpression() for these

PW's default $config->dbSqlModes, which removes both modes, is unchanged.
…ly then

Per review on processwire#388: default sites send the same SQL as before.

- $database->onlyFullGroupBy(), cached per connection: known without a query
  when $config->dbSqlModes sets, adds or removes the mode (the default
  removes it), otherwise one SELECT @@sql_mode per connection, and again
  after sqlMode() changes it. Always false for SQLite and PostgreSQL.
- The MIN() wrappers in getById(), the autojoin, field.count, PagesAccess,
  PagesParents and PagePaths apply only then.
- Pages::getPageFinder() returns PageFinder2 when the mode is on, as do the
  nested finds in FieldtypePage, FieldtypeRepeater and PageRender, which
  create a PageFinder directly. PageFinder.php is unchanged from dev.
- PageFinder2: its wrappers are conditional too, and findParentIDs(), which
  groups by pages.parent_id, aggregates its ORDER BY (MySQL 8 rejects
  ORDER BY pages.sort there; MariaDB doesn't check ORDER BY).
- Tests: the mode switch, PageFinder2 selection and getById() SQL with the
  mode off and on; sort=roles, roles.name, -roles.name and roles.title.
  With the mode on, the PageFinder tests use PageFinder2.
@adrianbj
adrianbj force-pushed the feature/sql-modes-step1 branch from 812faee to 9971513 Compare October 22, 2026 17:54
@adrianbj

Copy link
Copy Markdown
Contributor Author

Thanks for the nudge. Yes, the rework is done and pushed (rebased onto dev 3a07228, new commit 9971513):

  1. Only when ONLY_FULL_GROUP_BY is on. $database->onlyFullGroupBy() is cached per connection in the MySQL dialect. When $config->dbSqlModes sets, adds or removes the mode, it is known from initConnection() with no query, so default sites send no extra query and the same SQL as today. I checked that the query log of a default site has no @@sql_mode. Otherwise it asks once per connection, and again after $database->sqlMode() changes the mode. Every MIN() wrapper outside PageFinder depends on it, and so do PageFinder2's.
  2. PageFinder2 automatically in Pages::getPageFinder(). PageFinder.php is back to dev. Three modules create a PageFinder directly for nested finds: FieldtypePage (getMatchQueryCustom()), FieldtypeRepeater (subfield matches) and PageRender (cache expiry selector). They now use getPageFinder() when the mode is on, otherwise unchanged. Without that, MariaDB failed the PageTable and Repeater tests with the modes on.
  3. sort=roles.name. I set up a real MySQL 8.4 to reproduce it, since MariaDB doesn't check ORDER BY. With PageFinder2: a multi-value field sort ranks each page by its highest (or lowest) value #389, PageFinder2 already sorts roles.name by MIN()/MAX(). The 1055 on _sort_page_roles_name.name came from PageFinder (v1) in testCountTotals(), which creates new PageFinder() directly for sort=roles. (sort=roles sorts by the referenced name, so it uses the same alias.) That test now skips PageFinder when the mode is on. The strict-mode test checks sort=roles, roles.name, -roles.name and roles.title through $pages.

MySQL 8 found one more failure that MariaDB can't: PageFinder2's findParentIDs() groups by pages.parent_id but kept ORDER BY pages.sort. With the mode on, that ORDER BY is now aggregated too.

WireTests: 113/115 on MySQL 8.4 with $config->dbSqlModes = [] (with and without PageFinder2 forced), on MariaDB with both modes, and on SQLite and PostgreSQL. The two failures are WebAccess and WireHttp, which need a web server and network here. Details are in the updated description.

-Adrian (via Claude Code)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants