|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24707] `introspectSchema` on a PostgreSQL session whose `search_path` |
| 5 | + * names MORE THAN ONE schema. |
| 6 | + * |
| 7 | + * The table listing spans every `current_schemas(false)` schema, but the |
| 8 | + * answer is keyed by BARE name, and each table's columns were read through |
| 9 | + * knex `columnInfo()` on that bare name — which scopes to `current_schema()`, |
| 10 | + * the FIRST schema on the path only. Two wrong answers followed, both measured |
| 11 | + * on PostgreSQL 16 with `search_path = public, sales`: |
| 12 | + * |
| 13 | + * 1. a table living only in the later schema (`sales.orders`) was listed with |
| 14 | + * `columns: []` — `listRemoteTables` answered it `columnCount: 0`, and a |
| 15 | + * draft built from it would carry no fields; |
| 16 | + * 2. a same-named table in the later schema (`sales.customers` behind |
| 17 | + * `public.customers`) was silently dropped: one key, two tables. |
| 18 | + * |
| 19 | + * The fix reads each table's columns in its own schema, and refuses the |
| 20 | + * listing — `RESOURCE_CONFLICT` / 409, naming the table and both schemas — |
| 21 | + * when one bare key would have to hold two tables. The third case is the |
| 22 | + * control: a session that reaches ONE schema answers exactly what the |
| 23 | + * bare-name read always answered. |
| 24 | + * |
| 25 | + * ## Why this file is PG-only |
| 26 | + * |
| 27 | + * SQLite has no schemas and the MySQL arm lists `DATABASE()` alone, so neither |
| 28 | + * can put two schemas on one session's resolution path. The cell list is |
| 29 | + * exactly `pg`, declared through `declareDialectCell` so an unprovisioned run |
| 30 | + * is a NAMED skip, never a silent pass. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 34 | +import { SqlDriver } from '../src/index.js'; |
| 35 | +import { |
| 36 | + PG_CELL, |
| 37 | + currentLiveSchema, |
| 38 | + declareDialectCell, |
| 39 | + type DialectCell, |
| 40 | +} from './live-dialect-matrix.testkit.js'; |
| 41 | + |
| 42 | +const MATRIX = 'introspectSchema multi-schema search_path'; |
| 43 | + |
| 44 | +/** Lives in this file's own schema — the FIRST on the path. */ |
| 45 | +const CUSTOMERS = 'os24707_customers'; |
| 46 | +/** Lives only in the SECOND schema on the path. */ |
| 47 | +const ORDERS = 'os24707_orders'; |
| 48 | + |
| 49 | +/** `introspectColumns` is `protected`; this is the narrowest way to reach it. */ |
| 50 | +class ColumnProbeDriver extends SqlDriver { |
| 51 | + columns(table: string, schema?: string) { |
| 52 | + return this.introspectColumns(table, schema); |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +function declareMultiSchemaSuite(cell: DialectCell): void { |
| 57 | + describe(`introspectSchema multi-schema search_path — ${cell.label} (#24707)`, () => { |
| 58 | + /** Session whose `search_path` is `here, there`. */ |
| 59 | + let driver: ColumnProbeDriver; |
| 60 | + /** Session whose `search_path` is `here` alone — the control. */ |
| 61 | + let single: ColumnProbeDriver; |
| 62 | + let here: string; |
| 63 | + let there: string; |
| 64 | + |
| 65 | + beforeAll(async () => { |
| 66 | + here = currentLiveSchema(); |
| 67 | + there = `${here}_b`; |
| 68 | + // Postgres TRUNCATES an over-long identifier silently, which would fold |
| 69 | + // the second schema back onto this file's own. |
| 70 | + expect(there.length, `${there} exceeds Postgres' 63-byte identifier limit`).toBeLessThanOrEqual(63); |
| 71 | + |
| 72 | + single = new ColumnProbeDriver(cell.config()); |
| 73 | + driver = new ColumnProbeDriver({ ...cell.config(), searchPath: [here, there] }); |
| 74 | + |
| 75 | + await single.execute(`drop schema if exists "${there}" cascade`); |
| 76 | + await single.execute(`create schema "${there}"`); |
| 77 | + await single.execute(`drop table if exists "${here}".${CUSTOMERS} cascade`); |
| 78 | + await single.execute( |
| 79 | + `create table "${here}".${CUSTOMERS} (id serial primary key, name text not null, email varchar(120) unique)`, |
| 80 | + ); |
| 81 | + await single.execute( |
| 82 | + `create table "${there}".${ORDERS} (id serial primary key, customer_id integer, total numeric(10, 2), placed_at timestamptz)`, |
| 83 | + ); |
| 84 | + }); |
| 85 | + |
| 86 | + afterAll(async () => { |
| 87 | + await single.execute(`drop schema if exists "${there}" cascade`).catch(() => {}); |
| 88 | + await single.execute(`drop table if exists "${here}".${CUSTOMERS} cascade`).catch(() => {}); |
| 89 | + await driver.disconnect().catch(() => {}); |
| 90 | + await single.disconnect().catch(() => {}); |
| 91 | + }); |
| 92 | + |
| 93 | + it('the fixture really reaches two schemas, and a bare-name read sees only the first', async () => { |
| 94 | + // Non-vacuity: the session's path is both schemas, in this order… |
| 95 | + const path: any = await driver.execute( |
| 96 | + `select s::text as s from unnest(current_schemas(false)) with ordinality as t(s, ord) order by ord`, |
| 97 | + ); |
| 98 | + expect(path.rows.map((r: any) => r.s)).toEqual([here, there]); |
| 99 | + // …and the pre-fix read — `columnInfo()` on the bare name — answers |
| 100 | + // nothing for the later schema's table. If this ever reads columns, the |
| 101 | + // next case is measuring nothing. |
| 102 | + expect(Object.keys(await driver.getKnex()(ORDERS).columnInfo())).toEqual([]); |
| 103 | + }); |
| 104 | + |
| 105 | + it("reads a later schema's table in its own schema, not as zero columns", async () => { |
| 106 | + const schema = await driver.introspectSchema(); |
| 107 | + |
| 108 | + expect(Object.keys(schema.tables).sort()).toEqual([CUSTOMERS, ORDERS]); |
| 109 | + expect(schema.tables[ORDERS].columns.map((c) => [c.name, c.type, c.primaryKey])).toEqual([ |
| 110 | + ['id', 'integer', true], |
| 111 | + ['customer_id', 'integer', false], |
| 112 | + ['total', 'numeric', false], |
| 113 | + ['placed_at', 'timestamp with time zone', false], |
| 114 | + ]); |
| 115 | + expect(schema.tables[ORDERS].primaryKeys).toEqual(['id']); |
| 116 | + // The first schema's table is unaffected by the second being on the path. |
| 117 | + expect(schema.tables[CUSTOMERS].columns.map((c) => c.name)).toEqual(['id', 'name', 'email']); |
| 118 | + }); |
| 119 | + |
| 120 | + it('refuses a same-named pair across schemas instead of dropping one', async () => { |
| 121 | + await single.execute(`create table "${there}".${CUSTOMERS} (code varchar(20) primary key, region text)`); |
| 122 | + try { |
| 123 | + let thrown: any; |
| 124 | + await driver.introspectSchema().catch((e) => { |
| 125 | + thrown = e; |
| 126 | + }); |
| 127 | + expect(thrown, 'a same-named pair must not be answered with one of the two tables').toBeDefined(); |
| 128 | + expect({ code: thrown.code, status: thrown.status }).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 }); |
| 129 | + // The named subjects: the colliding name and both schemas, first on |
| 130 | + // the path first — what the operator needs to act on. |
| 131 | + expect(thrown.message).toContain(`'${CUSTOMERS}' ('${here}', '${there}')`); |
| 132 | + } finally { |
| 133 | + await single.execute(`drop table if exists "${there}".${CUSTOMERS} cascade`); |
| 134 | + } |
| 135 | + }); |
| 136 | + |
| 137 | + it('a session that reaches ONE schema answers exactly what the bare-name read does', async () => { |
| 138 | + const schema = await single.introspectSchema(); |
| 139 | + |
| 140 | + // The second schema is not on this session's path, so it lists nothing. |
| 141 | + expect(Object.keys(schema.tables)).toEqual([CUSTOMERS]); |
| 142 | + // And the schema-scoped column read equals the bare-name one on the |
| 143 | + // first schema — the single-schema answer the fix must not move. |
| 144 | + // (`introspectSchema` then flips `primaryKey` / `isUnique` from the key |
| 145 | + // reads, so the comparison is of the read itself.) |
| 146 | + expect(await single.columns(CUSTOMERS, here)).toEqual(await single.columns(CUSTOMERS)); |
| 147 | + const unflagged = (cols: { primaryKey?: boolean; isUnique?: boolean }[]) => |
| 148 | + cols.map(({ primaryKey: _pk, isUnique: _uq, ...rest }) => rest); |
| 149 | + expect(unflagged(schema.tables[CUSTOMERS].columns)).toEqual(unflagged(await single.columns(CUSTOMERS))); |
| 150 | + expect(schema.tables[CUSTOMERS].columns.map((c) => [c.name, c.primaryKey, c.isUnique])).toEqual([ |
| 151 | + ['id', true, false], |
| 152 | + ['name', false, false], |
| 153 | + ['email', false, true], |
| 154 | + ]); |
| 155 | + }); |
| 156 | + }); |
| 157 | +} |
| 158 | + |
| 159 | +declareDialectCell(PG_CELL, MATRIX, declareMultiSchemaSuite); |
0 commit comments