Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): correlate PG introspectForeignKeys on the constraint and the key ordinal - #11396
Conversation
… and the key ordinal Rewrites the Postgres arm of `SqlDriver.introspectForeignKeys` onto `pg_constraint`, replacing a three-view `information_schema` join that was wrong in two independent ways (both measured on live PostgreSQL 16.13): 1. `ccu.table_schema = tc.table_schema` demanded parent and child share a schema. `constraint_column_usage` describes the REFERENCED side of a foreign key, so its `table_schema` is the parent's — a cross-schema target contributed zero rows and the table reported having no foreign keys at all. 2. The kcu/ccu join carried no ordinal correlation, so an N-column key came back as the N x N cartesian product (a 2-column key measured as 4 rows). `constraint_column_usage` exposes no ordinal column, so defect 2 has nothing to correlate on inside `information_schema`; correlating on `tc.constraint_schema` (the spelling `introspectUniqueConstraints` carries) was measured to fix defect 1 and leave defect 2 at 4 rows. `pg_constraint` carries both facts on one row: `conkey`/`confkey` are parallel `smallint[]`s in key order, so `unnest(...) WITH ORDINALITY` — the shape `introspectPrimaryKeys` already uses for `indkey` — pairs child column with parent column by construction and pins the key order. `IntrospectedForeignKey`'s shape is unchanged; its doc comment now states the ordered-sibling-rows contract the ORDER BY establishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
…rrelation fix Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 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 3d7556f5c635aa1dcde31abe03f3461b41fbec60 && git checkout 3d7556f5c635aa1dcde31abe03f3461b41fbec60
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8519095308a35d1e151944bc20cc7b9ce2c4eaf9 574b8366d937e4a1de8701e5a01072e4f5f7b0c8 && git checkout -B drift-repro 8519095308a35d1e151944bc20cc7b9ce2c4eaf9 && git merge --no-ff 574b8366d937e4a1de8701e5a01072e4f5f7b0c8
node scripts/docs-audit/affected-docs.mjs --json 8519095308a35d1e151944bc20cc7b9ce2c4eaf9 |
os-zhuang
commented
Aug 23, 2026
ACCEPT — engine seat. Marked ready for review, then enqueued (that order deliberately: Green read by job name31 check runs, 0 still running, none non-green; two
For the record, that stale-server situation is my error, not the dev's: my dispatch order asserted "Live Postgres 16 is ALREADY RUNNING in this shared container", carrying forward an 11:00 reading as a 14:40 fact. Corrected in the seat post — a dispatch order must say check, and start it if needed, with CI's timezone parameters. What earns the acceptThe card asked for one thing to be verified rather than assumed — whether the whole query needed to move — and it was, on the server:
Every assertion here is a shape assertion that could pass on a fixture that never collided, and each is armed by a control that re-issues the pre-fix query verbatim and requires the defect to still be present. The ordering fixture declares its key out of column sequence with a control asserting key order and column order genuinely disagree — without which "the rows are in key order" would be satisfied by any query returning column order. The reverse-verification prediction was specific enough to be falsifiable — exactly 4 of 9 red, the controls and #11201's 3 tests staying green because the controls assert the defect is present — and was observed exactly. Restore proven byte-identical by Two process disclosures worth naming because they were volunteered: the gate sweep was SIGTERM'd at a 10-minute foreground cap after 14 of 20, and the remainder was re-run in batches — no gate skipped; and the first on-hold symbol count returned a false zero because On #11377, which this PR makes reachableMerging this converts a silent omission into a possibly-silently-wrong reference: a cross-schema FK is now returned with a bare Not holding the PR, and the reason is that no working behaviour regresses — those constraints were dropped entirely before, so nobody can have a functioning cross-schema deployment today. Both states are defects; this one is visible and #11377 records exactly how to finish it. The composite-key half is a pure win and unrelated.
Generated by Claude Code |
Fixes#11324
Rewrites the Postgres arm of
SqlDriver.introspectForeignKeysontopg_constraint. The three-viewinformation_schemajoin it replaces was wrong in two independent ways, both reproduced here on live PostgreSQL 16.13 before a line was changed.The two defects, reproduced not re-derived
Fixture,
search_path = os11324_probe, parent in a sibling schema:Issuing the arm's query verbatim:
cross_childcomp_childx->a,x->b,y->a,y->b— defect 2Defect 1 is
ccu.table_schema = tc.table_schema. For a FOREIGN KEY,constraint_column_usagedescribes the referenced side — that is exactly why the projection aliases itreferenced_table— so itstable_schemais the parent's, not the constraint's. A cross-schema target contributes zero rows and the table reports having no foreign keys at all.[]does not read downstream as "I could not see it"; it reads as this table has no foreign keys, which federated-object codegen, the persistedexternal_catalog(ADR-0015) and schema-drift comparison all act on.Defect 2 is the absent ordinal correlation between
kcuandccu. BecauseIntrospectedForeignKeyis a flat per-column record, the two phantom pairs are indistinguishable from the real ones to every consumer.Why the whole query moves, rather than the join predicate being patched
The card asked this to be verified rather than assumed, so it was, on the same server:
ccucorrelated ontc.constraint_schema,cross_childcomp_childinformation_schema.constraint_column_usagecolumn_nameSo the conservative half-fix is real but partial, and probe E is why:
constraint_column_usageexposes no ordinal column at all, so defect 2 has nothing to correlate on insideinformation_schema. That settles the split question — keepinginformation_schemafor defect 1 would mean two different sources for two halves of one row, with the composite half still unfixable.pg_constraintcarries both facts on one row.conkeyandconfkeyare parallel arrays in key order — measuredsmallint[], notint2vector, which is what makes multi-argumentunnestlegal here:Unnesting them together pairs child column with parent column by construction, and
WITH ORDINALITYkeeps the key position the old join threw away.The two siblings the card pointed at
introspectUniqueConstraintsjoinsccuontc.constraint_schema = ccu.constraint_schema— the constraint's schema, invariant to where the parent lives. That is the correct correlation and it is the one probe C measured. This PR diverges from that spelling deliberately, and the reason is probe D: for a UNIQUE constraintccudescribes the same table, so one row per column is all there is and no ordinal is needed; for a FOREIGN KEY it describes a different table and the ordinal is exactly what is missing. Matching the sibling would have fixed half this card.introspectPrimaryKeysis the precedent that is followed: it already readspg_indexand joinsunnest(i.indkey) WITH ORDINALITYfor the same reason (driver-sql: the Postgres and MySQL introspectPrimaryKeys arms return the key in UNSPECIFIED row order — neither orders by key position #11101), withk.ord <= i.indnkeyattsadded later (driver-sql (PG): introspectPrimaryKeys reports a covering primary key's INCLUDE'd columns as key members — indkey is read whole, indnkeyatts is ignored #11162). This arm now uses the same shape one table over. Dropping topg_catalogalso matches that arm andintrospectIndexes, which already read the catalog directly rather than throughinformation_schema's privilege views.IntrospectedForeignKey's shape is NOT changedThe declaration region is free (#11270 landed), so this was a design choice rather than a scheduling one, and the card's position was adopted: a flat per-column record expresses a composite key correctly as ordered sibling rows. What was missing was not a field but a guarantee — nothing pinned the order.
ORDER BY con.conname, con.oid, k.ordpins it, and the type's docblock now states the contract every arm owes: contiguous, in declared key order, each record pairing its own child column with its own parent column.An ordinal field was considered and rejected. It would let a wrong
ORDER BYkeep shipping wrong rows that merely describe their wrongness, where the pairing is a fact the query itself has to get right; and it would widen an interface to record something the array index already carries.con.oidis in the ORDER BY only as a tiebreaker, so two same-named constraints in two schemas both onsearch_pathstill cannot interleave their columns.Behaviour deliberately left alone: an unknown table name still yields an empty list rather than a throw (measured), so this does not quietly adopt the
?::regclassfailure modeintrospectPrimaryKeyshas, and the #7332onFailurecontract is untouched. Scoping is unchanged in meaning —ns.nspname = ANY (current_schemas(false))is #11201's predicate expressed over the catalog.Tests, and the controls that keep them from passing for the wrong reason
New:
packages/drivers/driver-sql/src/sql-driver-11324-introspect-fk-join-correlations.test.ts, PG-only throughdeclareDialectCell(neither defect is expressible on the other arms — SQLite'sPRAGMA foreign_key_listand MySQL'sKEY_COLUMN_USAGEboth carry both sides on one row).Both assertions are shape assertions that could pass for the wrong reason, so each is preceded by a control that requires the fixture to still exhibit the defect:
pg_namespace); the constraint really exists (read frompg_constraint); and the pre-fix query, re-issued verbatim, still returns zero rows.array_length(conkey, 1)is really 2; and the pre-fix query, re-issued verbatim, still returns the four cartesian pairs.foreign key (second_col, first_col)— and a control asserts key order and column order really do disagree (first_col@col2/key2,second_col@col3/key1). Without it, "the rows are in key order" would be satisfied by any query returning column order.Each control's failure message says that the assertion beside it is measuring nothing.
Reverse verification, direction predicted before the leg
Predicted: reverting
sql-driver.tstoorigin/mainwith the tests kept turns the pair red with exactly 4 of 9 failing — the two cross-schema assertions and the two composite ones — while the 2 controls and #11201's 3 tests stay green, because the controls assert the defect is present.Observed:
Tests 4 failed | 5 passed (9), exactly those four:The mutation was proven on disk before the leg (
FROM pg_constraint con1 → 0,AND ccu.table_schema = tc.table_schema0 → 1, blob hash changed,git status --porcelaina lone unstagedMrather thanMM), and the restore proven byte-identical afterwards bygit hash-objectmatching the committed blob — not by an insertion count. No rebuild is involved on either leg: the suite reaches the subject through a relative import inside the same package, so it reads source, which the mutation changing the result demonstrates directly.Gates
Union derived from the actual diff with
node scripts/pm/dispatch-gates.mjs, no paths passed. Every family run at574b8366d, which is this branch's final commit; each exit status captured before any pipe.check:changeset-gate-self-testscheck:driver-conformancecheck:objectui-changesetcheck:published-filescheck:slot-lookupcheck:test-source-aliascheck:type-source-resolutionscripts/check-adr-0087-registration.mjsscripts/check-changeset-no-major.mjsscripts/check-ci-filter-parity.mjsscripts/check-empty-changeset.mjsscripts/check-plugin-teardown-shape.mjsscripts/docs-audit/check-affected-docs.mjscheck:query-options-erasurecheck:type-check-coveragecheck:type-check-debtcheck:engine-double-contractcheck:cross-package-test-inputscheck:where-matchercheck:nul-bytes@objectstack/driver-sqltypecheckTheir own verdict lines rather than a bare status, for the three that speak to this diff:
Repo-wide
pnpm lintwas not run locally; CI owns that run.The full package suite: 2496 passed, 3 failed, and the 3 are environmental
Test Files 2 failed | 121 passed (123)/Tests 3 failed | 2496 passed (2499). All three failures are the testkit's three-way timezone-skew non-vacuity guard insql-driver-temporal-conformance.test.tsandsql-driver-datetime-mysql-storage.test.ts— this container's Postgres and MySQL were not running when this task started and had to be started here, so they run at UTC, where CI provisionstimezone=Asia/Shanghai/default_time_zone='+08:00'withTZ=America/New_York. Attempting to reconfigure the servers to CI parity was blocked in this environment.Rather than assert that from the message alone, it was controlled: the same two files were re-run with
sql-driver.tsrestored toorigin/main, and produced the identical 3 failures (Tests 3 failed | 153 passed (156)). Predicted before the run, matched. The tree was restored byte-identically afterwards. Nothing in this diff reaches those assertions, which read only server and process timezones. CI'sTemporal Conformance (live PG + MySQL)job runs these cells against properly-provisioned containers.Out of scope, filed rather than fixed
referencedTable, and the bare name does not resolve on the session's search_path #11377 — repairing defect 1 makes a new surface reachable:IntrospectedForeignKey.referencedTablecarries no schema, so a cross-schema parent is now returned by a bare name the session'ssearch_pathdoes not resolve, andpackages/objectql/src/util.ts:209turns that straight into a lookup field'sreference. Filed rather than fixed here because it is a shape question about the same interface this PR deliberately left alone, and its options reach every consumer ofIntrospectedTable.ORDER BY ORDINAL_POSITION#11379 — an observation: the MySQL arm of this same method has noORDER BY ORDINAL_POSITION, so a composite key's row order is unspecified. Filed honestly as not reproduced: measured on live MySQL 8.0.46 with an out-of-sequence key it returned key order. It is recorded only because the siblingintrospectPrimaryKeysMySQL arm documents the same view returning column order on the same shape.Neither is addressed by this PR; both remain open.
Deliberately not done
IntrospectedForeignKey's shape — argued above, not merely deferred.ORDER BY ORDINAL_POSITION#11379 records the one gap found.tc.table_schema = ANY (current_schemas(false))permits (a name resolvable in two schemas both on the path contributes from both) is preserved exactly, not narrowed: that is driver-sql (PG): introspectForeignKeys' information_schema query is not schema-scoped — a same-named table in another schema contributes its foreign keys #11201's territory and every sibling arm shares it.CURRENT_TIMESTAMPwhile a declaredField.datetimeNOW() default in the SAME table gets the canonical ISO-8601 form #11321: this diff's only hunks insql-driver.tsare at theIntrospectedForeignKeydocblock andintrospectForeignKeys.createAuditTimestampColumn(12473) and its call sites (8041/8042, 8482/8483) are untouched. The twopm:on-holdsymbol sets declared over this file read 0 occurrences on this branch's changed lines —insertOnlyUpsertColumns,sqliteCanonicalDatetimeSql,backfillCanonicalDatetimes— and those zeros are readings, not empty queries: over 488 extracted added lines, the same pattern reads 2/10/7 over the whole file, a synthetic+line carrying each literal reads 1, and symbols this diff does touch read 7. The first attempt at that count returned a false zero (a BRE^\+\+\+exclusion, where GNU grep reads\+as the repetition operator, silently dropped every line) and was caught by the synthetic control reading 0 rather than 1.Generated by Claude Code