turbine-orm 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
package/dist/index-advisor.d.ts
CHANGED
|
@@ -46,7 +46,19 @@ export interface CreateIndexSqlOptions {
|
|
|
46
46
|
* it must also carry the `-- turbine:no-transaction` directive.
|
|
47
47
|
*/
|
|
48
48
|
concurrently?: boolean;
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Emit `IF NOT EXISTS`. Defaults to true for the plain (in-transaction) form
|
|
51
|
+
* and FALSE when `concurrently` is set.
|
|
52
|
+
*
|
|
53
|
+
* `IF NOT EXISTS` matches on the index NAME, never on its validity, so over
|
|
54
|
+
* the INVALID index a failed concurrent build leaves behind it silently
|
|
55
|
+
* no-ops: the migration records as applied, the index doctor reported is
|
|
56
|
+
* still missing, and the documented remedy (DROP INDEX CONCURRENTLY, then
|
|
57
|
+
* rerun) is unreachable through `migrate up` because the rerun no-ops too.
|
|
58
|
+
* The concurrent form gets its idempotency from a preceding
|
|
59
|
+
* `DROP INDEX CONCURRENTLY IF EXISTS` instead; see
|
|
60
|
+
* {@link buildCreateIndexStatements}, which is what the CLI emits.
|
|
61
|
+
*/
|
|
50
62
|
ifNotExists?: boolean;
|
|
51
63
|
/**
|
|
52
64
|
* Emit a partial index `... WHERE <col> IS NOT NULL`. Only applied for a
|
|
@@ -63,6 +75,26 @@ export interface CreateIndexSqlOptions {
|
|
|
63
75
|
* module topology-only.
|
|
64
76
|
*/
|
|
65
77
|
export declare function buildCreateIndexSql(table: string, columns: string[], indexName: string, options?: CreateIndexSqlOptions): string;
|
|
78
|
+
/**
|
|
79
|
+
* The statement SEQUENCE that builds one fix index and converges on a VALID
|
|
80
|
+
* index however many times it is rerun.
|
|
81
|
+
*
|
|
82
|
+
* A no-transaction migration is recorded only after ALL its statements succeed,
|
|
83
|
+
* so a mid-file failure leaves earlier indexes built and the migration
|
|
84
|
+
* unrecorded: a rerun must be safe. The old answer was `CREATE INDEX
|
|
85
|
+
* CONCURRENTLY IF NOT EXISTS`, which is safe but not CONVERGENT: a concurrent
|
|
86
|
+
* build that fails partway leaves an INVALID index with the right name, and
|
|
87
|
+
* every subsequent run skips it. Measured: the index was INVALID before the fix
|
|
88
|
+
* migration, `migrate up` reported 1 applied and 0 errors, and the index was
|
|
89
|
+
* still INVALID after.
|
|
90
|
+
*
|
|
91
|
+
* So the concurrent form drops first instead. `DROP INDEX CONCURRENTLY IF
|
|
92
|
+
* EXISTS` is a no-op on the first run (the index is missing, which is why
|
|
93
|
+
* doctor proposed it), and on a rerun it clears the corpse so the CREATE
|
|
94
|
+
* actually rebuilds. Neither statement takes a blocking lock, and both are
|
|
95
|
+
* legal only outside a transaction, which this file already is.
|
|
96
|
+
*/
|
|
97
|
+
export declare function buildCreateIndexStatements(table: string, columns: string[], indexName: string, options?: CreateIndexSqlOptions): string[];
|
|
66
98
|
/** Build the matching `DROP INDEX` statement. `concurrently` requires no-transaction execution. */
|
|
67
99
|
export declare function buildDropIndexSql(indexName: string, options?: {
|
|
68
100
|
concurrently?: boolean;
|
package/dist/index-advisor.js
CHANGED
|
@@ -25,7 +25,12 @@ import { normalizeKeyColumns } from './schema.js';
|
|
|
25
25
|
*/
|
|
26
26
|
export function buildCreateIndexSql(table, columns, indexName, options = {}) {
|
|
27
27
|
const concurrently = options.concurrently ? 'CONCURRENTLY ' : '';
|
|
28
|
-
|
|
28
|
+
// Default differs by form: the plain statement runs inside a transaction and
|
|
29
|
+
// can never leave an INVALID corpse, so IF NOT EXISTS is a pure win there.
|
|
30
|
+
// The CONCURRENTLY form can, and IF NOT EXISTS would then match that corpse
|
|
31
|
+
// by name and skip the rebuild forever (see CreateIndexSqlOptions).
|
|
32
|
+
const wantIfNotExists = options.ifNotExists ?? !options.concurrently;
|
|
33
|
+
const ifNotExists = wantIfNotExists ? 'IF NOT EXISTS ' : '';
|
|
29
34
|
const cols = columns.map(quoteIdent).join(', ');
|
|
30
35
|
let sql = `CREATE INDEX ${concurrently}${ifNotExists}${quoteIdent(indexName)} ON ${quoteIdent(table)} (${cols})`;
|
|
31
36
|
if (options.partialNotNull && columns.length === 1 && columns[0] !== undefined) {
|
|
@@ -33,6 +38,31 @@ export function buildCreateIndexSql(table, columns, indexName, options = {}) {
|
|
|
33
38
|
}
|
|
34
39
|
return `${sql};`;
|
|
35
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The statement SEQUENCE that builds one fix index and converges on a VALID
|
|
43
|
+
* index however many times it is rerun.
|
|
44
|
+
*
|
|
45
|
+
* A no-transaction migration is recorded only after ALL its statements succeed,
|
|
46
|
+
* so a mid-file failure leaves earlier indexes built and the migration
|
|
47
|
+
* unrecorded: a rerun must be safe. The old answer was `CREATE INDEX
|
|
48
|
+
* CONCURRENTLY IF NOT EXISTS`, which is safe but not CONVERGENT: a concurrent
|
|
49
|
+
* build that fails partway leaves an INVALID index with the right name, and
|
|
50
|
+
* every subsequent run skips it. Measured: the index was INVALID before the fix
|
|
51
|
+
* migration, `migrate up` reported 1 applied and 0 errors, and the index was
|
|
52
|
+
* still INVALID after.
|
|
53
|
+
*
|
|
54
|
+
* So the concurrent form drops first instead. `DROP INDEX CONCURRENTLY IF
|
|
55
|
+
* EXISTS` is a no-op on the first run (the index is missing, which is why
|
|
56
|
+
* doctor proposed it), and on a rerun it clears the corpse so the CREATE
|
|
57
|
+
* actually rebuilds. Neither statement takes a blocking lock, and both are
|
|
58
|
+
* legal only outside a transaction, which this file already is.
|
|
59
|
+
*/
|
|
60
|
+
export function buildCreateIndexStatements(table, columns, indexName, options = {}) {
|
|
61
|
+
const create = buildCreateIndexSql(table, columns, indexName, options);
|
|
62
|
+
if (!options.concurrently)
|
|
63
|
+
return [create];
|
|
64
|
+
return [buildDropIndexSql(indexName, { concurrently: true, ifExists: true }), create];
|
|
65
|
+
}
|
|
36
66
|
/** Build the matching `DROP INDEX` statement. `concurrently` requires no-transaction execution. */
|
|
37
67
|
export function buildDropIndexSql(indexName, options = {}) {
|
|
38
68
|
const concurrently = options.concurrently ? 'CONCURRENTLY ' : '';
|
package/dist/introspect.d.ts
CHANGED
|
@@ -283,6 +283,54 @@ export declare function buildRelationsFromForeignKeys(foreignKeys: ForeignKeyEnt
|
|
|
283
283
|
* instead of silently dropping the relation.
|
|
284
284
|
*/
|
|
285
285
|
export declare function addAutoManyToManyRelations(tableNames: Iterable<string>, foreignKeys: ForeignKeyEntry[], pkByTable: Map<string, string[]>, columnNamesByTable: Map<string, string[]>, relationsByTable: Map<string, Record<string, RelationDef>>, columnFieldsByTable?: Map<string, Set<string>>, unknownTypedFieldsByTable?: Map<string, Set<string>>, uniqueIndexColsByTable?: Map<string, string[][]>): void;
|
|
286
|
+
/** Everything the catalog relation derivation reads. See {@link deriveCatalogRelations}. */
|
|
287
|
+
export interface CatalogRelationInputs {
|
|
288
|
+
/** The introspected table set, post include/exclude filtering. */
|
|
289
|
+
tableNames: string[];
|
|
290
|
+
/** FK rows already grouped per constraint (one entry per declared constraint). */
|
|
291
|
+
foreignKeys: ForeignKeyEntry[];
|
|
292
|
+
/** Primary-key columns per table. */
|
|
293
|
+
pkByTable: Map<string, string[]>;
|
|
294
|
+
/** Column metadata per table (only name/field/tsType/pgType are read). */
|
|
295
|
+
columnsByTable: Map<string, Pick<ColumnMetadata, 'name' | 'field' | 'tsType' | 'pgType'>[]>;
|
|
296
|
+
/** UNIQUE-constraint column sets per table. */
|
|
297
|
+
uniqueByTable: Map<string, string[][]>;
|
|
298
|
+
/** Indexes per table, `definition` must be the raw `pg_indexes.indexdef`. */
|
|
299
|
+
indexesByTable: Map<string, IndexMetadata[]>;
|
|
300
|
+
/** Enum types in the schema, so an enum column is not mistaken for a json shadow. */
|
|
301
|
+
enums: Record<string, string[]>;
|
|
302
|
+
/** Referential actions keyed "<table>::<constraint>". Optional. */
|
|
303
|
+
fkActions?: Map<string, {
|
|
304
|
+
onDelete: ReferentialAction;
|
|
305
|
+
onUpdate: ReferentialAction;
|
|
306
|
+
}>;
|
|
307
|
+
/** Opt out of the unique-FK → `hasOne` flip (F2), see {@link IntrospectOptions.legacyToManyUniques}. */
|
|
308
|
+
legacyToManyUniques?: boolean;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* One-stop relation derivation for every surface that reads a live PostgreSQL
|
|
312
|
+
* CATALOG: `turbine generate` (via {@link introspectPostgresCatalog}) and the
|
|
313
|
+
* MCP server, which introspects for itself because it cannot assume generated
|
|
314
|
+
* metadata exists.
|
|
315
|
+
*
|
|
316
|
+
* THE REASON THIS IS ONE FUNCTION and not two call sites: the pipeline it drives
|
|
317
|
+
* is `buildRelationsFromForeignKeys` (five parameters, two optional) plus
|
|
318
|
+
* `addAutoManyToManyRelations` (eight parameters, three optional), and every
|
|
319
|
+
* optional one CHANGES THE ANSWER while omitting it stays silently type-correct.
|
|
320
|
+
* Hand-mirroring them drifted exactly that way: MCP passed four arguments and so
|
|
321
|
+
* never received `uniqueSetsByTable`, which meant a UNIQUE foreign key produced
|
|
322
|
+
* `users.profile` (hasOne) under `turbine generate` and `users.profiles`
|
|
323
|
+
* (hasMany) under `turbine mcp`, against the same database. An MCP client
|
|
324
|
+
* following its own schema tool then queried `with: { profiles: true }` and got
|
|
325
|
+
* `TURBINE_E005 Unknown relation`. MCP also omitted `uniqueIndexColsByTable`,
|
|
326
|
+
* losing every auto-m2m relation through a Prisma-style PK-less junction. Adding
|
|
327
|
+
* an argument here now reaches both surfaces or neither.
|
|
328
|
+
*
|
|
329
|
+
* The engine introspectors (SQLite / MySQL / MSSQL) keep their own
|
|
330
|
+
* {@link deriveEngineRelations} because they deliberately do NOT do the
|
|
331
|
+
* unique-FK → `hasOne` flip.
|
|
332
|
+
*/
|
|
333
|
+
export declare function deriveCatalogRelations(inputs: CatalogRelationInputs): Map<string, Record<string, RelationDef>>;
|
|
286
334
|
/**
|
|
287
335
|
* One-stop relation derivation for the engine introspectors (SQLite / MySQL /
|
|
288
336
|
* MSSQL): filters the FK list to the introspected table set, seeds the
|
package/dist/introspect.js
CHANGED
|
@@ -71,22 +71,88 @@ const SQL_PRIMARY_KEYS = `
|
|
|
71
71
|
AND tc.table_schema = $1
|
|
72
72
|
ORDER BY tc.table_name, kcu.ordinal_position
|
|
73
73
|
`;
|
|
74
|
+
// Foreign keys, read from pg_catalog rather than information_schema.
|
|
75
|
+
//
|
|
76
|
+
// This CANNOT be expressed against information_schema. The obvious formulation
|
|
77
|
+
// joins key_column_usage (the constrained columns) to constraint_column_usage
|
|
78
|
+
// (the referenced columns) on the constraint NAME, and that is wrong twice:
|
|
79
|
+
//
|
|
80
|
+
// 1. The two column lists have no positional link there, so the join is an
|
|
81
|
+
// N-by-N cross product. A composite FK cities(country, region_code) ->
|
|
82
|
+
// regions(country, code) came back as four rows, and grouping them gave
|
|
83
|
+
// foreignKey ['country','country','region_code','region_code'] against
|
|
84
|
+
// referenceKey ['country','code','country','code'] - four AND-ed
|
|
85
|
+
// correlations, two of them pairing the wrong columns. Every read through
|
|
86
|
+
// the relation silently returned nothing, with no error.
|
|
87
|
+
// 2. Postgres only requires a constraint name to be unique per TABLE
|
|
88
|
+
// (conrelid, conname), so two tables in one schema may both have a
|
|
89
|
+
// `shared_fk`. Joining on the name alone crosses them: each table's FK
|
|
90
|
+
// picks up the other's referenced column, so one relation is lost and the
|
|
91
|
+
// other points at a column that does not exist on its target (42703 at
|
|
92
|
+
// query time). Which one won depended on catalog row order.
|
|
93
|
+
//
|
|
94
|
+
// conkey and confkey are parallel arrays, so unnesting BOTH `WITH ORDINALITY`
|
|
95
|
+
// and joining on the ordinal is the pairing, exactly. The constraint OID is the
|
|
96
|
+
// grouping key: it is unique catalog-wide, unlike the name.
|
|
97
|
+
//
|
|
98
|
+
// `target_schema` is selected so a cross-schema reference can be recognized
|
|
99
|
+
// rather than mistaken for a same-named local table (see the FK grouping loop).
|
|
100
|
+
// Referential actions come from the same row, which also removes the separate
|
|
101
|
+
// name-keyed actions query that shared bug 2.
|
|
102
|
+
//
|
|
103
|
+
// `conparentid = 0` keeps only DECLARED constraints. Declaring one foreign key
|
|
104
|
+
// against a PARTITIONED table makes Postgres materialize an extra constraint per
|
|
105
|
+
// partition, each pointing at that partition rather than at the parent, and each
|
|
106
|
+
// with `conparentid` set to the declared constraint's OID. Without this filter a
|
|
107
|
+
// single `items(bucket_id) REFERENCES buckets(id)` against a two-partition
|
|
108
|
+
// `buckets` introspected as THREE belongsTo relations (`bucket`, `bucketsLo`,
|
|
109
|
+
// `bucketsHi`), measured on PG 16. The two extras are fully generated, typed and
|
|
110
|
+
// autocompleting, and resolve to `null` for every row whose parent lives in the
|
|
111
|
+
// other partition, so they read as an intermittently-empty relation rather than
|
|
112
|
+
// as an error. The same clone exists when the REFERENCING side is partitioned
|
|
113
|
+
// (verified: `conparentid` is set there too), where the partition inherits the
|
|
114
|
+
// parent table's declared FK and needs no relation of its own.
|
|
115
|
+
//
|
|
116
|
+
// ORDER BY is (source table, constraint name), NOT `con.oid`. The OID is
|
|
117
|
+
// ALLOCATION order, so it encodes the order the DDL happened to run in, and the
|
|
118
|
+
// FK walk order decides which relation wins a contested NAME: relation naming in
|
|
119
|
+
// buildRelationsFromForeignKeys accumulates `taken` names as it walks, and the
|
|
120
|
+
// loser gets a `Rel` suffix. On a `users` table with both a `profiles` child
|
|
121
|
+
// (UNIQUE FK, so hasOne) and a `profile` child (plain FK, so hasMany), both
|
|
122
|
+
// derive the name `profile`, and the two creation orders produced
|
|
123
|
+
// `users.profile = hasOne -> profiles` versus `users.profile = hasMany ->
|
|
124
|
+
// profile`. Same logical schema, different cardinality and a different TABLE
|
|
125
|
+
// behind the same relation name, so a database restored from a dump disagreed
|
|
126
|
+
// with one built by running the migrations. Sorting by name makes the walk a
|
|
127
|
+
// function of the schema instead of its history. (conrelid, conname) is unique
|
|
128
|
+
// in Postgres and relname is unique per namespace, so the pair is a total order
|
|
129
|
+
// here, and sk.ord still pairs conkey to confkey within a constraint.
|
|
74
130
|
const SQL_FOREIGN_KEYS = `
|
|
75
131
|
SELECT
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
con.oid::text AS constraint_oid,
|
|
133
|
+
con.conname AS constraint_name,
|
|
134
|
+
src.relname AS source_table,
|
|
135
|
+
src_att.attname AS source_column,
|
|
136
|
+
tgt_ns.nspname AS target_schema,
|
|
137
|
+
tgt.relname AS target_table,
|
|
138
|
+
tgt_att.attname AS target_column,
|
|
139
|
+
con.confdeltype,
|
|
140
|
+
con.confupdtype
|
|
141
|
+
FROM pg_catalog.pg_constraint con
|
|
142
|
+
JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
|
|
143
|
+
JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
|
|
144
|
+
JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
|
|
145
|
+
JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
|
|
146
|
+
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS sk(attnum, ord) ON TRUE
|
|
147
|
+
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS tk(attnum, ord) ON tk.ord = sk.ord
|
|
148
|
+
JOIN pg_catalog.pg_attribute src_att
|
|
149
|
+
ON src_att.attrelid = con.conrelid AND src_att.attnum = sk.attnum
|
|
150
|
+
JOIN pg_catalog.pg_attribute tgt_att
|
|
151
|
+
ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = tk.attnum
|
|
152
|
+
WHERE con.contype = 'f'
|
|
153
|
+
AND con.conparentid = 0
|
|
154
|
+
AND src_ns.nspname = $1
|
|
155
|
+
ORDER BY src.relname, con.conname, sk.ord
|
|
90
156
|
`;
|
|
91
157
|
const SQL_UNIQUE_CONSTRAINTS = `
|
|
92
158
|
SELECT
|
|
@@ -102,19 +168,19 @@ const SQL_UNIQUE_CONSTRAINTS = `
|
|
|
102
168
|
AND tc.table_schema = $1
|
|
103
169
|
ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position
|
|
104
170
|
`;
|
|
171
|
+
// Both of the next two queries are ordered for the same reason the FK query is:
|
|
172
|
+
// their rows land in metadata.ts as ARRAYS (`indexes`, `checks`), so an unordered
|
|
173
|
+
// read makes the generated file a function of physical catalog order rather than
|
|
174
|
+
// of the schema. A `DROP INDEX` + `CREATE INDEX` of an unchanged index, or a
|
|
175
|
+
// VACUUM FULL, is enough to permute them, which shows up as generated-file diff
|
|
176
|
+
// noise between one developer's machine and CI and defeats the byte-identical
|
|
177
|
+
// claim that makes a regenerate safe to commit. Index and check constraint names
|
|
178
|
+
// are both unique per schema, so each sort is total.
|
|
105
179
|
const SQL_INDEXES = `
|
|
106
180
|
SELECT tablename, indexname, indexdef
|
|
107
181
|
FROM pg_indexes
|
|
108
182
|
WHERE schemaname = $1
|
|
109
|
-
|
|
110
|
-
// Foreign-key referential actions (ON DELETE / ON UPDATE) live in pg_catalog,
|
|
111
|
-
// not information_schema. Keyed by constraint name for join with SQL_FOREIGN_KEYS.
|
|
112
|
-
const SQL_FK_ACTIONS = `
|
|
113
|
-
SELECT con.conname, con.confdeltype, con.confupdtype
|
|
114
|
-
FROM pg_constraint con
|
|
115
|
-
JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
|
|
116
|
-
WHERE con.contype = 'f'
|
|
117
|
-
AND n.nspname = $1
|
|
183
|
+
ORDER BY tablename, indexname
|
|
118
184
|
`;
|
|
119
185
|
// CHECK constraints (contype = 'c'). NOT NULL is stored as attnotnull, not a
|
|
120
186
|
// check constraint, so it never appears here.
|
|
@@ -125,6 +191,7 @@ const SQL_CHECKS = `
|
|
|
125
191
|
JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
|
|
126
192
|
WHERE con.contype = 'c'
|
|
127
193
|
AND n.nspname = $1
|
|
194
|
+
ORDER BY rel.relname, con.conname
|
|
128
195
|
`;
|
|
129
196
|
// Views (relkind 'v'), column metadata comes free from information_schema.columns.
|
|
130
197
|
const SQL_VIEWS = `
|
|
@@ -312,12 +379,11 @@ export async function introspectPostgresCatalog(options) {
|
|
|
312
379
|
});
|
|
313
380
|
try {
|
|
314
381
|
// Run all information_schema queries in parallel
|
|
315
|
-
const [tablesResult, columnsResult, pkResult, fkResult,
|
|
382
|
+
const [tablesResult, columnsResult, pkResult, fkResult, uniqueResult, indexResult, checkResult, enumResult] = await Promise.all([
|
|
316
383
|
pool.query(SQL_TABLES, [schema]),
|
|
317
384
|
pool.query(SQL_COLUMNS, [schema]),
|
|
318
385
|
pool.query(SQL_PRIMARY_KEYS, [schema]),
|
|
319
386
|
pool.query(SQL_FOREIGN_KEYS, [schema]),
|
|
320
|
-
pool.query(SQL_FK_ACTIONS, [schema]),
|
|
321
387
|
pool.query(SQL_UNIQUE_CONSTRAINTS, [schema]),
|
|
322
388
|
pool.query(SQL_INDEXES, [schema]),
|
|
323
389
|
pool.query(SQL_CHECKS, [schema]),
|
|
@@ -340,14 +406,6 @@ export async function introspectPostgresCatalog(options) {
|
|
|
340
406
|
viewNameSet.add(r.table_name);
|
|
341
407
|
matviewColumnRows.push(...matviewColsResult.rows);
|
|
342
408
|
}
|
|
343
|
-
// constraint_name → { onDelete, onUpdate } referential actions.
|
|
344
|
-
const fkActions = new Map();
|
|
345
|
-
for (const row of fkActionsResult.rows) {
|
|
346
|
-
fkActions.set(row.conname, {
|
|
347
|
-
onDelete: pgConfActionToReferential(row.confdeltype),
|
|
348
|
-
onUpdate: pgConfActionToReferential(row.confupdtype),
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
409
|
// Filter tables by include/exclude + default bookkeeping-table exclusions
|
|
352
410
|
// (F12). Views/matviews join the base tables as candidates so the filters
|
|
353
411
|
// apply uniformly.
|
|
@@ -478,20 +536,39 @@ export async function introspectPostgresCatalog(options) {
|
|
|
478
536
|
enums[row.typname].push(row.enumlabel);
|
|
479
537
|
}
|
|
480
538
|
// ----- Build foreign key map -----
|
|
481
|
-
// Group FK rows by
|
|
482
|
-
//
|
|
539
|
+
// Group FK rows by constraint OID, NOT by constraint name: Postgres only
|
|
540
|
+
// requires a name to be unique per table, so two tables in one schema may
|
|
541
|
+
// both own a `shared_fk` and grouping by name merges them into one entry
|
|
542
|
+
// (see SQL_FOREIGN_KEYS). Rows arrive ordered by (oid, ordinal), so pushing
|
|
543
|
+
// in arrival order preserves the column pairing the query established.
|
|
544
|
+
//
|
|
545
|
+
// A reference to a table in ANOTHER schema is skipped DELIBERATELY, and by
|
|
546
|
+
// its schema rather than by tableSet membership. Generated metadata keys
|
|
547
|
+
// tables by bare name and emits unqualified SQL, so a relation to
|
|
548
|
+
// `other.things` has no table to point at; worse, checking only
|
|
549
|
+
// `tableSet.has(target_table)` would bind it to a same-named table in THIS
|
|
550
|
+
// schema and generate a relation that reads the wrong table entirely.
|
|
551
|
+
// Skipped references are reported once, so they are visible rather than
|
|
552
|
+
// silently absent.
|
|
483
553
|
const fkGroups = new Map();
|
|
554
|
+
const crossSchemaRefs = [];
|
|
484
555
|
for (const row of fkResult.rows) {
|
|
485
|
-
if (!tableSet.has(row.source_table)
|
|
556
|
+
if (!tableSet.has(row.source_table))
|
|
486
557
|
continue;
|
|
487
|
-
|
|
558
|
+
if (row.target_schema !== schema) {
|
|
559
|
+
crossSchemaRefs.push(`${row.source_table}.${row.source_column} -> ${row.target_schema}.${row.target_table}.${row.target_column}`);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (!tableSet.has(row.target_table))
|
|
563
|
+
continue;
|
|
564
|
+
const key = row.constraint_oid;
|
|
488
565
|
if (!fkGroups.has(key)) {
|
|
489
566
|
fkGroups.set(key, {
|
|
490
567
|
sourceTable: row.source_table,
|
|
491
568
|
sourceColumns: [],
|
|
492
569
|
targetTable: row.target_table,
|
|
493
570
|
targetColumns: [],
|
|
494
|
-
constraintName:
|
|
571
|
+
constraintName: row.constraint_name,
|
|
495
572
|
});
|
|
496
573
|
}
|
|
497
574
|
const entry = fkGroups.get(key);
|
|
@@ -499,60 +576,43 @@ export async function introspectPostgresCatalog(options) {
|
|
|
499
576
|
entry.targetColumns.push(row.target_column);
|
|
500
577
|
}
|
|
501
578
|
const foreignKeys = Array.from(fkGroups.values());
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
// relation can never shadow a column (which generated unsound types and
|
|
507
|
-
// made both surfaces unusable).
|
|
508
|
-
const columnFieldsByTable = new Map();
|
|
509
|
-
const unknownTypedFieldsByTable = new Map();
|
|
510
|
-
for (const [tbl, cols] of columnsByTable) {
|
|
511
|
-
columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
|
|
512
|
-
// Enum-typed columns also report tsType 'unknown' here, but generate.ts
|
|
513
|
-
// gives them a concrete union type, a shadow of one was type-broken on
|
|
514
|
-
// main, so only genuine json/jsonb columns qualify as historical shadows.
|
|
515
|
-
unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
|
|
579
|
+
if (crossSchemaRefs.length > 0) {
|
|
580
|
+
console.warn(`[turbine] Skipped ${crossSchemaRefs.length} foreign key(s) referencing a table outside schema "${schema}": ` +
|
|
581
|
+
`${crossSchemaRefs.join(', ')}. Generated clients address tables by bare name within one schema, so no ` +
|
|
582
|
+
`relation is emitted for these. Introspect the other schema separately, or add the target table to this one.`);
|
|
516
583
|
}
|
|
517
|
-
//
|
|
518
|
-
// constraint
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
// table is a first-class entity, not a join table. Prisma and Drizzle both
|
|
529
|
-
// require explicit m2m declaration for exactly this reason.
|
|
530
|
-
//
|
|
531
|
-
// We only treat a table J as a PURE junction when ALL of these hold:
|
|
532
|
-
// 1. J's primary key is exactly two columns.
|
|
533
|
-
// 2. J has exactly two FKs, each single-column.
|
|
534
|
-
// 3. Each FK's source column is one of J's two PK columns (the PK *is* the
|
|
535
|
-
// two FK columns, no surrogate PK, no extra identity).
|
|
536
|
-
// 4. The two FKs target two DISTINCT tables (A and B).
|
|
537
|
-
// 5. J has no columns beyond those two FK/PK columns (no payload columns
|
|
538
|
-
// like `grade` or `created_at`).
|
|
539
|
-
//
|
|
540
|
-
// For such a J linking A and B we ADD a `manyToMany` relation on A → B and
|
|
541
|
-
// symmetrically on B → A, both routed `through` J. The existing belongsTo /
|
|
542
|
-
// hasMany relations derived from J's FKs are left untouched, this block
|
|
543
|
-
// never removes or renames anything. Naming/collision handling lives in the
|
|
544
|
-
// shared addAutoManyToManyRelations helper.
|
|
545
|
-
//
|
|
546
|
-
// Prisma's implicit m2m junctions have no primary key (just a two-column
|
|
547
|
-
// UNIQUE index over the FK columns), so pass the introspected two-column
|
|
548
|
-
// unique indexes as the fallback junction-key source.
|
|
549
|
-
const uniqueIndexColsByTable = new Map();
|
|
550
|
-
for (const [tbl, idxs] of indexesByTable) {
|
|
551
|
-
const twoColUniques = idxs.filter((idx) => idx.unique && idx.columns.length === 2).map((idx) => idx.columns);
|
|
552
|
-
if (twoColUniques.length > 0)
|
|
553
|
-
uniqueIndexColsByTable.set(tbl, twoColUniques);
|
|
584
|
+
// Referential actions (ON DELETE / ON UPDATE) per constraint. Keyed
|
|
585
|
+
// "<table>::<constraint>" because the NAME alone is not unique (the same
|
|
586
|
+
// collision SQL_FOREIGN_KEYS documents); buildRelationsFromForeignKeys
|
|
587
|
+
// prefers that key and falls back to the bare name for callers that build
|
|
588
|
+
// the map from a code-first schema, where names are synthesized per table.
|
|
589
|
+
const fkActions = new Map();
|
|
590
|
+
for (const row of fkResult.rows) {
|
|
591
|
+
fkActions.set(`${row.source_table}::${row.constraint_name}`, {
|
|
592
|
+
onDelete: pgConfActionToReferential(row.confdeltype),
|
|
593
|
+
onUpdate: pgConfActionToReferential(row.confupdtype),
|
|
594
|
+
});
|
|
554
595
|
}
|
|
555
|
-
|
|
596
|
+
// ----- Build relations from foreign keys -----
|
|
597
|
+
// Delegated to the shared catalog derivation, which `turbine mcp` also
|
|
598
|
+
// calls. Relation names are derived per-FK-column when several FKs point at
|
|
599
|
+
// the same target, every name is collision-checked against the table's
|
|
600
|
+
// scalar column fields so a relation can never shadow a column (which
|
|
601
|
+
// generated unsound types and made both surfaces unusable), a UNIQUE FK
|
|
602
|
+
// flips the reverse side to `hasOne`, and pure junction tables additionally
|
|
603
|
+
// get a `manyToMany` on each side. See deriveCatalogRelations for why the
|
|
604
|
+
// whole pipeline is one function rather than a call site per surface.
|
|
605
|
+
const relationsByTable = deriveCatalogRelations({
|
|
606
|
+
tableNames,
|
|
607
|
+
foreignKeys,
|
|
608
|
+
pkByTable,
|
|
609
|
+
columnsByTable,
|
|
610
|
+
uniqueByTable,
|
|
611
|
+
indexesByTable,
|
|
612
|
+
enums,
|
|
613
|
+
fkActions,
|
|
614
|
+
legacyToManyUniques: options.legacyToManyUniques,
|
|
615
|
+
});
|
|
556
616
|
// ----- Assemble TableMetadata for each table -----
|
|
557
617
|
const tables = {};
|
|
558
618
|
for (const tableName of tableNames) {
|
|
@@ -917,7 +977,24 @@ export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable,
|
|
|
917
977
|
takenFor(fk.sourceTable).add(belongsToName);
|
|
918
978
|
assignedFor(fk.sourceTable).add(belongsToName);
|
|
919
979
|
// Referential actions (omit the 'no action' default to keep metadata lean).
|
|
920
|
-
|
|
980
|
+
// A constraint NAME is only unique per table in Postgres, so the catalog
|
|
981
|
+
// introspector keys this map "<table>::<constraint>". Callers that
|
|
982
|
+
// synthesize names from a code-first schema key it by bare name, so both
|
|
983
|
+
// spellings resolve.
|
|
984
|
+
//
|
|
985
|
+
// The bare-name fallback is LIVE, not defensive: `schemaDefToMetadata` keys
|
|
986
|
+
// its map by the synthesized `<table>_<column>_fkey` alone, so the qualified
|
|
987
|
+
// lookup always misses there and this second lookup is the ONLY thing that
|
|
988
|
+
// carries a `defineSchema` relation's onDelete/onUpdate into the metadata.
|
|
989
|
+
// Deleting it would silently drop referential actions from every code-first
|
|
990
|
+
// schema (verified: a `references: { onDelete: 'cascade' }` resolves through
|
|
991
|
+
// this branch and through no other). It is safe for that producer precisely
|
|
992
|
+
// because the name it synthesizes already embeds the source table, so a bare
|
|
993
|
+
// hit cannot belong to a different table's constraint. A future producer
|
|
994
|
+
// emitting TABLE-AGNOSTIC constraint names would break that property and
|
|
995
|
+
// could mis-attribute one table's ON DELETE to another's relation; such a
|
|
996
|
+
// producer must key the map "<table>::<constraint>" like the catalog reader.
|
|
997
|
+
const actions = fkActions?.get(`${fk.sourceTable}::${fk.constraintName}`) ?? fkActions?.get(fk.constraintName) ?? undefined;
|
|
921
998
|
const actionFields = {};
|
|
922
999
|
if (actions?.onDelete && actions.onDelete !== 'no action')
|
|
923
1000
|
actionFields.onDelete = actions.onDelete;
|
|
@@ -1089,6 +1166,59 @@ export function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, c
|
|
|
1089
1166
|
addM2M(fkB, fkA); // B → A
|
|
1090
1167
|
}
|
|
1091
1168
|
}
|
|
1169
|
+
/**
|
|
1170
|
+
* One-stop relation derivation for every surface that reads a live PostgreSQL
|
|
1171
|
+
* CATALOG: `turbine generate` (via {@link introspectPostgresCatalog}) and the
|
|
1172
|
+
* MCP server, which introspects for itself because it cannot assume generated
|
|
1173
|
+
* metadata exists.
|
|
1174
|
+
*
|
|
1175
|
+
* THE REASON THIS IS ONE FUNCTION and not two call sites: the pipeline it drives
|
|
1176
|
+
* is `buildRelationsFromForeignKeys` (five parameters, two optional) plus
|
|
1177
|
+
* `addAutoManyToManyRelations` (eight parameters, three optional), and every
|
|
1178
|
+
* optional one CHANGES THE ANSWER while omitting it stays silently type-correct.
|
|
1179
|
+
* Hand-mirroring them drifted exactly that way: MCP passed four arguments and so
|
|
1180
|
+
* never received `uniqueSetsByTable`, which meant a UNIQUE foreign key produced
|
|
1181
|
+
* `users.profile` (hasOne) under `turbine generate` and `users.profiles`
|
|
1182
|
+
* (hasMany) under `turbine mcp`, against the same database. An MCP client
|
|
1183
|
+
* following its own schema tool then queried `with: { profiles: true }` and got
|
|
1184
|
+
* `TURBINE_E005 Unknown relation`. MCP also omitted `uniqueIndexColsByTable`,
|
|
1185
|
+
* losing every auto-m2m relation through a Prisma-style PK-less junction. Adding
|
|
1186
|
+
* an argument here now reaches both surfaces or neither.
|
|
1187
|
+
*
|
|
1188
|
+
* The engine introspectors (SQLite / MySQL / MSSQL) keep their own
|
|
1189
|
+
* {@link deriveEngineRelations} because they deliberately do NOT do the
|
|
1190
|
+
* unique-FK → `hasOne` flip.
|
|
1191
|
+
*/
|
|
1192
|
+
export function deriveCatalogRelations(inputs) {
|
|
1193
|
+
const { tableNames, foreignKeys, pkByTable, columnsByTable, uniqueByTable, indexesByTable, enums } = inputs;
|
|
1194
|
+
const columnFieldsByTable = new Map();
|
|
1195
|
+
const unknownTypedFieldsByTable = new Map();
|
|
1196
|
+
for (const [tbl, cols] of columnsByTable) {
|
|
1197
|
+
columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
|
|
1198
|
+
// Enum-typed columns also report tsType 'unknown' here, but generate.ts
|
|
1199
|
+
// gives them a concrete union type, so a shadow of one is type-broken and
|
|
1200
|
+
// must NOT be preserved as a historical json/jsonb shadow.
|
|
1201
|
+
unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
|
|
1202
|
+
}
|
|
1203
|
+
// F2: unless the caller opts out, detect child FK column sets that a unique
|
|
1204
|
+
// constraint / plain unique index exactly covers, so the reverse relation is
|
|
1205
|
+
// emitted as a one-to-one (`hasOne`) instead of `hasMany`.
|
|
1206
|
+
const uniqueSetsByTable = inputs.legacyToManyUniques
|
|
1207
|
+
? undefined
|
|
1208
|
+
: detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable);
|
|
1209
|
+
const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, inputs.fkActions, unknownTypedFieldsByTable, uniqueSetsByTable);
|
|
1210
|
+
// Prisma's implicit m2m junctions have no primary key (just a two-column
|
|
1211
|
+
// UNIQUE index over the FK columns), so pass the introspected two-column
|
|
1212
|
+
// unique indexes as the fallback junction-key source.
|
|
1213
|
+
const uniqueIndexColsByTable = new Map();
|
|
1214
|
+
for (const [tbl, idxs] of indexesByTable) {
|
|
1215
|
+
const twoColUniques = idxs.filter((idx) => idx.unique && idx.columns.length === 2).map((idx) => idx.columns);
|
|
1216
|
+
if (twoColUniques.length > 0)
|
|
1217
|
+
uniqueIndexColsByTable.set(tbl, twoColUniques);
|
|
1218
|
+
}
|
|
1219
|
+
addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable, uniqueIndexColsByTable);
|
|
1220
|
+
return relationsByTable;
|
|
1221
|
+
}
|
|
1092
1222
|
/**
|
|
1093
1223
|
* One-stop relation derivation for the engine introspectors (SQLite / MySQL /
|
|
1094
1224
|
* MSSQL): filters the FK list to the introspected table set, seeds the
|
package/dist/mssql.js
CHANGED
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
*/
|
|
92
92
|
import { TurbineClient } from './client.js';
|
|
93
93
|
import { postgresDialect, } from './dialect.js';
|
|
94
|
-
import { ConnectionError, RelationError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
94
|
+
import { ConnectionError, malformedConnectionStringMessage, markValueBearingMessage, RelationError, UnsupportedFeatureError, ValidationError, } from './errors.js';
|
|
95
95
|
import { applyTableFilters, deriveEngineRelations } from './introspect.js';
|
|
96
96
|
import importOptionalPeer from './optional-peer-import.cjs';
|
|
97
97
|
import { camelToSnake, isDateType, normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
@@ -208,6 +208,16 @@ function augmentMssqlError(err) {
|
|
|
208
208
|
const m = /(?:constraint|index)\s+'([^']+)'/i.exec(msg) ?? /'([^']+)'/.exec(msg);
|
|
209
209
|
if (m?.[1])
|
|
210
210
|
target.constraint = m[1];
|
|
211
|
+
// The message ends `The duplicate key value is (alice@example.com).`, i.e.
|
|
212
|
+
// SQL Server reports the conflicting ROW VALUE in the message text and has
|
|
213
|
+
// no `detail` field for 'safe' mode to redact, so this error used to reach
|
|
214
|
+
// `.cause` verbatim. Flag it so the message is withheld in 'safe' mode
|
|
215
|
+
// (see markValueBearingMessage); the constraint NAME is already captured
|
|
216
|
+
// above and survives in both modes.
|
|
217
|
+
//
|
|
218
|
+
// Deliberately only 2627/2601: 547 (FK/CHECK conflict) and 515 (NULL into
|
|
219
|
+
// a non-nullable column) name the database/table/column and never a value.
|
|
220
|
+
markValueBearingMessage(err);
|
|
211
221
|
return err;
|
|
212
222
|
}
|
|
213
223
|
// 547 = FOREIGN KEY / CHECK constraint conflict (message distinguishes them).
|
|
@@ -524,6 +534,26 @@ export const mssqlDialect = {
|
|
|
524
534
|
quoteIdentifier(name) {
|
|
525
535
|
return `[${name.replace(/]/g, ']]')}]`;
|
|
526
536
|
},
|
|
537
|
+
/**
|
|
538
|
+
* T-SQL's `LIKE` has a FOURTH metacharacter the SQL-standard set does not:
|
|
539
|
+
* `[` opens a character class (`[abc]`, `[a-z]`, `[^x]`). So the shared
|
|
540
|
+
* `escapeLike` left `contains: '[draft]'` meaning "contains any one of d, r,
|
|
541
|
+
* a, f or t" here, while PostgreSQL, MySQL and SQLite all read the value as a
|
|
542
|
+
* literal (verified: none of the other three treat `[` specially). Not
|
|
543
|
+
* injection, the operand is still a bound parameter; a wrong ANSWER, and one
|
|
544
|
+
* that gets broader the longer the bracketed text is.
|
|
545
|
+
*
|
|
546
|
+
* Only `[` needs escaping. A `]` outside a class is a literal to T-SQL, and
|
|
547
|
+
* escaping the opener is enough to stop a class from ever being opened, so
|
|
548
|
+
* `]` is deliberately left alone (escaping it would be harmless but is not
|
|
549
|
+
* required, and the smaller pattern is the one that stays obviously correct).
|
|
550
|
+
* The prefix is the same backslash the emitted `ESCAPE '\'` clause names, and
|
|
551
|
+
* the backslash pass runs FIRST so an escape character in the value is itself
|
|
552
|
+
* escaped before any is added.
|
|
553
|
+
*/
|
|
554
|
+
escapeLikePattern(value) {
|
|
555
|
+
return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_').replace(/\[/g, '\\[');
|
|
556
|
+
},
|
|
527
557
|
// SQL Server aggregate casts: COUNT → INT, AVG/float → FLOAT.
|
|
528
558
|
jsonWireRule(columnType) {
|
|
529
559
|
const t = columnType.toLowerCase();
|
|
@@ -553,6 +583,17 @@ export const mssqlDialect = {
|
|
|
553
583
|
},
|
|
554
584
|
};
|
|
555
585
|
}
|
|
586
|
+
// NO json rule here, and that is a checked answer rather than an omission.
|
|
587
|
+
// SQL Server on the supported floor (2016 through 2022) has no JSON column
|
|
588
|
+
// type: `mssqlColumnType` maps JSON/JSONB to NVARCHAR(MAX), and
|
|
589
|
+
// introspection reports the carrier as `nvarchar`. FOR JSON PATH escapes an
|
|
590
|
+
// nvarchar cell as a JSON *string* (only `buildRelationSubquery`'s nested
|
|
591
|
+
// relations are JSON_QUERY-wrapped, never a scalar column), so parsing the
|
|
592
|
+
// row hands back exactly the string the driver returns for a top-level
|
|
593
|
+
// read. Both routes already agree, and casting would only add noise. This
|
|
594
|
+
// is the divergence MySQL DOES have, where JSON_OBJECT embeds a real JSON
|
|
595
|
+
// column as a nested value while the driver hands back text.
|
|
596
|
+
//
|
|
556
597
|
// Binary columns come out of FOR JSON PATH as BASE64 text ("AQL/") rather
|
|
557
598
|
// than bytes. Style 2 converts to bare hex, which rebuilds exactly.
|
|
558
599
|
if (t === 'binary' || t === 'varbinary' || t === 'image') {
|
|
@@ -1228,7 +1269,8 @@ function parseMssqlConfig(connectionString) {
|
|
|
1228
1269
|
return config;
|
|
1229
1270
|
}
|
|
1230
1271
|
catch {
|
|
1231
|
-
|
|
1272
|
+
// Never echo the value, see malformedConnectionStringMessage.
|
|
1273
|
+
throw new ConnectionError(malformedConnectionStringMessage('SQL Server', 'mssql://user:password@localhost:1433/app'));
|
|
1232
1274
|
}
|
|
1233
1275
|
}
|
|
1234
1276
|
/**
|
package/dist/mysql.d.ts
CHANGED
|
@@ -164,8 +164,11 @@ export interface TurbineMysqlOptions extends EngineClientConfig {
|
|
|
164
164
|
* `disconnect()` is a no-op, advanced config like SSL lives here).
|
|
165
165
|
*
|
|
166
166
|
* When Turbine builds the pool (string/config), it pins the correct mysql2 flags
|
|
167
|
-
* (named placeholders, bignum, UTC dates
|
|
168
|
-
*
|
|
167
|
+
* (named placeholders, bignum, UTC dates), probes `SELECT VERSION()` to reject
|
|
168
|
+
* MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created. None of
|
|
169
|
+
* those flags changes a column's TYPE, so an injected pool reads every value
|
|
170
|
+
* the same way (see {@link MYSQL_DRIVER_FLAGS}); `namedPlaceholders: true` is
|
|
171
|
+
* the one an injected pool must set for itself.
|
|
169
172
|
*
|
|
170
173
|
* @example
|
|
171
174
|
* ```ts
|