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
14 changes: 14 additions & 0 deletions .changeset/driver-sql-pg-introspection-search-path.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/driver-sql": patch
---

**Bug fix:** on Postgres, index and schema introspection now resolve tables the way the session does, instead of assuming the `public` schema (#9350).

`introspectIndexes` pinned `n.nspname = 'public'` and `introspectSchema` pinned `table_schema = 'public'`. For a driver whose connection carries a `searchPath` pointing anywhere else, both returned **empty** — not an error, an empty result. Measured on a live Postgres 16: for a table carrying a primary key *and* a declared unique index, `introspectIndexes` returned `[]` and `introspectSchema` listed no tables at all.

Empty does not read as "I could not see" downstream; it reads as "there are no indexes". `assertConflictTargetHonoured` turns that into a refusal, so an `upsert` against a perfectly well-indexed table would be rejected with *no PRIMARY KEY or UNIQUE index backs them* — and index-drift detection would propose creating indexes that already exist.

- `introspectIndexes` now resolves the table with `to_regclass(?)` and reads `pg_index` by OID. That is the same resolution every other statement in the session performs — first match along `search_path` — and it removes an ambiguity a schema list would introduce, since two schemas on the path can hold the same table name and only one of them is the one a query reaches.
- `introspectSchema` now lists `table_schema = ANY (current_schemas(false))`.

**No change for a default deployment.** With the default `search_path`, `current_schemas(false)` is exactly `{public}` and `to_regclass` resolves into `public`, so both queries return what they returned before. The behaviour only differs where the old queries returned nothing.
100 changes: 100 additions & 0 deletions packages/drivers/driver-sql/src/live-dialect-matrix.globalsetup.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #9350 — create the per-file schemas before any test opens a connection.
*
* ## Why this runs here and not in a hook
*
* The isolation names each file's database in the CONNECTION (see
* `mysqlUrlForSchema`), which is what keeps knex's `client.database()` and the
* session the same value. The cost of that choice is an ordering constraint:
* connecting to a MySQL database that does not exist fails at the handshake, so
* the databases have to exist before the first pool opens.
*
* A `beforeAll` cannot do it. `cell.config()` is called from inside `beforeEach`
* in most of the eleven consumers, which is too late to register a hook, and the
* testkit module is cached PER WORKER rather than per file — so a hook
* registered at its module scope would attach to whichever file that worker
* collected first and to no other. `globalSetup` runs once, in the main process,
* before any worker starts, and can await. That is exactly the shape of the
* constraint.
*
* ## Deliberately total, and deliberately cheap
*
* It creates a schema for EVERY test file in the package rather than for the
* live ones only. Deciding which files are live would mean parsing them, and a
* wrong answer is a handshake failure in a required check. A schema costs one
* dictionary row on both dialects (`create database` on MySQL is not a template
* copy the way Postgres' `createdb` is), and the teardown removes them.
*
* Without either URL this does nothing at all: a developer running without
* servers sees no connection attempt, exactly as before.
*/

import knex from 'knex';
import { liveSchemaLedger } from './live-dialect-matrix.testkit.js';

const PG_URL = process.env.OS_TEST_POSTGRES_URL;
const MYSQL_URL = process.env.OS_TEST_MYSQL_URL;

/**
* Statements are built from the ledger's names, never from anything a caller
* supplies, and `liveSchemaNameFor` refuses to emit a name outside
* `/^[a-z][a-z0-9_]*$/` — so the interpolation below cannot carry a quote.
*/
async function withServer<T>(
client: 'pg' | 'mysql2',
connection: string,
run: (db: ReturnType<typeof knex>) => Promise<T>,
): Promise<T> {
const db = knex({ client, connection, pool: { min: 0, max: 1 } });
try {
return await run(db);
} finally {
await db.destroy();
}
}

export async function setup(): Promise<void> {
const ledger = liveSchemaLedger();
if (PG_URL) {
await withServer('pg', PG_URL, async (db) => {
for (const { schema } of ledger) {
await db.raw(`create schema if not exists "${schema}"`);
}
});
}
if (MYSQL_URL) {
await withServer('mysql2', MYSQL_URL, async (db) => {
for (const { schema } of ledger) {
await db.raw(`create database if not exists \`${schema}\``);
}
});
}
}

/**
* Drop what the setup created.
*
* Best-effort by design: a failed drop must not turn a green run red — the
* schemas are re-created idempotently next time, and CI's servers are thrown
* away with the job. It exists for the developer running against a long-lived
* local server, who would otherwise accumulate one schema per test file.
*/
export async function teardown(): Promise<void> {
const ledger = liveSchemaLedger();
if (PG_URL) {
await withServer('pg', PG_URL, async (db) => {
for (const { schema } of ledger) {
await db.raw(`drop schema if exists "${schema}" cascade`).catch(() => {});
}
}).catch(() => {});
}
if (MYSQL_URL) {
await withServer('mysql2', MYSQL_URL, async (db) => {
for (const { schema } of ledger) {
await db.raw(`drop database if exists \`${schema}\``).catch(() => {});
}
}).catch(() => {});
}
}
Loading
Loading