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/cjs/introspect.js
CHANGED
|
@@ -16,6 +16,11 @@ exports.pgConfActionToReferential = pgConfActionToReferential;
|
|
|
16
16
|
exports.introspect = introspect;
|
|
17
17
|
exports.introspectPostgresCatalog = introspectPostgresCatalog;
|
|
18
18
|
exports.stripCheckWrapper = stripCheckWrapper;
|
|
19
|
+
exports.relationNameFromColumn = relationNameFromColumn;
|
|
20
|
+
exports.isUnknownTsType = isUnknownTsType;
|
|
21
|
+
exports.buildRelationsFromForeignKeys = buildRelationsFromForeignKeys;
|
|
22
|
+
exports.addAutoManyToManyRelations = addAutoManyToManyRelations;
|
|
23
|
+
exports.deriveEngineRelations = deriveEngineRelations;
|
|
19
24
|
const pg_1 = __importDefault(require("pg"));
|
|
20
25
|
const dialect_js_1 = require("./dialect.js");
|
|
21
26
|
const schema_js_1 = require("./schema.js");
|
|
@@ -53,6 +58,7 @@ const SQL_COLUMNS = `
|
|
|
53
58
|
table_name,
|
|
54
59
|
column_name,
|
|
55
60
|
udt_name,
|
|
61
|
+
udt_schema,
|
|
56
62
|
data_type,
|
|
57
63
|
is_nullable,
|
|
58
64
|
column_default,
|
|
@@ -155,6 +161,7 @@ const SQL_MATVIEW_COLUMNS = `
|
|
|
155
161
|
c.relname AS table_name,
|
|
156
162
|
a.attname AS column_name,
|
|
157
163
|
t.typname AS udt_name,
|
|
164
|
+
tn.nspname AS udt_schema,
|
|
158
165
|
CASE WHEN t.typcategory = 'A' THEN 'ARRAY' ELSE 'base' END AS data_type,
|
|
159
166
|
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
|
|
160
167
|
NULL AS column_default,
|
|
@@ -167,6 +174,7 @@ const SQL_MATVIEW_COLUMNS = `
|
|
|
167
174
|
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
168
175
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
169
176
|
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
177
|
+
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace
|
|
170
178
|
WHERE n.nspname = $1
|
|
171
179
|
AND c.relkind = 'm'
|
|
172
180
|
AND a.attnum > 0
|
|
@@ -297,6 +305,14 @@ async function introspectPostgresCatalog(options) {
|
|
|
297
305
|
arrayType,
|
|
298
306
|
pgArrayType: arrayType,
|
|
299
307
|
maxLength: row.character_maximum_length ?? undefined,
|
|
308
|
+
// Record the type's schema ONLY when it lives outside the introspected
|
|
309
|
+
// schema (and isn't a pg_catalog builtin). A same-named enum in another
|
|
310
|
+
// schema must NOT get this schema's `::"enum"` cast — search_path would
|
|
311
|
+
// resolve the cast to the wrong type (see enumTypeForColumn). Omitting
|
|
312
|
+
// it for the common case keeps generated metadata byte-identical.
|
|
313
|
+
...(typeof row.udt_schema === 'string' && row.udt_schema !== schema && row.udt_schema !== 'pg_catalog'
|
|
314
|
+
? { pgTypeSchema: row.udt_schema }
|
|
315
|
+
: {}),
|
|
300
316
|
};
|
|
301
317
|
if (!columnsByTable.has(tableName))
|
|
302
318
|
columnsByTable.set(tableName, []);
|
|
@@ -368,6 +384,9 @@ async function introspectPostgresCatalog(options) {
|
|
|
368
384
|
enums[row.typname] = [];
|
|
369
385
|
enums[row.typname].push(row.enumlabel);
|
|
370
386
|
}
|
|
387
|
+
// ----- Build foreign key map -----
|
|
388
|
+
// Group FK rows by constraint_name to correctly handle multi-column composite FKs.
|
|
389
|
+
// Each constraint becomes one FKEntry with arrays of columns.
|
|
371
390
|
const fkGroups = new Map();
|
|
372
391
|
for (const row of fkResult.rows) {
|
|
373
392
|
if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
|
|
@@ -388,65 +407,21 @@ async function introspectPostgresCatalog(options) {
|
|
|
388
407
|
}
|
|
389
408
|
const foreignKeys = Array.from(fkGroups.values());
|
|
390
409
|
// ----- Build relations from foreign keys -----
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
for (const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
|
|
405
|
-
// --- belongsTo on the source (child) table ---
|
|
406
|
-
// e.g. posts.user_id → users.id creates posts.user (belongsTo)
|
|
407
|
-
// For composite FKs with disambiguation, use the constraint name
|
|
408
|
-
const belongsToName = needsDisambiguation
|
|
409
|
-
? fk.sourceColumns.length === 1
|
|
410
|
-
? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
|
|
411
|
-
: (0, schema_js_1.snakeToCamel)(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
|
|
412
|
-
: (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
|
|
413
|
-
// Referential actions (omit the 'no action' default to keep metadata lean).
|
|
414
|
-
const actions = fkActions.get(fk.constraintName);
|
|
415
|
-
const actionFields = {};
|
|
416
|
-
if (actions?.onDelete && actions.onDelete !== 'no action')
|
|
417
|
-
actionFields.onDelete = actions.onDelete;
|
|
418
|
-
if (actions?.onUpdate && actions.onUpdate !== 'no action')
|
|
419
|
-
actionFields.onUpdate = actions.onUpdate;
|
|
420
|
-
if (!relationsByTable.has(fk.sourceTable))
|
|
421
|
-
relationsByTable.set(fk.sourceTable, {});
|
|
422
|
-
relationsByTable.get(fk.sourceTable)[belongsToName] = {
|
|
423
|
-
type: 'belongsTo',
|
|
424
|
-
name: belongsToName,
|
|
425
|
-
from: fk.sourceTable,
|
|
426
|
-
to: fk.targetTable,
|
|
427
|
-
foreignKey,
|
|
428
|
-
referenceKey,
|
|
429
|
-
...actionFields,
|
|
430
|
-
};
|
|
431
|
-
// --- hasMany on the target (parent) table ---
|
|
432
|
-
// e.g. posts.user_id → users.id creates users.posts (hasMany)
|
|
433
|
-
const hasManyName = needsDisambiguation
|
|
434
|
-
? fk.sourceColumns.length === 1
|
|
435
|
-
? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
436
|
-
: (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
|
|
437
|
-
: (0, schema_js_1.snakeToCamel)(fk.sourceTable);
|
|
438
|
-
if (!relationsByTable.has(fk.targetTable))
|
|
439
|
-
relationsByTable.set(fk.targetTable, {});
|
|
440
|
-
relationsByTable.get(fk.targetTable)[hasManyName] = {
|
|
441
|
-
type: 'hasMany',
|
|
442
|
-
name: hasManyName,
|
|
443
|
-
from: fk.targetTable,
|
|
444
|
-
to: fk.sourceTable,
|
|
445
|
-
foreignKey,
|
|
446
|
-
referenceKey,
|
|
447
|
-
...actionFields,
|
|
448
|
-
};
|
|
410
|
+
// Delegated to the pure, unit-testable builder. Relation names are derived
|
|
411
|
+
// per-FK-column when several FKs point at the same target, and every name
|
|
412
|
+
// is collision-checked against the table's scalar column fields so a
|
|
413
|
+
// relation can never shadow a column (which generated unsound types and
|
|
414
|
+
// made both surfaces unusable — dogfood T-4).
|
|
415
|
+
const columnFieldsByTable = new Map();
|
|
416
|
+
const unknownTypedFieldsByTable = new Map();
|
|
417
|
+
for (const [tbl, cols] of columnsByTable) {
|
|
418
|
+
columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
|
|
419
|
+
// Enum-typed columns also report tsType 'unknown' here, but generate.ts
|
|
420
|
+
// gives them a concrete union type — a shadow of one was type-broken on
|
|
421
|
+
// main, so only genuine json/jsonb columns qualify as historical shadows.
|
|
422
|
+
unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
|
|
449
423
|
}
|
|
424
|
+
const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable);
|
|
450
425
|
// ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
|
|
451
426
|
//
|
|
452
427
|
// Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
|
|
@@ -466,68 +441,9 @@ async function introspectPostgresCatalog(options) {
|
|
|
466
441
|
// For such a J linking A and B we ADD a `manyToMany` relation on A → B and
|
|
467
442
|
// symmetrically on B → A, both routed `through` J. The existing belongsTo /
|
|
468
443
|
// hasMany relations derived from J's FKs are left untouched — this block
|
|
469
|
-
// never removes or renames anything.
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
for (const tableName of tableNames) {
|
|
473
|
-
const pk = pkByTable.get(tableName) ?? [];
|
|
474
|
-
if (pk.length !== 2)
|
|
475
|
-
continue;
|
|
476
|
-
// FKs whose source is this table.
|
|
477
|
-
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
478
|
-
if (tableFks.length !== 2)
|
|
479
|
-
continue;
|
|
480
|
-
// Both FKs must be single-column.
|
|
481
|
-
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
482
|
-
continue;
|
|
483
|
-
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
484
|
-
const pkSet = new Set(pk);
|
|
485
|
-
// Both FK columns must be the PK columns (and vice-versa).
|
|
486
|
-
if (!fkCols.every((c) => pkSet.has(c)))
|
|
487
|
-
continue;
|
|
488
|
-
if (new Set(fkCols).size !== 2)
|
|
489
|
-
continue;
|
|
490
|
-
// Two DISTINCT target tables.
|
|
491
|
-
const [fkA, fkB] = tableFks;
|
|
492
|
-
if (fkA.targetTable === fkB.targetTable)
|
|
493
|
-
continue;
|
|
494
|
-
// No payload columns: J's columns are exactly the two FK/PK columns.
|
|
495
|
-
const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
|
|
496
|
-
if (jCols.length !== 2)
|
|
497
|
-
continue;
|
|
498
|
-
// For each direction, the m2m `referenceKey` is the *targeted* table's
|
|
499
|
-
// referenced column(s); the junction's sourceKey is the FK column pointing
|
|
500
|
-
// to that table; the targetKey is the FK column pointing to the OTHER table.
|
|
501
|
-
const addM2M = (self, other) => {
|
|
502
|
-
const sourceTbl = self.targetTable; // A
|
|
503
|
-
const targetTbl = other.targetTable; // B
|
|
504
|
-
const relName = (0, schema_js_1.snakeToCamel)(targetTbl); // plural table name → e.g. "tags"
|
|
505
|
-
if (!relationsByTable.has(sourceTbl))
|
|
506
|
-
relationsByTable.set(sourceTbl, {});
|
|
507
|
-
const existing = relationsByTable.get(sourceTbl);
|
|
508
|
-
// Additive-only: never clobber an existing relation name.
|
|
509
|
-
if (existing[relName])
|
|
510
|
-
return;
|
|
511
|
-
existing[relName] = {
|
|
512
|
-
type: 'manyToMany',
|
|
513
|
-
name: relName,
|
|
514
|
-
from: sourceTbl,
|
|
515
|
-
to: targetTbl,
|
|
516
|
-
// referenceKey = A's referenced column(s) that J's sourceKey points at.
|
|
517
|
-
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
518
|
-
// foreignKey is unused for m2m correlation but kept for shape parity
|
|
519
|
-
// (mirrors the source-side reference for back-compat consumers).
|
|
520
|
-
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
521
|
-
through: {
|
|
522
|
-
table: tableName,
|
|
523
|
-
sourceKey: self.sourceColumns[0], // J col → A
|
|
524
|
-
targetKey: other.sourceColumns[0], // J col → B
|
|
525
|
-
},
|
|
526
|
-
};
|
|
527
|
-
};
|
|
528
|
-
addM2M(fkA, fkB); // A → B
|
|
529
|
-
addM2M(fkB, fkA); // B → A
|
|
530
|
-
}
|
|
444
|
+
// never removes or renames anything. Naming/collision handling lives in the
|
|
445
|
+
// shared addAutoManyToManyRelations helper.
|
|
446
|
+
addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
|
|
531
447
|
// ----- Assemble TableMetadata for each table -----
|
|
532
448
|
const tables = {};
|
|
533
449
|
for (const tableName of tableNames) {
|
|
@@ -603,3 +519,317 @@ function stripCheckWrapper(def) {
|
|
|
603
519
|
}
|
|
604
520
|
return s;
|
|
605
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Derive a belongsTo relation name from its FK column. Strips a trailing
|
|
524
|
+
* `_id` (snake_case) or `Id` (camelCase column names — common in Prisma-ported
|
|
525
|
+
* schemas where columns are quoted camelCase identifiers), then camelCases:
|
|
526
|
+
* `current_version_id` and `currentVersionId` both yield `currentVersion`.
|
|
527
|
+
* Stripping is what keeps the scalar FK field (`currentVersionId`) targetable
|
|
528
|
+
* alongside the relation. A column literally named `id` (nothing left after
|
|
529
|
+
* stripping) keeps its own name.
|
|
530
|
+
*/
|
|
531
|
+
function relationNameFromColumn(column) {
|
|
532
|
+
let base = column;
|
|
533
|
+
if (/_id$/i.test(base))
|
|
534
|
+
base = base.slice(0, -3);
|
|
535
|
+
else if (/[a-z0-9]Id$/.test(base))
|
|
536
|
+
base = base.slice(0, -2);
|
|
537
|
+
if (base.length === 0)
|
|
538
|
+
base = column;
|
|
539
|
+
return (0, schema_js_1.snakeToCamel)(base);
|
|
540
|
+
}
|
|
541
|
+
/** Uppercase the first character (camelCase → PascalCase join helper). */
|
|
542
|
+
function upperFirst(s) {
|
|
543
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* True for the tsType forms a json/jsonb column maps to (`unknown`, nullable
|
|
547
|
+
* `unknown | null`). A relation shadowing such a column is a HISTORICAL shadow
|
|
548
|
+
* that worked at runtime and compiled (`unknown` absorbs the relation
|
|
549
|
+
* payload), so the legacy-first naming keeps it instead of renaming.
|
|
550
|
+
*/
|
|
551
|
+
function isUnknownTsType(tsType) {
|
|
552
|
+
return tsType === 'unknown' || tsType === 'unknown | null';
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Resolve a derived relation name against the names already taken on the
|
|
556
|
+
* table (scalar column fields + previously assigned relations). On collision,
|
|
557
|
+
* applies a deterministic `Rel` / `Rel2` / `Rel3`… suffix and warns — a
|
|
558
|
+
* colliding name would otherwise shadow a column field and generate types
|
|
559
|
+
* that fail `tsc --strict` (TS2430/TS2322).
|
|
560
|
+
*/
|
|
561
|
+
function resolveRelationNameCollision(candidate, taken, table, source) {
|
|
562
|
+
if (!taken.has(candidate))
|
|
563
|
+
return candidate;
|
|
564
|
+
let name = `${candidate}Rel`;
|
|
565
|
+
for (let i = 2; taken.has(name); i++)
|
|
566
|
+
name = `${candidate}Rel${i}`;
|
|
567
|
+
console.warn(`[turbine] Relation name "${candidate}" on table "${table}" (from ${source}) collides with an existing column or relation — using "${name}" instead.`);
|
|
568
|
+
return name;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Build the belongsTo/hasMany relation maps for every table from its foreign
|
|
572
|
+
* keys. Naming rules (LEGACY-FIRST — a relation name that previously worked at
|
|
573
|
+
* runtime must never change out from under a regenerating app):
|
|
574
|
+
*
|
|
575
|
+
* 1. First compute the historical derivation exactly as it shipped before
|
|
576
|
+
* the collision guard existed: belongsTo strips a case-SENSITIVE `_id`
|
|
577
|
+
* suffix (`snakeToCamel(col.replace(/_id$/, ''))` when several FKs point
|
|
578
|
+
* at the same target, else the singularized target table), and hasMany is
|
|
579
|
+
* `snakeToCamel(`${source}_by_${strippedColumn}`)` (else the source
|
|
580
|
+
* table). If that legacy name is free, KEEP IT — even when it looks odd
|
|
581
|
+
* (`blogPostsByAuthorId`, `postsBy_Author`): those names were collision-
|
|
582
|
+
* free and worked, so regenerating must not rename them.
|
|
583
|
+
* 2. If the legacy name collides ONLY with a scalar column whose tsType is
|
|
584
|
+
* `unknown` (json/jsonb), keep it anyway with a warning: the shadow is
|
|
585
|
+
* historical, ran fine at runtime, and compiled (`unknown` absorbs the
|
|
586
|
+
* relation payload; generate.ts's typeSafeRelations omits the relation
|
|
587
|
+
* from the type layer).
|
|
588
|
+
* 3. On a genuine collision (concrete-typed column shadow, or a previously
|
|
589
|
+
* assigned relation), fall back to the modern derivation — the `_id`/`Id`
|
|
590
|
+
* case-insensitive strip of {@link relationNameFromColumn} plus the
|
|
591
|
+
* `By`-composed reverse name — which fixes the camelCase-FK shadowing
|
|
592
|
+
* shapes that were actually BROKEN before (relation name === scalar FK
|
|
593
|
+
* field → unusable types).
|
|
594
|
+
* 4. Last resort: deterministic `Rel`/`Rel2` suffix + warning.
|
|
595
|
+
*
|
|
596
|
+
* @param columnFieldsByTable camelCase column *fields* per table — used to
|
|
597
|
+
* guarantee relations never shadow concrete-typed scalar columns.
|
|
598
|
+
* @param unknownTypedFieldsByTable subset of the column fields whose tsType is
|
|
599
|
+
* `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
|
|
600
|
+
*/
|
|
601
|
+
function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable) {
|
|
602
|
+
// Count FKs per (source, target) pair for disambiguation.
|
|
603
|
+
const fkCounts = new Map();
|
|
604
|
+
for (const fk of foreignKeys) {
|
|
605
|
+
const key = `${fk.sourceTable}→${fk.targetTable}`;
|
|
606
|
+
fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
|
|
607
|
+
}
|
|
608
|
+
const relationsByTable = new Map();
|
|
609
|
+
// Names already taken per table: seeded with the scalar column fields so a
|
|
610
|
+
// relation can never shadow a column; relation names are added as assigned.
|
|
611
|
+
const takenByTable = new Map();
|
|
612
|
+
const takenFor = (table) => {
|
|
613
|
+
let taken = takenByTable.get(table);
|
|
614
|
+
if (!taken) {
|
|
615
|
+
taken = new Set(columnFieldsByTable.get(table) ?? []);
|
|
616
|
+
takenByTable.set(table, taken);
|
|
617
|
+
}
|
|
618
|
+
return taken;
|
|
619
|
+
};
|
|
620
|
+
// Relation names actually assigned so far (as opposed to column fields) —
|
|
621
|
+
// needed to tell "collides only with a column" apart from "collides with an
|
|
622
|
+
// already-assigned relation" for the legacy-shadow-preserving rule.
|
|
623
|
+
const assignedByTable = new Map();
|
|
624
|
+
const assignedFor = (table) => {
|
|
625
|
+
let assigned = assignedByTable.get(table);
|
|
626
|
+
if (!assigned) {
|
|
627
|
+
assigned = new Set();
|
|
628
|
+
assignedByTable.set(table, assigned);
|
|
629
|
+
}
|
|
630
|
+
return assigned;
|
|
631
|
+
};
|
|
632
|
+
/** Legacy-first name resolution — see the naming rules in the JSDoc above. */
|
|
633
|
+
const resolveName = (legacy, modern, table, source) => {
|
|
634
|
+
const taken = takenFor(table);
|
|
635
|
+
if (!taken.has(legacy))
|
|
636
|
+
return legacy;
|
|
637
|
+
// Historical json/jsonb shadow: previously worked at runtime AND compiled
|
|
638
|
+
// (tsType `unknown` absorbs the relation payload). Keep the name, warn —
|
|
639
|
+
// typeSafeRelations() keeps the generated type layer sound.
|
|
640
|
+
if (!assignedFor(table).has(legacy) && unknownTypedFieldsByTable?.get(table)?.has(legacy)) {
|
|
641
|
+
console.warn(`[turbine] Relation "${legacy}" on table "${table}" (from ${source}) shadows the json/jsonb column ` +
|
|
642
|
+
`"${legacy}" — keeping the historical name for runtime compatibility; the relation is omitted from ` +
|
|
643
|
+
`the generated types. Rename the column to expose it.`);
|
|
644
|
+
return legacy;
|
|
645
|
+
}
|
|
646
|
+
if (modern !== null && modern !== legacy && !taken.has(modern))
|
|
647
|
+
return modern;
|
|
648
|
+
return resolveRelationNameCollision(modern ?? legacy, taken, table, source);
|
|
649
|
+
};
|
|
650
|
+
for (const fk of foreignKeys) {
|
|
651
|
+
const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
|
|
652
|
+
const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
|
|
653
|
+
const singleColumn = fk.sourceColumns.length === 1;
|
|
654
|
+
// For single-column FKs, keep string form for backwards compatibility.
|
|
655
|
+
// For multi-column (composite) FKs, use array form.
|
|
656
|
+
const foreignKey = singleColumn ? fk.sourceColumns[0] : fk.sourceColumns;
|
|
657
|
+
const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
|
|
658
|
+
// Composite FKs have no single column to derive from — fall back to the
|
|
659
|
+
// constraint name (with the usual fk_/-_fkey affixes stripped).
|
|
660
|
+
const constraintBase = fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '');
|
|
661
|
+
// --- belongsTo on the source (child) table ---
|
|
662
|
+
// e.g. posts.user_id → users.id creates posts.user (belongsTo)
|
|
663
|
+
const legacyBelongsTo = needsDisambiguation
|
|
664
|
+
? singleColumn
|
|
665
|
+
? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
|
|
666
|
+
: (0, schema_js_1.snakeToCamel)(constraintBase)
|
|
667
|
+
: (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
|
|
668
|
+
const modernBelongsTo = needsDisambiguation && singleColumn ? relationNameFromColumn(fk.sourceColumns[0]) : null;
|
|
669
|
+
const belongsToName = resolveName(legacyBelongsTo, modernBelongsTo, fk.sourceTable, `FK ${fk.constraintName}`);
|
|
670
|
+
takenFor(fk.sourceTable).add(belongsToName);
|
|
671
|
+
assignedFor(fk.sourceTable).add(belongsToName);
|
|
672
|
+
// Referential actions (omit the 'no action' default to keep metadata lean).
|
|
673
|
+
const actions = fkActions?.get(fk.constraintName);
|
|
674
|
+
const actionFields = {};
|
|
675
|
+
if (actions?.onDelete && actions.onDelete !== 'no action')
|
|
676
|
+
actionFields.onDelete = actions.onDelete;
|
|
677
|
+
if (actions?.onUpdate && actions.onUpdate !== 'no action')
|
|
678
|
+
actionFields.onUpdate = actions.onUpdate;
|
|
679
|
+
if (!relationsByTable.has(fk.sourceTable))
|
|
680
|
+
relationsByTable.set(fk.sourceTable, {});
|
|
681
|
+
relationsByTable.get(fk.sourceTable)[belongsToName] = {
|
|
682
|
+
type: 'belongsTo',
|
|
683
|
+
name: belongsToName,
|
|
684
|
+
from: fk.sourceTable,
|
|
685
|
+
to: fk.targetTable,
|
|
686
|
+
foreignKey,
|
|
687
|
+
referenceKey,
|
|
688
|
+
...actionFields,
|
|
689
|
+
};
|
|
690
|
+
// --- hasMany on the target (parent) table ---
|
|
691
|
+
// e.g. posts.user_id → users.id creates users.posts (hasMany)
|
|
692
|
+
const legacyHasMany = needsDisambiguation
|
|
693
|
+
? singleColumn
|
|
694
|
+
? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
695
|
+
: (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${constraintBase}`)
|
|
696
|
+
: (0, schema_js_1.snakeToCamel)(fk.sourceTable);
|
|
697
|
+
const modernHasMany = needsDisambiguation
|
|
698
|
+
? singleColumn
|
|
699
|
+
? `${(0, schema_js_1.snakeToCamel)(fk.sourceTable)}By${upperFirst(relationNameFromColumn(fk.sourceColumns[0]))}`
|
|
700
|
+
: `${(0, schema_js_1.snakeToCamel)(fk.sourceTable)}By${upperFirst((0, schema_js_1.snakeToCamel)(constraintBase))}`
|
|
701
|
+
: null;
|
|
702
|
+
const hasManyName = resolveName(legacyHasMany, modernHasMany, fk.targetTable, `FK ${fk.constraintName}`);
|
|
703
|
+
takenFor(fk.targetTable).add(hasManyName);
|
|
704
|
+
assignedFor(fk.targetTable).add(hasManyName);
|
|
705
|
+
if (!relationsByTable.has(fk.targetTable))
|
|
706
|
+
relationsByTable.set(fk.targetTable, {});
|
|
707
|
+
relationsByTable.get(fk.targetTable)[hasManyName] = {
|
|
708
|
+
type: 'hasMany',
|
|
709
|
+
name: hasManyName,
|
|
710
|
+
from: fk.targetTable,
|
|
711
|
+
to: fk.sourceTable,
|
|
712
|
+
foreignKey,
|
|
713
|
+
referenceKey,
|
|
714
|
+
...actionFields,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return relationsByTable;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Conservative auto-`manyToMany` detection over pure junction tables, shared
|
|
721
|
+
* by the Postgres introspector, the engine introspectors (SQLite / MySQL /
|
|
722
|
+
* MSSQL), the MCP server, and `schemaDefToMetadata()` so all surfaces derive
|
|
723
|
+
* IDENTICAL relation names for the same logical schema.
|
|
724
|
+
*
|
|
725
|
+
* A table J is a PURE junction only when ALL of these hold:
|
|
726
|
+
* 1. J's primary key is exactly two columns.
|
|
727
|
+
* 2. J has exactly two FKs, each single-column.
|
|
728
|
+
* 3. Each FK's source column is one of J's two PK columns.
|
|
729
|
+
* 4. The two FKs target two DISTINCT tables (A and B).
|
|
730
|
+
* 5. J has no payload columns beyond the two FK/PK columns.
|
|
731
|
+
*
|
|
732
|
+
* For such a J linking A and B this ADDS a `manyToMany` on A → B and B → A
|
|
733
|
+
* routed `through` J. It never removes or renames an existing relation:
|
|
734
|
+
* - an already-assigned relation with the same name → SKIP (additive-only,
|
|
735
|
+
* unchanged historical behavior);
|
|
736
|
+
* - a shadowed json/jsonb (`unknown`-typed) column → keep the historical
|
|
737
|
+
* name + warn (it worked at runtime and compiled);
|
|
738
|
+
* - a shadowed concrete-typed column → deterministic `Rel` suffix + warn
|
|
739
|
+
* instead of silently dropping the relation.
|
|
740
|
+
*/
|
|
741
|
+
function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, columnNamesByTable, relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable) {
|
|
742
|
+
for (const tableName of tableNames) {
|
|
743
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
744
|
+
if (pk.length !== 2)
|
|
745
|
+
continue;
|
|
746
|
+
// FKs whose source is this table — both must be single-column.
|
|
747
|
+
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
748
|
+
if (tableFks.length !== 2)
|
|
749
|
+
continue;
|
|
750
|
+
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
751
|
+
continue;
|
|
752
|
+
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
753
|
+
const pkSet = new Set(pk);
|
|
754
|
+
// Both FK columns must be the PK columns (and vice-versa).
|
|
755
|
+
if (!fkCols.every((c) => pkSet.has(c)))
|
|
756
|
+
continue;
|
|
757
|
+
if (new Set(fkCols).size !== 2)
|
|
758
|
+
continue;
|
|
759
|
+
// Two DISTINCT target tables.
|
|
760
|
+
const [fkA, fkB] = tableFks;
|
|
761
|
+
if (fkA.targetTable === fkB.targetTable)
|
|
762
|
+
continue;
|
|
763
|
+
// No payload columns: J's columns are exactly the two FK/PK columns.
|
|
764
|
+
const jCols = columnNamesByTable.get(tableName) ?? [];
|
|
765
|
+
if (jCols.length !== 2)
|
|
766
|
+
continue;
|
|
767
|
+
// For each direction, the m2m `referenceKey` is the *targeted* table's
|
|
768
|
+
// referenced column(s); the junction's sourceKey is the FK column pointing
|
|
769
|
+
// to that table; the targetKey is the FK column pointing to the OTHER table.
|
|
770
|
+
const addM2M = (self, other) => {
|
|
771
|
+
const sourceTbl = self.targetTable; // A
|
|
772
|
+
const targetTbl = other.targetTable; // B
|
|
773
|
+
let relName = (0, schema_js_1.snakeToCamel)(targetTbl); // plural table name → e.g. "tags"
|
|
774
|
+
if (!relationsByTable.has(sourceTbl))
|
|
775
|
+
relationsByTable.set(sourceTbl, {});
|
|
776
|
+
const existing = relationsByTable.get(sourceTbl);
|
|
777
|
+
// Additive-only: never clobber an existing relation name.
|
|
778
|
+
if (existing[relName])
|
|
779
|
+
return;
|
|
780
|
+
const columnFields = columnFieldsByTable?.get(sourceTbl);
|
|
781
|
+
if (columnFields?.has(relName)) {
|
|
782
|
+
if (unknownTypedFieldsByTable?.get(sourceTbl)?.has(relName)) {
|
|
783
|
+
// Historical json/jsonb shadow — worked at runtime, compiled fine.
|
|
784
|
+
console.warn(`[turbine] Relation "${relName}" on table "${sourceTbl}" (junction ${tableName}) shadows the ` +
|
|
785
|
+
`json/jsonb column "${relName}" — keeping the historical name for runtime compatibility; ` +
|
|
786
|
+
`the relation is omitted from the generated types.`);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
const taken = new Set([...columnFields, ...Object.keys(existing)]);
|
|
790
|
+
relName = resolveRelationNameCollision(relName, taken, sourceTbl, `junction ${tableName}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
existing[relName] = {
|
|
794
|
+
type: 'manyToMany',
|
|
795
|
+
name: relName,
|
|
796
|
+
from: sourceTbl,
|
|
797
|
+
to: targetTbl,
|
|
798
|
+
// referenceKey = A's referenced column(s) that J's sourceKey points at.
|
|
799
|
+
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
800
|
+
// foreignKey is unused for m2m correlation but kept for shape parity
|
|
801
|
+
// (mirrors the source-side reference for back-compat consumers).
|
|
802
|
+
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
803
|
+
through: {
|
|
804
|
+
table: tableName,
|
|
805
|
+
sourceKey: self.sourceColumns[0], // J col → A
|
|
806
|
+
targetKey: other.sourceColumns[0], // J col → B
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
};
|
|
810
|
+
addM2M(fkA, fkB); // A → B
|
|
811
|
+
addM2M(fkB, fkA); // B → A
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* One-stop relation derivation for the engine introspectors (SQLite / MySQL /
|
|
816
|
+
* MSSQL): filters the FK list to the introspected table set, seeds the
|
|
817
|
+
* taken-name / json-shadow maps from the engine's column metadata, and runs
|
|
818
|
+
* the SAME `buildRelationsFromForeignKeys` + `addAutoManyToManyRelations`
|
|
819
|
+
* pipeline as the Postgres introspector — so every engine derives identical
|
|
820
|
+
* relation names for the same logical schema (the engines previously carried
|
|
821
|
+
* stale copies of a retired naming scheme).
|
|
822
|
+
*/
|
|
823
|
+
function deriveEngineRelations(tableNames, foreignKeys, pkByTable, columnsByTable) {
|
|
824
|
+
const tableSet = new Set(tableNames);
|
|
825
|
+
const fks = foreignKeys.filter((fk) => tableSet.has(fk.sourceTable) && tableSet.has(fk.targetTable));
|
|
826
|
+
const columnFieldsByTable = new Map();
|
|
827
|
+
const unknownTypedFieldsByTable = new Map();
|
|
828
|
+
for (const [tbl, cols] of columnsByTable) {
|
|
829
|
+
columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
|
|
830
|
+
unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType)).map((c) => c.field)));
|
|
831
|
+
}
|
|
832
|
+
const relationsByTable = buildRelationsFromForeignKeys(fks, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
|
|
833
|
+
addAutoManyToManyRelations(tableNames, fks, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
|
|
834
|
+
return relationsByTable;
|
|
835
|
+
}
|