turbine-orm 0.28.3 → 0.30.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 +1 -1
- package/dist/cjs/cli/index.js +5 -0
- package/dist/cjs/cli/mcp.js +22 -92
- package/dist/cjs/client.js +69 -5
- package/dist/cjs/generate.js +71 -25
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/introspect.js +350 -120
- package/dist/cjs/mssql.js +18 -133
- package/dist/cjs/mysql.js +16 -129
- package/dist/cjs/optional-peer-import.cjs +122 -0
- package/dist/cjs/powdb.js +440 -81
- package/dist/cjs/powql.js +49 -25
- package/dist/cjs/query/builder.js +290 -23
- package/dist/cjs/query/filters.js +32 -1
- package/dist/cjs/schema-metadata.js +316 -0
- package/dist/cjs/sqlite.js +8 -89
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/mcp.d.ts +18 -0
- package/dist/cli/mcp.js +22 -93
- package/dist/client.d.ts +44 -6
- package/dist/client.js +69 -5
- package/dist/generate.d.ts +16 -4
- package/dist/generate.js +71 -25
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/introspect.d.ts +94 -1
- package/dist/introspect.js +345 -120
- package/dist/mssql.js +16 -101
- package/dist/mysql.js +14 -97
- package/dist/optional-peer-import.cjs +89 -0
- package/dist/optional-peer-import.d.cts +53 -0
- package/dist/powdb.d.ts +94 -26
- package/dist/powdb.js +435 -80
- package/dist/powql.d.ts +6 -0
- package/dist/powql.js +51 -27
- package/dist/query/builder.d.ts +60 -3
- package/dist/query/builder.js +291 -24
- package/dist/query/deferred.d.ts +7 -2
- package/dist/query/filters.d.ts +18 -0
- package/dist/query/filters.js +30 -0
- package/dist/query/types.d.ts +19 -0
- package/dist/schema-metadata.d.ts +77 -0
- package/dist/schema-metadata.js +313 -0
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +9 -90
- package/package.json +3 -3
package/dist/introspect.js
CHANGED
|
@@ -44,6 +44,7 @@ const SQL_COLUMNS = `
|
|
|
44
44
|
table_name,
|
|
45
45
|
column_name,
|
|
46
46
|
udt_name,
|
|
47
|
+
udt_schema,
|
|
47
48
|
data_type,
|
|
48
49
|
is_nullable,
|
|
49
50
|
column_default,
|
|
@@ -146,6 +147,7 @@ const SQL_MATVIEW_COLUMNS = `
|
|
|
146
147
|
c.relname AS table_name,
|
|
147
148
|
a.attname AS column_name,
|
|
148
149
|
t.typname AS udt_name,
|
|
150
|
+
tn.nspname AS udt_schema,
|
|
149
151
|
CASE WHEN t.typcategory = 'A' THEN 'ARRAY' ELSE 'base' END AS data_type,
|
|
150
152
|
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
|
|
151
153
|
NULL AS column_default,
|
|
@@ -158,6 +160,7 @@ const SQL_MATVIEW_COLUMNS = `
|
|
|
158
160
|
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
159
161
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
160
162
|
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
163
|
+
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace
|
|
161
164
|
WHERE n.nspname = $1
|
|
162
165
|
AND c.relkind = 'm'
|
|
163
166
|
AND a.attnum > 0
|
|
@@ -288,6 +291,14 @@ export async function introspectPostgresCatalog(options) {
|
|
|
288
291
|
arrayType,
|
|
289
292
|
pgArrayType: arrayType,
|
|
290
293
|
maxLength: row.character_maximum_length ?? undefined,
|
|
294
|
+
// Record the type's schema ONLY when it lives outside the introspected
|
|
295
|
+
// schema (and isn't a pg_catalog builtin). A same-named enum in another
|
|
296
|
+
// schema must NOT get this schema's `::"enum"` cast — search_path would
|
|
297
|
+
// resolve the cast to the wrong type (see enumTypeForColumn). Omitting
|
|
298
|
+
// it for the common case keeps generated metadata byte-identical.
|
|
299
|
+
...(typeof row.udt_schema === 'string' && row.udt_schema !== schema && row.udt_schema !== 'pg_catalog'
|
|
300
|
+
? { pgTypeSchema: row.udt_schema }
|
|
301
|
+
: {}),
|
|
291
302
|
};
|
|
292
303
|
if (!columnsByTable.has(tableName))
|
|
293
304
|
columnsByTable.set(tableName, []);
|
|
@@ -359,6 +370,9 @@ export async function introspectPostgresCatalog(options) {
|
|
|
359
370
|
enums[row.typname] = [];
|
|
360
371
|
enums[row.typname].push(row.enumlabel);
|
|
361
372
|
}
|
|
373
|
+
// ----- Build foreign key map -----
|
|
374
|
+
// Group FK rows by constraint_name to correctly handle multi-column composite FKs.
|
|
375
|
+
// Each constraint becomes one FKEntry with arrays of columns.
|
|
362
376
|
const fkGroups = new Map();
|
|
363
377
|
for (const row of fkResult.rows) {
|
|
364
378
|
if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
|
|
@@ -379,65 +393,21 @@ export async function introspectPostgresCatalog(options) {
|
|
|
379
393
|
}
|
|
380
394
|
const foreignKeys = Array.from(fkGroups.values());
|
|
381
395
|
// ----- Build relations from foreign keys -----
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
for (const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
|
|
396
|
-
// --- belongsTo on the source (child) table ---
|
|
397
|
-
// e.g. posts.user_id → users.id creates posts.user (belongsTo)
|
|
398
|
-
// For composite FKs with disambiguation, use the constraint name
|
|
399
|
-
const belongsToName = needsDisambiguation
|
|
400
|
-
? fk.sourceColumns.length === 1
|
|
401
|
-
? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
|
|
402
|
-
: snakeToCamel(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
|
|
403
|
-
: singularize(snakeToCamel(fk.targetTable));
|
|
404
|
-
// Referential actions (omit the 'no action' default to keep metadata lean).
|
|
405
|
-
const actions = fkActions.get(fk.constraintName);
|
|
406
|
-
const actionFields = {};
|
|
407
|
-
if (actions?.onDelete && actions.onDelete !== 'no action')
|
|
408
|
-
actionFields.onDelete = actions.onDelete;
|
|
409
|
-
if (actions?.onUpdate && actions.onUpdate !== 'no action')
|
|
410
|
-
actionFields.onUpdate = actions.onUpdate;
|
|
411
|
-
if (!relationsByTable.has(fk.sourceTable))
|
|
412
|
-
relationsByTable.set(fk.sourceTable, {});
|
|
413
|
-
relationsByTable.get(fk.sourceTable)[belongsToName] = {
|
|
414
|
-
type: 'belongsTo',
|
|
415
|
-
name: belongsToName,
|
|
416
|
-
from: fk.sourceTable,
|
|
417
|
-
to: fk.targetTable,
|
|
418
|
-
foreignKey,
|
|
419
|
-
referenceKey,
|
|
420
|
-
...actionFields,
|
|
421
|
-
};
|
|
422
|
-
// --- hasMany on the target (parent) table ---
|
|
423
|
-
// e.g. posts.user_id → users.id creates users.posts (hasMany)
|
|
424
|
-
const hasManyName = needsDisambiguation
|
|
425
|
-
? fk.sourceColumns.length === 1
|
|
426
|
-
? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
427
|
-
: snakeToCamel(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
|
|
428
|
-
: snakeToCamel(fk.sourceTable);
|
|
429
|
-
if (!relationsByTable.has(fk.targetTable))
|
|
430
|
-
relationsByTable.set(fk.targetTable, {});
|
|
431
|
-
relationsByTable.get(fk.targetTable)[hasManyName] = {
|
|
432
|
-
type: 'hasMany',
|
|
433
|
-
name: hasManyName,
|
|
434
|
-
from: fk.targetTable,
|
|
435
|
-
to: fk.sourceTable,
|
|
436
|
-
foreignKey,
|
|
437
|
-
referenceKey,
|
|
438
|
-
...actionFields,
|
|
439
|
-
};
|
|
396
|
+
// Delegated to the pure, unit-testable builder. Relation names are derived
|
|
397
|
+
// per-FK-column when several FKs point at the same target, and every name
|
|
398
|
+
// is collision-checked against the table's scalar column fields so a
|
|
399
|
+
// relation can never shadow a column (which generated unsound types and
|
|
400
|
+
// made both surfaces unusable — dogfood T-4).
|
|
401
|
+
const columnFieldsByTable = new Map();
|
|
402
|
+
const unknownTypedFieldsByTable = new Map();
|
|
403
|
+
for (const [tbl, cols] of columnsByTable) {
|
|
404
|
+
columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
|
|
405
|
+
// Enum-typed columns also report tsType 'unknown' here, but generate.ts
|
|
406
|
+
// gives them a concrete union type — a shadow of one was type-broken on
|
|
407
|
+
// main, so only genuine json/jsonb columns qualify as historical shadows.
|
|
408
|
+
unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
|
|
440
409
|
}
|
|
410
|
+
const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable);
|
|
441
411
|
// ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
|
|
442
412
|
//
|
|
443
413
|
// Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
|
|
@@ -457,68 +427,9 @@ export async function introspectPostgresCatalog(options) {
|
|
|
457
427
|
// For such a J linking A and B we ADD a `manyToMany` relation on A → B and
|
|
458
428
|
// symmetrically on B → A, both routed `through` J. The existing belongsTo /
|
|
459
429
|
// hasMany relations derived from J's FKs are left untouched — this block
|
|
460
|
-
// never removes or renames anything.
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
for (const tableName of tableNames) {
|
|
464
|
-
const pk = pkByTable.get(tableName) ?? [];
|
|
465
|
-
if (pk.length !== 2)
|
|
466
|
-
continue;
|
|
467
|
-
// FKs whose source is this table.
|
|
468
|
-
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
469
|
-
if (tableFks.length !== 2)
|
|
470
|
-
continue;
|
|
471
|
-
// Both FKs must be single-column.
|
|
472
|
-
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
473
|
-
continue;
|
|
474
|
-
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
475
|
-
const pkSet = new Set(pk);
|
|
476
|
-
// Both FK columns must be the PK columns (and vice-versa).
|
|
477
|
-
if (!fkCols.every((c) => pkSet.has(c)))
|
|
478
|
-
continue;
|
|
479
|
-
if (new Set(fkCols).size !== 2)
|
|
480
|
-
continue;
|
|
481
|
-
// Two DISTINCT target tables.
|
|
482
|
-
const [fkA, fkB] = tableFks;
|
|
483
|
-
if (fkA.targetTable === fkB.targetTable)
|
|
484
|
-
continue;
|
|
485
|
-
// No payload columns: J's columns are exactly the two FK/PK columns.
|
|
486
|
-
const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
|
|
487
|
-
if (jCols.length !== 2)
|
|
488
|
-
continue;
|
|
489
|
-
// For each direction, the m2m `referenceKey` is the *targeted* table's
|
|
490
|
-
// referenced column(s); the junction's sourceKey is the FK column pointing
|
|
491
|
-
// to that table; the targetKey is the FK column pointing to the OTHER table.
|
|
492
|
-
const addM2M = (self, other) => {
|
|
493
|
-
const sourceTbl = self.targetTable; // A
|
|
494
|
-
const targetTbl = other.targetTable; // B
|
|
495
|
-
const relName = snakeToCamel(targetTbl); // plural table name → e.g. "tags"
|
|
496
|
-
if (!relationsByTable.has(sourceTbl))
|
|
497
|
-
relationsByTable.set(sourceTbl, {});
|
|
498
|
-
const existing = relationsByTable.get(sourceTbl);
|
|
499
|
-
// Additive-only: never clobber an existing relation name.
|
|
500
|
-
if (existing[relName])
|
|
501
|
-
return;
|
|
502
|
-
existing[relName] = {
|
|
503
|
-
type: 'manyToMany',
|
|
504
|
-
name: relName,
|
|
505
|
-
from: sourceTbl,
|
|
506
|
-
to: targetTbl,
|
|
507
|
-
// referenceKey = A's referenced column(s) that J's sourceKey points at.
|
|
508
|
-
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
509
|
-
// foreignKey is unused for m2m correlation but kept for shape parity
|
|
510
|
-
// (mirrors the source-side reference for back-compat consumers).
|
|
511
|
-
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
512
|
-
through: {
|
|
513
|
-
table: tableName,
|
|
514
|
-
sourceKey: self.sourceColumns[0], // J col → A
|
|
515
|
-
targetKey: other.sourceColumns[0], // J col → B
|
|
516
|
-
},
|
|
517
|
-
};
|
|
518
|
-
};
|
|
519
|
-
addM2M(fkA, fkB); // A → B
|
|
520
|
-
addM2M(fkB, fkA); // B → A
|
|
521
|
-
}
|
|
430
|
+
// never removes or renames anything. Naming/collision handling lives in the
|
|
431
|
+
// shared addAutoManyToManyRelations helper.
|
|
432
|
+
addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
|
|
522
433
|
// ----- Assemble TableMetadata for each table -----
|
|
523
434
|
const tables = {};
|
|
524
435
|
for (const tableName of tableNames) {
|
|
@@ -594,3 +505,317 @@ export function stripCheckWrapper(def) {
|
|
|
594
505
|
}
|
|
595
506
|
return s;
|
|
596
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* Derive a belongsTo relation name from its FK column. Strips a trailing
|
|
510
|
+
* `_id` (snake_case) or `Id` (camelCase column names — common in Prisma-ported
|
|
511
|
+
* schemas where columns are quoted camelCase identifiers), then camelCases:
|
|
512
|
+
* `current_version_id` and `currentVersionId` both yield `currentVersion`.
|
|
513
|
+
* Stripping is what keeps the scalar FK field (`currentVersionId`) targetable
|
|
514
|
+
* alongside the relation. A column literally named `id` (nothing left after
|
|
515
|
+
* stripping) keeps its own name.
|
|
516
|
+
*/
|
|
517
|
+
export function relationNameFromColumn(column) {
|
|
518
|
+
let base = column;
|
|
519
|
+
if (/_id$/i.test(base))
|
|
520
|
+
base = base.slice(0, -3);
|
|
521
|
+
else if (/[a-z0-9]Id$/.test(base))
|
|
522
|
+
base = base.slice(0, -2);
|
|
523
|
+
if (base.length === 0)
|
|
524
|
+
base = column;
|
|
525
|
+
return snakeToCamel(base);
|
|
526
|
+
}
|
|
527
|
+
/** Uppercase the first character (camelCase → PascalCase join helper). */
|
|
528
|
+
function upperFirst(s) {
|
|
529
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* True for the tsType forms a json/jsonb column maps to (`unknown`, nullable
|
|
533
|
+
* `unknown | null`). A relation shadowing such a column is a HISTORICAL shadow
|
|
534
|
+
* that worked at runtime and compiled (`unknown` absorbs the relation
|
|
535
|
+
* payload), so the legacy-first naming keeps it instead of renaming.
|
|
536
|
+
*/
|
|
537
|
+
export function isUnknownTsType(tsType) {
|
|
538
|
+
return tsType === 'unknown' || tsType === 'unknown | null';
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Resolve a derived relation name against the names already taken on the
|
|
542
|
+
* table (scalar column fields + previously assigned relations). On collision,
|
|
543
|
+
* applies a deterministic `Rel` / `Rel2` / `Rel3`… suffix and warns — a
|
|
544
|
+
* colliding name would otherwise shadow a column field and generate types
|
|
545
|
+
* that fail `tsc --strict` (TS2430/TS2322).
|
|
546
|
+
*/
|
|
547
|
+
function resolveRelationNameCollision(candidate, taken, table, source) {
|
|
548
|
+
if (!taken.has(candidate))
|
|
549
|
+
return candidate;
|
|
550
|
+
let name = `${candidate}Rel`;
|
|
551
|
+
for (let i = 2; taken.has(name); i++)
|
|
552
|
+
name = `${candidate}Rel${i}`;
|
|
553
|
+
console.warn(`[turbine] Relation name "${candidate}" on table "${table}" (from ${source}) collides with an existing column or relation — using "${name}" instead.`);
|
|
554
|
+
return name;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Build the belongsTo/hasMany relation maps for every table from its foreign
|
|
558
|
+
* keys. Naming rules (LEGACY-FIRST — a relation name that previously worked at
|
|
559
|
+
* runtime must never change out from under a regenerating app):
|
|
560
|
+
*
|
|
561
|
+
* 1. First compute the historical derivation exactly as it shipped before
|
|
562
|
+
* the collision guard existed: belongsTo strips a case-SENSITIVE `_id`
|
|
563
|
+
* suffix (`snakeToCamel(col.replace(/_id$/, ''))` when several FKs point
|
|
564
|
+
* at the same target, else the singularized target table), and hasMany is
|
|
565
|
+
* `snakeToCamel(`${source}_by_${strippedColumn}`)` (else the source
|
|
566
|
+
* table). If that legacy name is free, KEEP IT — even when it looks odd
|
|
567
|
+
* (`blogPostsByAuthorId`, `postsBy_Author`): those names were collision-
|
|
568
|
+
* free and worked, so regenerating must not rename them.
|
|
569
|
+
* 2. If the legacy name collides ONLY with a scalar column whose tsType is
|
|
570
|
+
* `unknown` (json/jsonb), keep it anyway with a warning: the shadow is
|
|
571
|
+
* historical, ran fine at runtime, and compiled (`unknown` absorbs the
|
|
572
|
+
* relation payload; generate.ts's typeSafeRelations omits the relation
|
|
573
|
+
* from the type layer).
|
|
574
|
+
* 3. On a genuine collision (concrete-typed column shadow, or a previously
|
|
575
|
+
* assigned relation), fall back to the modern derivation — the `_id`/`Id`
|
|
576
|
+
* case-insensitive strip of {@link relationNameFromColumn} plus the
|
|
577
|
+
* `By`-composed reverse name — which fixes the camelCase-FK shadowing
|
|
578
|
+
* shapes that were actually BROKEN before (relation name === scalar FK
|
|
579
|
+
* field → unusable types).
|
|
580
|
+
* 4. Last resort: deterministic `Rel`/`Rel2` suffix + warning.
|
|
581
|
+
*
|
|
582
|
+
* @param columnFieldsByTable camelCase column *fields* per table — used to
|
|
583
|
+
* guarantee relations never shadow concrete-typed scalar columns.
|
|
584
|
+
* @param unknownTypedFieldsByTable subset of the column fields whose tsType is
|
|
585
|
+
* `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
|
|
586
|
+
*/
|
|
587
|
+
export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable) {
|
|
588
|
+
// Count FKs per (source, target) pair for disambiguation.
|
|
589
|
+
const fkCounts = new Map();
|
|
590
|
+
for (const fk of foreignKeys) {
|
|
591
|
+
const key = `${fk.sourceTable}→${fk.targetTable}`;
|
|
592
|
+
fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
|
|
593
|
+
}
|
|
594
|
+
const relationsByTable = new Map();
|
|
595
|
+
// Names already taken per table: seeded with the scalar column fields so a
|
|
596
|
+
// relation can never shadow a column; relation names are added as assigned.
|
|
597
|
+
const takenByTable = new Map();
|
|
598
|
+
const takenFor = (table) => {
|
|
599
|
+
let taken = takenByTable.get(table);
|
|
600
|
+
if (!taken) {
|
|
601
|
+
taken = new Set(columnFieldsByTable.get(table) ?? []);
|
|
602
|
+
takenByTable.set(table, taken);
|
|
603
|
+
}
|
|
604
|
+
return taken;
|
|
605
|
+
};
|
|
606
|
+
// Relation names actually assigned so far (as opposed to column fields) —
|
|
607
|
+
// needed to tell "collides only with a column" apart from "collides with an
|
|
608
|
+
// already-assigned relation" for the legacy-shadow-preserving rule.
|
|
609
|
+
const assignedByTable = new Map();
|
|
610
|
+
const assignedFor = (table) => {
|
|
611
|
+
let assigned = assignedByTable.get(table);
|
|
612
|
+
if (!assigned) {
|
|
613
|
+
assigned = new Set();
|
|
614
|
+
assignedByTable.set(table, assigned);
|
|
615
|
+
}
|
|
616
|
+
return assigned;
|
|
617
|
+
};
|
|
618
|
+
/** Legacy-first name resolution — see the naming rules in the JSDoc above. */
|
|
619
|
+
const resolveName = (legacy, modern, table, source) => {
|
|
620
|
+
const taken = takenFor(table);
|
|
621
|
+
if (!taken.has(legacy))
|
|
622
|
+
return legacy;
|
|
623
|
+
// Historical json/jsonb shadow: previously worked at runtime AND compiled
|
|
624
|
+
// (tsType `unknown` absorbs the relation payload). Keep the name, warn —
|
|
625
|
+
// typeSafeRelations() keeps the generated type layer sound.
|
|
626
|
+
if (!assignedFor(table).has(legacy) && unknownTypedFieldsByTable?.get(table)?.has(legacy)) {
|
|
627
|
+
console.warn(`[turbine] Relation "${legacy}" on table "${table}" (from ${source}) shadows the json/jsonb column ` +
|
|
628
|
+
`"${legacy}" — keeping the historical name for runtime compatibility; the relation is omitted from ` +
|
|
629
|
+
`the generated types. Rename the column to expose it.`);
|
|
630
|
+
return legacy;
|
|
631
|
+
}
|
|
632
|
+
if (modern !== null && modern !== legacy && !taken.has(modern))
|
|
633
|
+
return modern;
|
|
634
|
+
return resolveRelationNameCollision(modern ?? legacy, taken, table, source);
|
|
635
|
+
};
|
|
636
|
+
for (const fk of foreignKeys) {
|
|
637
|
+
const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
|
|
638
|
+
const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
|
|
639
|
+
const singleColumn = fk.sourceColumns.length === 1;
|
|
640
|
+
// For single-column FKs, keep string form for backwards compatibility.
|
|
641
|
+
// For multi-column (composite) FKs, use array form.
|
|
642
|
+
const foreignKey = singleColumn ? fk.sourceColumns[0] : fk.sourceColumns;
|
|
643
|
+
const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
|
|
644
|
+
// Composite FKs have no single column to derive from — fall back to the
|
|
645
|
+
// constraint name (with the usual fk_/-_fkey affixes stripped).
|
|
646
|
+
const constraintBase = fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '');
|
|
647
|
+
// --- belongsTo on the source (child) table ---
|
|
648
|
+
// e.g. posts.user_id → users.id creates posts.user (belongsTo)
|
|
649
|
+
const legacyBelongsTo = needsDisambiguation
|
|
650
|
+
? singleColumn
|
|
651
|
+
? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
|
|
652
|
+
: snakeToCamel(constraintBase)
|
|
653
|
+
: singularize(snakeToCamel(fk.targetTable));
|
|
654
|
+
const modernBelongsTo = needsDisambiguation && singleColumn ? relationNameFromColumn(fk.sourceColumns[0]) : null;
|
|
655
|
+
const belongsToName = resolveName(legacyBelongsTo, modernBelongsTo, fk.sourceTable, `FK ${fk.constraintName}`);
|
|
656
|
+
takenFor(fk.sourceTable).add(belongsToName);
|
|
657
|
+
assignedFor(fk.sourceTable).add(belongsToName);
|
|
658
|
+
// Referential actions (omit the 'no action' default to keep metadata lean).
|
|
659
|
+
const actions = fkActions?.get(fk.constraintName);
|
|
660
|
+
const actionFields = {};
|
|
661
|
+
if (actions?.onDelete && actions.onDelete !== 'no action')
|
|
662
|
+
actionFields.onDelete = actions.onDelete;
|
|
663
|
+
if (actions?.onUpdate && actions.onUpdate !== 'no action')
|
|
664
|
+
actionFields.onUpdate = actions.onUpdate;
|
|
665
|
+
if (!relationsByTable.has(fk.sourceTable))
|
|
666
|
+
relationsByTable.set(fk.sourceTable, {});
|
|
667
|
+
relationsByTable.get(fk.sourceTable)[belongsToName] = {
|
|
668
|
+
type: 'belongsTo',
|
|
669
|
+
name: belongsToName,
|
|
670
|
+
from: fk.sourceTable,
|
|
671
|
+
to: fk.targetTable,
|
|
672
|
+
foreignKey,
|
|
673
|
+
referenceKey,
|
|
674
|
+
...actionFields,
|
|
675
|
+
};
|
|
676
|
+
// --- hasMany on the target (parent) table ---
|
|
677
|
+
// e.g. posts.user_id → users.id creates users.posts (hasMany)
|
|
678
|
+
const legacyHasMany = needsDisambiguation
|
|
679
|
+
? singleColumn
|
|
680
|
+
? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
681
|
+
: snakeToCamel(`${fk.sourceTable}_by_${constraintBase}`)
|
|
682
|
+
: snakeToCamel(fk.sourceTable);
|
|
683
|
+
const modernHasMany = needsDisambiguation
|
|
684
|
+
? singleColumn
|
|
685
|
+
? `${snakeToCamel(fk.sourceTable)}By${upperFirst(relationNameFromColumn(fk.sourceColumns[0]))}`
|
|
686
|
+
: `${snakeToCamel(fk.sourceTable)}By${upperFirst(snakeToCamel(constraintBase))}`
|
|
687
|
+
: null;
|
|
688
|
+
const hasManyName = resolveName(legacyHasMany, modernHasMany, fk.targetTable, `FK ${fk.constraintName}`);
|
|
689
|
+
takenFor(fk.targetTable).add(hasManyName);
|
|
690
|
+
assignedFor(fk.targetTable).add(hasManyName);
|
|
691
|
+
if (!relationsByTable.has(fk.targetTable))
|
|
692
|
+
relationsByTable.set(fk.targetTable, {});
|
|
693
|
+
relationsByTable.get(fk.targetTable)[hasManyName] = {
|
|
694
|
+
type: 'hasMany',
|
|
695
|
+
name: hasManyName,
|
|
696
|
+
from: fk.targetTable,
|
|
697
|
+
to: fk.sourceTable,
|
|
698
|
+
foreignKey,
|
|
699
|
+
referenceKey,
|
|
700
|
+
...actionFields,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return relationsByTable;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Conservative auto-`manyToMany` detection over pure junction tables, shared
|
|
707
|
+
* by the Postgres introspector, the engine introspectors (SQLite / MySQL /
|
|
708
|
+
* MSSQL), the MCP server, and `schemaDefToMetadata()` so all surfaces derive
|
|
709
|
+
* IDENTICAL relation names for the same logical schema.
|
|
710
|
+
*
|
|
711
|
+
* A table J is a PURE junction only when ALL of these hold:
|
|
712
|
+
* 1. J's primary key is exactly two columns.
|
|
713
|
+
* 2. J has exactly two FKs, each single-column.
|
|
714
|
+
* 3. Each FK's source column is one of J's two PK columns.
|
|
715
|
+
* 4. The two FKs target two DISTINCT tables (A and B).
|
|
716
|
+
* 5. J has no payload columns beyond the two FK/PK columns.
|
|
717
|
+
*
|
|
718
|
+
* For such a J linking A and B this ADDS a `manyToMany` on A → B and B → A
|
|
719
|
+
* routed `through` J. It never removes or renames an existing relation:
|
|
720
|
+
* - an already-assigned relation with the same name → SKIP (additive-only,
|
|
721
|
+
* unchanged historical behavior);
|
|
722
|
+
* - a shadowed json/jsonb (`unknown`-typed) column → keep the historical
|
|
723
|
+
* name + warn (it worked at runtime and compiled);
|
|
724
|
+
* - a shadowed concrete-typed column → deterministic `Rel` suffix + warn
|
|
725
|
+
* instead of silently dropping the relation.
|
|
726
|
+
*/
|
|
727
|
+
export function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, columnNamesByTable, relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable) {
|
|
728
|
+
for (const tableName of tableNames) {
|
|
729
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
730
|
+
if (pk.length !== 2)
|
|
731
|
+
continue;
|
|
732
|
+
// FKs whose source is this table — both must be single-column.
|
|
733
|
+
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
734
|
+
if (tableFks.length !== 2)
|
|
735
|
+
continue;
|
|
736
|
+
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
737
|
+
continue;
|
|
738
|
+
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
739
|
+
const pkSet = new Set(pk);
|
|
740
|
+
// Both FK columns must be the PK columns (and vice-versa).
|
|
741
|
+
if (!fkCols.every((c) => pkSet.has(c)))
|
|
742
|
+
continue;
|
|
743
|
+
if (new Set(fkCols).size !== 2)
|
|
744
|
+
continue;
|
|
745
|
+
// Two DISTINCT target tables.
|
|
746
|
+
const [fkA, fkB] = tableFks;
|
|
747
|
+
if (fkA.targetTable === fkB.targetTable)
|
|
748
|
+
continue;
|
|
749
|
+
// No payload columns: J's columns are exactly the two FK/PK columns.
|
|
750
|
+
const jCols = columnNamesByTable.get(tableName) ?? [];
|
|
751
|
+
if (jCols.length !== 2)
|
|
752
|
+
continue;
|
|
753
|
+
// For each direction, the m2m `referenceKey` is the *targeted* table's
|
|
754
|
+
// referenced column(s); the junction's sourceKey is the FK column pointing
|
|
755
|
+
// to that table; the targetKey is the FK column pointing to the OTHER table.
|
|
756
|
+
const addM2M = (self, other) => {
|
|
757
|
+
const sourceTbl = self.targetTable; // A
|
|
758
|
+
const targetTbl = other.targetTable; // B
|
|
759
|
+
let relName = snakeToCamel(targetTbl); // plural table name → e.g. "tags"
|
|
760
|
+
if (!relationsByTable.has(sourceTbl))
|
|
761
|
+
relationsByTable.set(sourceTbl, {});
|
|
762
|
+
const existing = relationsByTable.get(sourceTbl);
|
|
763
|
+
// Additive-only: never clobber an existing relation name.
|
|
764
|
+
if (existing[relName])
|
|
765
|
+
return;
|
|
766
|
+
const columnFields = columnFieldsByTable?.get(sourceTbl);
|
|
767
|
+
if (columnFields?.has(relName)) {
|
|
768
|
+
if (unknownTypedFieldsByTable?.get(sourceTbl)?.has(relName)) {
|
|
769
|
+
// Historical json/jsonb shadow — worked at runtime, compiled fine.
|
|
770
|
+
console.warn(`[turbine] Relation "${relName}" on table "${sourceTbl}" (junction ${tableName}) shadows the ` +
|
|
771
|
+
`json/jsonb column "${relName}" — keeping the historical name for runtime compatibility; ` +
|
|
772
|
+
`the relation is omitted from the generated types.`);
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
const taken = new Set([...columnFields, ...Object.keys(existing)]);
|
|
776
|
+
relName = resolveRelationNameCollision(relName, taken, sourceTbl, `junction ${tableName}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
existing[relName] = {
|
|
780
|
+
type: 'manyToMany',
|
|
781
|
+
name: relName,
|
|
782
|
+
from: sourceTbl,
|
|
783
|
+
to: targetTbl,
|
|
784
|
+
// referenceKey = A's referenced column(s) that J's sourceKey points at.
|
|
785
|
+
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
786
|
+
// foreignKey is unused for m2m correlation but kept for shape parity
|
|
787
|
+
// (mirrors the source-side reference for back-compat consumers).
|
|
788
|
+
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
789
|
+
through: {
|
|
790
|
+
table: tableName,
|
|
791
|
+
sourceKey: self.sourceColumns[0], // J col → A
|
|
792
|
+
targetKey: other.sourceColumns[0], // J col → B
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
};
|
|
796
|
+
addM2M(fkA, fkB); // A → B
|
|
797
|
+
addM2M(fkB, fkA); // B → A
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* One-stop relation derivation for the engine introspectors (SQLite / MySQL /
|
|
802
|
+
* MSSQL): filters the FK list to the introspected table set, seeds the
|
|
803
|
+
* taken-name / json-shadow maps from the engine's column metadata, and runs
|
|
804
|
+
* the SAME `buildRelationsFromForeignKeys` + `addAutoManyToManyRelations`
|
|
805
|
+
* pipeline as the Postgres introspector — so every engine derives identical
|
|
806
|
+
* relation names for the same logical schema (the engines previously carried
|
|
807
|
+
* stale copies of a retired naming scheme).
|
|
808
|
+
*/
|
|
809
|
+
export function deriveEngineRelations(tableNames, foreignKeys, pkByTable, columnsByTable) {
|
|
810
|
+
const tableSet = new Set(tableNames);
|
|
811
|
+
const fks = foreignKeys.filter((fk) => tableSet.has(fk.sourceTable) && tableSet.has(fk.targetTable));
|
|
812
|
+
const columnFieldsByTable = new Map();
|
|
813
|
+
const unknownTypedFieldsByTable = new Map();
|
|
814
|
+
for (const [tbl, cols] of columnsByTable) {
|
|
815
|
+
columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
|
|
816
|
+
unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType)).map((c) => c.field)));
|
|
817
|
+
}
|
|
818
|
+
const relationsByTable = buildRelationsFromForeignKeys(fks, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
|
|
819
|
+
addAutoManyToManyRelations(tableNames, fks, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
|
|
820
|
+
return relationsByTable;
|
|
821
|
+
}
|
package/dist/mssql.js
CHANGED
|
@@ -92,7 +92,9 @@
|
|
|
92
92
|
import { TurbineClient } from './client.js';
|
|
93
93
|
import { postgresDialect, } from './dialect.js';
|
|
94
94
|
import { ConnectionError, RelationError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
95
|
-
import {
|
|
95
|
+
import { deriveEngineRelations } from './introspect.js';
|
|
96
|
+
import importOptionalPeer from './optional-peer-import.cjs';
|
|
97
|
+
import { camelToSnake, isDateType, normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
96
98
|
// ---------------------------------------------------------------------------
|
|
97
99
|
// SQL Server / connection limits
|
|
98
100
|
// ---------------------------------------------------------------------------
|
|
@@ -853,103 +855,15 @@ function buildForJsonManyToMany(dialect, ctx, h) {
|
|
|
853
855
|
}
|
|
854
856
|
const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
|
|
855
857
|
/**
|
|
856
|
-
* Derive
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
858
|
+
* Derive relations from the FK list via the SHARED introspection pipeline
|
|
859
|
+
* (`deriveEngineRelations` → `buildRelationsFromForeignKeys` +
|
|
860
|
+
* `addAutoManyToManyRelations` in introspect.ts), so this engine derives
|
|
861
|
+
* IDENTICAL relation names to `turbine generate` against Postgres for the
|
|
862
|
+
* same logical schema — legacy-first naming, per-column disambiguation, and
|
|
863
|
+
* collision resolution against scalar column fields included.
|
|
860
864
|
*/
|
|
861
865
|
function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
|
|
862
|
-
|
|
863
|
-
const fkCounts = new Map();
|
|
864
|
-
for (const fk of foreignKeys) {
|
|
865
|
-
const key = `${fk.sourceTable}->${fk.targetTable}`;
|
|
866
|
-
fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
|
|
867
|
-
}
|
|
868
|
-
const relationsByTable = new Map();
|
|
869
|
-
for (const fk of foreignKeys) {
|
|
870
|
-
if (!tableSet.has(fk.targetTable))
|
|
871
|
-
continue;
|
|
872
|
-
const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
|
|
873
|
-
const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
|
|
874
|
-
const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
|
|
875
|
-
const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
|
|
876
|
-
? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
|
|
877
|
-
: singularize(snakeToCamel(fk.targetTable));
|
|
878
|
-
if (!relationsByTable.has(fk.sourceTable))
|
|
879
|
-
relationsByTable.set(fk.sourceTable, {});
|
|
880
|
-
relationsByTable.get(fk.sourceTable)[belongsToName] = {
|
|
881
|
-
type: 'belongsTo',
|
|
882
|
-
name: belongsToName,
|
|
883
|
-
from: fk.sourceTable,
|
|
884
|
-
to: fk.targetTable,
|
|
885
|
-
foreignKey,
|
|
886
|
-
referenceKey,
|
|
887
|
-
};
|
|
888
|
-
const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
|
|
889
|
-
? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
890
|
-
: snakeToCamel(fk.sourceTable);
|
|
891
|
-
if (!relationsByTable.has(fk.targetTable))
|
|
892
|
-
relationsByTable.set(fk.targetTable, {});
|
|
893
|
-
relationsByTable.get(fk.targetTable)[hasManyName] = {
|
|
894
|
-
type: 'hasMany',
|
|
895
|
-
name: hasManyName,
|
|
896
|
-
from: fk.targetTable,
|
|
897
|
-
to: fk.sourceTable,
|
|
898
|
-
foreignKey,
|
|
899
|
-
referenceKey,
|
|
900
|
-
};
|
|
901
|
-
}
|
|
902
|
-
// Conservative many-to-many auto-detection (additive): a table J is a pure
|
|
903
|
-
// junction iff PK is exactly two columns, exactly two single-column FKs whose
|
|
904
|
-
// source columns ARE the PK, two distinct target tables, and no payload columns.
|
|
905
|
-
for (const tableName of tableNames) {
|
|
906
|
-
const pk = pkByTable.get(tableName) ?? [];
|
|
907
|
-
if (pk.length !== 2)
|
|
908
|
-
continue;
|
|
909
|
-
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
910
|
-
if (tableFks.length !== 2)
|
|
911
|
-
continue;
|
|
912
|
-
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
913
|
-
continue;
|
|
914
|
-
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
915
|
-
const pkSet = new Set(pk);
|
|
916
|
-
if (!fkCols.every((c) => pkSet.has(c)))
|
|
917
|
-
continue;
|
|
918
|
-
if (new Set(fkCols).size !== 2)
|
|
919
|
-
continue;
|
|
920
|
-
const [fkA, fkB] = tableFks;
|
|
921
|
-
if (fkA.targetTable === fkB.targetTable)
|
|
922
|
-
continue;
|
|
923
|
-
const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
|
|
924
|
-
if (jCols.length !== 2)
|
|
925
|
-
continue;
|
|
926
|
-
const addM2M = (self, other) => {
|
|
927
|
-
const sourceTbl = self.targetTable;
|
|
928
|
-
const targetTbl = other.targetTable;
|
|
929
|
-
const relName = snakeToCamel(targetTbl);
|
|
930
|
-
if (!relationsByTable.has(sourceTbl))
|
|
931
|
-
relationsByTable.set(sourceTbl, {});
|
|
932
|
-
const existing = relationsByTable.get(sourceTbl);
|
|
933
|
-
if (existing[relName])
|
|
934
|
-
return;
|
|
935
|
-
existing[relName] = {
|
|
936
|
-
type: 'manyToMany',
|
|
937
|
-
name: relName,
|
|
938
|
-
from: sourceTbl,
|
|
939
|
-
to: targetTbl,
|
|
940
|
-
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
941
|
-
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
942
|
-
through: {
|
|
943
|
-
table: tableName,
|
|
944
|
-
sourceKey: self.sourceColumns[0],
|
|
945
|
-
targetKey: other.sourceColumns[0],
|
|
946
|
-
},
|
|
947
|
-
};
|
|
948
|
-
};
|
|
949
|
-
addM2M(fkA, fkB);
|
|
950
|
-
addM2M(fkB, fkA);
|
|
951
|
-
}
|
|
952
|
-
return relationsByTable;
|
|
866
|
+
return deriveEngineRelations(tableNames, foreignKeys, pkByTable, columnsByTable);
|
|
953
867
|
}
|
|
954
868
|
/**
|
|
955
869
|
* Introspect a SQL Server database into the same {@link SchemaMetadata} shape the
|
|
@@ -1196,11 +1110,12 @@ async function loadMssql() {
|
|
|
1196
1110
|
let mod;
|
|
1197
1111
|
try {
|
|
1198
1112
|
// `mssql` ships no bundled type declarations (it needs @types/mssql, which
|
|
1199
|
-
// Turbine deliberately does not depend on) — the structural MssqlModule
|
|
1200
|
-
// is our typed surface
|
|
1201
|
-
//
|
|
1202
|
-
|
|
1203
|
-
|
|
1113
|
+
// Turbine deliberately does not depend on) — the structural MssqlModule
|
|
1114
|
+
// above is our typed surface; the helper returns `unknown` so no TS7016.
|
|
1115
|
+
// Via the .cts helper so the CJS build keeps a path to a REAL dynamic
|
|
1116
|
+
// import() even if a future mssql major goes ESM-only (the CommonJS pass
|
|
1117
|
+
// transpiles a plain `import()` here into `require()`).
|
|
1118
|
+
mod = (await importOptionalPeer('mssql'));
|
|
1204
1119
|
}
|
|
1205
1120
|
catch (err) {
|
|
1206
1121
|
throw new ConnectionError("[turbine] turbine-orm/mssql requires the optional peer dependency 'mssql'. Install it: npm i mssql. " +
|