turbine-orm 0.41.0 → 0.42.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/dist/cjs/cli/index.js +23 -3
- package/dist/cjs/cli/prisma-resolve.js +69 -16
- package/dist/cjs/generate.js +17 -3
- package/dist/cjs/introspect.js +91 -19
- package/dist/cjs/prisma-compat.js +56 -99
- package/dist/cjs/query/compound-unique.js +0 -0
- package/dist/cli/index.js +23 -3
- package/dist/cli/prisma-resolve.d.ts +14 -1
- package/dist/cli/prisma-resolve.js +70 -17
- package/dist/generate.js +17 -3
- package/dist/introspect.d.ts +32 -4
- package/dist/introspect.js +89 -19
- package/dist/prisma-compat.js +56 -99
- package/dist/query/compound-unique.d.ts +6 -4
- package/dist/query/compound-unique.js +0 -0
- package/dist/schema.d.ts +10 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1035,11 +1035,12 @@ async function cmdMigrateFromPrisma(args, config) {
|
|
|
1035
1035
|
label('Enums', String(ast.enums.length));
|
|
1036
1036
|
// Resolve against the live database, unless --no-db (parse-only).
|
|
1037
1037
|
let schemaMeta = null;
|
|
1038
|
+
let url;
|
|
1038
1039
|
if (args.noDb) {
|
|
1039
1040
|
info('Parse-only mode (--no-db): names will not be resolved.');
|
|
1040
1041
|
}
|
|
1041
1042
|
else {
|
|
1042
|
-
|
|
1043
|
+
url = requireUrl(config);
|
|
1043
1044
|
label('Database', redactUrl(url));
|
|
1044
1045
|
const spinner = new Spinner('Introspecting database schema').start();
|
|
1045
1046
|
schemaMeta = await introspect({
|
|
@@ -1055,7 +1056,10 @@ async function cmdMigrateFromPrisma(args, config) {
|
|
|
1055
1056
|
spinner.succeed(`Introspected ${bold(String(Object.keys(schemaMeta.tables).length))} tables`);
|
|
1056
1057
|
}
|
|
1057
1058
|
newline();
|
|
1058
|
-
|
|
1059
|
+
// `--keep-column-names` makes the generated client key fields by raw DB column
|
|
1060
|
+
// names; resolve the name map against the same transformed schema so the
|
|
1061
|
+
// emitted PRISMA_MAP field values agree with the client (D).
|
|
1062
|
+
const result = resolvePrismaSchema(ast, schemaMeta, { keepColumnNames: config.keepColumnNames });
|
|
1059
1063
|
// Console summary.
|
|
1060
1064
|
header('Resolution');
|
|
1061
1065
|
for (const line of summaryLines(result)) {
|
|
@@ -1075,10 +1079,26 @@ async function cmdMigrateFromPrisma(args, config) {
|
|
|
1075
1079
|
const reportPath = join(outDir, 'prisma-migration-report.md');
|
|
1076
1080
|
writeFileSync(reportPath, formatPrismaReport(result, { schemaPath: prismaPath, noTimestamp: args.noTimestamp }), 'utf-8');
|
|
1077
1081
|
console.log(` ${dim(symbols.teeEnd)} ${cyan(reportPath)} ${dim('(report)')}`);
|
|
1078
|
-
if (!args.noDb) {
|
|
1082
|
+
if (!args.noDb && schemaMeta) {
|
|
1079
1083
|
const mapPath = join(outDir, 'prisma-map.ts');
|
|
1080
1084
|
writeFileSync(mapPath, generatePrismaMap(result.map, { noTimestamp: args.noTimestamp }), 'utf-8');
|
|
1081
1085
|
console.log(` ${dim(symbols.teeEnd)} ${cyan(mapPath)} ${dim('(typed name map)')}`);
|
|
1086
|
+
// Always emit the standard generated client alongside the report + name map.
|
|
1087
|
+
// It is built from the live introspected metadata, so unresolved Prisma
|
|
1088
|
+
// items never block it; a partially resolved run (--allow-partial) still
|
|
1089
|
+
// gets a working client (C). `--keep-column-names` flows through so the
|
|
1090
|
+
// client's field names match the name map (D).
|
|
1091
|
+
const gen = generate({
|
|
1092
|
+
schema: schemaMeta,
|
|
1093
|
+
outDir: config.out,
|
|
1094
|
+
connectionString: url,
|
|
1095
|
+
noTimestamp: args.noTimestamp,
|
|
1096
|
+
importExtension: config.importExtension,
|
|
1097
|
+
keepColumnNames: config.keepColumnNames,
|
|
1098
|
+
});
|
|
1099
|
+
for (const file of gen.files) {
|
|
1100
|
+
console.log(` ${dim(symbols.teeEnd)} ${cyan(join(outDir, file))} ${dim('(client)')}`);
|
|
1101
|
+
}
|
|
1082
1102
|
}
|
|
1083
1103
|
newline();
|
|
1084
1104
|
// Exit non-zero when anything is UNRESOLVED, unless --allow-partial.
|
|
@@ -81,7 +81,20 @@ export interface ResolutionResult {
|
|
|
81
81
|
/** True when resolution was skipped (`--no-db`): the report is parse-only. */
|
|
82
82
|
noDb: boolean;
|
|
83
83
|
}
|
|
84
|
+
/** Options controlling how names are resolved. */
|
|
85
|
+
export interface ResolveOptions {
|
|
86
|
+
/**
|
|
87
|
+
* Resolve field names against the raw database column names instead of the
|
|
88
|
+
* camelCase default, matching a client generated with `--keep-column-names`.
|
|
89
|
+
* Applied by running the introspected metadata through {@link withDbFieldNames}
|
|
90
|
+
* up front, so every resolved `turbineField` (and compound-unique
|
|
91
|
+
* `turbineFields`) is the DB column spelling and the emitted PRISMA_MAP agrees
|
|
92
|
+
* with the generated client. Table names, accessors, and relations are
|
|
93
|
+
* unaffected (those never carry camelCased column names).
|
|
94
|
+
*/
|
|
95
|
+
keepColumnNames?: boolean;
|
|
96
|
+
}
|
|
84
97
|
/**
|
|
85
98
|
* Resolve `ast` against introspected `schema` (or `null` for parse-only).
|
|
86
99
|
*/
|
|
87
|
-
export declare function resolvePrismaSchema(ast: PrismaSchemaAst, schema: SchemaMetadata | null): ResolutionResult;
|
|
100
|
+
export declare function resolvePrismaSchema(ast: PrismaSchemaAst, schema: SchemaMetadata | null, options?: ResolveOptions): ResolutionResult;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Pure leaf: no filesystem, database, or process access. Passing `schema: null`
|
|
13
13
|
* yields a parse-only report (every item `parsed`, no map) for `--no-db`.
|
|
14
14
|
*/
|
|
15
|
-
import { camelToSnake, snakeToCamel } from '../schema.js';
|
|
15
|
+
import { camelToSnake, snakeToCamel, withDbFieldNames } from '../schema.js';
|
|
16
16
|
export { DEFAULT_EXCLUDED_TABLES } from '../introspect.js';
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Name-candidate helpers
|
|
@@ -70,16 +70,18 @@ function fieldColumn(model, fieldName) {
|
|
|
70
70
|
function isRelationField(typeName, modelNames) {
|
|
71
71
|
return modelNames.has(typeName);
|
|
72
72
|
}
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
// Main entry
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
73
|
/**
|
|
77
74
|
* Resolve `ast` against introspected `schema` (or `null` for parse-only).
|
|
78
75
|
*/
|
|
79
|
-
export function resolvePrismaSchema(ast, schema) {
|
|
80
|
-
|
|
76
|
+
export function resolvePrismaSchema(ast, schema, options = {}) {
|
|
77
|
+
// Under keep-column-names the generated client keys fields by raw DB column
|
|
78
|
+
// names; resolve against the same transformed metadata so the name map's
|
|
79
|
+
// field values match the client (D).
|
|
80
|
+
const resolvedSchema = schema && options.keepColumnNames ? withDbFieldNames(schema) : schema;
|
|
81
|
+
const noDb = resolvedSchema === null;
|
|
81
82
|
const modelNames = new Set(ast.models.map((m) => m.name));
|
|
82
|
-
const
|
|
83
|
+
const modelsByName = new Map(ast.models.map((m) => [m.name, m]));
|
|
84
|
+
const tableNames = resolvedSchema ? new Set(Object.keys(resolvedSchema.tables)) : new Set();
|
|
83
85
|
// Pass 1 - resolve each model to a table so relation targets are known.
|
|
84
86
|
const modelTable = new Map();
|
|
85
87
|
for (const model of ast.models) {
|
|
@@ -97,7 +99,7 @@ export function resolvePrismaSchema(ast, schema) {
|
|
|
97
99
|
for (const model of ast.models) {
|
|
98
100
|
const rt = modelTable.get(model.name);
|
|
99
101
|
const table = rt.table;
|
|
100
|
-
const tableMeta = table &&
|
|
102
|
+
const tableMeta = table && resolvedSchema ? resolvedSchema.tables[table] : undefined;
|
|
101
103
|
const accessor = table ? snakeToCamel(table) : null;
|
|
102
104
|
const status = noDb ? 'parsed' : table ? 'resolved' : 'unresolved';
|
|
103
105
|
const resolved = {
|
|
@@ -114,7 +116,7 @@ export function resolvePrismaSchema(ast, schema) {
|
|
|
114
116
|
};
|
|
115
117
|
for (const field of model.fields) {
|
|
116
118
|
if (isRelationField(field.type, modelNames)) {
|
|
117
|
-
resolved.relations.push(resolveRelation(model, field.name, field.type, field.isList, modelTable,
|
|
119
|
+
resolved.relations.push(resolveRelation(model, field.name, field.type, field.isList, modelTable, modelsByName, resolvedSchema, tableMeta, noDb));
|
|
118
120
|
}
|
|
119
121
|
else {
|
|
120
122
|
resolved.fields.push(resolveScalarField(model, field.name, tableMeta, noDb));
|
|
@@ -151,7 +153,7 @@ export function resolvePrismaSchema(ast, schema) {
|
|
|
151
153
|
}
|
|
152
154
|
// Enums.
|
|
153
155
|
for (const en of ast.enums) {
|
|
154
|
-
const r = resolveEnum(en.name, en.map,
|
|
156
|
+
const r = resolveEnum(en.name, en.map, resolvedSchema, noDb);
|
|
155
157
|
result.enums.push(r);
|
|
156
158
|
if (!noDb && r.status === 'resolved' && r.turbineName)
|
|
157
159
|
result.map.enums[en.name] = r.turbineName;
|
|
@@ -208,7 +210,28 @@ function resolveScalarField(model, fieldName, tableMeta, noDb) {
|
|
|
208
210
|
reason: `column "${column}" not found on table "${tableMeta.name}"`,
|
|
209
211
|
};
|
|
210
212
|
}
|
|
211
|
-
|
|
213
|
+
/**
|
|
214
|
+
* The `@relation("Name")` name on a field, from the `name:` argument or the
|
|
215
|
+
* first positional string argument. Absent when the field has no `@relation`
|
|
216
|
+
* attribute or the attribute carries no name.
|
|
217
|
+
*/
|
|
218
|
+
function relationNameOf(field) {
|
|
219
|
+
const relAttr = field?.attrs.find((a) => a.name === 'relation');
|
|
220
|
+
if (!relAttr)
|
|
221
|
+
return undefined;
|
|
222
|
+
const named = relAttr.args.find((a) => a.key === 'name' && a.kind === 'string');
|
|
223
|
+
if (named?.value)
|
|
224
|
+
return named.value;
|
|
225
|
+
const positional = relAttr.args.find((a) => a.key === undefined && a.kind === 'string');
|
|
226
|
+
return positional?.value;
|
|
227
|
+
}
|
|
228
|
+
/** The FK column list a field pins via `@relation(fields: [...])`, resolved to columns. */
|
|
229
|
+
function relationFkColumns(model, field) {
|
|
230
|
+
const relAttr = field?.attrs.find((a) => a.name === 'relation');
|
|
231
|
+
const fieldsArg = relAttr?.args.find((a) => a.key === 'fields' && a.kind === 'array');
|
|
232
|
+
return fieldsArg?.items?.map((pf) => fieldColumn(model, pf)) ?? null;
|
|
233
|
+
}
|
|
234
|
+
function resolveRelation(model, fieldName, targetModelName, isList, modelTable, modelsByName, schema, tableMeta, noDb) {
|
|
212
235
|
const cardinality = isList ? 'many' : 'one';
|
|
213
236
|
const base = {
|
|
214
237
|
prismaName: fieldName,
|
|
@@ -225,9 +248,24 @@ function resolveRelation(model, fieldName, targetModelName, isList, modelTable,
|
|
|
225
248
|
const targetTable = modelTable.get(targetModelName)?.table ?? null;
|
|
226
249
|
// Explicit @relation(fields: [...]) names the FK columns on THIS side.
|
|
227
250
|
const field = model.fields.find((f) => f.name === fieldName);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
251
|
+
let fkColumns = relationFkColumns(model, field);
|
|
252
|
+
// Inverse side (no fields on this side) with a @relation("Name"): pair by that
|
|
253
|
+
// name FIRST. Find the opposing model's field carrying the same relation name
|
|
254
|
+
// AND the FK (fields: [...]), and resolve through ITS foreign key. This is how
|
|
255
|
+
// Prisma disambiguates two or more relations to the same target model. Only
|
|
256
|
+
// fall back to the ambiguity handling below when there is no relation name or
|
|
257
|
+
// the named pair cannot be found.
|
|
258
|
+
if (!fkColumns || fkColumns.length === 0) {
|
|
259
|
+
const relName = relationNameOf(field);
|
|
260
|
+
const targetModel = modelsByName.get(targetModelName);
|
|
261
|
+
if (relName && targetModel) {
|
|
262
|
+
const opposing = targetModel.fields.find((f) => f.type === model.name &&
|
|
263
|
+
relationNameOf(f) === relName &&
|
|
264
|
+
(relationFkColumns(targetModel, f)?.length ?? 0) > 0);
|
|
265
|
+
if (opposing)
|
|
266
|
+
fkColumns = relationFkColumns(targetModel, opposing);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
231
269
|
const candidates = Object.values(tableMeta.relations).filter((def) => {
|
|
232
270
|
if (targetTable && def.to !== targetTable)
|
|
233
271
|
return false;
|
|
@@ -288,10 +326,25 @@ function resolveCompoundUnique(model, key, tableMeta, noDb) {
|
|
|
288
326
|
return { ...base, turbineFields, status: 'resolved' };
|
|
289
327
|
return { ...base, reason: `no compound primary key on "${tableMeta.name}" matches (${columns.join(', ')})` };
|
|
290
328
|
}
|
|
291
|
-
//
|
|
292
|
-
|
|
329
|
+
// A composite unique can surface EITHER as a unique constraint (uniqueColumns)
|
|
330
|
+
// OR as a plain UNIQUE INDEX (Prisma creates unique indexes, not table
|
|
331
|
+
// constraints), so accept a matching unique index too. A partial unique index
|
|
332
|
+
// does not enforce uniqueness across the whole table, so it never satisfies a
|
|
333
|
+
// @@unique; skip it defensively (the marker may be added to IndexMetadata).
|
|
334
|
+
const uniqueIndexMatches = tableMeta.indexes.some((idx) => {
|
|
335
|
+
if (!idx.unique)
|
|
336
|
+
return false;
|
|
337
|
+
if (idx.partial)
|
|
338
|
+
return false;
|
|
339
|
+
return [...idx.columns].sort().join(',') === want;
|
|
340
|
+
});
|
|
341
|
+
if (matches(tableMeta.uniqueColumns) || uniqueIndexMatches) {
|
|
293
342
|
return { ...base, turbineFields, status: 'resolved' };
|
|
294
|
-
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
...base,
|
|
346
|
+
reason: `no unique constraint or unique index on "${tableMeta.name}" matches (${columns.join(', ')})`,
|
|
347
|
+
};
|
|
295
348
|
}
|
|
296
349
|
function resolveEnum(name, map, schema, noDb) {
|
|
297
350
|
if (noDb || !schema)
|
package/dist/generate.js
CHANGED
|
@@ -448,13 +448,23 @@ export function generateTypes(schema, options) {
|
|
|
448
448
|
for (const uc of table.uniqueColumns)
|
|
449
449
|
addCompound(uc);
|
|
450
450
|
for (const idx of table.indexes) {
|
|
451
|
-
|
|
451
|
+
// A partial unique index does not guarantee table-wide row uniqueness,
|
|
452
|
+
// so it must not become a compound-unique selector (matches the runtime
|
|
453
|
+
// exclusion in query/compound-unique.ts).
|
|
454
|
+
if (idx.unique && !idx.docPath && !idx.partial)
|
|
452
455
|
addCompound(idx.columns);
|
|
453
456
|
}
|
|
454
457
|
const selectorEntries = compoundSets.map((cols) => {
|
|
455
458
|
const members = cols.map(memberType);
|
|
456
459
|
return {
|
|
457
|
-
|
|
460
|
+
// The selector NAME is the underscore-join of the member FIELD names.
|
|
461
|
+
// It is normally a valid identifier (`orgId_userId`), but a
|
|
462
|
+
// junction-style column that is not a valid identifier (e.g. a quoted
|
|
463
|
+
// uppercase `"A"` / `"B"`) would join into a broken object key. Emit
|
|
464
|
+
// any non-identifier name as ONE quoted string-literal key so the
|
|
465
|
+
// generated types.ts always parses; the runtime selector map in
|
|
466
|
+
// query/compound-unique.ts registers the same name spelling.
|
|
467
|
+
selectorName: quoteIfNeeded(members.map((m) => m.field).join('_')),
|
|
458
468
|
memberType: `{ ${members.map((m) => `${quoteIfNeeded(m.field)}: ${m.tsType}`).join('; ')} }`,
|
|
459
469
|
};
|
|
460
470
|
});
|
|
@@ -713,7 +723,11 @@ export function generateMetadata(schema, options) {
|
|
|
713
723
|
// indexes
|
|
714
724
|
lines.push(' indexes: [');
|
|
715
725
|
for (const idx of table.indexes) {
|
|
716
|
-
|
|
726
|
+
// `partial` must round-trip: the runtime compound-unique derivation reads
|
|
727
|
+
// the GENERATED metadata, so dropping the flag here would re-arm a
|
|
728
|
+
// partial-unique selector that types.ts correctly excludes.
|
|
729
|
+
const partialSeg = idx.partial ? ', partial: true' : '';
|
|
730
|
+
lines.push(` { name: '${escSQ(idx.name)}', columns: [${idx.columns.map((c) => `'${escSQ(c)}'`).join(', ')}], unique: ${idx.unique}${partialSeg}, definition: ${JSON.stringify(idx.definition)} },`);
|
|
717
731
|
}
|
|
718
732
|
lines.push(' ],');
|
|
719
733
|
// checks: introspected named CHECK constraints. Emitted only when present
|
package/dist/introspect.d.ts
CHANGED
|
@@ -108,6 +108,30 @@ export declare function introspect(options: IntrospectOptions): Promise<SchemaMe
|
|
|
108
108
|
* `postgresDialect.introspector`; call {@link introspect} for dialect routing.
|
|
109
109
|
*/
|
|
110
110
|
export declare function introspectPostgresCatalog(options: IntrospectOptions): Promise<SchemaMetadata>;
|
|
111
|
+
/**
|
|
112
|
+
* Parse the indexed column names out of a `pg_indexes.indexdef` string.
|
|
113
|
+
*
|
|
114
|
+
* `indexdef` always reads `CREATE [UNIQUE] INDEX name ON tbl USING method
|
|
115
|
+
* (col, ...) [WHERE predicate]`. We anchor on the `USING` clause's parenthesised
|
|
116
|
+
* column list (the same precedent as `describeIndexDefMismatch` in
|
|
117
|
+
* schema-sql.ts) so a PARTIAL index's trailing `WHERE (...)` parentheses are
|
|
118
|
+
* never mistaken for the column list. The older greedy `/\((.+)\)/` swallowed
|
|
119
|
+
* `) WHERE (` and spliced a raw predicate fragment into the column names, which
|
|
120
|
+
* then leaked into generated compound-unique selector names.
|
|
121
|
+
*
|
|
122
|
+
* Each column is de-quoted (Postgres quotes non-lowercase identifiers such as a
|
|
123
|
+
* Prisma implicit m2m junction's `"A"` / `"B"`), so the names match the
|
|
124
|
+
* unquoted column names carried elsewhere in the metadata. Expression columns
|
|
125
|
+
* (anything containing a parenthesis) are dropped conservatively: a functional
|
|
126
|
+
* index does not name a plain column.
|
|
127
|
+
*/
|
|
128
|
+
export declare function parseIndexColumns(indexdef: string): string[];
|
|
129
|
+
/**
|
|
130
|
+
* Whether an `indexdef` carries a top-level `WHERE` predicate (a PARTIAL index).
|
|
131
|
+
* pg_indexes only ever emits `WHERE` as the partial predicate, so a keyword
|
|
132
|
+
* match is sufficient (matches the `describeIndexDefMismatch` precedent).
|
|
133
|
+
*/
|
|
134
|
+
export declare function indexHasWhere(indexdef: string): boolean;
|
|
111
135
|
/**
|
|
112
136
|
* Recover the raw check expression from `pg_get_constraintdef` output, which
|
|
113
137
|
* wraps it as `CHECK ((expr))`. Strips the leading `CHECK ` keyword and one
|
|
@@ -213,11 +237,15 @@ export declare function buildRelationsFromForeignKeys(foreignKeys: ForeignKeyEnt
|
|
|
213
237
|
* IDENTICAL relation names for the same logical schema.
|
|
214
238
|
*
|
|
215
239
|
* A table J is a PURE junction only when ALL of these hold:
|
|
216
|
-
* 1. J's
|
|
240
|
+
* 1. J's junction KEY is exactly two columns: either a two-column primary
|
|
241
|
+
* key, OR (Prisma implicit m2m junctions have NO primary key) a two-column
|
|
242
|
+
* UNIQUE index over exactly the two FK columns, supplied via the optional
|
|
243
|
+
* `uniqueIndexColsByTable`. When that map is absent the behavior is
|
|
244
|
+
* unchanged: only a two-column PK qualifies.
|
|
217
245
|
* 2. J has exactly two FKs, each single-column.
|
|
218
|
-
* 3. Each FK's source column is one of J's two
|
|
246
|
+
* 3. Each FK's source column is one of J's two key columns.
|
|
219
247
|
* 4. The two FKs target two DISTINCT tables (A and B).
|
|
220
|
-
* 5. J has no payload columns beyond the two FK/
|
|
248
|
+
* 5. J has no payload columns beyond the two FK/key columns.
|
|
221
249
|
*
|
|
222
250
|
* For such a J linking A and B this ADDS a `manyToMany` on A → B and B → A
|
|
223
251
|
* routed `through` J. It never removes or renames an existing relation:
|
|
@@ -228,7 +256,7 @@ export declare function buildRelationsFromForeignKeys(foreignKeys: ForeignKeyEnt
|
|
|
228
256
|
* - a shadowed concrete-typed column → deterministic `Rel` suffix + warn
|
|
229
257
|
* instead of silently dropping the relation.
|
|
230
258
|
*/
|
|
231
|
-
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
|
|
259
|
+
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;
|
|
232
260
|
/**
|
|
233
261
|
* One-stop relation derivation for the engine introspectors (SQLite / MySQL /
|
|
234
262
|
* MSSQL): filters the FK list to the introspected table set, seeds the
|
package/dist/introspect.js
CHANGED
|
@@ -395,15 +395,15 @@ export async function introspectPostgresCatalog(options) {
|
|
|
395
395
|
continue;
|
|
396
396
|
if (!indexesByTable.has(row.tablename))
|
|
397
397
|
indexesByTable.set(row.tablename, []);
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
const columns = colMatch ? colMatch[1].split(',').map((c) => c.trim().replace(/ (ASC|DESC)/i, '')) : [];
|
|
398
|
+
const indexdef = row.indexdef;
|
|
399
|
+
const isUnique = indexdef.includes('UNIQUE');
|
|
400
|
+
const isPartial = indexHasWhere(indexdef);
|
|
402
401
|
indexesByTable.get(row.tablename).push({
|
|
403
402
|
name: row.indexname,
|
|
404
|
-
columns,
|
|
403
|
+
columns: parseIndexColumns(indexdef),
|
|
405
404
|
unique: isUnique,
|
|
406
|
-
definition:
|
|
405
|
+
definition: indexdef,
|
|
406
|
+
...(isPartial ? { partial: true } : {}),
|
|
407
407
|
});
|
|
408
408
|
}
|
|
409
409
|
// ----- Group check constraints by table -----
|
|
@@ -492,7 +492,17 @@ export async function introspectPostgresCatalog(options) {
|
|
|
492
492
|
// hasMany relations derived from J's FKs are left untouched — this block
|
|
493
493
|
// never removes or renames anything. Naming/collision handling lives in the
|
|
494
494
|
// shared addAutoManyToManyRelations helper.
|
|
495
|
-
|
|
495
|
+
//
|
|
496
|
+
// Prisma's implicit m2m junctions have no primary key (just a two-column
|
|
497
|
+
// UNIQUE index over the FK columns), so pass the introspected two-column
|
|
498
|
+
// unique indexes as the fallback junction-key source.
|
|
499
|
+
const uniqueIndexColsByTable = new Map();
|
|
500
|
+
for (const [tbl, idxs] of indexesByTable) {
|
|
501
|
+
const twoColUniques = idxs.filter((idx) => idx.unique && idx.columns.length === 2).map((idx) => idx.columns);
|
|
502
|
+
if (twoColUniques.length > 0)
|
|
503
|
+
uniqueIndexColsByTable.set(tbl, twoColUniques);
|
|
504
|
+
}
|
|
505
|
+
addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable, uniqueIndexColsByTable);
|
|
496
506
|
// ----- Assemble TableMetadata for each table -----
|
|
497
507
|
const tables = {};
|
|
498
508
|
for (const tableName of tableNames) {
|
|
@@ -537,6 +547,50 @@ export async function introspectPostgresCatalog(options) {
|
|
|
537
547
|
await pool.end();
|
|
538
548
|
}
|
|
539
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* Parse the indexed column names out of a `pg_indexes.indexdef` string.
|
|
552
|
+
*
|
|
553
|
+
* `indexdef` always reads `CREATE [UNIQUE] INDEX name ON tbl USING method
|
|
554
|
+
* (col, ...) [WHERE predicate]`. We anchor on the `USING` clause's parenthesised
|
|
555
|
+
* column list (the same precedent as `describeIndexDefMismatch` in
|
|
556
|
+
* schema-sql.ts) so a PARTIAL index's trailing `WHERE (...)` parentheses are
|
|
557
|
+
* never mistaken for the column list. The older greedy `/\((.+)\)/` swallowed
|
|
558
|
+
* `) WHERE (` and spliced a raw predicate fragment into the column names, which
|
|
559
|
+
* then leaked into generated compound-unique selector names.
|
|
560
|
+
*
|
|
561
|
+
* Each column is de-quoted (Postgres quotes non-lowercase identifiers such as a
|
|
562
|
+
* Prisma implicit m2m junction's `"A"` / `"B"`), so the names match the
|
|
563
|
+
* unquoted column names carried elsewhere in the metadata. Expression columns
|
|
564
|
+
* (anything containing a parenthesis) are dropped conservatively: a functional
|
|
565
|
+
* index does not name a plain column.
|
|
566
|
+
*/
|
|
567
|
+
export function parseIndexColumns(indexdef) {
|
|
568
|
+
const m = indexdef.match(/USING\s+\w+\s*\(([^)]*)\)/i) ?? indexdef.match(/\(([^)]*)\)/);
|
|
569
|
+
if (!m)
|
|
570
|
+
return [];
|
|
571
|
+
return m[1]
|
|
572
|
+
.split(',')
|
|
573
|
+
.map((c) => unquoteIndexIdent(c
|
|
574
|
+
.trim()
|
|
575
|
+
.replace(/\s+(ASC|DESC)$/i, '')
|
|
576
|
+
.trim()))
|
|
577
|
+
.filter((c) => c.length > 0 && !c.includes('(') && !c.includes(')'));
|
|
578
|
+
}
|
|
579
|
+
/** Strip one pair of surrounding double quotes and unescape doubled `""`. */
|
|
580
|
+
function unquoteIndexIdent(col) {
|
|
581
|
+
if (col.length >= 2 && col.startsWith('"') && col.endsWith('"')) {
|
|
582
|
+
return col.slice(1, -1).replace(/""/g, '"');
|
|
583
|
+
}
|
|
584
|
+
return col;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Whether an `indexdef` carries a top-level `WHERE` predicate (a PARTIAL index).
|
|
588
|
+
* pg_indexes only ever emits `WHERE` as the partial predicate, so a keyword
|
|
589
|
+
* match is sufficient (matches the `describeIndexDefMismatch` precedent).
|
|
590
|
+
*/
|
|
591
|
+
export function indexHasWhere(indexdef) {
|
|
592
|
+
return /\bWHERE\b/i.test(indexdef);
|
|
593
|
+
}
|
|
540
594
|
/**
|
|
541
595
|
* Recover the raw check expression from `pg_get_constraintdef` output, which
|
|
542
596
|
* wraps it as `CHECK ((expr))`. Strips the leading `CHECK ` keyword and one
|
|
@@ -881,11 +935,15 @@ export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable,
|
|
|
881
935
|
* IDENTICAL relation names for the same logical schema.
|
|
882
936
|
*
|
|
883
937
|
* A table J is a PURE junction only when ALL of these hold:
|
|
884
|
-
* 1. J's
|
|
938
|
+
* 1. J's junction KEY is exactly two columns: either a two-column primary
|
|
939
|
+
* key, OR (Prisma implicit m2m junctions have NO primary key) a two-column
|
|
940
|
+
* UNIQUE index over exactly the two FK columns, supplied via the optional
|
|
941
|
+
* `uniqueIndexColsByTable`. When that map is absent the behavior is
|
|
942
|
+
* unchanged: only a two-column PK qualifies.
|
|
885
943
|
* 2. J has exactly two FKs, each single-column.
|
|
886
|
-
* 3. Each FK's source column is one of J's two
|
|
944
|
+
* 3. Each FK's source column is one of J's two key columns.
|
|
887
945
|
* 4. The two FKs target two DISTINCT tables (A and B).
|
|
888
|
-
* 5. J has no payload columns beyond the two FK/
|
|
946
|
+
* 5. J has no payload columns beyond the two FK/key columns.
|
|
889
947
|
*
|
|
890
948
|
* For such a J linking A and B this ADDS a `manyToMany` on A → B and B → A
|
|
891
949
|
* routed `through` J. It never removes or renames an existing relation:
|
|
@@ -896,11 +954,8 @@ export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable,
|
|
|
896
954
|
* - a shadowed concrete-typed column → deterministic `Rel` suffix + warn
|
|
897
955
|
* instead of silently dropping the relation.
|
|
898
956
|
*/
|
|
899
|
-
export function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, columnNamesByTable, relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable) {
|
|
957
|
+
export function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, columnNamesByTable, relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable, uniqueIndexColsByTable) {
|
|
900
958
|
for (const tableName of tableNames) {
|
|
901
|
-
const pk = pkByTable.get(tableName) ?? [];
|
|
902
|
-
if (pk.length !== 2)
|
|
903
|
-
continue;
|
|
904
959
|
// FKs whose source is this table — both must be single-column.
|
|
905
960
|
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
906
961
|
if (tableFks.length !== 2)
|
|
@@ -908,20 +963,35 @@ export function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, c
|
|
|
908
963
|
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
909
964
|
continue;
|
|
910
965
|
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
911
|
-
const pkSet = new Set(pk);
|
|
912
|
-
// Both FK columns must be the PK columns (and vice-versa).
|
|
913
|
-
if (!fkCols.every((c) => pkSet.has(c)))
|
|
914
|
-
continue;
|
|
915
966
|
if (new Set(fkCols).size !== 2)
|
|
916
967
|
continue;
|
|
968
|
+
const fkSet = new Set(fkCols);
|
|
969
|
+
// The junction KEY is normally the two-column PK. Prisma's implicit m2m
|
|
970
|
+
// junctions have NO primary key, so accept instead a two-column UNIQUE
|
|
971
|
+
// index that covers exactly the two FK columns. Only a PK-less table is
|
|
972
|
+
// eligible for the unique-index fallback, so a real entity that happens to
|
|
973
|
+
// carry a two-column unique index is never mistaken for a junction.
|
|
974
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
975
|
+
let keyCols;
|
|
976
|
+
if (pk.length === 2 && pk.every((c) => fkSet.has(c))) {
|
|
977
|
+
keyCols = pk;
|
|
978
|
+
}
|
|
979
|
+
else if (pk.length === 0) {
|
|
980
|
+
const uniques = uniqueIndexColsByTable?.get(tableName) ?? [];
|
|
981
|
+
keyCols = uniques.find((u) => u.length === 2 && u.every((c) => fkSet.has(c)));
|
|
982
|
+
}
|
|
983
|
+
if (!keyCols)
|
|
984
|
+
continue;
|
|
917
985
|
// Two DISTINCT target tables.
|
|
918
986
|
const [fkA, fkB] = tableFks;
|
|
919
987
|
if (fkA.targetTable === fkB.targetTable)
|
|
920
988
|
continue;
|
|
921
|
-
// No payload columns: J's columns are exactly the two FK/
|
|
989
|
+
// No payload columns: J's columns are exactly the two FK/key columns.
|
|
922
990
|
const jCols = columnNamesByTable.get(tableName) ?? [];
|
|
923
991
|
if (jCols.length !== 2)
|
|
924
992
|
continue;
|
|
993
|
+
if (!jCols.every((c) => fkSet.has(c)))
|
|
994
|
+
continue;
|
|
925
995
|
// For each direction, the m2m `referenceKey` is the *targeted* table's
|
|
926
996
|
// referenced column(s); the junction's sourceKey is the FK column pointing
|
|
927
997
|
// to that table; the targetKey is the FK column pointing to the OTHER table.
|