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