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
19 changes: 19 additions & 0 deletions .changeset/24707-pg-multischema-introspect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/driver-sql': minor
---

fix(driver-sql)!: on PostgreSQL, `introspectSchema` reads each table's columns in the table's own schema, and refuses a `search_path` that reaches one table name in two schemas instead of silently dropping one

`introspectSchema` lists the base tables of every schema in `current_schemas(false)` and keys the answer by bare table name. Two things went wrong when a connection's `search_path` named more than one schema (`ALTER DATABASE … SET search_path = public, sales`). First, each table's columns came from knex `columnInfo()` on the bare name, which reads only the first schema on the path, so a table that lives only in a later schema (`sales.orders`) was listed with no columns. `listRemoteTables` answered it `columnCount: 0`, and a draft built from it had no fields. Second, a same-named table in a later schema (`sales.customers` behind `public.customers`) was dropped without a word, because one key cannot hold two tables. The PostgreSQL branch now reads every table's columns in its own schema. When one bare name is reached in more than one schema, it refuses the whole introspection with `RESOURCE_CONFLICT` / `409`, naming each colliding table with its schemas in `search_path` order. A connection that reaches one schema, the default `public` deployment included, answers exactly what it answered before. The SQLite and MySQL branches are unchanged.

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the SQL driver's PostgreSQL introspection gives for a connection whose search_path reaches more than one schema: a table in a later schema now carries its own columns, and a table name reached in two schemas is refused rather than answered with one of the two tables. The remedy is connection configuration (one schema per connection) or a rename on the remote database, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** (introspection's accept set narrows on PostgreSQL), shipped as `minor` under the launch-window convention for breaking changes.

- **FROM:** a PostgreSQL connection whose `search_path` reached a table name in two schemas got a successful `introspectSchema()` answer holding one of the two tables (the one in the earliest schema on the path), and so did every caller built on it: `listRemoteTables`, object drafting, catalog refresh and the external datasource's connection test.
- **TO:** the same call throws `RESOURCE_CONFLICT` (`status: 409`), naming each colliding table and its schemas, for example `'customers' ('public', 'sales')`. Those callers surface that error.
- **Also changed (a wrong answer made right, no accept set moved):** a table that lives only in a later schema on the path now reports its own columns where it reported none.
- **Unchanged:** a connection that reaches one schema, the default `public` deployment included. One schema cannot hold two same-named tables, so it is never refused, and its answer is byte-identical to before.
- **Fix:** point the connection at one schema (the datasource's `schema` setting sets its `search_path`), or rename one of the two tables on the remote database.
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24707] `introspectSchema` on a PostgreSQL session whose `search_path`
* names MORE THAN ONE schema.
*
* The table listing spans every `current_schemas(false)` schema, but the
* answer is keyed by BARE name, and each table's columns were read through
* knex `columnInfo()` on that bare name — which scopes to `current_schema()`,
* the FIRST schema on the path only. Two wrong answers followed, both measured
* on PostgreSQL 16 with `search_path = public, sales`:
*
* 1. a table living only in the later schema (`sales.orders`) was listed with
* `columns: []` — `listRemoteTables` answered it `columnCount: 0`, and a
* draft built from it would carry no fields;
* 2. a same-named table in the later schema (`sales.customers` behind
* `public.customers`) was silently dropped: one key, two tables.
*
* The fix reads each table's columns in its own schema, and refuses the
* listing — `RESOURCE_CONFLICT` / 409, naming the table and both schemas —
* when one bare key would have to hold two tables. The third case is the
* control: a session that reaches ONE schema answers exactly what the
* bare-name read always answered.
*
* ## Why this file is PG-only
*
* SQLite has no schemas and the MySQL arm lists `DATABASE()` alone, so neither
* can put two schemas on one session's resolution path. The cell list is
* exactly `pg`, declared through `declareDialectCell` so an unprovisioned run
* is a NAMED skip, never a silent pass.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqlDriver } from '../src/index.js';
import {
PG_CELL,
currentLiveSchema,
declareDialectCell,
type DialectCell,
} from './live-dialect-matrix.testkit.js';

const MATRIX = 'introspectSchema multi-schema search_path';

/** Lives in this file's own schema — the FIRST on the path. */
const CUSTOMERS = 'os24707_customers';
/** Lives only in the SECOND schema on the path. */
const ORDERS = 'os24707_orders';

/** `introspectColumns` is `protected`; this is the narrowest way to reach it. */
class ColumnProbeDriver extends SqlDriver {
columns(table: string, schema?: string) {
return this.introspectColumns(table, schema);
}
}

function declareMultiSchemaSuite(cell: DialectCell): void {
describe(`introspectSchema multi-schema search_path — ${cell.label} (#24707)`, () => {
/** Session whose `search_path` is `here, there`. */
let driver: ColumnProbeDriver;
/** Session whose `search_path` is `here` alone — the control. */
let single: ColumnProbeDriver;
let here: string;
let there: string;

beforeAll(async () => {
here = currentLiveSchema();
there = `${here}_b`;
// Postgres TRUNCATES an over-long identifier silently, which would fold
// the second schema back onto this file's own.
expect(there.length, `${there} exceeds Postgres' 63-byte identifier limit`).toBeLessThanOrEqual(63);

single = new ColumnProbeDriver(cell.config());
driver = new ColumnProbeDriver({ ...cell.config(), searchPath: [here, there] });

await single.execute(`drop schema if exists "${there}" cascade`);
await single.execute(`create schema "${there}"`);
await single.execute(`drop table if exists "${here}".${CUSTOMERS} cascade`);
await single.execute(
`create table "${here}".${CUSTOMERS} (id serial primary key, name text not null, email varchar(120) unique)`,
);
await single.execute(
`create table "${there}".${ORDERS} (id serial primary key, customer_id integer, total numeric(10, 2), placed_at timestamptz)`,
);
});

afterAll(async () => {
await single.execute(`drop schema if exists "${there}" cascade`).catch(() => {});
await single.execute(`drop table if exists "${here}".${CUSTOMERS} cascade`).catch(() => {});
await driver.disconnect().catch(() => {});
await single.disconnect().catch(() => {});
});

it('the fixture really reaches two schemas, and a bare-name read sees only the first', async () => {
// Non-vacuity: the session's path is both schemas, in this order…
const path: any = await driver.execute(
`select s::text as s from unnest(current_schemas(false)) with ordinality as t(s, ord) order by ord`,
);
expect(path.rows.map((r: any) => r.s)).toEqual([here, there]);
// …and the pre-fix read — `columnInfo()` on the bare name — answers
// nothing for the later schema's table. If this ever reads columns, the
// next case is measuring nothing.
expect(Object.keys(await driver.getKnex()(ORDERS).columnInfo())).toEqual([]);
});

it("reads a later schema's table in its own schema, not as zero columns", async () => {
const schema = await driver.introspectSchema();

expect(Object.keys(schema.tables).sort()).toEqual([CUSTOMERS, ORDERS]);
expect(schema.tables[ORDERS].columns.map((c) => [c.name, c.type, c.primaryKey])).toEqual([
['id', 'integer', true],
['customer_id', 'integer', false],
['total', 'numeric', false],
['placed_at', 'timestamp with time zone', false],
]);
expect(schema.tables[ORDERS].primaryKeys).toEqual(['id']);
// The first schema's table is unaffected by the second being on the path.
expect(schema.tables[CUSTOMERS].columns.map((c) => c.name)).toEqual(['id', 'name', 'email']);
});

it('refuses a same-named pair across schemas instead of dropping one', async () => {
await single.execute(`create table "${there}".${CUSTOMERS} (code varchar(20) primary key, region text)`);
try {
let thrown: any;
await driver.introspectSchema().catch((e) => {
thrown = e;
});
expect(thrown, 'a same-named pair must not be answered with one of the two tables').toBeDefined();
expect({ code: thrown.code, status: thrown.status }).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 });
// The named subjects: the colliding name and both schemas, first on
// the path first — what the operator needs to act on.
expect(thrown.message).toContain(`'${CUSTOMERS}' ('${here}', '${there}')`);
} finally {
await single.execute(`drop table if exists "${there}".${CUSTOMERS} cascade`);
}
});

it('a session that reaches ONE schema answers exactly what the bare-name read does', async () => {
const schema = await single.introspectSchema();

// The second schema is not on this session's path, so it lists nothing.
expect(Object.keys(schema.tables)).toEqual([CUSTOMERS]);
// And the schema-scoped column read equals the bare-name one on the
// first schema — the single-schema answer the fix must not move.
// (`introspectSchema` then flips `primaryKey` / `isUnique` from the key
// reads, so the comparison is of the read itself.)
expect(await single.columns(CUSTOMERS, here)).toEqual(await single.columns(CUSTOMERS));
const unflagged = (cols: { primaryKey?: boolean; isUnique?: boolean }[]) =>
cols.map(({ primaryKey: _pk, isUnique: _uq, ...rest }) => rest);
expect(unflagged(schema.tables[CUSTOMERS].columns)).toEqual(unflagged(await single.columns(CUSTOMERS)));
expect(schema.tables[CUSTOMERS].columns.map((c) => [c.name, c.primaryKey, c.isUnique])).toEqual([
['id', true, false],
['name', false, false],
['email', false, true],
]);
});
});
}

declareDialectCell(PG_CELL, MATRIX, declareMultiSchemaSuite);
Loading
Loading