From f73c5458702810c5183e002ef92de7dd718362f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:53:56 +0000 Subject: [PATCH] refactor(driver-sql): move introspectSchema's body out of sql-driver.ts into sql-driver-introspection.ts (#25521 cut 4) Move-only. The body of SqlDriver#introspectSchema moves unedited into cut 1's module and reaches the class through cut 1's IntrospectionHost, which gains the five members the body reads that it did not list yet (dialectName as a getter; introspectColumns, introspectForeignKeys, introspectPrimaryKeys and introspectUniqueConstraints as late-bound closures). The method keeps its name, signature and position. postgresTableNameCollisionError, read only by this body, stays in sql-driver.ts and gains a leading export. listPhysicalColumns stays in the class: its body is shorter than the host literal a stub would need. sql-driver.ts goes from 21,678 to 21,595 lines. Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .../src/sql-driver-introspection.ts | 147 +++++++++++++++++- packages/drivers/driver-sql/src/sql-driver.ts | 131 +++------------- 2 files changed, 165 insertions(+), 113 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-introspection.ts b/packages/drivers/driver-sql/src/sql-driver-introspection.ts index 19d033291ac..d922dbdd6df 100644 --- a/packages/drivers/driver-sql/src/sql-driver-introspection.ts +++ b/packages/drivers/driver-sql/src/sql-driver-introspection.ts @@ -7,6 +7,13 @@ * catalog reads `introspectSchema` and the schema-sync drift checks are built * on — moved out of `sql-driver.ts` whole. * + * [#25521 cut 4] `introspectSchema`, the public whole-schema read those + * helpers serve, moved here too: it lists the tables per dialect and reads each + * one through four of them. `listPhysicalColumns`, the other member of the + * class's Schema Introspection section, stayed in the class: its body is + * shorter than the host literal a stub would need, so moving it would make + * `sql-driver.ts` longer, not shorter. + * * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` * method of the same name as it stood, re-indented one level and otherwise * unedited: the same catalog statements, the same dialect arms, the same @@ -27,22 +34,39 @@ * * `introspectColumns` reads `introspectColumnOrder` through a closure that * calls it on the driver at call time, so a subclass override or a test spy on - * the instance is still the one the body reaches. + * the instance is still the one the body reaches. `introspectSchema` reads the + * four helpers it calls the same way, so `TursoDriver`'s override of + * `introspectColumns` (and a test spy on the instance) is still the one it + * reaches. `TursoDriver` also overrides `introspectSchema` itself and calls + * `super`, which reaches the class method, which hands this body its host. * - * The field `knex` and the three dialect getters go as GETTERS, so each read - * happens when the body makes it, as it did on the class. + * The field `knex`, the three dialect getters and the getter `dialectName` go + * as GETTERS, so each read happens when the body makes it, as it did on the + * class. * * The two module-level declarations the bodies read, `UniqueConstraintMember` * and `singleColumnUniqueColumns`, stay in `sql-driver.ts`, which exports them * already (a test imports `singleColumnUniqueColumns` from there), and are * imported from there: the module cycle is evaluation-safe because the one * value import is read only inside a function body, never while the two - * modules are first evaluated. `index.ts` re-exports nothing from this module, + * modules are first evaluated. `introspectSchema` also reads the types + * `IntrospectedSchema` and `IntrospectedTable`, which `sql-driver.ts` exports + * already, and the function `postgresTableNameCollisionError`, which only + * this body reads: it stays in `sql-driver.ts` and gained a leading `export` + * there (the shape cut 2 applied), so it is imported, not copied, and is read + * only inside the body too. `index.ts` re-exports nothing from this module, * so the published surface does not move. */ -import type { IntrospectedColumn, IntrospectedForeignKey, SqlDriver, UniqueConstraintMember } from './sql-driver.js'; -import { singleColumnUniqueColumns } from './sql-driver.js'; +import type { + IntrospectedColumn, + IntrospectedForeignKey, + IntrospectedSchema, + IntrospectedTable, + SqlDriver, + UniqueConstraintMember, +} from './sql-driver.js'; +import { postgresTableNameCollisionError, singleColumnUniqueColumns } from './sql-driver.js'; /** * The `SqlDriver` members the functions below read — every one of them, and @@ -53,7 +77,12 @@ import { singleColumnUniqueColumns } from './sql-driver.js'; * for which members are getters. */ export interface IntrospectionHost { + readonly dialectName: SqlDriver['dialectName']; introspectColumnOrder: SqlDriver['introspectColumnOrder']; + introspectColumns: SqlDriver['introspectColumns']; + introspectForeignKeys: SqlDriver['introspectForeignKeys']; + introspectPrimaryKeys: SqlDriver['introspectPrimaryKeys']; + introspectUniqueConstraints: SqlDriver['introspectUniqueConstraints']; readonly isMysql: SqlDriver['isMysql']; readonly isPostgres: SqlDriver['isPostgres']; readonly isSqlite: SqlDriver['isSqlite']; @@ -663,3 +692,109 @@ export async function introspectUniqueConstraints( return singleColumnUniqueColumns(members); } + +/** The body of `SqlDriver#introspectSchema`; the method's signature there is its contract. */ +export async function introspectSchema(this: Pick): Promise { + const tables: Record = {}; + // Stamped BEFORE the reads, not after: a consumer asking "has the remote + // changed since this snapshot?" must not be told the snapshot covers a + // moment later than the first table it actually read. + const introspectedAt = new Date().toISOString(); + let tableNames: string[] = []; + // PostgreSQL only (#24707): the schema each listed table lives in, so its + // columns are read THERE. Every other dialect leaves it empty. + const schemaByTable = new Map(); + + if (this.isPostgres) { + // Every schema the session can actually reach unqualified, rather than + // the literal `'public'` — same answer for a default deployment, where + // `current_schemas(false)` is exactly `{public}`, and the right answer for + // a session pointed at another schema, where the literal listed nothing. + // + // `table_schema` and its `search_path` position are selected too + // (#24707). The result is keyed by BARE name, so two schemas holding the + // same name used to collapse into one key with the later table silently + // dropped — refused below instead. And the schema is what the column read + // needs: knex `columnInfo()` on a bare name reads `current_schema()`, the + // FIRST schema on the path only, so a table in a later schema reported + // zero columns. No `ORDER BY` is added: the key order of a single-schema + // answer stays exactly what it was. + const result = await this.knex.raw(` + SELECT table_schema, table_name, + array_position(current_schemas(false), table_schema::name) AS path_position + FROM information_schema.tables + WHERE table_schema = ANY (current_schemas(false)) + AND table_type = 'BASE TABLE' + `); + const rows = result.rows as { table_schema: string; table_name: string; path_position: number }[]; + const schemasByTable = new Map(); + for (const row of rows) { + const seen = schemasByTable.get(row.table_name); + const entry = { schema: row.table_schema, position: Number(row.path_position) }; + if (seen) seen.push(entry); + else schemasByTable.set(row.table_name, [entry]); + } + const collisions = [...schemasByTable] + .filter(([, schemas]) => schemas.length > 1) + .map(([table, schemas]) => ({ + table, + schemas: schemas.sort((a, b) => a.position - b.position).map((s) => s.schema), + })) + .sort((a, b) => (a.table < b.table ? -1 : a.table > b.table ? 1 : 0)); + if (collisions.length > 0) throw postgresTableNameCollisionError(collisions); + + tableNames = rows.map((row) => row.table_name); + for (const row of rows) schemaByTable.set(row.table_name, row.table_schema); + } else if (this.isMysql) { + const result = await this.knex.raw(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + `); + tableNames = result[0].map((row: any) => row.TABLE_NAME); + } else if (this.isSqlite) { + const result = await this.knex.raw(` + SELECT name as table_name + FROM sqlite_master + WHERE type='table' + AND name NOT LIKE 'sqlite_%' + `); + tableNames = result.map((row: any) => row.table_name); + } + + for (const tableName of tableNames) { + // All four reads take the #7332 default (`onFailure: 'throw'`): this + // caller cannot correct a short read, and `introspectColumns` has never + // swallowed — so a partially-readable database fails the whole-schema + // introspection loudly instead of emitting tables whose keys silently + // read as absent (#11161). Every in-tree caller of `introspectSchema` + // already handles a throw (the datasource health check reports + // `{ ok: false }`, the REST/CLI seams surface the error). + // On PostgreSQL the columns are read in the table's OWN schema (#24707); + // the three key reads below already scope to `current_schemas(false)` + // or resolve along `search_path`, which the refusal above makes + // unambiguous among base tables. + const columns = await this.introspectColumns(tableName, schemaByTable.get(tableName)); + const foreignKeys = await this.introspectForeignKeys(tableName); + const primaryKeys = await this.introspectPrimaryKeys(tableName); + const uniqueConstraints = await this.introspectUniqueConstraints(tableName); + + for (const col of columns) { + if (primaryKeys.includes(col.name)) col.primaryKey = true; + if (uniqueConstraints.includes(col.name)) col.isUnique = true; + } + + tables[tableName] = { name: tableName, columns, foreignKeys, primaryKeys }; + } + + // `dialectName` — not the raw Knex client, and not the spec's + // `SQLDialectSchema` enum. The only in-tree consumer of this key is + // `suggestFieldTypeForSqlType(col.type, schema.dialect as SqlDialect)`, + // whose `SqlDialect` vocabulary (`packages/spec/src/data/type-compat.ts`) + // spells PostgreSQL `postgres`, exactly as `dialectName` does. Emitting + // the enum's `postgresql` instead would put the key in `Object.keys()` + // while leaving every per-dialect type alias unreachable — the omission + // this repairs, wearing a fix's clothes. + return { tables, dialect: this.dialectName, introspectedAt }; +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 80341a2b4a7..f2cc60c8020 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -160,14 +160,15 @@ import { } from './builtin-column-collision.js'; import { recoverUnencodedJsonText } from './unencoded-json-text.js'; // [#25521 cut 1] The catalog-introspection helpers (`introspectColumnOrder`, -// `introspectColumns` and the foreign-key, primary-key and unique-constraint reads); -// the methods of the same names below hand each body the members it reads -// (`IntrospectionHost`). +// `introspectColumns` and the foreign-key, primary-key and unique-constraint reads), +// and [cut 4] `introspectSchema`, the whole-schema read built on them; the methods +// of the same names below hand each body the members it reads (`IntrospectionHost`). import { introspectColumnOrder, introspectColumns, introspectForeignKeys, introspectPrimaryKeys, + introspectSchema, introspectUniqueConstraints, } from './sql-driver-introspection.js'; // [#25521 cut 2] The raw-execution path (`execute`, its refusal terminal @@ -4960,7 +4961,7 @@ function classifyFilterKey( * * `collisions` is non-empty; each entry's `schemas` has two or more members. */ -function postgresTableNameCollisionError(collisions: ReadonlyArray<{ table: string; schemas: string[] }>): Error { +export function postgresTableNameCollisionError(collisions: ReadonlyArray<{ table: string; schemas: string[] }>): Error { const list = (names: string[]) => names.map((s) => `'${s}'`).join(', '); const [first] = collisions; const err = new Error( @@ -15822,109 +15823,25 @@ export class SqlDriver implements IDataDriver { return Object.keys(info ?? {}); } - async introspectSchema(): Promise { - const tables: Record = {}; - // Stamped BEFORE the reads, not after: a consumer asking "has the remote - // changed since this snapshot?" must not be told the snapshot covers a - // moment later than the first table it actually read. - const introspectedAt = new Date().toISOString(); - let tableNames: string[] = []; - // PostgreSQL only (#24707): the schema each listed table lives in, so its - // columns are read THERE. Every other dialect leaves it empty. - const schemaByTable = new Map(); - - if (this.isPostgres) { - // Every schema the session can actually reach unqualified, rather than - // the literal `'public'` — same answer for a default deployment, where - // `current_schemas(false)` is exactly `{public}`, and the right answer for - // a session pointed at another schema, where the literal listed nothing. - // - // `table_schema` and its `search_path` position are selected too - // (#24707). The result is keyed by BARE name, so two schemas holding the - // same name used to collapse into one key with the later table silently - // dropped — refused below instead. And the schema is what the column read - // needs: knex `columnInfo()` on a bare name reads `current_schema()`, the - // FIRST schema on the path only, so a table in a later schema reported - // zero columns. No `ORDER BY` is added: the key order of a single-schema - // answer stays exactly what it was. - const result = await this.knex.raw(` - SELECT table_schema, table_name, - array_position(current_schemas(false), table_schema::name) AS path_position - FROM information_schema.tables - WHERE table_schema = ANY (current_schemas(false)) - AND table_type = 'BASE TABLE' - `); - const rows = result.rows as { table_schema: string; table_name: string; path_position: number }[]; - const schemasByTable = new Map(); - for (const row of rows) { - const seen = schemasByTable.get(row.table_name); - const entry = { schema: row.table_schema, position: Number(row.path_position) }; - if (seen) seen.push(entry); - else schemasByTable.set(row.table_name, [entry]); - } - const collisions = [...schemasByTable] - .filter(([, schemas]) => schemas.length > 1) - .map(([table, schemas]) => ({ - table, - schemas: schemas.sort((a, b) => a.position - b.position).map((s) => s.schema), - })) - .sort((a, b) => (a.table < b.table ? -1 : a.table > b.table ? 1 : 0)); - if (collisions.length > 0) throw postgresTableNameCollisionError(collisions); - - tableNames = rows.map((row) => row.table_name); - for (const row of rows) schemaByTable.set(row.table_name, row.table_schema); - } else if (this.isMysql) { - const result = await this.knex.raw(` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = DATABASE() - AND table_type = 'BASE TABLE' - `); - tableNames = result[0].map((row: any) => row.TABLE_NAME); - } else if (this.isSqlite) { - const result = await this.knex.raw(` - SELECT name as table_name - FROM sqlite_master - WHERE type='table' - AND name NOT LIKE 'sqlite_%' - `); - tableNames = result.map((row: any) => row.table_name); - } - - for (const tableName of tableNames) { - // All four reads take the #7332 default (`onFailure: 'throw'`): this - // caller cannot correct a short read, and `introspectColumns` has never - // swallowed — so a partially-readable database fails the whole-schema - // introspection loudly instead of emitting tables whose keys silently - // read as absent (#11161). Every in-tree caller of `introspectSchema` - // already handles a throw (the datasource health check reports - // `{ ok: false }`, the REST/CLI seams surface the error). - // On PostgreSQL the columns are read in the table's OWN schema (#24707); - // the three key reads below already scope to `current_schemas(false)` - // or resolve along `search_path`, which the refusal above makes - // unambiguous among base tables. - const columns = await this.introspectColumns(tableName, schemaByTable.get(tableName)); - const foreignKeys = await this.introspectForeignKeys(tableName); - const primaryKeys = await this.introspectPrimaryKeys(tableName); - const uniqueConstraints = await this.introspectUniqueConstraints(tableName); - - for (const col of columns) { - if (primaryKeys.includes(col.name)) col.primaryKey = true; - if (uniqueConstraints.includes(col.name)) col.isUnique = true; - } - - tables[tableName] = { name: tableName, columns, foreignKeys, primaryKeys }; - } - - // `dialectName` — not the raw Knex client, and not the spec's - // `SQLDialectSchema` enum. The only in-tree consumer of this key is - // `suggestFieldTypeForSqlType(col.type, schema.dialect as SqlDialect)`, - // whose `SqlDialect` vocabulary (`packages/spec/src/data/type-compat.ts`) - // spells PostgreSQL `postgres`, exactly as `dialectName` does. Emitting - // the enum's `postgresql` instead would put the key in `Object.keys()` - // while leaving every per-dialect type alias unreachable — the omission - // this repairs, wearing a fix's clothes. - return { tables, dialect: this.dialectName, introspectedAt }; + introspectSchema(): Promise { + // [#25521 cut 4] The body moved to `sql-driver-introspection.ts` (see `IntrospectionHost`). + const readDialectName = () => this.dialectName; + const readIsMysql = () => this.isMysql; + const readIsPostgres = () => this.isPostgres; + const readIsSqlite = () => this.isSqlite; + const readKnex = () => this.knex; + const host: ThisParameterType = { + get dialectName() { return readDialectName(); }, + introspectColumns: (...a) => this.introspectColumns(...a), + introspectForeignKeys: (...a) => this.introspectForeignKeys(...a), + introspectPrimaryKeys: (...a) => this.introspectPrimaryKeys(...a), + introspectUniqueConstraints: (...a) => this.introspectUniqueConstraints(...a), + get isMysql() { return readIsMysql(); }, + get isPostgres() { return readIsPostgres(); }, + get isSqlite() { return readIsSqlite(); }, + get knex() { return readKnex(); }, + }; + return introspectSchema.call(host); } // ===================================