Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): aggregate() attributes an unresolvable column to its clause - #11729
Conversation
…ause aggregate() answered DATABASE_ERROR/500 for an unresolvable column where find() and count() answer the #8790 refusal. The dialect-named column is now attributed to the clause the caller's own query names it in: a groupBy field or an aggregation field refuses INVALID_FIELD/400 (the ingress door's own code for this condition), a column in neither clause is the WHERE and keeps the #8790 INVALID_FILTER/400 refusal verbatim, and a wording that parses to no name keeps the #11455 terminal envelope unchanged (no supportable attribution, per the #8931 ruling). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a32683859464dd8b5a5e202834c3339a5e3c491e && git checkout a32683859464dd8b5a5e202834c3339a5e3c491e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e170b0ae5399c98097522ac1224e8101b867c22b a49b099349d78765ce079f7a88a90c1e14954d47 && git checkout -B drift-repro e170b0ae5399c98097522ac1224e8101b867c22b && git merge --no-ff a49b099349d78765ce079f7a88a90c1e14954d47
node scripts/docs-audit/affected-docs.mjs --json e170b0ae5399c98097522ac1224e8101b867c22b
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11541
What changed — contract surface, named (clause ②)
SqlDriver.aggregate()stops answering an unresolvable column with the generic terminal and instead answers the same class of refusalfind()andcount()give, attributed to the clause the caller's own query names the column in:whereDATABASE_ERROR/ 500INVALID_FILTER/ 400 — #8790'sunresolvableFilterColumnRefusal, byte-identical message tofind()/count()groupByfield or an aggregationfieldDATABASE_ERROR/ 500INVALID_FIELD/ 400, message naming the clause and the column,field+objectset the way the ingress door's INVALID_FIELD family carries themDATABASE_ERROR/ 500DATABASE_ERROR/ 500New message (groupBy arm, aggregation arm analogous):
The groupBy of this query names 'nosuchcol', a column that object 'x' has no column for, so the aggregate never ran. … Check the name against the object's fields; if the field was declared recently, run schema sync so the column exists before aggregating on it.The dialect text (statement + inlined bound literals) goes to the server log only, same as the #8790 WHERE refusal.Implementation: one new classifier
SqlDriver.aggregateBackendFault(object, query, error)called fromaggregate()'s catch, plus the composerunresolvableAggregateColumnRefusal.isUnresolvableColumnError/unresolvableColumnNameOfare untouched (no new dialect recognizer; MySQL's clause-position wording is deliberately unread — the query AST decides). Subclasses (driver-tursoembedded face,driver-sqlite-wasm) inherit.Why
INVALID_FIELDand notINVALID_QUERY— read off the repo, not preferredThe dispatch flagged this choice for verification. Evidence, in order of weight:
400 INVALID_FIELD+field+object(assertGroupByFieldsExist/assertAggregationFieldsExist,packages/metadata-protocol/src/protocol.ts, REST 读路径:searchFields/groupBy/aggregations指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254), reservingINVALID_QUERYon those axes for entries the spec cannot read (shape violations). This condition is a well-shaped query naming a missing column, i.e. the first kind.find()silently returns [] whilecount()throws a raw dialect error with no ADR-0112 envelope #8790's WHERE refusal took the ingress door's code (INVALID_FILTER) for the same condition one layer down, on the stated principle that one condition refused at two layers must not be explained two ways. Applied to the groupBy/aggregations axes, that yieldsINVALID_FIELD.Unsupported aggregate function两面都是裸Error(code/status 皆 undefined),且三个 spec 已声明的聚合函数无任何 SQL 后端编译 #5907'sINVALID_QUERYis a different condition: a function name the protocol does not declare — a query no backend can run. A missing column is a query that answers rows the moment schema sync runs. (The card's own honest bound said the drivers(sql,turso):Unsupported aggregate function两面都是裸Error(code/status 皆 undefined),且三个 spec 已声明的聚合函数无任何 SQL 后端编译 #5907 precedent covers function names, not columns.)INVALID_FIELDis a standard-catalog member and already the write path's answer for an unknown column at the REST boundary;@objectstack/rest's existingINVALID_FIELDbranch serves the enriched envelope unchanged.Five-row probe, re-measured on all three dialects
Before (main @ 4e786cd, live PG 16 + MySQL 8.0.46 + embedded SQLite — identical on all three):
After (this branch, same three dialects — identical on all three):
Tests
New suite
sql-driver-11541-aggregate-unresolvable-column-refusal.test.ts: the five-row parity pins per dialect cell (SQLite always; live PG + MySQL cells ran here), attribution pins (structured groupBy entry, both-clause case, where+groupBy same column), disclosure pins (no statement shape, no bound literal, dialect text to server log), controls (working aggregate, honest empty answer, missing table keeps the terminal), and a classifier block on crafted wordings covering all three arms and the fences (exact-match — a dottedtitle.xis never attributed to an aggregation overx; MySQL's clause position unread; an alias never treated as a column).@objectstack/driver-sqlsuite at CI parity (TZ=America/New_York, PG atAsia/Shanghai, MySQL at+08:00, live cells running): 130 files / 2651 tests passed ata49b099349(clean tree), vitest exit read pre-pipe via the verify-lock entry point (VERDICT command-exit 0).pnpm --filter @objectstack/driver-sql typecheckgreen ata49b099349;driver-tursoanddriver-sqlite-wasmtypecheck green (turbo run, 17 successful).aggregateBackendFaultbehind anABLATION-11541marker; mutation proven on disk (marker grep count 1), run: 12 failed / 16 passed / 5 skipped — every red inside the new suite (attribution pins receivingDATABASE_ERROR), whilesql-driver-11455-aggregate-fault-envelope.test.tsstayed fully green; restore proven (marker count 0,git checkout HEAD), post-restore full suite green (the 130/2651 run above). The suite imports./sql-driver.jsrelatively, so vitest compiles the mutated source directly — no dist build in the ablation loop.Gates (all at
a49b099349)Derived by
node scripts/pm/dispatch-gates.mjs(no paths passed; stderr names this repo/commit). All exit 0, verdict lines read from each gate's own output:check:changeset-gate-self-tests,check:objectui-changeset,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:type-check-coverage,check:engine-double-contract(399 pinned / 133 DEBT / 2 exempt),check:cross-package-test-inputs,check:where-matcher,check:nul-bytes,check:error-code-casing,check:driver-conformance,check-adr-0087-registration(non-breaking changeset, no marker owed),check-changeset-no-major,check-empty-changeset,check-plugin-teardown-shape,docs-audit/check-affected-docs,release-rehearsal-clone --self-test.Declared narrowing:
check:type-check-debtnot run locally (its re-measure requires the full workspace closure built). Evidence the narrowing excludes nothing: the diff's only package isdriver-sql, which appears in neither the DEBT nor the TEST_DEBT ledger ofscripts/check-type-check-coverage.mjs(grep over both ledger spans: 0 hits); its owntsc --noEmitincludes the new test files (it went red on an interim test-file error, then green) — and the diff edits no tsconfig/turbo/ledger, so no other package's tsc program changes. CI runs the full gate.Driver-conformance lane numbers: before 45 covered / 0 DEBT / 0 exempt → after 45 covered / 0 DEBT / 0 exempt (the matrix counts case-set coverage; this card adds refusal pins, not case-set rows).
Scope notes
AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152 / [Decision]min/maxover a boolean aggregand: pin the cross-driver JSON answer —0/1(SQL) vsfalse/true(both in-memory faces) #11249 (boolean aggregate answer-or-refuse) — that fence is kept: the 42883 route still takes the terminal envelope, pinned unchanged in the driver-sql (PG): sum/avg/min/max over a boolean column throw the raw PostgreSQL 42883 with no ADR-0112 envelope (status undefined) #11455 suite (its head note updated to record that the classifier landed and what the terminal still owns).where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 (dotted-path verdict) is untouched: the classifier never inspects a key for a dot, and the exact-match fence pins that a dotted where key is not attributed to an aggregation over its tail segment.aggregate()'s catch plus two new protected methods inserted directly afteraggregate()in the Aggregation section (~7635-8000); the shared helpers (unresolvableColumnNameOf666,unresolvableFilterColumnRefusal7366,backendStatementFault7416) are called, not edited. No overlap with the other in-flight claims on this file ([observation] driver-sql (MySQL): introspectForeignKeys returns a composite key's columns in UNSPECIFIED row order — noORDER BY ORDINAL_POSITION#11379 at 13757+, PR fix(driver-sql): introspectUniqueConstraints reports single-column uniqueness on all three dialects (#11202) #11657 at 3686/14059+).Generated by Claude Code