fix(driver-sql): refuse an unresolvable WHERE column on both find() and count() (#8790) - #8927
Conversation
…nd count() (#8790) One predicate had two answers: the #3821 ladder's rungs all re-apply `query.where`, so a WHERE-side unknown column could never be recovered and fell to `return []`, while `count()` — which has no ladder — threw the dialect's own error with the bound literals inlined. Both halves now refuse with the ADR-0112 envelope this path already declares: `INVALID_FILTER` / 400, naming the column. The dialect message goes to the server log instead of the caller. The ladder's projection and ORDER-BY recoveries are deliberately untouched — only the WHERE-failure terminal becomes a refusal (maintainer ruling 2026-08-15). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
`check:query-options-erasure` measured the new suite growing the test surface 240 -> 256: sixteen `as any` casts on the query argument. That is the erasure `DriverQuery` exists to prevent — its own docblock records that a direct caller holding only a `where` reached for a blanket cast and lost `where`'s type with it. Typed instead of ceiling-raised; the ratchet is back at 240. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
… dotted key per dialect
Live-measured on PG 16.13 and MySQL 8.0.46 (origin/main vs this branch): the
three dialects do not agree on what a dotted key IS. knex compiles
{'title.x': v} to the qualified reference "title"."x", so Postgres reads
`title` as a TABLE and raises undefined_table (42P01), not undefined_column
(42703) — a shape the classifier does not match and never has. On Postgres a
dotted key therefore raised a raw 42P01 on BOTH halves before this card and
after it, byte for byte; the card's headline repro is SQLite-specific.
The refusal pins now cover the RULED scope (a plain column the table lacks),
which holds on both recognised dialects. The dotted key is RECORDED per
dialect in DOTTED_STATUS_QUO with #8371 — the open FILTER-axis dotted-path
verdict — named as the owner, plus a both-halves-agree assertion that is true
whichever way #8371 lands. No classifier change: teaching it 42P01 would mint
a dotted-path verdict at the driver and pre-empt that card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…esolvable-where-column-refusal # Conflicts: # packages/spec/src/migrations/registry.ts
…8939 relay) gen:migration-registry, spec build, gen:export-origins, gen:api-surface, gen:spec-changes, gen:upgrade-guide, gen:openapi restore. check:generated: all 13 artifacts up to date. Survival asserted on the merged tree, one hit each in the regenerated registry: engine-dotted-filter-refused (this branch), driver-sql-unresolvable-where-column-refused (#8927), filter-preset-ordering-comparand-refused (#8935), identity-api-key-schema-retired (#8932) - plus implementation bodies: sql-driver.ts INVALID_FIELD refusal + envelope test (#8927), isDateRangePresetName (#8935), ApiKeySchema still absent (#8932), classifyDottedFilterHead at both doors (this branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt
Fixes #8790
One unresolvable WHERE column had two answers.
find()returned[]in silence;count()threw the dialect's own error with the statement's bound literals inlined. Both now refuse with the ADR-0112 envelope this path already declares —INVALID_FILTER/ 400, naming the column — per the maintainer ruling of 2026-08-15 (issue comment 5302931807).This is the question the earlier revision of this body left implied, and the answer is per dialect, because the three backends do not agree on what a dotted key even is. All figures below were measured against live servers (PostgreSQL 16.13, MySQL 8.0.46), running
origin/mainand this branch side by side.{'title.x': v}findcountfindcountno such column: title.x)[]0 rowsSQLITE_ERRORINVALID_FILTER/400INVALID_FILTER/40042P01 missing FROM-clause entry)42P0142P0142P0142P011054 Unknown column 'title.x')1054105410541054So: is a dotted key refused
INVALID_FILTER? On SQLite yes; on Postgres and MySQL no — the raw dialect error escapes, exactly as it did before this PR, byte for byte.Is the Postgres raw escape a new leak introduced here? No. knex compiles
{'title.x': v}to the qualified reference"title"."x", so Postgres readstitleas a table and raises42P01(undefined_table), not42703(undefined_column). That text contains neitherno such columnnorcolumn+does not exist, so it took theelse { throw error }branch onorigin/mainand takes the same branch now — the classifier here is byte-identical to the inline predicate it replaced. Thereturn []terminal was never reachable for a dotted key on Postgres. It is a real, pre-existing raw-error escape on an axis this card does not own; it is reported to the PM and deliberately unresolved here.Why the SQLite arm is not minting #8371's verdict. The driver never asks "is this a dotted path?" — it asks the database whether the column resolved, and refuses what the database rejected. SQLite hands it one message for both
{'title.x': v}(a path) and a column literally namedtitle.x; they are not separable at this layer. Excluding dotted keys would require the driver to inspect the key for a., which is judging dotted-ness — #8371's call — and would re-split the two halves on SQLite, reintroducing this card's own defect on that route. The refusal follows the dialect's classification, not a policy about paths.Correction to the card's framing, worth recording: the headline repro (
find()→[],count()→ throws) is SQLite-specific. On Postgres the dotted key never produced the silent[]; both halves already agreed. On Postgres the genuine two-answer defect is the plain unknown column ([]vs raw42703) — which is what this PR closes.The test suite reflects exactly this: the refusal pins cover the ruled scope (a plain column the table lacks), and the dotted key is recorded per dialect in a
DOTTED_STATUS_QUOtable naming #8371 as the owner of the verdict, alongside a both-halves-agree assertion that holds whichever way #8371 lands.The ruling was premised, so the premise was verified first
The ruling's part 2 required a survey before changing behaviour: no in-repo caller may depend on the permissive
[]to render. The premise holds — no fork. Method and evidence:① The platform's declared posture on this exact condition is already refusal.
assertFilterFieldsExist(@objectstack/metadata-protocol) answersINVALID_FIELD/ 400 for everything reachingfindData— the REST list route,POST /data/:object/query, the export route, the RPC dispatcher — with the sentence "A filter on a field that does not exist can only match zero records, so the query was refused instead of answered with an empty list." The driver's[]was only ever reachable where that door returns early (resolveQueryFieldsyields nothing), or where a dotted key clears it on its head segment. So the narrowing aligns the backstop with the door, and this refusal echoes that sentence verbatim rather than inventing a second vocabulary.② The two named UI consumer classes do not execute filters in this repo. Saved views (
ui/view.zod.ts) and dashboard widgets (ui/dashboard.zod.ts) are metadata consumed by the UI, which composes a REST query — so they reach the driver through the door in ① and are refused before it. Grepped for a server-side executor ofview.filters/ widget filters: none outsidepackages/spec.③ The unpopulated-registry backstop path. The driver comment that names it (the cloud multi-tenant runtime) ties the backstop to the projection — "where the projection otherwise zeroes the list" — and the #7589 ruling comment likewise keeps "the unknown-PLAIN-column tolerance ... an unknown plain column is simply absent from each row." Neither rationale mentions the WHERE. The ladder's projection and ORDER-BY recoveries are untouched, so the backstop keeps doing the job it was justified by.
④ Direct engine callers with authored filters already tolerate a throw.
plugin-reports'executeReportandservice-messaging's recipient resolver forward an authored filter intoengine.findwithout the door in ①. The resolver already catches and logs ('... lookup failed; 0 recipients'), degrading to the same outcome with a diagnosable warning instead of a silent one. Deferred DDL — the one shape where a declared field can lack a column — is armed only by the CLI migrate planner (setDeferredDdl), a dry-run posture that serves no queries.⑤ Mechanically: ~15,000 consumer tests, all green (below).
What changed, and what deliberately did not
The #3821 ladder's rungs are all built from
buildBase(), which always re-appliesquery.where— so it can drop a projection and drop an ORDER BY, but never the clause that failed. Both rungs raised the same error and the method fell toreturn []. Only that terminal became a refusal. Both recoveries are kept, and the suite pins them: "rows matter more than their order" is about how rows are presented and does not transfer to a predicate — a dropped sort is a correct answer in an unhelpful order, a dropped WHERE is records the caller excluded.count()needed no ladder — it carries no projection and no ORDER BY, so the only clause an unresolvable column can be in is the one no rung may drop. It needed the ladder's terminal.The caller-visible message names the column and the object and nothing else. The dialect's own message — compiled statement, bound literals and all — goes to the server log (#7929's line, applied to the one refusal on this path raised from a dialect error rather than composed from the filter AST).
MySQL is untouched throughout: its
ER_BAD_FIELD_ERRORwording matches neither arm of the classifier, so it gets no envelope and no #3821 recovery. Filed as #8926 rather than widened in passing — widening would also hand MySQL the recoveries it has never had, an accept-set change in the opposite direction. The gap is pinned as a fact in the new suite, so it goes red the day someone closes it.Verification
Ablation-proved — the fix was committed first, then each half reverted and the pins observed going red, direction predicted beforehand:
return []: 6 failed / 17 passed, all on thefind()half and the shared pins. Ladder-preservation and control pins stayed green — which is what shows the suite discriminates.count()unguarded: 5 failed / 18 passed, mirror image —expected 'SQLITE_ERROR' to be 'INVALID_FILTER', and the redaction pin firing on the real disclosure:expected 'select count(*) as `count` from `unre…' not to contain 'zz-bound-literal-must-not-leak'.driver-sqlin the exactTemporal Conformance (live PG + MySQL)job configuration — live Postgres atAsia/Shanghai, live MySQL at+08:00,TZ=America/New_York,OS_EXPECT_LIVE_DIALECT_MATRIX=1: 103 files / 2185 tests, all passing. The three earlier dotted-key failures are gone, and the Postgres cell now genuinely runs instead of being skipped as it was in the first local pass — which is why they were missed.driver-sql(live PG + MySQL)specdriver-turso(subclass)driver-sqlite-wasm(subclass)objectqlmetadata-protocolrestservice-analyticsservice-messagingplugin-reportsConsumers selected with the prefix filter (
pnpm --filter '...@objectstack/driver-sql'— downstream dependents); bothSqlDriversubclasses included because they inheritfindRows/countunmodified.Gates
Union run at
1fe3d65ca— the merge commit, working tree clean, on a fully rebuilt workspace. Re-run in full after the merge rather than carried over: a merge can import another PR's test surface, so the ratchets in particular are only meaningful when measured at this sha.All green:
check:query-options-erasure(240, at the ceiling),check:type-check-debt,check:type-check-coverage,check-dev-prereqs,check:nul-bytes,check:changeset-gate-self-tests,check:merge-driver,check:objectui-changeset,check:spec-parsed-alias,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset, plus the spec ledger familycheck:migration-registry,check:spec-changes,check:upgrade-guide,check:authorable-surface.check:query-options-erasurecaught a real regression in this PR: the new suite grew the test surface 240 → 256 via sixteenas anycasts on the query argument. Raising a ceiling is maintainer-only, so the queries were typed asDriverQueryinstead — precisely the erasure that type exists to prevent.Merge with
main(1fe3d65ca): the only conflict waspackages/spec/src/migrations/registry.ts, which is generated fromentries/. Resolved by regeneration rather than textually, viascripts/pm/os-regen-merge.sh(merge → commit → regenerate, in that order — regenerating in MERGE state silently rolls artifacts back to the fork point). Both intents verified present afterward by exact-name grep: main'sdatasource-config-url-query-credential-refusedand this PR'sdriver-sql-unresolvable-where-column-refused, index and entry body.Changeset
BREAKING, per the ruling — an accept-set narrowing on a GA public data API. Shipped as
minorunder the lockstep launch-window convention (check-changeset-no-major.mjs), with the migration prescription registered in the ADR-0087 ledger as the semantic entrydriver-sql-unresolvable-where-column-refusedunder protocol major 18.Cross-reference
#8371 (FILTER-axis dotted-path verdict) sits on the same axis and is cross-cited by the ruling's instruction; it is not addressed by this PR and remains open. No ordering dependency in either direction: this refusal is the backstop and stays correct under any outcome there. The measurements in the first section are offered as input to it — three dialects, three different classifications of the same dotted key.
Generated by Claude Code