From d51eb112cee30b6cabb45dd6d435416ac5027f8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:40:36 +0000 Subject: [PATCH 1/3] feat(driver-sql): qualify a cross-schema foreign key with referencedSchema (#11377) IntrospectedForeignKey gains an optional referencedSchema, present when - and only when - the referenced parent lives outside the session's own resolution scope (PG: the parent's schema vs current_schemas(false); MySQL: REFERENCED_TABLE_SCHEMA vs DATABASE(), null-safe). referencedTable stays a bare name unconditionally. SQLite never sets the key - no schemas, and a foreign key cannot cross an ATTACHed database. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- ...pect-fk-cross-schema-qualification.test.ts | 418 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 88 +++- 2 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-11377-introspect-fk-cross-schema-qualification.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-11377-introspect-fk-cross-schema-qualification.test.ts b/packages/drivers/driver-sql/src/sql-driver-11377-introspect-fk-cross-schema-qualification.test.ts new file mode 100644 index 0000000000..5f1e5530ef --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11377-introspect-fk-cross-schema-qualification.test.ts @@ -0,0 +1,418 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11377] A cross-schema foreign key is QUALIFIED by `referencedSchema`; + * `referencedTable` stays a bare name always. + * + * Maintainer ruling (2026-08-24, on the card): `IntrospectedForeignKey` gains + * an optional `referencedSchema`, filled when — and only when — the parent + * lives outside the session's resolution scope; `referencedTable` stays a + * BARE name unconditionally (a conditionally-qualified spelling was rejected + * as a trap, and omitting the constraint was rejected as hiding truth). + * + * Until #11324 this fact was unreachable on Postgres: a cross-schema foreign + * key contributed zero rows. Repairing that made the constraint visible under + * a bare name the session's `search_path` does not resolve — true of the + * constraint, unusable as an address, and (the #11201 family) collidable with + * a same-named table in the current schema. The consumer half of the same + * ruling lives in `@objectstack/objectql`'s + * `convertIntrospectedSchemaToObjects`, which refuses to wire a lookup to a + * bare name whose answer carries `referencedSchema`. + * + * ## The two presence pins are both non-vacuous only WITH their controls + * + * "The cross-schema answer carries the key" goes green for free if the far + * schema collapsed onto the session's own (a truncated identifier, a `create + * schema` that landed elsewhere), and "the in-path answer omits the key" goes + * green for free if the in-path parent were accidentally out of path. So each + * suite first reads the catalog — which schema each parent REALLY sits in, + * and what the session's resolution scope REALLY is — before asserting either + * shape. Absence is asserted with `toStrictEqual` + an `Object.keys` read: + * `toEqual` treats `{ referencedSchema: undefined }` and `{}` as the same + * object, and the contract is that the KEY is absent, not `undefined`. + * + * ## Cells + * + * - **Postgres** (live): the card's measured shape — parent in a schema off + * the `search_path`, sibling in-path parent as the unchanged control. + * - **MySQL** (live): the symmetric fact — InnoDB permits a foreign key into + * another DATABASE, `KEY_COLUMN_USAGE.REFERENCED_TABLE_SCHEMA` names it, + * and the session's resolution scope for a bare name is `DATABASE()`. + * - **MySQL** (emission probe, no server — the #11379 pattern): pins that the + * arm's one statement PROJECTS the qualification and that the TS mapping + * emits the key present-or-absent by row value. This half runs in every CI + * job, so the mapping cannot regress in the jobs that have no live server. + * - **SQLite**: no cell, deliberately. SQLite has no schemas and a foreign + * key cannot cross an ATTACHed database, so the fact this key carries + * cannot exist there — there is nothing to measure, and the arm never sets + * the key (see the interface TSDoc). + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; +import { + MYSQL_CELL, + PG_CELL, + currentLiveSchema, + declareDialectCell, + type DialectCell, +} from './live-dialect-matrix.testkit.js'; + +const MATRIX = 'introspectForeignKeys cross-schema qualification'; + +/** The child, in the session's own resolution scope. */ +const CROSS_CHILD = 'os11377_cross_child'; +/** The cross-schema parent — deliberately OUTSIDE the session's scope. */ +const REMOTE_PARENT = 'os11377_remote_parent'; +/** The in-path parent — the unchanged-shape control. */ +const LOCAL_PARENT = 'os11377_local_parent'; +const FK_CROSS = 'os11377_fk_cross'; +const FK_LOCAL = 'os11377_fk_local'; + +/** `introspectForeignKeys` is `protected`; this is the narrowest way to reach it. */ +class ForeignKeyProbeDriver extends SqlDriver { + foreignKeys(table: string) { + return this.introspectForeignKeys(table); + } +} + +// ── Postgres: the card's measured shape ────────────────────────────────────── + +function declarePgSuite(cell: DialectCell): void { + describe(`introspectForeignKeys cross-schema qualification — ${cell.label} (#11377)`, () => { + let driver: ForeignKeyProbeDriver; + /** This file's own schema (#9350) — the only one on `search_path`. */ + let here: string; + /** Where the cross-schema parent lives. Created by this file, dropped by it. */ + let far: string; + + beforeAll(async () => { + driver = new ForeignKeyProbeDriver(cell.config()); + here = currentLiveSchema(); + far = `${here}_far`; + // Postgres TRUNCATES an over-long identifier silently, which would fold + // the far schema back onto this file's own and delete the cross-schema + // condition this suite exists to measure. + expect( + far.length, + `the parent schema name ${far} exceeds Postgres' 63-byte identifier limit and would be ` + + `silently truncated onto ${here} — shorten the suffix`, + ).toBeLessThanOrEqual(63); + + await driver.execute(`drop schema if exists "${far}" cascade`); + await driver.execute(`create schema "${far}"`); + + await driver.execute(`drop table if exists ${CROSS_CHILD} cascade`); + await driver.execute(`drop table if exists ${LOCAL_PARENT} cascade`); + await driver.execute(`create table "${far}".${REMOTE_PARENT} (id varchar(64) primary key)`); + await driver.execute(`create table ${LOCAL_PARENT} (id varchar(64) primary key)`); + await driver.execute( + `create table ${CROSS_CHILD} ( + id varchar(64) primary key, + p varchar(64), + q varchar(64), + constraint ${FK_CROSS} foreign key (p) references "${far}".${REMOTE_PARENT} (id), + constraint ${FK_LOCAL} foreign key (q) references ${LOCAL_PARENT} (id) + )`, + ); + }); + + afterAll(async () => { + await driver.execute(`drop schema if exists "${far}" cascade`).catch(() => {}); + for (const t of [CROSS_CHILD, LOCAL_PARENT]) { + await driver.execute(`drop table if exists ${t} cascade`).catch(() => {}); + } + await driver.disconnect().catch(() => {}); + }); + + it('control: one parent is really out of path, the other really on it', async () => { + // Part one — where the three tables really sit, read straight from the + // catalog rather than through the method under test. + const placed: any = await driver.execute( + `select n.nspname, c.relname + from pg_class c join pg_namespace n on n.oid = c.relnamespace + where c.relname in (?, ?, ?) + order by c.relname`, + [CROSS_CHILD, REMOTE_PARENT, LOCAL_PARENT], + ); + expect( + placed.rows.map((r: any) => `${r.nspname}.${r.relname}`), + `the fixture collapsed — presence/absence of referencedSchema would then be measuring ` + + `nothing`, + ).toEqual([ + `${here}.${CROSS_CHILD}`, + `${here}.${LOCAL_PARENT}`, + `${far}.${REMOTE_PARENT}`, + ]); + + // Part two — the session's OWN resolution scope: `here` is on it, `far` + // is not. This is the exact predicate the fix's CASE asks, so if the + // cell's searchPath config ever changed shape, this reds with the reason + // rather than letting both pins go vacuous. + const scope: any = await driver.execute(`select current_schemas(false) as path`); + const path: string[] = scope.rows[0].path; + expect(path, 'the file schema must be the session scope').toContain(here); + expect(path, `the far schema must NOT be on the session scope`).not.toContain(far); + }); + + it('qualifies the cross-schema key with referencedSchema and keeps referencedTable bare', async () => { + const foreignKeys = await driver.foreignKeys(CROSS_CHILD); + + // `toStrictEqual`, deliberately: the in-path record must not carry the + // key AT ALL — `toEqual` would accept `referencedSchema: undefined`. + expect( + foreignKeys, + `${cell.label}: the ${far} parent must be qualified by referencedSchema and the ` + + `in-path parent must stay byte-identical to the pre-#11377 shape`, + ).toStrictEqual([ + { + columnName: 'p', + referencedTable: REMOTE_PARENT, + referencedColumn: 'id', + constraintName: FK_CROSS, + referencedSchema: far, + }, + { + columnName: 'q', + referencedTable: LOCAL_PARENT, + referencedColumn: 'id', + constraintName: FK_LOCAL, + }, + ]); + + // The ruling's "bare name ALWAYS", named: qualification is the separate + // key, never a spelling change of referencedTable. + const cross = foreignKeys[0]!; + expect(cross.referencedTable).toBe(REMOTE_PARENT); + expect(cross.referencedTable).not.toContain('.'); + + // Key ABSENCE on the in-path record, asserted on its own so a future + // matcher swap cannot silently weaken it. + const inPath = foreignKeys[1]!; + expect(Object.keys(inPath)).not.toContain('referencedSchema'); + }); + + it('carries the qualification through `introspectSchema`, the in-tree consumer seam', async () => { + const schema = await driver.introspectSchema(); + + expect(Object.keys(schema.tables)).toContain(CROSS_CHILD); + const keys = schema.tables[CROSS_CHILD].foreignKeys; + expect(keys.find((k) => k.columnName === 'p')?.referencedSchema).toBe(far); + const local = keys.find((k) => k.columnName === 'q')!; + expect(Object.keys(local)).not.toContain('referencedSchema'); + }); + }); +} + +declareDialectCell(PG_CELL, MATRIX, declarePgSuite); + +// ── MySQL: the symmetric fact, on a live server ────────────────────────────── + +function declareMysqlSuite(cell: DialectCell): void { + describe(`introspectForeignKeys cross-schema qualification — ${cell.label} (#11377)`, () => { + let driver: ForeignKeyProbeDriver; + /** This file's own database (#9350) — the session's `DATABASE()`. */ + let here: string; + /** Where the cross-database parent lives. Created by this file, dropped by it. */ + let far: string; + + beforeAll(async () => { + driver = new ForeignKeyProbeDriver(cell.config()); + here = currentLiveSchema(); + far = `${here}_far`; + // MySQL's identifier limit is 64; a silent fold-back is not the failure + // shape there (CREATE DATABASE errors instead), but the guard keeps the + // fixture honest for the same reason as the PG suite's. + expect(far.length).toBeLessThanOrEqual(64); + + await driver.execute(`drop database if exists ${far}`); + await driver.execute(`create database ${far}`); + + await driver.execute(`drop table if exists ${CROSS_CHILD}`); + await driver.execute(`drop table if exists ${LOCAL_PARENT}`); + await driver.execute( + `create table ${far}.${REMOTE_PARENT} (id varchar(64) primary key) engine=InnoDB`, + ); + await driver.execute(`create table ${LOCAL_PARENT} (id varchar(64) primary key) engine=InnoDB`); + await driver.execute( + `create table ${CROSS_CHILD} ( + id varchar(64) primary key, + p varchar(64), + q varchar(64), + constraint ${FK_CROSS} foreign key (p) references ${far}.${REMOTE_PARENT} (id), + constraint ${FK_LOCAL} foreign key (q) references ${LOCAL_PARENT} (id) + ) engine=InnoDB`, + ); + }); + + afterAll(async () => { + await driver.execute(`drop table if exists ${CROSS_CHILD}`).catch(() => {}); + await driver.execute(`drop table if exists ${LOCAL_PARENT}`).catch(() => {}); + await driver.execute(`drop database if exists ${far}`).catch(() => {}); + await driver.disconnect().catch(() => {}); + }); + + it('control: the parent really is in another database, and DATABASE() is this file\'s own', async () => { + const placed: any = await driver.execute( + `select TABLE_SCHEMA as s, TABLE_NAME as t + from information_schema.TABLES + where TABLE_NAME in (?, ?, ?) + order by TABLE_NAME`, + [CROSS_CHILD, REMOTE_PARENT, LOCAL_PARENT], + ); + expect( + placed[0].map((r: any) => `${r.s}.${r.t}`), + `the fixture collapsed into one database — presence/absence of referencedSchema would ` + + `then be measuring nothing`, + ).toEqual([`${here}.${CROSS_CHILD}`, `${here}.${LOCAL_PARENT}`, `${far}.${REMOTE_PARENT}`]); + + const db: any = await driver.execute(`select DATABASE() as d`); + expect(db[0][0].d, 'the session scope must be the file database').toBe(here); + }); + + it('qualifies the cross-database key with referencedSchema and keeps referencedTable bare', async () => { + const foreignKeys = await driver.foreignKeys(CROSS_CHILD); + + const byColumn = Object.fromEntries(foreignKeys.map((k) => [k.columnName, k])); + expect(Object.keys(byColumn).sort()).toEqual(['p', 'q']); + + expect(byColumn.p).toStrictEqual({ + columnName: 'p', + referencedTable: REMOTE_PARENT, + referencedColumn: 'id', + constraintName: FK_CROSS, + referencedSchema: far, + }); + expect(byColumn.p!.referencedTable).not.toContain('.'); + + expect(byColumn.q).toStrictEqual({ + columnName: 'q', + referencedTable: LOCAL_PARENT, + referencedColumn: 'id', + constraintName: FK_LOCAL, + }); + expect(Object.keys(byColumn.q!)).not.toContain('referencedSchema'); + }); + }); +} + +declareDialectCell(MYSQL_CELL, MATRIX, declareMysqlSuite); + +// ── MySQL: the mapping, with no server (the #11379 emission-probe pattern) ─── + +/** One row of `KEY_COLUMN_USAGE` as the MySQL arm's projection aliases it. */ +interface FkRow { + column_name: string; + referenced_table: string; + referenced_column: string; + constraint_name: string; + referenced_schema: string | null; +} + +/** + * A driver that DECLARES MySQL and answers from a canned result set — the + * documented `MysqlFkEmissionProbe` shape from the #11379 pin, reused so the + * projection and the TS mapping stay pinned in the CI jobs that provision no + * live MySQL. See that file for why re-declaring the client is the real + * dispatch path and not a hole. + */ +class MysqlFkQualificationProbe extends SqlDriver { + readonly emitted: { sql: string; bindings: unknown }[] = []; + + constructor(private readonly rows: FkRow[]) { + super({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + + (this.config as { client?: string }).client = 'mysql2'; + + const knex = this.knex as unknown as Record; + Object.defineProperty(knex, 'raw', { + configurable: true, + value: (sql: unknown, bindings: unknown) => { + this.emitted.push({ sql: String(sql), bindings }); + return [this.rows, []]; + }, + }); + } + + foreignKeys(table: string) { + return this.introspectForeignKeys(table); + } + + soleStatement(): string { + expect( + this.emitted.length, + 'introspectForeignKeys emitted no statement, or more than one — the ' + + 'capture below would be measuring nothing. Did the dialect dispatch ' + + 'stop reaching the MySQL arm?', + ).toBe(1); + return this.emitted[0]!.sql; + } +} + +describe('introspectForeignKeys (MySQL) projects and maps the qualification (#11377)', () => { + let probe: MysqlFkQualificationProbe | undefined; + + afterEach(async () => { + await (probe as unknown as { knex?: { destroy(): Promise } } | undefined)?.knex?.destroy(); + probe = undefined; + }); + + it('emits the null-safe REFERENCED_TABLE_SCHEMA vs DATABASE() projection', async () => { + probe = new MysqlFkQualificationProbe([]); + await probe.foreignKeys('cross_child'); + + const sql = probe.soleStatement(); + + // Control first: the captured statement really is this method's + // foreign-key read (the #11379 file says why a file-level grep cannot be + // trusted here — the literal appears in sibling reads too). + expect(sql).toMatch(/information_schema\.KEY_COLUMN_USAGE/i); + expect(sql).toMatch(/REFERENCED_TABLE_NAME IS NOT NULL/i); + + // The pin: the projection carries the qualification, null-safely — with + // no default database, NO bare name resolves, so every parent qualifies. + expect(sql).toMatch(/REFERENCED_TABLE_SCHEMA\s*<=>\s*DATABASE\(\)/i); + expect(sql).toMatch(/referenced_schema/); + }); + + it('maps a qualified row to referencedSchema and an in-database row to key ABSENCE', async () => { + probe = new MysqlFkQualificationProbe([ + { + column_name: 'p', + referenced_table: 'remote_parent', + referenced_column: 'id', + constraint_name: 'fk_cross', + referenced_schema: 'far_db', + }, + { + column_name: 'q', + referenced_table: 'local_parent', + referenced_column: 'id', + constraint_name: 'fk_local', + referenced_schema: null, + }, + ]); + const keys = await probe.foreignKeys('cross_child'); + + expect(keys).toStrictEqual([ + { + columnName: 'p', + referencedTable: 'remote_parent', + referencedColumn: 'id', + constraintName: 'fk_cross', + referencedSchema: 'far_db', + }, + { + columnName: 'q', + referencedTable: 'local_parent', + referencedColumn: 'id', + constraintName: 'fk_local', + }, + ]); + expect(Object.keys(keys[1]!)).not.toContain('referencedSchema'); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 3bfe91a94d..c9e3c7801d 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -3746,9 +3746,48 @@ export interface IntrospectedColumn extends SpecIntrospectedColumn { */ export interface IntrospectedForeignKey { columnName: string; + /** + * The parent's BARE relation name, always — never conditionally qualified + * (#11377). When the parent lives outside the session's resolution scope, + * the qualification arrives as the separate {@link referencedSchema} key + * rather than as a `"schema.table"` spelling here: a key whose spelling + * depends on context is exactly the shape a consumer mis-parses. + */ referencedTable: string; referencedColumn: string; constraintName?: string; + /** + * The parent table's schema, PRESENT WHEN AND ONLY WHEN the parent lives + * outside the session's own resolution scope — i.e. when the bare + * {@link referencedTable} name is NOT the name this session would resolve + * to that table (#11377). For an in-scope parent the key is ABSENT (never + * `undefined`), so an answer that carries it is saying something. + * + * Until #11324 a cross-schema foreign key contributed zero rows on + * Postgres, so this fact was unreachable; repairing that made the + * constraint visible under a bare name the session's `search_path` does + * not resolve — an answer true of the constraint and unusable as an + * address, or worse, one that collides with a same-named table in the + * current schema (the #11201 family). A consumer that turns + * `referencedTable` into an object reference MUST read this key and refuse + * to wire the bare name when it is present + * (`convertIntrospectedSchemaToObjects` in `@objectstack/objectql` is the + * reference consumer). + * + * Per dialect arm: + * - **Postgres**: the parent's schema, filled when that schema is not in + * `current_schemas(false)` — the same `search_path` scoping every other + * statement in the session resolves bare names against (#11201). + * - **MySQL**: filled symmetrically — the parent's database + * (`REFERENCED_TABLE_SCHEMA`) when it differs from the session's default + * database (`DATABASE()`); InnoDB permits cross-database foreign keys, + * and `KEY_COLUMN_USAGE` carries the parent's database on the same row + * the arm already reads. + * - **SQLite**: never present — SQLite has no schemas, and a foreign key + * cannot cross an ATTACHed database at all, so the fact this key carries + * cannot exist there. + */ + referencedSchema?: string; } /** @@ -14514,17 +14553,33 @@ export class SqlDriver implements IDataDriver { // (measured), i.e. this rewrite deliberately does NOT adopt the // `?::regclass` failure mode `introspectPrimaryKeys` has; the #7332 // `onFailure` contract below is unchanged by it. + // `referenced_schema` (#11377): the PARENT's schema when — and only + // when — it is not on the session's `search_path`, NULL otherwise. + // The scoping predicate on the CHILD (`ns.nspname = ANY (…)`) is + // #11201's and is untouched; this CASE asks the same question about + // the parent's namespace, so "in path" here means exactly what bare- + // name resolution means to every other statement in the session. + // Measured on live PostgreSQL 16.13 (the card's fixture shape): a + // parent in `os11377_far` answers `referenced_schema = os11377_far` + // with `referenced_table` still the bare name; an in-path parent + // answers NULL. The bare spelling of `referenced_table` is + // deliberate and unconditional — see `IntrospectedForeignKey`. const result = await this.knex.raw( ` SELECT att.attname AS column_name, parent.relname AS referenced_table, patt.attname AS referenced_column, - con.conname AS constraint_name + con.conname AS constraint_name, + CASE + WHEN pns.nspname = ANY (current_schemas(false)) THEN NULL + ELSE pns.nspname + END AS referenced_schema FROM pg_constraint con JOIN pg_class child ON child.oid = con.conrelid JOIN pg_namespace ns ON ns.oid = child.relnamespace JOIN pg_class parent ON parent.oid = con.confrelid + JOIN pg_namespace pns ON pns.oid = parent.relnamespace CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(attnum, fattnum, ord) JOIN pg_attribute att @@ -14547,6 +14602,14 @@ export class SqlDriver implements IDataDriver { referencedTable: row.referenced_table, referencedColumn: row.referenced_column, constraintName: row.constraint_name, + // The key is ABSENT for an in-path parent, not `undefined`: an + // explicit `referencedSchema: undefined` still spells the key + // into `Object.keys`, JSON round-trips differently, and reads as + // "someone considered this" — the contract is that presence + // itself carries the fact (#11377). + ...(row.referenced_schema != null + ? { referencedSchema: row.referenced_schema } + : {}), }); } } else if (this.isMysql) { @@ -14577,13 +14640,29 @@ export class SqlDriver implements IDataDriver { // `sql-driver-11379-introspect-fk-mysql-ordinal-order.test.ts`, which is // deliberately a pin on the emitted SQL rather than on the row order, // because a row-order assertion passes here with or without this line. + // `referenced_schema` (#11377), symmetric with the Postgres arm: the + // PARENT's database when it differs from the session's default + // database, NULL otherwise. MySQL's resolution scope for a bare name + // is `DATABASE()` (there is no search path to be partially on), and + // `KEY_COLUMN_USAGE` carries the parent's database as + // `REFERENCED_TABLE_SCHEMA` on the same row this arm already reads — + // the child-side filter `TABLE_SCHEMA = DATABASE()` never constrained + // the referenced side, so a cross-database InnoDB foreign key was + // already returned here, under a bare name `DATABASE()` does not + // resolve. `<=>` is the null-safe comparison: with no default + // database selected, NO bare name resolves, so every parent is + // out-of-scope and qualified. const result = await this.knex.raw( ` SELECT COLUMN_NAME as column_name, REFERENCED_TABLE_NAME as referenced_table, REFERENCED_COLUMN_NAME as referenced_column, - CONSTRAINT_NAME as constraint_name + CONSTRAINT_NAME as constraint_name, + CASE + WHEN REFERENCED_TABLE_SCHEMA <=> DATABASE() THEN NULL + ELSE REFERENCED_TABLE_SCHEMA + END as referenced_schema FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? @@ -14599,6 +14678,11 @@ export class SqlDriver implements IDataDriver { referencedTable: row.referenced_table, referencedColumn: row.referenced_column, constraintName: row.constraint_name, + // Absent, not `undefined`, for an in-database parent — same + // presence-is-the-fact contract as the Postgres arm (#11377). + ...(row.referenced_schema != null + ? { referencedSchema: row.referenced_schema } + : {}), }); } } else if (this.isSqlite) { From 14882bba0d7df65f37c2dfa694ea048a57aae9b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:46:55 +0000 Subject: [PATCH 2/3] feat(objectql): refuse to wire a lookup to a foreign key carrying referencedSchema (#11377) convertIntrospectedSchemaToObjects reads the driver's new qualification: a foreign key whose target carries referencedSchema is loudly skipped and flagged through options.logger (default console) - never wired to the bare name - and the column converts as a plain field so the data stays visible. Resolvable foreign keys keep wiring byte-identically. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- packages/objectql/src/util.test.ts | 118 ++++++++++++++++++++++++++++- packages/objectql/src/util.ts | 71 ++++++++++++++++- 2 files changed, 186 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/util.test.ts b/packages/objectql/src/util.test.ts index d37b7bb997..1e10e705af 100644 --- a/packages/objectql/src/util.test.ts +++ b/packages/objectql/src/util.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { toTitleCase, convertIntrospectedSchemaToObjects, @@ -215,6 +215,122 @@ describe('convertIntrospectedSchemaToObjects', () => { expect(metrics.fields.quantity.type).toBe('number'); }); + describe('a foreign key whose target carries referencedSchema is refused, loudly (#11377)', () => { + /** + * The card's measured shape: `cross_child.p` references + * `os11377_far.remote_parent`, a table outside the introspecting + * session's resolution scope, so the driver's answer carries + * `referencedSchema` beside the BARE `referencedTable`. `q` is the + * in-scope control — no `referencedSchema`, wired exactly as before. + */ + const crossSchema: IntrospectedSchema = { + dialect: 'postgres', + introspectedAt: '2026-08-24T00:00:00.000Z', + tables: { + cross_child: { + name: 'cross_child', + columns: [ + { name: 'id', type: 'varchar', nullable: false, primaryKey: true }, + { name: 'p', type: 'varchar', nullable: true, primaryKey: false, maxLength: 64 }, + { name: 'q', type: 'varchar', nullable: false, primaryKey: false }, + ], + foreignKeys: [ + { + columnName: 'p', + referencedTable: 'remote_parent', + referencedColumn: 'id', + constraintName: 'fk_cross', + referencedSchema: 'os11377_far', + }, + { + columnName: 'q', + referencedTable: 'local_parent', + referencedColumn: 'id', + constraintName: 'fk_local', + }, + ], + primaryKeys: ['id'], + }, + }, + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates NO lookup field — the column converts as a plain field instead', () => { + const logger = { warn: vi.fn() }; + const objects = convertIntrospectedSchemaToObjects(crossSchema, { + skipSystemColumns: false, + logger, + }); + + // The whole field, strictly: not a lookup, no `reference` key at all — + // a lookup wired to the bare name is exactly the #11201 wrong-object + // collision this refusal exists to prevent. The plain path still reads + // the column's own facts (`maxLength`, nullability). + expect(objects[0].fields.p).toStrictEqual({ + name: 'p', + type: 'text', + label: 'P', + required: false, + maxLength: 64, + }); + expect(Object.keys(objects[0].fields.p)).not.toContain('reference'); + }); + + it('flags the refusal through options.logger, naming the constraint, the address and the remedy', () => { + const logger = { warn: vi.fn() }; + convertIntrospectedSchemaToObjects(crossSchema, { skipSystemColumns: false, logger }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + const [message, meta] = logger.warn.mock.calls[0]!; + // The pinned message: what was refused, why, and what to do. + expect(message).toContain('[convert-introspected-schema]'); + expect(message).toContain('foreign key fk_cross on cross_child.p'); + expect(message).toContain('references os11377_far.remote_parent'); + expect(message).toContain("OUTSIDE the introspecting session's resolution scope"); + expect(message).toContain('NO lookup field was created'); + expect(message).toContain('converted as a plain field'); + expect(message).toContain("Re-introspect with the parent's schema on the session's search path"); + expect(meta).toStrictEqual({ + table: 'cross_child', + column: 'p', + constraint: 'fk_cross', + referencedSchema: 'os11377_far', + referencedTable: 'remote_parent', + }); + }); + + it('is loud with NO logger passed — the flag defaults to console.warn', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + convertIntrospectedSchemaToObjects(crossSchema, { skipSystemColumns: false }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(String(spy.mock.calls[0]![0])).toContain('[convert-introspected-schema]'); + }); + + it('keeps wiring a resolvable foreign key byte-identically, with no flag', () => { + const logger = { warn: vi.fn() }; + const objects = convertIntrospectedSchemaToObjects(crossSchema, { + skipSystemColumns: false, + logger, + }); + + // The in-scope control: the pre-#11377 lookup shape, the whole object. + expect(objects[0].fields.q).toStrictEqual({ + name: 'q', + type: 'lookup', + reference: 'local_parent', + label: 'Q', + required: true, + }); + // ONE warn — the cross-schema refusal above, nothing about `q`. + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(String(logger.warn.mock.calls[0]![0])).not.toContain('fk_local'); + }); + }); + it('should handle time type', () => { const schema: IntrospectedSchema = { dialect: 'sqlite', diff --git a/packages/objectql/src/util.ts b/packages/objectql/src/util.ts index 1d6af0a026..b769d25d23 100644 --- a/packages/objectql/src/util.ts +++ b/packages/objectql/src/util.ts @@ -57,12 +57,33 @@ export interface IntrospectedColumn extends SpecIntrospectedColumn { export interface IntrospectedForeignKey { /** Column name in the source table */ columnName: string; - /** Referenced table name */ + /** + * Referenced table name — always the BARE name, never schema-qualified + * (#11377). When the parent lives outside the introspecting session's + * resolution scope, the qualification arrives as {@link referencedSchema}. + */ referencedTable: string; /** Referenced column name */ referencedColumn: string; /** Constraint name */ constraintName?: string; + /** + * The parent table's schema, present when — and only when — the parent + * lives OUTSIDE the introspecting session's resolution scope, i.e. when the + * bare {@link referencedTable} name is NOT what that session would resolve + * to the parent (#11377). Absent (never `undefined`) for an in-scope + * parent. The producer's declaration — `SqlDriver`'s + * `IntrospectedForeignKey.referencedSchema` in `@objectstack/driver-sql` — + * is the contract sentence; this is the consumer-side copy of the same key + * and must not drift from it. + * + * A consumer that turns `referencedTable` into an object reference must + * read this key: a bare name the session cannot resolve either points at + * nothing or — the #11201 collision family — at a same-named table in the + * current schema. {@link convertIntrospectedSchemaToObjects} refuses to + * wire a lookup for such a key and says so loudly. + */ + referencedSchema?: string; } /** @@ -169,6 +190,19 @@ function mapDatabaseTypeToFieldType( * * This allows using existing database tables without manually defining metadata. * + * ## A foreign key whose target carries `referencedSchema` is NOT wired (#11377) + * + * `referencedSchema` present means the parent lives outside the introspecting + * session's resolution scope, so the bare `referencedTable` name is not an + * address: as an object `reference` it either points at nothing or — the + * #11201 collision family — at a same-named table in the current schema, + * silently. Maintainer ruling (2026-08-24, on the card): such a key is LOUDLY + * skipped and flagged, never wired to the bare name. The column itself is + * kept — converted as a plain field from its database type, exactly as a + * column with no foreign key — so the data stays visible while the false + * address does not ship. The flag goes through `options.logger` (defaults to + * `console`, so a bare call is loud by default). + * * @param introspectedSchema - The schema returned from driver.introspectSchema() * @param options - Optional filtering / conversion settings * @returns Array of ServiceObject definitions that can be registered with ObjectQL @@ -191,12 +225,20 @@ export function convertIntrospectedSchemaToObjects( includeTables?: string[]; /** Whether to skip system columns like id, created_at, updated_at (default: true) */ skipSystemColumns?: boolean; + /** + * Where the unresolvable-foreign-key flag is delivered (#11377) — the + * minimal logger surface, matching `PluginContext.logger`. Defaults to + * `console`: the flag exists to be seen, so a caller that passes nothing + * still gets it loudly. + */ + logger?: { warn(message: string, meta?: Record): void }; } ): ServiceObject[] { const objects: ServiceObject[] = []; const excludeTables = options?.excludeTables || []; const includeTables = options?.includeTables; const skipSystemColumns = options?.skipSystemColumns !== false; + const logger = options?.logger ?? console; for (const [tableName, table] of Object.entries(introspectedSchema.tables)) { if (excludeTables.includes(tableName)) continue; @@ -213,7 +255,32 @@ export function convertIntrospectedSchemaToObjects( // Check for foreign key → lookup field const foreignKey = table.foreignKeys.find((fk) => fk.columnName === column.name); - if (foreignKey) { + if (foreignKey && foreignKey.referencedSchema !== undefined) { + // #11377: the parent lives outside the introspecting session's + // resolution scope — the bare name is not an address (nothing, or the + // #11201 wrong-object collision). Refuse the wiring loudly; the + // column falls through to the plain-field path below, so the data + // stays visible while the false reference does not ship. + logger.warn( + `[convert-introspected-schema] foreign key ${foreignKey.constraintName ?? '(unnamed)'} ` + + `on ${tableName}.${column.name} references ` + + `${foreignKey.referencedSchema}.${foreignKey.referencedTable}, a table OUTSIDE the ` + + `introspecting session's resolution scope — the bare name ` + + `"${foreignKey.referencedTable}" cannot be trusted to resolve to it, so NO lookup ` + + `field was created for this column; it is converted as a plain field instead. ` + + `Re-introspect with the parent's schema on the session's search path to wire this ` + + `lookup.`, + { + table: tableName, + column: column.name, + constraint: foreignKey.constraintName, + referencedSchema: foreignKey.referencedSchema, + referencedTable: foreignKey.referencedTable, + }, + ); + } + + if (foreignKey && foreignKey.referencedSchema === undefined) { fields[column.name] = { name: column.name, type: 'lookup' as const, From d67d93793e27c24d90698e309d2e2535844b0e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 23:47:52 +0000 Subject: [PATCH 3/3] test(driver-sql): expect the #11377 qualification in #11324's cross-schema pins; add changeset The #11324 fixture IS the cross-schema shape #11377 qualifies, so its two cross-schema assertions now carry referencedSchema; presence/absence semantics stay pinned in the #11377 file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- .changeset/cross-schema-fk-qualification.md | 20 +++++++++++++++++++ ...24-introspect-fk-join-correlations.test.ts | 8 ++++++++ 2 files changed, 28 insertions(+) create mode 100644 .changeset/cross-schema-fk-qualification.md diff --git a/.changeset/cross-schema-fk-qualification.md b/.changeset/cross-schema-fk-qualification.md new file mode 100644 index 0000000000..da49b7e68a --- /dev/null +++ b/.changeset/cross-schema-fk-qualification.md @@ -0,0 +1,20 @@ +--- +'@objectstack/driver-sql': minor +'@objectstack/objectql': minor +--- + +Cross-schema foreign keys are now qualified instead of shipping an unusable bare name (#11377). + +`IntrospectedForeignKey` (driver-sql) gains an optional `referencedSchema`, present when — and +only when — the referenced parent table lives outside the introspecting session's resolution +scope (Postgres: the parent's schema is not on `current_schemas(false)`; MySQL: the parent's +database differs from `DATABASE()`; SQLite never sets it — no schemas, and a foreign key cannot +cross an ATTACHed database). `referencedTable` stays a bare name always — the qualification is a +separate key, never a conditional spelling. + +`convertIntrospectedSchemaToObjects` (objectql) reads the new key: a foreign key whose target +carries `referencedSchema` is loudly skipped and flagged through the new `options.logger` +(default `console`) instead of being wired to the bare name — which either resolved to nothing +or to a same-named table in the current schema, silently. The column is kept as a plain field so +its data stays visible. Foreign keys with in-scope targets keep producing identical lookup +fields. diff --git a/packages/drivers/driver-sql/src/sql-driver-11324-introspect-fk-join-correlations.test.ts b/packages/drivers/driver-sql/src/sql-driver-11324-introspect-fk-join-correlations.test.ts index 87f89b096f..b901ffbf75 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11324-introspect-fk-join-correlations.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11324-introspect-fk-join-correlations.test.ts @@ -229,6 +229,11 @@ function declareJoinCorrelationSuite(cell: DialectCell): void { it('returns a foreign key whose target lives in ANOTHER schema', async () => { const foreignKeys = await driver.foreignKeys(CROSS_CHILD); + // `referencedSchema` is #11377's half of this answer: the parent is off + // the session's `search_path`, so the (still bare) name arrives + // qualified. Presence/absence semantics and their own controls are + // pinned in `sql-driver-11377-introspect-fk-cross-schema-qualification`; + // this file keeps owning the #11324 fact — the key is RETURNED at all. expect( foreignKeys, `${cell.label}: ${CROSS_CHILD} has a declared foreign key into ${far} and must not be ` + @@ -239,6 +244,7 @@ function declareJoinCorrelationSuite(cell: DialectCell): void { referencedTable: REMOTE_PARENT, referencedColumn: 'id', constraintName: FK_CROSS, + referencedSchema: far, }, ]); }); @@ -256,6 +262,8 @@ function declareJoinCorrelationSuite(cell: DialectCell): void { referencedTable: REMOTE_PARENT, referencedColumn: 'id', constraintName: FK_CROSS, + // #11377: the off-path parent arrives qualified — see above. + referencedSchema: far, }, ]); });