Skip to content

fix(driver-sql): order the MySQL introspectForeignKeys read by the key ordinal - #11715

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-11379-fk-introspection-ordinal-order
Aug 24, 2026
Merged

fix(driver-sql): order the MySQL introspectForeignKeys read by the key ordinal#11715
huangyiirene merged 1 commit into
mainfrom
claude/issue-11379-fk-introspection-ordinal-order

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#11379

Adds ORDER BY ORDINAL_POSITION to the MySQL arm of SqlDriver.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:

-- the arm's query verbatim, WITHOUT any ORDER BY
second_col -> ooo_parent.pa (ORDINAL_POSITION 1)
first_col -> ooo_parent.pb (ORDINAL_POSITION 2)

Key order. The right answer, unpinned.

But the same view, in the same session, on the same server, answers the sibling predicate wrongly.introspectPrimaryKeys reads KEY_COLUMN_USAGE through CONSTRAINT_NAME = 'PRIMARY' instead of REFERENCED_TABLE_NAME IS NOT NULL. Against PRIMARY KEY (shipment_id, carrier_code) on a table declared (carrier_code, shipment_id, leg_seq), that predicate without an ORDER BY returned:

carrier_code (ORDINAL_POSITION 2)
shipment_id (ORDINAL_POSITION 1)

COLUMN order — the wrong answer, reproducing the measurement recorded in the introspectPrimaryKeys arm (the #11101 work) exactly, on a small fixture.

So KEY_COLUMN_USAGE does not preserve the ordinal for free on this server. Which of the two orders comes back is decided by the WHERE clause, 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) is x -> p.a then y -> p.b — and IntrospectedForeignKey carries no ordinal field for a consumer to recover a permuted position from. The Postgres arm pins it with ORDER 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.ts instead 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:

introspectForeignKeys ORDER-BY count BEFORE = 1
introspectForeignKeys ORDER-BY count AFTER = 0 (mutation proven on disk, anchored on the deleted text)
sibling introspectPrimaryKeys still has it: 1 (mutation is method-scoped, not literal-scoped)
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)
FAIL > emits ORDER BY ORDINAL_POSITION on the KEY_COLUMN_USAGE read

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 TERM restore, and the restore leg was proven byte-identical to HEAD (git diff --exit-code = 0).

⚠️ The pin captures this method's emitted statement rather than grepping the source file. sql-driver.ts contains ORDER BY ORDINAL_POSITION three times — introspectColumnOrder (13696), this method (13924), and introspectPrimaryKeys (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.

checkexit
pnpm --filter @objectstack/driver-sql test0 — 122 passed / 8 skipped (130 files), 1913 passed / 104 skipped
pnpm --filter @objectstack/driver-sql typecheck0
pnpm lint (repo-wide eslint . --no-inline-config)0 — full scan, 2m27s, no narrowing
pnpm check:driver-conformance0
pnpm check:type-check-debt (--re-measure, built closure)0 — 32 entries, none above its recorded number
pnpm check:type-check-coverage0
pnpm check:changeset-gate-self-tests0
pnpm check:objectui-changeset0
pnpm check:published-files0
pnpm check:slot-lookup0
pnpm check:test-source-alias0
pnpm check:type-source-resolution0
pnpm check:nul-bytes0
pnpm check:query-options-erasure0
pnpm check:engine-double-contract0
pnpm check:cross-package-test-inputs0
pnpm check:where-matcher0
node scripts/check-adr-0087-registration.mjs0
node scripts/check-changeset-no-major.mjs0
node scripts/check-empty-changeset.mjs0
node scripts/check-plugin-teardown-shape.mjs0
node scripts/docs-audit/check-affected-docs.mjs0
node scripts/pm/release-rehearsal-clone.mjs --self-test0

Gate 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 4ceae8ab0b in a throwaway comparison worktree, and on this branch:

before: 45 covered cell(s), 0 in the DEBT ledger, 0 exempt
after: 45 covered cell(s), 0 in the DEBT ledger, 0 exempt

Unchanged, as expected: the new test imports no shared conformance case-set.

Notes

  • PR fix(driver-sql): introspectUniqueConstraints reports single-column uniqueness on all three dialects (#11202) #11657 has merged (992161b728), so the in-flight overlap on this file that the dispatch flagged as an open draft no longer exists. Its regions were introspectUniqueConstraints and the IntrospectedColumn interface either way; this diff is confined to introspectForeignKeys.
  • Neither on-hold trigger naming this file fires. Both declare restart conditions on symbols — sqliteCanonicalDatetimeSql / backfillCanonicalDatetimes, and insertOnlyUpsertColumns / a 4th dialect / a conflict target gaining a non-null DEFAULT. This diff touches none of them, so no rider is owed.
  • Live MySQL was down when this task started (shut down by another actor at 13:01:34 UTC, per the server error log). It was restarted and reconfigured to CI's parameters (@@global.time_zone = '+08:00'), which is how the measurements above were taken.

Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d5aecd3946067898b832bfdb1ed1f0d1b27cacd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 28cf873dc6a7f6c0e6fc79769c1ecc66f9cc81ab — the merge of head 8a43d5d63321e4b00e06c850978d8e624f3e6814 into base d5aecd3946067898b832bfdb1ed1f0d1b27cacd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[observation] driver-sql (MySQL): introspectForeignKeys returns a composite key's columns in UNSPECIFIED row order — no ORDER BY ORDINAL_POSITION

2 participants

@huangyiirene@claude