Skip to content

Commit fcd0efc

Browse files
os-zhuangclaude
andauthored
fix(driver-sql): scope the PG introspectForeignKeys read to the session's schemas (#11201) (#11325)
`information_schema.table_constraints` spans every schema the session has privilege on, so filtering only on `tc.table_name = ?` merged a same-named table's foreign keys from schemas `search_path` never reaches. Add the pin the rest of the family already carries — `AND tc.table_schema = ANY (current_schemas(false))` — spelled and placed as `introspectUniqueConstraints` spells it. Regression pin against a live PostgreSQL 16.13: two same-named tables in two schemas, each with a different foreign key. The MySQL arm already pins `TABLE_SCHEMA = DATABASE()`; SQLite has no schemas. Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8113ba3 commit fcd0efc

3 files changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): scope the Postgres `introspectForeignKeys` catalog read to the session's own schemas (#11201)
6+
7+
The Postgres arm queried `information_schema.table_constraints` with
8+
`tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ?` and **no `table_schema`
9+
predicate at all**. Those views span every schema the session has privilege on,
10+
independently of `search_path`, so a table name that exists in more than one schema had
11+
all of their foreign keys merged into a single answer — including foreign keys from
12+
schemas the session can never reach unqualified.
13+
14+
That is a wrong answer rather than a missing one, and it is consumed as fact:
15+
`introspectSchema` hangs the result on the table it just listed, and from there it reaches
16+
federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift
17+
comparison. A phantom foreign key makes a drafted federated object reference a table it
18+
does not reference.
19+
20+
The fix is the pin the rest of the family already carries —
21+
`AND tc.table_schema = ANY (current_schemas(false))` — spelled and placed exactly as
22+
`introspectUniqueConstraints` spells it, which in turn follows `introspectSchema`'s own
23+
table listing. `introspectForeignKeys` was the last unscoped introspection arm; the two
24+
`pg_index`-based arms (`introspectIndexes`, `introspectPrimaryKeys`) reach the same scoping
25+
from the other side by resolving the name to an OID through `regclass`. No interface shape
26+
and no accepted input changes: a same-named table in another schema simply stops
27+
contributing foreign keys it never should have contributed.
28+
29+
Measured on a live PostgreSQL 16.13. The regression pin
30+
(`sql-driver-11201-introspect-fk-schema-scope.test.ts`) builds the collision the repo's own
31+
live-PG isolation (#9350, one schema per test file in one database) already makes routine:
32+
two same-named tables in two schemas, each with a different foreign key. It first asserts
33+
the pre-fix predicate really sees both constraints — so the interesting assertion, an
34+
absence, cannot go green on a fixture that never collided — then requires the arm and
35+
`introspectSchema` to return only the current schema's. Reverse-verified: with the
36+
predicate reverted the pin fails with the neighbour's foreign key present in the answer.
37+
38+
The MySQL arm of the same method was checked and is not affected: it already pins
39+
`TABLE_SCHEMA = DATABASE()`. SQLite has no schemas.
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#11201] `introspectForeignKeys` must answer for the table the SESSION
5+
* resolves — not for every same-named table in the database.
6+
*
7+
* The Postgres arm filtered `information_schema.table_constraints` on
8+
* `tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ?` with no
9+
* `table_schema` predicate at all. Those views span every schema the session
10+
* has privilege on, `search_path` notwithstanding, so a table name that exists
11+
* in more than one schema had all of their foreign keys merged into one answer.
12+
*
13+
* That is a WRONG answer, not a missing one, and it is consumed as fact:
14+
* `introspectSchema` hangs it on the table it just listed, and from there it
15+
* reaches federated-object codegen, the persisted `external_catalog`
16+
* (ADR-0015) and schema-drift comparison. The fix is the family's existing
17+
* pin — `AND tc.table_schema = ANY (current_schemas(false))`, the same one
18+
* `introspectSchema`'s table listing and `introspectUniqueConstraints` carry.
19+
*
20+
* ## Why the collision is realistic rather than contrived
21+
*
22+
* The live-dialect isolation (#9350) gives every test FILE in this package its
23+
* own schema inside ONE database. Same-named tables in sibling schemas are
24+
* therefore the normal state of a live-PG run here, not an edge case someone
25+
* has to construct — this file just makes the collision explicit so it can be
26+
* asserted on.
27+
*
28+
* ## Why this file is PG-only
29+
*
30+
* SQLite has no schemas, and the MySQL arm of this same method already pins
31+
* `TABLE_SCHEMA = DATABASE()` — the defect is not expressible on either, so
32+
* the cell list is exactly `pg`, declared through `declareDialectCell` so an
33+
* unprovisioned run is a NAMED skip (and a red under
34+
* `OS_EXPECT_LIVE_DIALECT_MATRIX=1`), never a silent pass.
35+
*
36+
* ## Why the fixture pins the catalog fact first
37+
*
38+
* The interesting assertion here is an ABSENCE — "the other schema's foreign
39+
* key is not in the answer" — and an absence goes green for free if the
40+
* fixture never created the collision (a `create schema` that silently landed
41+
* somewhere else, a DDL statement that did not run). So the first case
42+
* re-issues the pre-fix predicate verbatim and requires it to see BOTH
43+
* constraints. If that case ever goes green with one row, the rest of this
44+
* file is measuring nothing and says so.
45+
*/
46+
47+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
48+
import { SqlDriver } from '../src/index.js';
49+
import {
50+
PG_CELL,
51+
currentLiveSchema,
52+
declareDialectCell,
53+
type DialectCell,
54+
} from './live-dialect-matrix.testkit.js';
55+
56+
const MATRIX = 'introspectForeignKeys schema scoping';
57+
58+
/** The COLLIDING name — one in this file's own schema, one next door. */
59+
const TABLE = 'os11201_orders';
60+
61+
/** The referenced table each side points at. Distinct, so the answer is attributable. */
62+
const REF_HERE = 'os11201_ref_here';
63+
const REF_THERE = 'os11201_ref_there';
64+
65+
/** Constraint names, likewise distinct — a PG constraint name is per-schema. */
66+
const FK_HERE = 'os11201_fk_here';
67+
const FK_THERE = 'os11201_fk_there';
68+
69+
/** `introspectForeignKeys` is `protected`; this is the narrowest way to reach it. */
70+
class ForeignKeyProbeDriver extends SqlDriver {
71+
foreignKeys(table: string) {
72+
return this.introspectForeignKeys(table);
73+
}
74+
}
75+
76+
function declareForeignKeyScopeSuite(cell: DialectCell): void {
77+
describe(`introspectForeignKeys schema scoping — ${cell.label} (#11201)`, () => {
78+
let driver: ForeignKeyProbeDriver;
79+
/** This file's own schema (#9350) — the only one on `search_path`. */
80+
let here: string;
81+
/** The neighbour. Created by this file, so it is dropped by this file. */
82+
let there: string;
83+
84+
beforeAll(async () => {
85+
driver = new ForeignKeyProbeDriver(cell.config());
86+
here = currentLiveSchema();
87+
there = `${here}_alt`;
88+
// Postgres TRUNCATES an over-long identifier silently, which would fold
89+
// the neighbour back onto this file's own schema and quietly delete the
90+
// collision this suite exists to measure.
91+
expect(
92+
there.length,
93+
`the neighbour schema name ${there} exceeds Postgres' 63-byte identifier limit and would ` +
94+
`be silently truncated — shorten the suffix`,
95+
).toBeLessThanOrEqual(63);
96+
97+
await driver.execute(`drop schema if exists "${there}" cascade`);
98+
await driver.execute(`create schema "${there}"`);
99+
100+
// This file's own schema: unqualified DDL lands here, because the cell's
101+
// config puts exactly this schema on `search_path`.
102+
await driver.execute(`drop table if exists ${TABLE} cascade`);
103+
await driver.execute(`drop table if exists ${REF_HERE} cascade`);
104+
await driver.execute(`create table ${REF_HERE} (id varchar(64) primary key)`);
105+
await driver.execute(
106+
`create table ${TABLE} (
107+
id varchar(64) primary key,
108+
here_ref varchar(64),
109+
constraint ${FK_HERE} foreign key (here_ref) references ${REF_HERE} (id)
110+
)`,
111+
);
112+
113+
// The neighbour: same TABLE name, a different foreign key, and NOT on
114+
// `search_path`. Every name is schema-qualified so nothing depends on the
115+
// session's resolution while building it.
116+
await driver.execute(`create table "${there}".${REF_THERE} (id varchar(64) primary key)`);
117+
await driver.execute(
118+
`create table "${there}".${TABLE} (
119+
id varchar(64) primary key,
120+
there_ref varchar(64),
121+
constraint ${FK_THERE} foreign key (there_ref) references "${there}".${REF_THERE} (id)
122+
)`,
123+
);
124+
});
125+
126+
afterAll(async () => {
127+
await driver.execute(`drop schema if exists "${there}" cascade`).catch(() => {});
128+
await driver.execute(`drop table if exists ${TABLE} cascade`).catch(() => {});
129+
await driver.execute(`drop table if exists ${REF_HERE} cascade`).catch(() => {});
130+
await driver.disconnect().catch(() => {});
131+
});
132+
133+
it('the fixture really collides: `search_path` sees one table, the catalog sees two', async () => {
134+
// Non-vacuity, part one — the session resolves the bare name to THIS
135+
// schema's table, so a scoped read has exactly one right answer.
136+
const resolved: any = await driver.execute(
137+
`select nspname from pg_class c join pg_namespace n on n.oid = c.relnamespace
138+
where c.oid = to_regclass(?)`,
139+
[TABLE],
140+
);
141+
expect(resolved.rows[0].nspname).toBe(here);
142+
143+
// Non-vacuity, part two — the PRE-FIX predicate, re-issued verbatim.
144+
// Both constraints are visible to it, which is the whole defect.
145+
const unscoped: any = await driver.execute(
146+
`select tc.table_schema, tc.constraint_name
147+
from information_schema.table_constraints as tc
148+
where tc.constraint_type = 'FOREIGN KEY'
149+
and tc.table_name = ?
150+
order by tc.constraint_name`,
151+
[TABLE],
152+
);
153+
expect(
154+
unscoped.rows.map((r: any) => `${r.table_schema}.${r.constraint_name}`),
155+
'the neighbour schema did not get its own colliding table — this suite would be ' +
156+
'asserting an absence that the fixture, not the fix, produced',
157+
).toEqual([`${here}.${FK_HERE}`, `${there}.${FK_THERE}`]);
158+
});
159+
160+
it('reports ONLY the current schema’s foreign keys', async () => {
161+
const foreignKeys = await driver.foreignKeys(TABLE);
162+
163+
// The exact array, not a `toContain`: the defect ADDS a row, so any
164+
// assertion satisfied by a superset is satisfied by the defect too.
165+
expect(
166+
foreignKeys,
167+
`${cell.label}: a same-named table in ${there} must contribute nothing to ${here}'s answer`,
168+
).toEqual([
169+
{
170+
columnName: 'here_ref',
171+
referencedTable: REF_HERE,
172+
referencedColumn: 'id',
173+
constraintName: FK_HERE,
174+
},
175+
]);
176+
177+
// The pre-fix answer, named — so a future reader can see what red looked
178+
// like without re-deriving it.
179+
expect(foreignKeys.map((fk) => fk.constraintName)).not.toContain(FK_THERE);
180+
expect(foreignKeys.map((fk) => fk.referencedTable)).not.toContain(REF_THERE);
181+
});
182+
183+
it('carries the scoping through `introspectSchema`, the in-tree consumer', async () => {
184+
// The call site at the top of this defect's blast radius: the whole-schema
185+
// read hangs these keys on the table it listed, and every downstream
186+
// consumer (federated codegen, `external_catalog`, drift comparison)
187+
// reads them from there rather than calling the arm directly.
188+
const schema = await driver.introspectSchema();
189+
190+
expect(Object.keys(schema.tables)).toContain(TABLE);
191+
expect(schema.tables[TABLE].foreignKeys).toEqual([
192+
{
193+
columnName: 'here_ref',
194+
referencedTable: REF_HERE,
195+
referencedColumn: 'id',
196+
constraintName: FK_HERE,
197+
},
198+
]);
199+
});
200+
});
201+
}
202+
203+
declareDialectCell(PG_CELL, MATRIX, declareForeignKeyScopeSuite);

packages/drivers/driver-sql/src/sql-driver.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13159,6 +13159,25 @@ export class SqlDriver implements IDataDriver {
1315913159

1316013160
try {
1316113161
if (this.isPostgres) {
13162+
// `information_schema.table_constraints` spans EVERY schema the session
13163+
// can read, so `tc.table_name = ?` on its own merges a same-named
13164+
// table's foreign keys from schemas `search_path` never reaches — a
13165+
// wrong answer, not a missing one, and downstream (federated-object
13166+
// codegen, the persisted `external_catalog` under ADR-0015,
13167+
// schema-drift comparison) acts on it. Not theoretical here: the
13168+
// live-dialect isolation (#9350) gives every test FILE its own schema
13169+
// inside one database, so colliding table names are the normal state of
13170+
// a run.
13171+
//
13172+
// The pin is the family's existing one, kept spelled and placed exactly
13173+
// as `introspectUniqueConstraints` spells it, which in turn follows
13174+
// `introspectSchema`'s own table listing: `current_schemas(false)` is
13175+
// the session's `search_path` minus the implicit `pg_catalog`, so a
13176+
// bare name is scoped the way every other statement in the session
13177+
// resolves it. (`introspectIndexes` and `introspectPrimaryKeys` reach
13178+
// the same scoping from the other side — they resolve the name to an
13179+
// OID through `regclass` — because `pg_index` takes a relation, not a
13180+
// schema name.)
1316213181
const result = await this.knex.raw(
1316313182
`
1316413183
SELECT
@@ -13175,6 +13194,7 @@ export class SqlDriver implements IDataDriver {
1317513194
AND ccu.table_schema = tc.table_schema
1317613195
WHERE tc.constraint_type = 'FOREIGN KEY'
1317713196
AND tc.table_name = ?
13197+
AND tc.table_schema = ANY (current_schemas(false))
1317813198
`,
1317913199
[tableName],
1318013200
);

0 commit comments

Comments
 (0)