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/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
- const url = requireUrl(config);
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
- const result = resolvePrismaSchema(ast, schemaMeta);
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
- const noDb = schema === null;
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 tableNames = schema ? new Set(Object.keys(schema.tables)) : new Set();
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 && schema ? schema.tables[table] : undefined;
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, schema, tableMeta, noDb));
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, schema, noDb);
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
- function resolveRelation(model, fieldName, targetModelName, isList, modelTable, schema, tableMeta, noDb) {
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
- const relAttr = field?.attrs.find((a) => a.name === 'relation');
229
- const fieldsArg = relAttr?.args.find((a) => a.key === 'fields' && a.kind === 'array');
230
- const fkColumns = fieldsArg?.items?.map((pf) => fieldColumn(model, pf)) ?? null;
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
- // Introspected metadata carries composite unique constraints in uniqueColumns.
292
- if (matches(tableMeta.uniqueColumns))
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
- return { ...base, reason: `no unique constraint on "${tableMeta.name}" matches (${columns.join(', ')})` };
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
- if (idx.unique && !idx.docPath)
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
- selectorName: members.map((m) => m.field).join('_'),
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
- lines.push(` { name: '${escSQ(idx.name)}', columns: [${idx.columns.map((c) => `'${escSQ(c)}'`).join(', ')}], unique: ${idx.unique}, definition: ${JSON.stringify(idx.definition)} },`);
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
@@ -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 primary key is exactly two columns.
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 PK columns.
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/PK columns.
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>>): void;
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
@@ -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 isUnique = row.indexdef.includes('UNIQUE');
399
- // Extract column names from indexdef (e.g. "CREATE INDEX idx ON tbl USING btree (col1, col2)")
400
- const colMatch = row.indexdef.match(/\((.+)\)/);
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: row.indexdef,
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
- addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
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 primary key is exactly two columns.
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 PK columns.
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/PK columns.
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/PK columns.
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.