Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): order the MySQL introspectForeignKeys read by the key ordinal - #11715
Conversation
…y ordinal (#11379) `information_schema.KEY_COLUMN_USAGE` was read with no `ORDER BY`, so the row order of a composite foreign key's columns was whatever the plan yielded. `IntrospectedForeignKey` is a flat per-column record with no ordinal field, so a composite key is expressed as ordered sibling rows and the order is load-bearing. Measured on MySQL 8.0.46: this predicate returned key order unpinned, but the sibling `introspectPrimaryKeys` predicate over the same view, in the same session, returned an out-of-sequence primary key in column order. The view does not preserve the ordinal for free — which order you get is decided by the WHERE clause. The pin is on the emitted SQL rather than on the row order, because a row-order assertion passes with or without the clause on this predicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ 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 28cf873dc6a7f6c0e6fc79769c1ecc66f9cc81ab && git checkout 28cf873dc6a7f6c0e6fc79769c1ecc66f9cc81ab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d5aecd3946067898b832bfdb1ed1f0d1b27cacd8 8a43d5d63321e4b00e06c850978d8e624f3e6814 && git checkout -B drift-repro d5aecd3946067898b832bfdb1ed1f0d1b27cacd8 && git merge --no-ff 8a43d5d63321e4b00e06c850978d8e624f3e6814
node scripts/docs-audit/affected-docs.mjs --json d5aecd3946067898b832bfdb1ed1f0d1b27cacd8 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11379
Adds
ORDER BY ORDINAL_POSITIONto the MySQL arm ofSqlDriver.introspectForeignKeys, plus a pin on the SQL that arm emits.The card said it did not reproduce. It was right — and here is why the line still ships
The card is an observation, filed honestly: the reporter measured live MySQL 8.0.46 with a foreign key declared out of column sequence and the server returned the correct answer. I re-measured the same fixture on this container's MySQL 8.0.46 before writing anything, and reproduce the non-reproduction:
Key order. The right answer, unpinned.
But the same view, in the same session, on the same server, answers the sibling predicate wrongly.
introspectPrimaryKeysreadsKEY_COLUMN_USAGEthroughCONSTRAINT_NAME = 'PRIMARY'instead ofREFERENCED_TABLE_NAME IS NOT NULL. AgainstPRIMARY KEY (shipment_id, carrier_code)on a table declared(carrier_code, shipment_id, leg_seq), that predicate without anORDER BYreturned:COLUMN order — the wrong answer, reproducing the measurement recorded in the
introspectPrimaryKeysarm (the #11101 work) exactly, on a small fixture.So
KEY_COLUMN_USAGEdoes not preserve the ordinal for free on this server. Which of the two orders comes back is decided by theWHEREclause, and nothing declared that. The foreign-key predicate is sitting on the lucky side of a choice nobody made. That is what this clause removes.Why the order is load-bearing at all: #11324 established that a composite foreign key is expressed as ordered sibling rows in this flat per-column record —
(x, y) references p (a, b)isx -> p.atheny -> p.b— andIntrospectedForeignKeycarries no ordinal field for a consumer to recover a permuted position from. The Postgres arm pins it withORDER BY ... k.ord; the MySQL arm was leaving it to the optimizer.The pin is structural, deliberately, and it was seen to fail
A behavioural pin here would be vacuous: "the columns come back in ordinal order" passes today, passes with this change, and passes with this change reverted, because on this predicate the server already answers correctly. A green that cannot go red is not evidence, so this PR does not write one and does not describe one as a guard.
sql-driver-11379-introspect-fk-mysql-ordinal-order.test.tsinstead captures the statement the arm hands to knex and asserts the ordinal clause is in that statement, after the predicate that identifies it. Reverse-verified by deleting the line and re-running — not assumed:The second test stayed green under the ablation, as predicted: it pins the other half of the contract — that the arm emits rows in the order the server returned them, which a sort or a regrouping pass in that loop could undo without touching the SQL. That half is fully determined locally and really can fail; it just does not depend on this clause. The mutation script carried a
trap ... EXIT INT TERMrestore, and the restore leg was proven byte-identical to HEAD (git diff --exit-code= 0).sql-driver.tscontainsORDER BY ORDINAL_POSITIONthree times —introspectColumnOrder(13696), this method (13924), andintrospectPrimaryKeys(14056) — so a file-level literal match would report this arm as already ordered while it was still unordered. Every claim above about "which method" was made by matching the enclosing method, never the literal.Not attempted, per the card's own scoping: proving that some plan shape on some supported MySQL version returns the foreign-key predicate out of ordinal order. That needs a fixture large enough to change the plan, and the card rules it out.
Verification
Union run at
8a43d5d633, the commit this PR pushes — no commit landed after it.pnpm --filter @objectstack/driver-sql testpnpm --filter @objectstack/driver-sql typecheckpnpm lint(repo-wideeslint . --no-inline-config)pnpm check:driver-conformancepnpm check:type-check-debt(--re-measure, built closure)pnpm check:type-check-coveragepnpm check:changeset-gate-self-testspnpm check:objectui-changesetpnpm check:published-filespnpm check:slot-lookuppnpm check:test-source-aliaspnpm check:type-source-resolutionpnpm check:nul-bytespnpm check:query-options-erasurepnpm check:engine-double-contractpnpm check:cross-package-test-inputspnpm check:where-matchernode scripts/check-adr-0087-registration.mjsnode scripts/check-changeset-no-major.mjsnode scripts/check-empty-changeset.mjsnode scripts/check-plugin-teardown-shape.mjsnode scripts/docs-audit/check-affected-docs.mjsnode scripts/pm/release-rehearsal-clone.mjs --self-testGate families derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not from a recalled list.Driver-conformance census, before and after (the lane's standing promise on driver cards) — measured at the merge base
4ceae8ab0bin a throwaway comparison worktree, and on this branch:Unchanged, as expected: the new test imports no shared conformance case-set.
Notes
992161b728), so the in-flight overlap on this file that the dispatch flagged as an open draft no longer exists. Its regions wereintrospectUniqueConstraintsand theIntrospectedColumninterface either way; this diff is confined tointrospectForeignKeys.sqliteCanonicalDatetimeSql/backfillCanonicalDatetimes, andinsertOnlyUpsertColumns/ a 4th dialect / a conflict target gaining a non-null DEFAULT. This diff touches none of them, so no rider is owed.@@global.time_zone = '+08:00'), which is how the measurements above were taken.Generated by Claude Code