Uh oh!
There was an error while loading. Please reload this page.
feat(driver-sql): MySQL joins the unresolvable-column predicate — refusal envelope and #3821 recoveries, full dialect parity (#8926) - #9061
Conversation
…e shared predicate (#8926) Adds ER_BAD_FIELD_ERROR's sentence (Unknown column 'x' in 'where clause' / 'field list' / 'order clause') as the third arm of isUnresolvableColumnError and teaches unresolvableColumnNameOf to extract the column it names — full dialect parity on the ONE shared predicate, per the maintainer ruling on issue 8926 (option A). Both directions of the accept-set change are intended and pinned: - narrowing: an unresolvable WHERE column on MySQL now refuses with the ADR-0112 INVALID_FILTER / 400 envelope naming the column, instead of the raw dialect error with the statement's bound literals inlined; - widening: MySQL gains the 3821 projection and ORDER-BY recoveries it never had — a query that used to throw now returns rows. The ladder cannot drop a WHERE: every rung is rebuilt from buildBase(), which re-applies query.where unconditionally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…ql-unresolvable-column-parity
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8926
Ruling enacted — option A, maintainer, 2026-08-16, verbatim 「按 A」
Adds MySQL's spelling of "the statement named a column the backend could not resolve" —
Unknown column 'x' in 'where clause'/'field list'/'order clause'(ER_BAD_FIELD_ERROR) — as the third arm of the ONE sharedisUnresolvableColumnErrorpredicate, and teachesunresolvableColumnNameOfto extract the name it carries. Full dialect parity; a split predicate (option B) and doing nothing (option C) were refused on the card.Both directions of the accept-set change are intended and were ruled together:
find()andcount()with the ADR-0112INVALID_FILTER/ 400 envelope naming the column; the dialect message (which inlines the statement's bound literals — the finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 disclosure shape) goes to the server log, not the caller.Measured on a live MySQL 8.0.46 — all three clause positions, before and after
No
OS_TEST_MYSQL_URLexisted in this container and its Docker daemon is unavailable, so a real MySQL 8.0.46 (mysql-server-8.0, Ubuntu) was installed and provisioned atmysql://root:root@127.0.0.1:3306/conformance, global time zone+08:00, processTZ=America/New_York— mirroring the CI conformance job. A measurement script drove a realSqlDriverover mysql2 against the BUILT dist, with a dist marker check per leg proving each leg ran the intended predicate (marker absent in the BEFORE dist built from origin/main's source, present in the AFTER dist).find()ER_BAD_FIELD_ERROR, nostatus, bound literal inlined in the messageINVALID_FILTER/ 400 namingnosuchcol; no literal, no statementcount()find()'s… in 'field list')… in 'order clause')rankat least 2 returned onlyt2)title.xINVALID_FILTER/ 400, both halves (same cell as SQLite — see below)The structural fact the ruling carries held under measurement: the ORDER-BY recovery with a live WHERE returned exactly the matching row — every ladder rung is rebuilt from
buildBase(), which re-appliesquery.where. No leak; no fork to report.Full
@objectstack/driver-sqlsuite (103 files) run with the live MySQL provisioned: 102 passed, 1 skipped — the skip is the unprovisioned live-Postgres cell reporting itself by name (CI provisions it). The refusal suite's live-mysql cell ran end-to-end: refusal pins, redaction pins, dotted status quo, and both KEEPS recovery pins, all green.The two "NOT recognised" pins — exactly one flipped
mysql (ER_BAD_FIELD_ERROR)— flipped torecognised: true, plus new fixtures for'field list','order clause'and MySQL's dotted-key wording, and an explicit direction pin: all three clause positions ride ONE arm, so the envelope and the recoveries arrive together (matching only'where clause'to withhold the recoveries would be the refused option B).postgres, DOTTED key — undefined_table— untouched, stillrecognised: false. That is [finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 / A dotted WHERE key escapes as a raw dialect error with the bound literal inlined on Postgres and MySQL — pre-existing, and measured against live servers #8931 territory: the driver's rule stays "refuse what the backend could not resolve", never "inspect the key for a dot". A dotted WHERE key escapes as a raw dialect error with the bound literal inlined on Postgres and MySQL — pre-existing, and measured against live servers #8931 is not addressed here and remains open.DOTTED_STATUS_QUOgains amysqlrow (INVALID_FILTER, enveloped): MySQL classifies a dotted key as an undefined COLUMN and spells it exactly like a plain missing one, so it lands in the same cell as SQLite — a consequence of the wording, not a dotted-path verdict; the key is never inspected for a dot.Also in this diff
isUnresolvableColumnError's docblock rewritten — its "REACH, deliberately UNCHANGED … matched by NEITHER arm" paragraph became false the moment the predicate widened (ruling condition 3); it now records both ruled directions and thebuildBase()reason the widening is safe.unresolvableColumnNameOfextracts MySQL's column name (ruling condition 4) — the refusal names the column on MySQL rather than answering the legal-but-worsenull.@objectstack/driver-sqlminor, mirroring driver-sql: one unresolvable WHERE column, two answers —find()silently returns [] whilecount()throws a raw dialect error with no ADR-0112 envelope #8790's lockstep convention) with ADR-0087 dispositionnot-required (already-registered driver-sql-unresolvable-where-column-refused)— same surface, same prescription, reach extended.Verification — union run at 82e0925, the head of this branch, after merging origin/main
pnpm --filter @objectstack/driver-sql testwithOS_TEST_MYSQL_URLset: 102 files / 1983 tests passed, 1 file skipped (named live-PG skip).pnpm --filter @objectstack/driver-sql typecheck: clean.scripts/pm/dispatch-gates.mjs) and re-run after the merge:check:changeset-gate-self-tests,check:objectui-changeset,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:query-options-erasure,check:type-check-coverage,check:type-check-debt(full closure built; no surplus),check:engine-double-contract,check:where-matcher,check:nul-bytes,check:error-code-casing— pluscheck:error-status-conformance, the ADR-0112 gate that landed on main while this branch was in flight, added to the union because this diff emitsINVALID_FILTER/ 400. All pass.Out of scope, filed separately
driver-sql-unresolvable-where-column-refusedstates a MySQL reach gap that #8926's parity change retires #9060 — the ADR-0087 registry entry18.driver-sql-unresolvable-where-column-refused(packages/spec) states in itsreasonthat on MySQL this condition still travels out raw; true when registered, false once this merges. Amending it pulls in the spec artifact regeneration gate family, outside this card's declared file surface, so it is filed rather than ridden along.Generated by Claude Code