Skip to content

Commit 716ac9b

Browse files
hotlongclaude
andauthored
fix(driver-sql): refuse an unresolvable WHERE column on both find() and count() (#8790) (#8927)
* fix(driver-sql): refuse an unresolvable WHERE column on both find() and 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 * test(driver-sql): type the refusal suite's queries as DriverQuery `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 * test(driver-sql): scope the refusal pins to plain columns; record the 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a777944 commit 716ac9b

5 files changed

Lines changed: 826 additions & 8 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
fix(driver-sql): one unresolvable WHERE column, one answer — `find()` and `count()` both refuse with `INVALID_FILTER` / 400 naming the column (#8790)
7+
8+
**BREAKING** accept-set narrowing on a GA public data API, shipped as `minor`
9+
under the lockstep launch-window convention. The migration prescription is
10+
registered under protocol major 18, where `os migrate meta` users will look.
11+
12+
<!-- adr-0087: registered driver-sql-unresolvable-where-column-refused -->
13+
14+
## The defect
15+
16+
One predicate had two answers. `SqlDriver.findRows()` carries the #3821
17+
unknown-column recovery ladder, and every rung of it is built from
18+
`buildBase()`, which **always re-applies `query.where`**. So the ladder can drop
19+
a projection and can drop an ORDER BY, but it can never drop the clause that
20+
actually failed when the unresolvable column is in the WHERE — both rungs raise
21+
the same error and the method fell to `return []`. `SqlDriver.count()` runs a
22+
separate statement and has no ladder at all, so the identical predicate threw.
23+
24+
Measured on a real `SqlDriver` over better-sqlite3, one table, one seeded row:
25+
26+
```
27+
where { 'title.x': 'y' }
28+
find() -> 0 rows, NO ERROR
29+
count() -> THREW code=SQLITE_ERROR status=undefined
30+
select count(*) as `count` from `task` where `title`.`x` = 'y'
31+
- no such column: title.x
32+
33+
CONTROL where { title: 'Design' }
34+
find() -> 1 row
35+
count() -> 1
36+
```
37+
38+
A list view calls both halves, so one query produced an empty page from the rows
39+
half and a 500-shaped failure from the total half. A caller reading only the rows
40+
got a silent empty page that says "no records exist" for what was really "your
41+
predicate never ran" — the single most AI-legible failure to get wrong, since an
42+
agent reads "no matching records" and writes its next query on that belief.
43+
44+
The thrown half was no better: the dialect's own `code`, no `status` (so an
45+
unclassified 5xx at the REST boundary rather than a caller mistake), and the
46+
statement's **bound literals inlined in the message** — the same predicate-text
47+
disclosure shape #7929 redacted elsewhere.
48+
49+
## The fix
50+
51+
Ruled 2026-08-15 on #8790: **refuse both halves** with `INVALID_FILTER` / 400,
52+
naming the column. That envelope is not minted here — it is what every sibling
53+
refusal on this path already answers, required on both SQL drivers by
54+
`cross-field-conformance-cases.ts` and pinned by
55+
`sql-driver-boolean-identity.test.ts` and
56+
`sql-driver-cross-field-conformance.test.ts`. What closes is a
57+
declared-vs-enforced gap, not a new posture.
58+
59+
The caller-visible message names the column and the object and nothing else. The
60+
dialect's own message — the compiled statement, bound literals and all — goes to
61+
the **server log** instead, so the operator keeps the debugging aid that
62+
`count()`'s raw throw used to provide without it reaching the caller.
63+
64+
**The #3821 ladder keeps both of its recoveries.** Only the WHERE-failure
65+
terminal `return []` became a refusal, and the asymmetry is the ruling rather
66+
than an oversight: "rows matter more than their order" is an argument about how
67+
rows are *presented*, and it does not transfer to a predicate. A dropped sort is
68+
a correct answer in an unhelpful order; a dropped WHERE is records the caller
69+
explicitly excluded. Recover-both was rejected for exactly that reason.
70+
71+
## Reach, stated rather than assumed
72+
73+
The refusal fires on the wordings the ladder has always recognised — SQLite
74+
(`no such column: x`) and Postgres (`column "x" does not exist`). MySQL spells
75+
the condition `Unknown column 'x' in 'where clause'`, which neither arm matches,
76+
so on MySQL an unresolvable column still travels out as the raw dialect error.
77+
That gap is pinned as a fact in the new suite and filed separately: widening the
78+
predicate would also hand MySQL the #3821 projection and ORDER-BY recoveries it
79+
has never had, which is an accept-set change in the opposite direction from this
80+
one.
81+
82+
## Who is affected
83+
84+
Callers that reach the driver with a filter key the table has no column for. The
85+
ingress doors already refuse this where they can judge — `assertFilterFieldsExist`
86+
(`@objectstack/metadata-protocol`) answers `INVALID_FIELD` / 400 for everything
87+
reaching `findData`, with the sentence this refusal now echoes verbatim: *a
88+
filter on a field that does not exist can only match zero records, so the query
89+
was refused instead of answered with an empty list*. What changes is the
90+
backstop underneath them: a registry the door could not read, and a dotted key
91+
judged on its head segment only.

0 commit comments

Comments
 (0)