Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/pg-introspect-fk-join-correlations.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
"@objectstack/driver-sql": patch
---

**Fix:** `introspectForeignKeys`' Postgres arm no longer drops a cross-schema foreign key, nor returns a composite one as a cartesian product (#11324).

The arm joined three `information_schema` views, and the correlations were wrong in two independent ways. Both were measured on live PostgreSQL 16.13, against the query as it stood after #11201, so neither was caused by nor repaired by that change.

**A foreign key whose target lived in another schema vanished.** The join carried `ccu.table_schema = tc.table_schema`, which demands parent and child sit in the same schema. For a FOREIGN KEY constraint, `constraint_column_usage` describes the *referenced* side — that is exactly why the projection aliases it `referenced_table` — so its `table_schema` is the **parent's**, not the constraint's. A cross-schema reference therefore contributed **zero rows**, and the table reported having no foreign keys at all. That is the #7332 failure mode through a different door and it has no `onFailure` to consult, because nothing failed: `[]` does not read downstream as "I could not see it", it reads as *this table has no foreign keys*, and federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift comparison all act on it. Cross-schema references are the normal shape for the federated remotes ADR-0015 points this driver at.

**A composite foreign key came back as the cartesian product of its columns.** The `kcu` ↔ `ccu` join carried no ordinal correlation at all, so an N-column key yielded N x N rows pairing every child column with every parent column. Measured, a 2-column key `(x, y) references p (a, b)` returned **four** records — `x -> a`, `x -> b`, `y -> a`, `y -> b` — where the answer is `x -> a`, `y -> b`. Because `IntrospectedForeignKey` is a flat per-column record, the two phantom pairs are indistinguishable from the real ones to every consumer: a wrong-shaped answer that type-checks.

**The whole query moves to `pg_constraint` rather than the join predicate being patched.** `constraint_column_usage` exposes no ordinal column at all — measured, its seven columns are the catalog/schema/name triples for the table and the constraint plus `column_name` — so the composite half has nothing to correlate on inside `information_schema`. The conservative half-fix was tried and measured: correlating `ccu` on `tc.constraint_schema`, the spelling `introspectUniqueConstraints` already carries, repairs the cross-schema case and leaves the composite case at four rows. `pg_constraint` carries both facts on one row — `conkey` and `confkey` are parallel `smallint[]`s in key order — so unnesting them *together* pairs child column with parent column by construction, and `unnest(...) WITH ORDINALITY` keeps the key position the old join threw away. That is the shape `introspectPrimaryKeys` already uses for `indkey` (#11101 / #11162), and dropping to the catalog matches what that arm and `introspectIndexes` already do.

**No interface change.** `IntrospectedForeignKey` keeps its flat per-column shape and gains no ordinal field. A composite key is expressed as **ordered sibling rows** — contiguous, in declared key order, each pairing its own child column with its own parent column — which `ORDER BY con.conname, con.oid, k.ord` now pins and the type's docblock now states. Measured on a key declared out of column sequence, `foreign key (second_col, first_col)`, the result is key order rather than column order. An ordinal field was considered and rejected: it would let a wrong `ORDER BY` keep shipping wrong rows that merely *describe* their wrongness, where the pairing is a fact the query itself has to get right.

Schema scoping is unchanged in meaning: `ns.nspname = ANY (current_schemas(false))` is #11201's `tc.table_schema = ANY (…)` expressed over the catalog, so a same-named table in a schema `search_path` never reaches still contributes nothing. An unknown table name still yields an empty list rather than a throw, so the #7332 `onFailure` contract is untouched.
Loading
Loading