@vibeorm/migrate 1.3.1 → 2.0.0-alpha.2

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.
Files changed (61) hide show
  1. package/README.md +33 -66
  2. package/dist/ddl/drop-order.d.ts +25 -0
  3. package/dist/ddl/drop-order.d.ts.map +1 -0
  4. package/dist/ddl/mysql.d.ts +46 -0
  5. package/dist/ddl/mysql.d.ts.map +1 -0
  6. package/dist/ddl/postgres.d.ts +24 -0
  7. package/dist/ddl/postgres.d.ts.map +1 -0
  8. package/dist/ddl/render.d.ts +25 -0
  9. package/dist/ddl/render.d.ts.map +1 -0
  10. package/dist/ddl/sqlite.d.ts +62 -0
  11. package/dist/ddl/sqlite.d.ts.map +1 -0
  12. package/dist/differ.d.ts +33 -0
  13. package/dist/differ.d.ts.map +1 -0
  14. package/dist/down.d.ts +55 -0
  15. package/dist/down.d.ts.map +1 -0
  16. package/dist/enum-values.d.ts +19 -0
  17. package/dist/enum-values.d.ts.map +1 -0
  18. package/dist/execute.d.ts +41 -0
  19. package/dist/execute.d.ts.map +1 -0
  20. package/dist/extensions.d.ts +74 -0
  21. package/dist/extensions.d.ts.map +1 -0
  22. package/dist/index.d.ts +26 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +3858 -0
  25. package/dist/index.js.map +31 -0
  26. package/dist/introspect/mysql.d.ts +31 -0
  27. package/dist/introspect/mysql.d.ts.map +1 -0
  28. package/dist/introspect/postgres.d.ts +25 -0
  29. package/dist/introspect/postgres.d.ts.map +1 -0
  30. package/dist/introspect/shared.d.ts +76 -0
  31. package/dist/introspect/shared.d.ts.map +1 -0
  32. package/dist/introspect/sqlite.d.ts +53 -0
  33. package/dist/introspect/sqlite.d.ts.map +1 -0
  34. package/dist/mysql-types.d.ts +66 -0
  35. package/dist/mysql-types.d.ts.map +1 -0
  36. package/dist/normalize.d.ts +115 -0
  37. package/dist/normalize.d.ts.map +1 -0
  38. package/dist/postgres-types.d.ts +95 -0
  39. package/dist/postgres-types.d.ts.map +1 -0
  40. package/dist/push.d.ts +45 -0
  41. package/dist/push.d.ts.map +1 -0
  42. package/dist/relations.d.ts +40 -0
  43. package/dist/relations.d.ts.map +1 -0
  44. package/dist/runner.d.ts +120 -0
  45. package/dist/runner.d.ts.map +1 -0
  46. package/dist/sqlite-types.d.ts +87 -0
  47. package/dist/sqlite-types.d.ts.map +1 -0
  48. package/dist/types.d.ts +217 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/package.json +33 -23
  51. package/src/cascade-actions.ts +0 -88
  52. package/src/ddl-builder.ts +0 -415
  53. package/src/index.ts +0 -41
  54. package/src/introspector.ts +0 -684
  55. package/src/migration-runner.ts +0 -127
  56. package/src/relation-utils.ts +0 -79
  57. package/src/schema-differ.ts +0 -865
  58. package/src/schema-printer.ts +0 -259
  59. package/src/snapshot.ts +0 -141
  60. package/src/sql-utils.ts +0 -45
  61. package/src/types.ts +0 -13
@@ -1,684 +0,0 @@
1
- /**
2
- * Introspector — PostgreSQL → Schema IR
3
- *
4
- * Queries the live database (information_schema + pg_catalog) and
5
- * produces a Schema IR. The reverse of ddl-builder.ts.
6
- */
7
-
8
- import type {
9
- Schema,
10
- Model,
11
- Field,
12
- ScalarField,
13
- EnumField,
14
- RelationField,
15
- PrimaryKey,
16
- UniqueConstraint,
17
- IndexDefinition,
18
- Enum,
19
- EnumValue,
20
- PrismaScalarType,
21
- PostgresType,
22
- DefaultValue,
23
- CascadeAction,
24
- } from "@vibeorm/parser";
25
- import { PRISMA_TO_TS, PRISMA_TO_PG } from "@vibeorm/parser";
26
- import type { SqlExecutor } from "./types.ts";
27
- import { PG_CHAR_TO_CASCADE_ACTION } from "./cascade-actions.ts";
28
-
29
- // ─── Reverse Type Mapping ─────────────────────────────────────────
30
-
31
- const PG_TO_PRISMA: Record<string, PrismaScalarType> = {
32
- text: "String",
33
- varchar: "String",
34
- "character varying": "String",
35
- char: "String",
36
- character: "String",
37
- uuid: "String",
38
- boolean: "Boolean",
39
- bool: "Boolean",
40
- integer: "Int",
41
- int4: "Int",
42
- serial: "Int",
43
- smallint: "Int",
44
- int2: "Int",
45
- bigint: "BigInt",
46
- int8: "BigInt",
47
- bigserial: "BigInt",
48
- "double precision": "Float",
49
- float8: "Float",
50
- real: "Float",
51
- float4: "Float",
52
- decimal: "Decimal",
53
- numeric: "Decimal",
54
- timestamp: "DateTime",
55
- "timestamp without time zone": "DateTime",
56
- timestamptz: "DateTime",
57
- "timestamp with time zone": "DateTime",
58
- jsonb: "Json",
59
- json: "Json",
60
- bytea: "Bytes",
61
- };
62
-
63
- // ─── Internal Types ───────────────────────────────────────────────
64
-
65
- type ColumnInfo = {
66
- tableName: string;
67
- columnName: string;
68
- dataType: string;
69
- udtName: string;
70
- isNullable: boolean;
71
- columnDefault: string | null;
72
- ordinalPosition: number;
73
- };
74
-
75
- type ConstraintInfo = {
76
- constraintName: string;
77
- constraintType: string; // PRIMARY KEY, UNIQUE, FOREIGN KEY
78
- tableName: string;
79
- columnName: string;
80
- };
81
-
82
- type ForeignKeyInfo = {
83
- constraintName: string;
84
- tableName: string;
85
- columnName: string;
86
- foreignTableName: string;
87
- foreignColumnName: string;
88
- /** ON DELETE action from `pg_constraint.confdeltype` (mapped to CascadeAction). */
89
- onDelete: CascadeAction;
90
- /** ON UPDATE action from `pg_constraint.confupdtype` (mapped to CascadeAction). */
91
- onUpdate: CascadeAction;
92
- };
93
-
94
- type EnumInfo = {
95
- typeName: string;
96
- enumValue: string;
97
- sortOrder: number;
98
- };
99
-
100
- type IndexInfo = {
101
- tableName: string;
102
- indexName: string;
103
- indexDef: string;
104
- isUnique: boolean;
105
- };
106
-
107
- // ─── SQL Queries ──────────────────────────────────────────────────
108
-
109
- const TABLES_QUERY = `
110
- SELECT table_name
111
- FROM information_schema.tables
112
- WHERE table_schema = 'public'
113
- AND table_type = 'BASE TABLE'
114
- AND table_name NOT LIKE '_vibeorm_%'
115
- ORDER BY table_name;
116
- `;
117
-
118
- const COLUMNS_QUERY = `
119
- SELECT
120
- table_name,
121
- column_name,
122
- data_type,
123
- udt_name,
124
- is_nullable,
125
- column_default,
126
- ordinal_position
127
- FROM information_schema.columns
128
- WHERE table_schema = 'public'
129
- ORDER BY table_name, ordinal_position;
130
- `;
131
-
132
- const CONSTRAINTS_QUERY = `
133
- SELECT
134
- tc.constraint_name,
135
- tc.constraint_type,
136
- tc.table_name,
137
- kcu.column_name
138
- FROM information_schema.table_constraints tc
139
- JOIN information_schema.key_column_usage kcu
140
- ON tc.constraint_name = kcu.constraint_name
141
- AND tc.table_schema = kcu.table_schema
142
- WHERE tc.table_schema = 'public'
143
- AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
144
- ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position;
145
- `;
146
-
147
- /**
148
- * Foreign keys via pg_catalog rather than information_schema, because
149
- * information_schema's `referential_constraints.delete_rule` exposes the rule
150
- * but doesn't preserve the column ordering of composite FKs reliably across
151
- * server versions. The pg_catalog path with `unnest(...) WITH ORDINALITY`
152
- * pairs up `conkey` / `confkey` in declaration order and also surfaces
153
- * `confdeltype` / `confupdtype` directly.
154
- */
155
- const FOREIGN_KEYS_QUERY = `
156
- SELECT
157
- c.conname AS constraint_name,
158
- cl.relname AS table_name,
159
- att.attname AS column_name,
160
- fcl.relname AS foreign_table_name,
161
- fatt.attname AS foreign_column_name,
162
- c.confdeltype AS on_delete_char,
163
- c.confupdtype AS on_update_char
164
- FROM pg_catalog.pg_constraint c
165
- JOIN pg_catalog.pg_class cl ON cl.oid = c.conrelid
166
- JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
167
- JOIN pg_catalog.pg_class fcl ON fcl.oid = c.confrelid
168
- JOIN pg_catalog.pg_namespace fn ON fn.oid = fcl.relnamespace
169
- JOIN unnest(c.conkey) WITH ORDINALITY AS lk(attnum, ord) ON true
170
- JOIN unnest(c.confkey) WITH ORDINALITY AS rk(attnum, ord) ON lk.ord = rk.ord
171
- JOIN pg_catalog.pg_attribute att ON att.attrelid = c.conrelid AND att.attnum = lk.attnum
172
- JOIN pg_catalog.pg_attribute fatt ON fatt.attrelid = c.confrelid AND fatt.attnum = rk.attnum
173
- WHERE c.contype = 'f'
174
- AND n.nspname = 'public'
175
- AND fn.nspname = 'public'
176
- ORDER BY cl.relname, c.conname, lk.ord;
177
- `;
178
-
179
- const ENUMS_QUERY = `
180
- SELECT
181
- t.typname AS type_name,
182
- e.enumlabel AS enum_value,
183
- e.enumsortorder AS sort_order
184
- FROM pg_type t
185
- JOIN pg_enum e ON t.oid = e.enumtypid
186
- JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
187
- WHERE n.nspname = 'public'
188
- ORDER BY t.typname, e.enumsortorder;
189
- `;
190
-
191
- const INDEXES_QUERY = `
192
- SELECT
193
- tablename AS table_name,
194
- indexname AS index_name,
195
- indexdef AS index_def
196
- FROM pg_indexes
197
- WHERE schemaname = 'public'
198
- ORDER BY tablename, indexname;
199
- `;
200
-
201
- // ─── Default Value Parsing ────────────────────────────────────────
202
-
203
- function parseDefault(params: { columnDefault: string | null; dataType: string }): DefaultValue | undefined {
204
- const { columnDefault, dataType } = params;
205
- if (!columnDefault) return undefined;
206
-
207
- const val = columnDefault;
208
-
209
- // Autoincrement: nextval('table_column_seq'::regclass)
210
- if (val.includes("nextval(")) {
211
- return { kind: "autoincrement" };
212
- }
213
-
214
- // now() / CURRENT_TIMESTAMP
215
- if (val === "CURRENT_TIMESTAMP" || val === "now()" || val.startsWith("CURRENT_TIMESTAMP")) {
216
- return { kind: "now" };
217
- }
218
-
219
- // uuid: gen_random_uuid()
220
- if (val.includes("gen_random_uuid()")) {
221
- return { kind: "uuid" };
222
- }
223
-
224
- // Boolean literals
225
- if (val === "true" || val === "false") {
226
- return { kind: "literal", value: val === "true" };
227
- }
228
-
229
- // Numeric literals
230
- if (/^-?\d+(\.\d+)?$/.test(val)) {
231
- return { kind: "literal", value: Number(val) };
232
- }
233
-
234
- // String literals: 'value'::type or 'value'
235
- const stringMatch = val.match(/^'(.+?)'::/);
236
- if (stringMatch) {
237
- return { kind: "literal", value: stringMatch[1]! };
238
- }
239
-
240
- // Plain string
241
- const plainStringMatch = val.match(/^'(.+?)'$/);
242
- if (plainStringMatch) {
243
- return { kind: "literal", value: plainStringMatch[1]! };
244
- }
245
-
246
- // dbgenerated for anything else
247
- return { kind: "dbgenerated", value: val };
248
- }
249
-
250
- // ─── Implicit M:N Detection ──────────────────────────────────────
251
-
252
- function isImplicitJoinTable(params: {
253
- tableName: string;
254
- columns: ColumnInfo[];
255
- foreignKeys: ForeignKeyInfo[];
256
- }): { modelA: string; modelB: string } | null {
257
- const { tableName, columns, foreignKeys } = params;
258
-
259
- // Pattern: _ModelAToModelB
260
- const match = tableName.match(/^_(\w+)To(\w+)$/);
261
- if (!match) return null;
262
-
263
- const tableColumns = columns.filter((c) => c.tableName === tableName);
264
- const tableFKs = foreignKeys.filter((fk) => fk.tableName === tableName);
265
-
266
- // Must have exactly columns A and B
267
- const colNames = tableColumns.map((c) => c.columnName).sort();
268
- if (colNames.length !== 2 || colNames[0] !== "A" || colNames[1] !== "B") return null;
269
-
270
- // Must have exactly 2 FKs
271
- if (tableFKs.length !== 2) return null;
272
-
273
- return { modelA: match[1]!, modelB: match[2]! };
274
- }
275
-
276
- // ─── Main Introspect Function ─────────────────────────────────────
277
-
278
- export async function introspect(params: { executor: SqlExecutor }): Promise<Schema> {
279
- const { executor } = params;
280
-
281
- // Run all queries in parallel
282
- const [tablesRaw, columnsRaw, constraintsRaw, foreignKeysRaw, enumsRaw, indexesRaw] = await Promise.all([
283
- executor({ text: TABLES_QUERY }),
284
- executor({ text: COLUMNS_QUERY }),
285
- executor({ text: CONSTRAINTS_QUERY }),
286
- executor({ text: FOREIGN_KEYS_QUERY }),
287
- executor({ text: ENUMS_QUERY }),
288
- executor({ text: INDEXES_QUERY }),
289
- ]);
290
-
291
- // Parse raw results
292
- const tables = tablesRaw.map((r) => r.table_name as string);
293
-
294
- const columns: ColumnInfo[] = columnsRaw.map((r) => ({
295
- tableName: r.table_name as string,
296
- columnName: r.column_name as string,
297
- dataType: r.data_type as string,
298
- udtName: r.udt_name as string,
299
- isNullable: (r.is_nullable as string) === "YES",
300
- columnDefault: r.column_default as string | null,
301
- ordinalPosition: r.ordinal_position as number,
302
- }));
303
-
304
- const constraints: ConstraintInfo[] = constraintsRaw.map((r) => ({
305
- constraintName: r.constraint_name as string,
306
- constraintType: r.constraint_type as string,
307
- tableName: r.table_name as string,
308
- columnName: r.column_name as string,
309
- }));
310
-
311
- const foreignKeys: ForeignKeyInfo[] = foreignKeysRaw.map((r) => {
312
- // Postgres uses single-char codes: a=NoAction, r=Restrict, c=Cascade,
313
- // n=SetNull, d=SetDefault. Fall back to NoAction (the Postgres default)
314
- // if we ever see an unrecognised char from a future PG version.
315
- const onDeleteChar = (r.on_delete_char as string) ?? "a";
316
- const onUpdateChar = (r.on_update_char as string) ?? "a";
317
- return {
318
- constraintName: r.constraint_name as string,
319
- tableName: r.table_name as string,
320
- columnName: r.column_name as string,
321
- foreignTableName: r.foreign_table_name as string,
322
- foreignColumnName: r.foreign_column_name as string,
323
- onDelete: PG_CHAR_TO_CASCADE_ACTION[onDeleteChar] ?? "NoAction",
324
- onUpdate: PG_CHAR_TO_CASCADE_ACTION[onUpdateChar] ?? "NoAction",
325
- };
326
- });
327
-
328
- const enumInfos: EnumInfo[] = enumsRaw.map((r) => ({
329
- typeName: r.type_name as string,
330
- enumValue: r.enum_value as string,
331
- sortOrder: r.sort_order as number,
332
- }));
333
-
334
- const indexInfos: IndexInfo[] = indexesRaw.map((r) => ({
335
- tableName: r.table_name as string,
336
- indexName: r.index_name as string,
337
- indexDef: r.index_def as string,
338
- isUnique: (r.index_def as string).includes("UNIQUE"),
339
- }));
340
-
341
- // ─── Build Enums ────────────────────────────────────────────────
342
-
343
- const enumMap = new Map<string, EnumValue[]>();
344
- for (const ei of enumInfos) {
345
- if (!enumMap.has(ei.typeName)) {
346
- enumMap.set(ei.typeName, []);
347
- }
348
- enumMap.get(ei.typeName)!.push({ name: ei.enumValue, dbName: undefined });
349
- }
350
-
351
- const enums: Enum[] = [];
352
- for (const [name, values] of enumMap) {
353
- enums.push({ name, dbName: undefined, values });
354
- }
355
-
356
- const enumNames = new Set(enumMap.keys());
357
-
358
- // ─── Detect join tables ─────────────────────────────────────────
359
-
360
- const joinTables = new Map<string, { modelA: string; modelB: string }>();
361
- for (const tableName of tables) {
362
- const result = isImplicitJoinTable({ tableName, columns, foreignKeys });
363
- if (result) {
364
- joinTables.set(tableName, result);
365
- }
366
- }
367
-
368
- // ─── Build constraints maps ─────────────────────────────────────
369
-
370
- // Primary keys: tableName → column names
371
- const primaryKeys = new Map<string, string[]>();
372
- for (const c of constraints) {
373
- if (c.constraintType !== "PRIMARY KEY") continue;
374
- if (!primaryKeys.has(c.tableName)) primaryKeys.set(c.tableName, []);
375
- primaryKeys.get(c.tableName)!.push(c.columnName);
376
- }
377
-
378
- // Unique constraints: tableName → array of { constraintName, columns }
379
- const uniqueConstraints = new Map<string, Map<string, string[]>>();
380
- for (const c of constraints) {
381
- if (c.constraintType !== "UNIQUE") continue;
382
- if (!uniqueConstraints.has(c.tableName)) uniqueConstraints.set(c.tableName, new Map());
383
- const tableUniques = uniqueConstraints.get(c.tableName)!;
384
- if (!tableUniques.has(c.constraintName)) tableUniques.set(c.constraintName, []);
385
- tableUniques.get(c.constraintName)!.push(c.columnName);
386
- }
387
-
388
- // FK columns with unique constraints (for 1:1 detection)
389
- const uniqueColumns = new Set<string>();
390
- for (const [tableName, tableUniques] of uniqueConstraints) {
391
- for (const [, cols] of tableUniques) {
392
- if (cols.length === 1) {
393
- uniqueColumns.add(`${tableName}.${cols[0]}`);
394
- }
395
- }
396
- }
397
-
398
- // Group foreign keys by constraint name
399
- const fkByConstraint = new Map<string, ForeignKeyInfo[]>();
400
- for (const fk of foreignKeys) {
401
- if (!fkByConstraint.has(fk.constraintName)) fkByConstraint.set(fk.constraintName, []);
402
- fkByConstraint.get(fk.constraintName)!.push(fk);
403
- }
404
-
405
- // ─── Build Models ───────────────────────────────────────────────
406
-
407
- const models: Model[] = [];
408
-
409
- for (const tableName of tables) {
410
- // Skip join tables
411
- if (joinTables.has(tableName)) continue;
412
-
413
- const tableColumns = columns.filter((c) => c.tableName === tableName);
414
- const tablePK = primaryKeys.get(tableName) ?? [];
415
- const tableUniques = uniqueConstraints.get(tableName) ?? new Map<string, string[]>();
416
- const tableFKs = foreignKeys.filter((fk) => fk.tableName === tableName);
417
-
418
- // Group FKs by constraint for multi-column FK support. Cascade actions are
419
- // a property of the constraint as a whole, so we carry them once per group.
420
- const fkGroups = new Map<string, {
421
- localCols: string[];
422
- foreignTable: string;
423
- foreignCols: string[];
424
- onDelete: CascadeAction;
425
- onUpdate: CascadeAction;
426
- }>();
427
- for (const fk of tableFKs) {
428
- if (!fkGroups.has(fk.constraintName)) {
429
- fkGroups.set(fk.constraintName, {
430
- localCols: [],
431
- foreignTable: fk.foreignTableName,
432
- foreignCols: [],
433
- onDelete: fk.onDelete,
434
- onUpdate: fk.onUpdate,
435
- });
436
- }
437
- const group = fkGroups.get(fk.constraintName)!;
438
- group.localCols.push(fk.columnName);
439
- group.foreignCols.push(fk.foreignColumnName);
440
- }
441
-
442
- const fields: Field[] = [];
443
-
444
- // Scalar/Enum fields
445
- for (const col of tableColumns) {
446
- const isId = tablePK.includes(col.columnName);
447
- const isUnique = uniqueColumns.has(`${tableName}.${col.columnName}`);
448
- const parsedDefault = parseDefault({ columnDefault: col.columnDefault, dataType: col.dataType });
449
-
450
- // Branch 1: scalar enum column (data_type=USER-DEFINED, udt_name=EnumName).
451
- if (col.dataType === "USER-DEFINED" && enumNames.has(col.udtName)) {
452
- const enumField: EnumField = {
453
- kind: "enum",
454
- name: col.columnName,
455
- dbName: col.columnName,
456
- enumName: col.udtName,
457
- isRequired: !col.isNullable,
458
- isList: false,
459
- isId,
460
- isUnique,
461
- default: parsedDefault,
462
- documentation: undefined,
463
- };
464
- fields.push(enumField);
465
- }
466
- // Branch 2: enum-array column. Postgres reports the underlying array type
467
- // as `_EnumName` (the leading underscore is the array-type naming convention),
468
- // and `data_type` is `ARRAY`. Without this branch the field would be parsed
469
- // as `String[]` and the round-trip drift differ would always want to ALTER it.
470
- // Bug 2 — see .ai/bug-report-2026-05-24.md.
471
- else if (
472
- col.dataType === "ARRAY" &&
473
- col.udtName.startsWith("_") &&
474
- enumNames.has(col.udtName.slice(1))
475
- ) {
476
- const enumField: EnumField = {
477
- kind: "enum",
478
- name: col.columnName,
479
- dbName: col.columnName,
480
- enumName: col.udtName.slice(1),
481
- isRequired: !col.isNullable,
482
- isList: true,
483
- isId,
484
- isUnique,
485
- default: parsedDefault,
486
- documentation: undefined,
487
- };
488
- fields.push(enumField);
489
- }
490
- // Branch 3: scalar / scalar-array fallback.
491
- else {
492
- const pgType = col.dataType === "ARRAY" ? col.udtName.replace(/^_/, "") : col.udtName;
493
- const prismaType = PG_TO_PRISMA[pgType] ?? PG_TO_PRISMA[col.dataType] ?? "String";
494
- const isArray = col.dataType === "ARRAY";
495
-
496
- const scalarField: ScalarField = {
497
- kind: "scalar",
498
- name: col.columnName,
499
- dbName: col.columnName,
500
- prismaType,
501
- tsType: PRISMA_TO_TS[prismaType],
502
- pgType: PRISMA_TO_PG[prismaType],
503
- isRequired: !col.isNullable,
504
- isList: isArray,
505
- isId,
506
- isUnique,
507
- isUpdatedAt: false, // Can't detect @updatedAt from DB
508
- default: parsedDefault,
509
- nativeType: undefined,
510
- documentation: undefined,
511
- };
512
- fields.push(scalarField);
513
- }
514
- }
515
-
516
- // Relation fields (FK side — this model owns the FK)
517
- for (const [constraintName, group] of fkGroups) {
518
- // Determine relation type: if FK column is unique → oneToOne, else manyToOne
519
- const isOneToOne = group.localCols.length === 1 &&
520
- uniqueColumns.has(`${tableName}.${group.localCols[0]}`);
521
-
522
- const relationField: RelationField = {
523
- kind: "relation",
524
- name: group.foreignTable.charAt(0).toLowerCase() + group.foreignTable.slice(1),
525
- relatedModel: group.foreignTable,
526
- isList: false,
527
- isRequired: group.localCols.every((col) => {
528
- const colInfo = tableColumns.find((c) => c.columnName === col);
529
- return colInfo ? !colInfo.isNullable : true;
530
- }),
531
- relation: {
532
- name: undefined,
533
- fields: group.localCols,
534
- references: group.foreignCols,
535
- relatedModel: group.foreignTable,
536
- type: isOneToOne ? "oneToOne" : "manyToOne",
537
- isForeignKey: true,
538
- onDelete: group.onDelete,
539
- onUpdate: group.onUpdate,
540
- },
541
- };
542
- fields.push(relationField);
543
- }
544
-
545
- // Build unique constraints (multi-column only, single-column is on the field)
546
- const modelUniques: UniqueConstraint[] = [];
547
- for (const [name, cols] of tableUniques) {
548
- if (cols.length > 1) {
549
- modelUniques.push({ fields: cols, name });
550
- }
551
- }
552
-
553
- // Build indexes (exclude PKs and unique constraints that are already tracked)
554
- const constraintNames = new Set(constraints.filter((c) => c.tableName === tableName).map((c) => c.constraintName));
555
- const tableIndexes = indexInfos.filter(
556
- (idx) => idx.tableName === tableName && !constraintNames.has(idx.indexName)
557
- );
558
- const modelIndexes: IndexDefinition[] = [];
559
- for (const idx of tableIndexes) {
560
- // Parse columns from index definition: CREATE [UNIQUE] INDEX name ON table (col1, col2)
561
- const colMatch = idx.indexDef.match(/\(([^)]+)\)/);
562
- if (colMatch) {
563
- const cols = colMatch[1]!.split(",").map((c) => c.trim().replace(/"/g, ""));
564
- if (idx.isUnique) {
565
- // Unique indexes → treat as unique constraints (multi-column only;
566
- // single-column uniqueness is already captured via information_schema)
567
- if (cols.length > 1) {
568
- modelUniques.push({ fields: cols, name: idx.indexName });
569
- }
570
- } else {
571
- modelIndexes.push({ fields: cols, name: idx.indexName });
572
- }
573
- }
574
- }
575
-
576
- const model: Model = {
577
- name: tableName,
578
- dbName: tableName,
579
- fields,
580
- primaryKey: {
581
- fields: tablePK,
582
- isComposite: tablePK.length > 1,
583
- },
584
- uniqueConstraints: modelUniques,
585
- indexes: modelIndexes,
586
- };
587
-
588
- models.push(model);
589
- }
590
-
591
- // ─── Add back-reference relation fields ─────────────────────────
592
-
593
- // For each FK-owning relation, add the inverse relation to the referenced model
594
- for (const model of models) {
595
- for (const field of model.fields) {
596
- if (field.kind !== "relation") continue;
597
- if (!field.relation.isForeignKey) continue;
598
-
599
- const relatedModel = models.find((m) => m.name === field.relatedModel);
600
- if (!relatedModel) continue;
601
-
602
- // Check if the related model already has a back-reference
603
- const hasBackRef = relatedModel.fields.some(
604
- (f) => f.kind === "relation" && f.relatedModel === model.name
605
- );
606
- if (hasBackRef) continue;
607
-
608
- const isOneToOne = field.relation.type === "oneToOne";
609
-
610
- const backRef: RelationField = {
611
- kind: "relation",
612
- name: isOneToOne
613
- ? model.name.charAt(0).toLowerCase() + model.name.slice(1)
614
- : model.name.charAt(0).toLowerCase() + model.name.slice(1),
615
- relatedModel: model.name,
616
- isList: !isOneToOne,
617
- isRequired: false,
618
- relation: {
619
- name: undefined,
620
- fields: [],
621
- references: [],
622
- relatedModel: model.name,
623
- type: isOneToOne ? "oneToOne" : "oneToMany",
624
- isForeignKey: false,
625
- },
626
- };
627
- relatedModel.fields.push(backRef);
628
- }
629
- }
630
-
631
- // ─── Add implicit M:N relation fields ───────────────────────────
632
-
633
- for (const [joinTableName, { modelA, modelB }] of joinTables) {
634
- const modelADef = models.find((m) => m.name === modelA);
635
- const modelBDef = models.find((m) => m.name === modelB);
636
- if (!modelADef || !modelBDef) continue;
637
-
638
- // Add relation field to model A → model B[]
639
- const hasRelA = modelADef.fields.some(
640
- (f) => f.kind === "relation" && f.relatedModel === modelB && f.isList
641
- );
642
- if (!hasRelA) {
643
- modelADef.fields.push({
644
- kind: "relation",
645
- name: modelB.charAt(0).toLowerCase() + modelB.slice(1),
646
- relatedModel: modelB,
647
- isList: true,
648
- isRequired: false,
649
- relation: {
650
- name: undefined,
651
- fields: [],
652
- references: [],
653
- relatedModel: modelB,
654
- type: "oneToMany", // M:N represented as two oneToMany from each side
655
- isForeignKey: false,
656
- },
657
- });
658
- }
659
-
660
- // Add relation field to model B → model A[]
661
- const hasRelB = modelBDef.fields.some(
662
- (f) => f.kind === "relation" && f.relatedModel === modelA && f.isList
663
- );
664
- if (!hasRelB) {
665
- modelBDef.fields.push({
666
- kind: "relation",
667
- name: modelA.charAt(0).toLowerCase() + modelA.slice(1),
668
- relatedModel: modelA,
669
- isList: true,
670
- isRequired: false,
671
- relation: {
672
- name: undefined,
673
- fields: [],
674
- references: [],
675
- relatedModel: modelA,
676
- type: "oneToMany",
677
- isForeignKey: false,
678
- },
679
- });
680
- }
681
- }
682
-
683
- return { models, enums };
684
- }