@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,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema Differ — Diff Two Schema IRs → Migration Operations
|
|
3
|
+
*
|
|
4
|
+
* Compares a "previous" and "current" Schema IR and produces
|
|
5
|
+
* an ordered list of DDL operations to migrate between them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Schema,
|
|
10
|
+
Model,
|
|
11
|
+
Field,
|
|
12
|
+
ScalarField,
|
|
13
|
+
EnumField,
|
|
14
|
+
RelationField,
|
|
15
|
+
Enum,
|
|
16
|
+
UniqueConstraint,
|
|
17
|
+
IndexDefinition,
|
|
18
|
+
} from "@vibeorm/parser";
|
|
19
|
+
import { PRISMA_TO_PG } from "@vibeorm/parser";
|
|
20
|
+
|
|
21
|
+
// ─── Types ────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type DiffOperationType =
|
|
24
|
+
| "createEnum"
|
|
25
|
+
| "addEnumValue"
|
|
26
|
+
| "removeEnumValue"
|
|
27
|
+
| "dropEnum"
|
|
28
|
+
| "createTable"
|
|
29
|
+
| "dropTable"
|
|
30
|
+
| "addColumn"
|
|
31
|
+
| "dropColumn"
|
|
32
|
+
| "alterColumnType"
|
|
33
|
+
| "alterColumnNullability"
|
|
34
|
+
| "alterColumnDefault"
|
|
35
|
+
| "addUnique"
|
|
36
|
+
| "dropUnique"
|
|
37
|
+
| "addIndex"
|
|
38
|
+
| "dropIndex"
|
|
39
|
+
| "addForeignKey"
|
|
40
|
+
| "dropForeignKey"
|
|
41
|
+
| "createJoinTable"
|
|
42
|
+
| "dropJoinTable";
|
|
43
|
+
|
|
44
|
+
export type DiffOperation = {
|
|
45
|
+
type: DiffOperationType;
|
|
46
|
+
sql: string;
|
|
47
|
+
isDestructive: boolean;
|
|
48
|
+
description: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ─── Helpers ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function q(params: { name: string }): string {
|
|
54
|
+
return `"${params.name}"`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getScalarFields(params: { model: Model }): (ScalarField | EnumField)[] {
|
|
58
|
+
return params.model.fields.filter(
|
|
59
|
+
(f): f is ScalarField | EnumField => f.kind === "scalar" || f.kind === "enum"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getRelationFields(params: { model: Model }): RelationField[] {
|
|
64
|
+
return params.model.fields.filter((f): f is RelationField => f.kind === "relation");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
|
|
68
|
+
const { field, enums } = params;
|
|
69
|
+
if (field.kind === "enum") {
|
|
70
|
+
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
71
|
+
return q({ name: enumDef?.dbName ?? field.enumName });
|
|
72
|
+
}
|
|
73
|
+
if (field.default?.kind === "autoincrement") {
|
|
74
|
+
return field.prismaType === "BigInt" ? "BIGSERIAL" : "SERIAL";
|
|
75
|
+
}
|
|
76
|
+
if (field.nativeType) return field.nativeType;
|
|
77
|
+
if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
|
|
78
|
+
return PRISMA_TO_PG[field.prismaType];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve to the base column type (no SERIAL — used for FK reference columns in join tables) */
|
|
82
|
+
function resolveBaseColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
|
|
83
|
+
const { field, enums } = params;
|
|
84
|
+
if (field.kind === "enum") {
|
|
85
|
+
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
86
|
+
return q({ name: enumDef?.dbName ?? field.enumName });
|
|
87
|
+
}
|
|
88
|
+
if (field.nativeType) return field.nativeType;
|
|
89
|
+
if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
|
|
90
|
+
return PRISMA_TO_PG[field.prismaType];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveDefault(params: { field: ScalarField | EnumField }): string | undefined {
|
|
94
|
+
const { field } = params;
|
|
95
|
+
const def = field.default;
|
|
96
|
+
if (!def) return undefined;
|
|
97
|
+
switch (def.kind) {
|
|
98
|
+
case "autoincrement": return undefined;
|
|
99
|
+
case "now": return "CURRENT_TIMESTAMP";
|
|
100
|
+
case "uuid": return "gen_random_uuid()::TEXT";
|
|
101
|
+
case "cuid": return undefined;
|
|
102
|
+
case "nanoid": return undefined;
|
|
103
|
+
case "ulid": return undefined;
|
|
104
|
+
case "dbgenerated": return def.value;
|
|
105
|
+
case "literal": {
|
|
106
|
+
const val = def.value;
|
|
107
|
+
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
|
|
108
|
+
if (typeof val === "boolean") return val ? "true" : "false";
|
|
109
|
+
return String(val);
|
|
110
|
+
}
|
|
111
|
+
default: return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function uniqueKey(params: { uc: UniqueConstraint; model: Model }): string {
|
|
116
|
+
return params.uc.fields
|
|
117
|
+
.map((f: string) => resolveFieldDbName({ model: params.model, fieldName: f }))
|
|
118
|
+
.sort()
|
|
119
|
+
.join(",");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function indexKey(params: { idx: IndexDefinition; model: Model }): string {
|
|
123
|
+
return params.idx.fields
|
|
124
|
+
.map((f: string) => resolveFieldDbName({ model: params.model, fieldName: f }))
|
|
125
|
+
.sort()
|
|
126
|
+
.join(",");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fkKey(params: { rel: RelationField; model: Model; allModels: Model[] }): string {
|
|
130
|
+
const { rel, model, allModels } = params;
|
|
131
|
+
const resolvedFields = rel.relation.fields
|
|
132
|
+
.map((f: string) => resolveFieldDbName({ model, fieldName: f }))
|
|
133
|
+
.join(",");
|
|
134
|
+
const relatedModel = allModels.find((m: Model) => m.name === rel.relatedModel);
|
|
135
|
+
const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
|
|
136
|
+
const resolvedRefs = rel.relation.references
|
|
137
|
+
.map((f: string) => {
|
|
138
|
+
if (relatedModel) return resolveFieldDbName({ model: relatedModel, fieldName: f });
|
|
139
|
+
return f;
|
|
140
|
+
})
|
|
141
|
+
.join(",");
|
|
142
|
+
return `${resolvedFields}->${relatedTableName}.${resolvedRefs}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Resolve a logical field name to its dbName by looking up the model's fields. */
|
|
146
|
+
function resolveFieldDbName(params: { model: Model; fieldName: string }): string {
|
|
147
|
+
const { model, fieldName } = params;
|
|
148
|
+
const field = model.fields.find((f: Field) => f.name === fieldName);
|
|
149
|
+
if (field && field.kind !== "relation") return field.dbName;
|
|
150
|
+
return fieldName;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Implicit M:N Detection ──────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
type JoinTableInfo = {
|
|
156
|
+
modelA: string;
|
|
157
|
+
modelB: string;
|
|
158
|
+
tableName: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function detectImplicitManyToMany(params: { schema: Schema }): JoinTableInfo[] {
|
|
162
|
+
const { schema } = params;
|
|
163
|
+
const result: JoinTableInfo[] = [];
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
|
|
166
|
+
for (const model of schema.models) {
|
|
167
|
+
for (const field of model.fields) {
|
|
168
|
+
if (field.kind !== "relation") continue;
|
|
169
|
+
if (!field.isList) continue;
|
|
170
|
+
if (field.relation.isForeignKey) continue;
|
|
171
|
+
if (field.relation.fields.length > 0) continue;
|
|
172
|
+
|
|
173
|
+
const relatedModel = schema.models.find((m: Model) => m.name === field.relatedModel);
|
|
174
|
+
if (!relatedModel) continue;
|
|
175
|
+
|
|
176
|
+
const backRef = relatedModel.fields.find(
|
|
177
|
+
(f: Field) =>
|
|
178
|
+
f.kind === "relation" &&
|
|
179
|
+
f.relatedModel === model.name &&
|
|
180
|
+
f.isList &&
|
|
181
|
+
!f.relation.isForeignKey &&
|
|
182
|
+
f.relation.fields.length === 0
|
|
183
|
+
);
|
|
184
|
+
if (!backRef) continue;
|
|
185
|
+
|
|
186
|
+
const sorted = [model.name, relatedModel.name].sort();
|
|
187
|
+
const nameA = sorted[0]!;
|
|
188
|
+
const nameB = sorted[1]!;
|
|
189
|
+
const key = `${nameA}_${nameB}`;
|
|
190
|
+
if (seen.has(key)) continue;
|
|
191
|
+
seen.add(key);
|
|
192
|
+
|
|
193
|
+
result.push({
|
|
194
|
+
modelA: nameA,
|
|
195
|
+
modelB: nameB,
|
|
196
|
+
tableName: `_${nameA}To${nameB}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Main Differ ──────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
export function diffSchemas(params: { previous: Schema; current: Schema }): DiffOperation[] {
|
|
207
|
+
const { previous, current } = params;
|
|
208
|
+
const ops: DiffOperation[] = [];
|
|
209
|
+
|
|
210
|
+
// Match models by dbName so that @@map("table_name") models match introspected models
|
|
211
|
+
const prevModelMap = new Map(previous.models.map((m: Model) => [m.dbName, m]));
|
|
212
|
+
const currModelMap = new Map(current.models.map((m: Model) => [m.dbName, m]));
|
|
213
|
+
const prevEnumMap = new Map(previous.enums.map((e: Enum) => [e.dbName ?? e.name, e]));
|
|
214
|
+
const currEnumMap = new Map(current.enums.map((e: Enum) => [e.dbName ?? e.name, e]));
|
|
215
|
+
|
|
216
|
+
// ─── Enum Diffs ───────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
// New enums
|
|
219
|
+
for (const [name, enumDef] of currEnumMap) {
|
|
220
|
+
if (!prevEnumMap.has(name)) {
|
|
221
|
+
const typeName = enumDef.dbName ?? enumDef.name;
|
|
222
|
+
const values = enumDef.values
|
|
223
|
+
.map((v) => `'${(v.dbName ?? v.name).replace(/'/g, "''")}'`)
|
|
224
|
+
.join(", ");
|
|
225
|
+
ops.push({
|
|
226
|
+
type: "createEnum",
|
|
227
|
+
sql: `DO $$ BEGIN\n CREATE TYPE ${q({ name: typeName })} AS ENUM (${values});\nEXCEPTION WHEN duplicate_object THEN NULL;\nEND $$;`,
|
|
228
|
+
isDestructive: false,
|
|
229
|
+
description: `Create enum type "${name}"`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Dropped enums
|
|
235
|
+
for (const [name, enumDef] of prevEnumMap) {
|
|
236
|
+
if (!currEnumMap.has(name)) {
|
|
237
|
+
const typeName = enumDef.dbName ?? enumDef.name;
|
|
238
|
+
ops.push({
|
|
239
|
+
type: "dropEnum",
|
|
240
|
+
sql: `DROP TYPE IF EXISTS ${q({ name: typeName })};`,
|
|
241
|
+
isDestructive: true,
|
|
242
|
+
description: `Drop enum type "${name}"`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Modified enums (added/removed values)
|
|
248
|
+
for (const [name, currEnum] of currEnumMap) {
|
|
249
|
+
const prevEnum = prevEnumMap.get(name);
|
|
250
|
+
if (!prevEnum) continue;
|
|
251
|
+
|
|
252
|
+
const typeName = currEnum.dbName ?? currEnum.name;
|
|
253
|
+
const prevValues = new Set(prevEnum.values.map((v) => v.name));
|
|
254
|
+
const currValues = new Set(currEnum.values.map((v) => v.name));
|
|
255
|
+
|
|
256
|
+
// Added values
|
|
257
|
+
for (const val of currEnum.values) {
|
|
258
|
+
if (!prevValues.has(val.name)) {
|
|
259
|
+
const dbVal = val.dbName ?? val.name;
|
|
260
|
+
ops.push({
|
|
261
|
+
type: "addEnumValue",
|
|
262
|
+
sql: `ALTER TYPE ${q({ name: typeName })} ADD VALUE IF NOT EXISTS '${dbVal.replace(/'/g, "''")}';`,
|
|
263
|
+
isDestructive: false,
|
|
264
|
+
description: `Add value "${val.name}" to enum "${name}"`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Removed values (destructive — PG doesn't support DROP VALUE easily)
|
|
270
|
+
for (const val of prevEnum.values) {
|
|
271
|
+
if (!currValues.has(val.name)) {
|
|
272
|
+
ops.push({
|
|
273
|
+
type: "removeEnumValue",
|
|
274
|
+
sql: `-- WARNING: PostgreSQL does not support removing enum values directly.\n-- You need to recreate the type. Value "${val.name}" removed from enum "${name}".`,
|
|
275
|
+
isDestructive: true,
|
|
276
|
+
description: `Remove value "${val.name}" from enum "${name}" (requires type recreation)`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── Table Diffs ──────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
// New tables
|
|
285
|
+
for (const [name, model] of currModelMap) {
|
|
286
|
+
if (prevModelMap.has(name)) continue;
|
|
287
|
+
|
|
288
|
+
const tableName = model.dbName;
|
|
289
|
+
const columns: string[] = [];
|
|
290
|
+
|
|
291
|
+
for (const field of getScalarFields({ model })) {
|
|
292
|
+
const colName = field.dbName;
|
|
293
|
+
const colType = resolveColumnType({ field, enums: current.enums });
|
|
294
|
+
let colDef = ` ${q({ name: colName })} ${colType}`;
|
|
295
|
+
|
|
296
|
+
if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
|
|
297
|
+
colDef += " NOT NULL";
|
|
298
|
+
} else if (field.kind === "enum" && field.isRequired) {
|
|
299
|
+
colDef += " NOT NULL";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const defaultExpr = resolveDefault({ field });
|
|
303
|
+
if (defaultExpr) colDef += ` DEFAULT ${defaultExpr}`;
|
|
304
|
+
if (field.isUnique) colDef += " UNIQUE";
|
|
305
|
+
columns.push(colDef);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const pkFields = model.primaryKey.fields
|
|
309
|
+
.map((f: string) => {
|
|
310
|
+
const fld = model.fields.find((mf: Field) => mf.name === f);
|
|
311
|
+
if (fld && fld.kind !== "relation") return q({ name: fld.dbName });
|
|
312
|
+
return q({ name: f });
|
|
313
|
+
})
|
|
314
|
+
.join(", ");
|
|
315
|
+
|
|
316
|
+
columns.push(` CONSTRAINT ${q({ name: `${tableName}_pkey` })} PRIMARY KEY (${pkFields})`);
|
|
317
|
+
|
|
318
|
+
ops.push({
|
|
319
|
+
type: "createTable",
|
|
320
|
+
sql: `CREATE TABLE ${q({ name: tableName })} (\n${columns.join(",\n")}\n);`,
|
|
321
|
+
isDestructive: false,
|
|
322
|
+
description: `Create table "${name}"`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Unique constraints, indexes, and foreign keys for new tables
|
|
327
|
+
for (const [name, model] of currModelMap) {
|
|
328
|
+
if (prevModelMap.has(name)) continue;
|
|
329
|
+
|
|
330
|
+
const tableName = model.dbName;
|
|
331
|
+
|
|
332
|
+
// Composite unique constraints
|
|
333
|
+
for (const uc of model.uniqueConstraints) {
|
|
334
|
+
const cols = uc.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
|
|
335
|
+
const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
|
|
336
|
+
const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
|
|
337
|
+
ops.push({
|
|
338
|
+
type: "addUnique",
|
|
339
|
+
sql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
340
|
+
isDestructive: false,
|
|
341
|
+
description: `Add unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Regular indexes
|
|
346
|
+
for (const idx of model.indexes) {
|
|
347
|
+
const cols = idx.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
|
|
348
|
+
const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
|
|
349
|
+
const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
|
|
350
|
+
ops.push({
|
|
351
|
+
type: "addIndex",
|
|
352
|
+
sql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
353
|
+
isDestructive: false,
|
|
354
|
+
description: `Add index on (${idx.fields.join(", ")}) in "${name}"`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Foreign keys
|
|
359
|
+
const fkRels = getRelationFields({ model })
|
|
360
|
+
.filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0);
|
|
361
|
+
for (const rel of fkRels) {
|
|
362
|
+
const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
|
|
363
|
+
const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
|
|
364
|
+
const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
|
|
365
|
+
const refCols = rel.relation.references.map((f: string) => {
|
|
366
|
+
if (relatedModel) return q({ name: resolveFieldDbName({ model: relatedModel, fieldName: f }) });
|
|
367
|
+
return q({ name: f });
|
|
368
|
+
}).join(", ");
|
|
369
|
+
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
|
|
370
|
+
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
371
|
+
ops.push({
|
|
372
|
+
type: "addForeignKey",
|
|
373
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ON DELETE RESTRICT ON UPDATE CASCADE;`,
|
|
374
|
+
isDestructive: false,
|
|
375
|
+
description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) \u2192 "${rel.relatedModel}"`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Dropped tables
|
|
381
|
+
for (const [name, model] of prevModelMap) {
|
|
382
|
+
if (currModelMap.has(name)) continue;
|
|
383
|
+
ops.push({
|
|
384
|
+
type: "dropTable",
|
|
385
|
+
sql: `DROP TABLE IF EXISTS ${q({ name: model.dbName })} CASCADE;`,
|
|
386
|
+
isDestructive: true,
|
|
387
|
+
description: `Drop table "${name}"`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─── Column Diffs (for surviving models) ──────────────────────
|
|
392
|
+
|
|
393
|
+
for (const [name, currModel] of currModelMap) {
|
|
394
|
+
const prevModel = prevModelMap.get(name);
|
|
395
|
+
if (!prevModel) continue; // New table, already handled
|
|
396
|
+
|
|
397
|
+
const tableName = currModel.dbName;
|
|
398
|
+
const prevFields = new Map(
|
|
399
|
+
getScalarFields({ model: prevModel }).map((f) => [f.dbName, f])
|
|
400
|
+
);
|
|
401
|
+
const currFields = new Map(
|
|
402
|
+
getScalarFields({ model: currModel }).map((f) => [f.dbName, f])
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// Added columns
|
|
406
|
+
for (const [fieldName, field] of currFields) {
|
|
407
|
+
if (prevFields.has(fieldName)) continue;
|
|
408
|
+
|
|
409
|
+
const colName = field.dbName;
|
|
410
|
+
const colType = resolveColumnType({ field, enums: current.enums });
|
|
411
|
+
const defaultExpr = resolveDefault({ field });
|
|
412
|
+
|
|
413
|
+
// Safe if nullable or has default
|
|
414
|
+
const isDestructive = field.isRequired && !defaultExpr;
|
|
415
|
+
|
|
416
|
+
let colDef = `${colType}`;
|
|
417
|
+
if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
|
|
418
|
+
colDef += " NOT NULL";
|
|
419
|
+
} else if (field.kind === "enum" && field.isRequired) {
|
|
420
|
+
colDef += " NOT NULL";
|
|
421
|
+
}
|
|
422
|
+
if (defaultExpr) colDef += ` DEFAULT ${defaultExpr}`;
|
|
423
|
+
|
|
424
|
+
ops.push({
|
|
425
|
+
type: "addColumn",
|
|
426
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ADD COLUMN ${q({ name: colName })} ${colDef};`,
|
|
427
|
+
isDestructive,
|
|
428
|
+
description: `Add column "${fieldName}" to "${name}"`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Dropped columns
|
|
433
|
+
for (const [fieldName, field] of prevFields) {
|
|
434
|
+
if (currFields.has(fieldName)) continue;
|
|
435
|
+
ops.push({
|
|
436
|
+
type: "dropColumn",
|
|
437
|
+
sql: `ALTER TABLE ${q({ name: tableName })} DROP COLUMN IF EXISTS ${q({ name: field.dbName })};`,
|
|
438
|
+
isDestructive: true,
|
|
439
|
+
description: `Drop column "${fieldName}" from "${name}"`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Modified columns
|
|
444
|
+
for (const [fieldName, currField] of currFields) {
|
|
445
|
+
const prevField = prevFields.get(fieldName);
|
|
446
|
+
if (!prevField) continue;
|
|
447
|
+
|
|
448
|
+
const colName = currField.dbName;
|
|
449
|
+
const prevType = resolveColumnType({ field: prevField, enums: previous.enums });
|
|
450
|
+
const currType = resolveColumnType({ field: currField, enums: current.enums });
|
|
451
|
+
|
|
452
|
+
// Type change
|
|
453
|
+
if (prevType !== currType) {
|
|
454
|
+
ops.push({
|
|
455
|
+
type: "alterColumnType",
|
|
456
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType};`,
|
|
457
|
+
isDestructive: true,
|
|
458
|
+
description: `Change type of "${fieldName}" in "${name}" from ${prevType} to ${currType}`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Nullability change
|
|
463
|
+
if (prevField.isRequired !== currField.isRequired) {
|
|
464
|
+
if (currField.isRequired) {
|
|
465
|
+
ops.push({
|
|
466
|
+
type: "alterColumnNullability",
|
|
467
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET NOT NULL;`,
|
|
468
|
+
isDestructive: true,
|
|
469
|
+
description: `Set NOT NULL on "${fieldName}" in "${name}"`,
|
|
470
|
+
});
|
|
471
|
+
} else {
|
|
472
|
+
ops.push({
|
|
473
|
+
type: "alterColumnNullability",
|
|
474
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP NOT NULL;`,
|
|
475
|
+
isDestructive: false,
|
|
476
|
+
description: `Drop NOT NULL on "${fieldName}" in "${name}"`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Default change
|
|
482
|
+
const prevDefault = resolveDefault({ field: prevField });
|
|
483
|
+
const currDefault = resolveDefault({ field: currField });
|
|
484
|
+
if (prevDefault !== currDefault) {
|
|
485
|
+
if (currDefault) {
|
|
486
|
+
ops.push({
|
|
487
|
+
type: "alterColumnDefault",
|
|
488
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DEFAULT ${currDefault};`,
|
|
489
|
+
isDestructive: false,
|
|
490
|
+
description: `Set default on "${fieldName}" in "${name}" to ${currDefault}`,
|
|
491
|
+
});
|
|
492
|
+
} else {
|
|
493
|
+
ops.push({
|
|
494
|
+
type: "alterColumnDefault",
|
|
495
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP DEFAULT;`,
|
|
496
|
+
isDestructive: false,
|
|
497
|
+
description: `Drop default on "${fieldName}" in "${name}"`,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ─── Unique Constraint Diffs ──────────────────────────────
|
|
504
|
+
|
|
505
|
+
const prevUniques = new Map(
|
|
506
|
+
prevModel.uniqueConstraints.map((uc: UniqueConstraint) => [uniqueKey({ uc, model: prevModel }), uc])
|
|
507
|
+
);
|
|
508
|
+
const currUniques = new Map(
|
|
509
|
+
currModel.uniqueConstraints.map((uc: UniqueConstraint) => [uniqueKey({ uc, model: currModel }), uc])
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
for (const [key, uc] of currUniques) {
|
|
513
|
+
if (prevUniques.has(key)) continue;
|
|
514
|
+
const cols = uc.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
|
|
515
|
+
const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
|
|
516
|
+
const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
|
|
517
|
+
ops.push({
|
|
518
|
+
type: "addUnique",
|
|
519
|
+
sql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
520
|
+
isDestructive: false,
|
|
521
|
+
description: `Add unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for (const [key, uc] of prevUniques) {
|
|
526
|
+
if (currUniques.has(key)) continue;
|
|
527
|
+
const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
|
|
528
|
+
const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
|
|
529
|
+
ops.push({
|
|
530
|
+
type: "dropUnique",
|
|
531
|
+
sql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
|
|
532
|
+
isDestructive: true,
|
|
533
|
+
description: `Drop unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ─── Index Diffs ──────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
const prevIndexes = new Map(
|
|
540
|
+
prevModel.indexes.map((idx: IndexDefinition) => [indexKey({ idx, model: prevModel }), idx])
|
|
541
|
+
);
|
|
542
|
+
const currIndexes = new Map(
|
|
543
|
+
currModel.indexes.map((idx: IndexDefinition) => [indexKey({ idx, model: currModel }), idx])
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
for (const [key, idx] of currIndexes) {
|
|
547
|
+
if (prevIndexes.has(key)) continue;
|
|
548
|
+
const cols = idx.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
|
|
549
|
+
const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
|
|
550
|
+
const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
|
|
551
|
+
ops.push({
|
|
552
|
+
type: "addIndex",
|
|
553
|
+
sql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
554
|
+
isDestructive: false,
|
|
555
|
+
description: `Add index on (${idx.fields.join(", ")}) in "${name}"`,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
for (const [key, idx] of prevIndexes) {
|
|
560
|
+
if (currIndexes.has(key)) continue;
|
|
561
|
+
const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
|
|
562
|
+
const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
|
|
563
|
+
ops.push({
|
|
564
|
+
type: "dropIndex",
|
|
565
|
+
sql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
|
|
566
|
+
isDestructive: true,
|
|
567
|
+
description: `Drop index on (${idx.fields.join(", ")}) in "${name}"`,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ─── Foreign Key Diffs ────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
const prevFKs = new Map(
|
|
574
|
+
getRelationFields({ model: prevModel })
|
|
575
|
+
.filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0)
|
|
576
|
+
.map((r: RelationField) => [fkKey({ rel: r, model: prevModel, allModels: previous.models }), r])
|
|
577
|
+
);
|
|
578
|
+
const currFKs = new Map(
|
|
579
|
+
getRelationFields({ model: currModel })
|
|
580
|
+
.filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0)
|
|
581
|
+
.map((r: RelationField) => [fkKey({ rel: r, model: currModel, allModels: current.models }), r])
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
for (const [key, rel] of currFKs) {
|
|
585
|
+
if (prevFKs.has(key)) continue;
|
|
586
|
+
const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
|
|
587
|
+
const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
|
|
588
|
+
const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
|
|
589
|
+
const refCols = rel.relation.references.map((f: string) => {
|
|
590
|
+
if (relatedModel) return q({ name: resolveFieldDbName({ model: relatedModel, fieldName: f }) });
|
|
591
|
+
return q({ name: f });
|
|
592
|
+
}).join(", ");
|
|
593
|
+
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
|
|
594
|
+
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
595
|
+
ops.push({
|
|
596
|
+
type: "addForeignKey",
|
|
597
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ON DELETE RESTRICT ON UPDATE CASCADE;`,
|
|
598
|
+
isDestructive: false,
|
|
599
|
+
description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
for (const [key, rel] of prevFKs) {
|
|
604
|
+
if (currFKs.has(key)) continue;
|
|
605
|
+
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
|
|
606
|
+
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
607
|
+
ops.push({
|
|
608
|
+
type: "dropForeignKey",
|
|
609
|
+
sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
610
|
+
isDestructive: true,
|
|
611
|
+
description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ─── Implicit M:N Join Table Diffs ────────────────────────────
|
|
617
|
+
|
|
618
|
+
const prevJoinTables = detectImplicitManyToMany({ schema: previous });
|
|
619
|
+
const currJoinTables = detectImplicitManyToMany({ schema: current });
|
|
620
|
+
|
|
621
|
+
const prevJoinSet = new Set(prevJoinTables.map((j: JoinTableInfo) => j.tableName));
|
|
622
|
+
const currJoinSet = new Set(currJoinTables.map((j: JoinTableInfo) => j.tableName));
|
|
623
|
+
|
|
624
|
+
for (const jt of currJoinTables) {
|
|
625
|
+
if (prevJoinSet.has(jt.tableName)) continue;
|
|
626
|
+
|
|
627
|
+
const modelADef = currModelMap.get(jt.modelA);
|
|
628
|
+
const modelBDef = currModelMap.get(jt.modelB);
|
|
629
|
+
if (!modelADef || !modelBDef) continue;
|
|
630
|
+
|
|
631
|
+
const pkFieldA = modelADef.primaryKey.fields[0]!;
|
|
632
|
+
const pkFieldB = modelBDef.primaryKey.fields[0]!;
|
|
633
|
+
|
|
634
|
+
// Resolve PK types
|
|
635
|
+
const pkFieldDefA = modelADef.fields.find((f: Field) => f.name === pkFieldA);
|
|
636
|
+
const pkFieldDefB = modelBDef.fields.find((f: Field) => f.name === pkFieldB);
|
|
637
|
+
|
|
638
|
+
const pkTypeA = pkFieldDefA && (pkFieldDefA.kind === "scalar" || pkFieldDefA.kind === "enum")
|
|
639
|
+
? resolveBaseColumnType({ field: pkFieldDefA, enums: current.enums })
|
|
640
|
+
: "INTEGER";
|
|
641
|
+
const pkTypeB = pkFieldDefB && (pkFieldDefB.kind === "scalar" || pkFieldDefB.kind === "enum")
|
|
642
|
+
? resolveBaseColumnType({ field: pkFieldDefB, enums: current.enums })
|
|
643
|
+
: "INTEGER";
|
|
644
|
+
|
|
645
|
+
const tableA = modelADef.dbName;
|
|
646
|
+
const tableB = modelBDef.dbName;
|
|
647
|
+
|
|
648
|
+
ops.push({
|
|
649
|
+
type: "createJoinTable",
|
|
650
|
+
sql: [
|
|
651
|
+
`CREATE TABLE ${q({ name: jt.tableName })} (\n "A" ${pkTypeA} NOT NULL,\n "B" ${pkTypeB} NOT NULL\n);`,
|
|
652
|
+
`CREATE UNIQUE INDEX ${q({ name: `${jt.tableName}_AB_unique` })} ON ${q({ name: jt.tableName })} ("A", "B");`,
|
|
653
|
+
`CREATE INDEX ${q({ name: `${jt.tableName}_B_index` })} ON ${q({ name: jt.tableName })} ("B");`,
|
|
654
|
+
`ALTER TABLE ${q({ name: jt.tableName })} ADD CONSTRAINT ${q({ name: `${jt.tableName}_A_fkey` })} FOREIGN KEY ("A") REFERENCES ${q({ name: tableA })} ("${pkFieldA}") ON DELETE CASCADE ON UPDATE CASCADE;`,
|
|
655
|
+
`ALTER TABLE ${q({ name: jt.tableName })} ADD CONSTRAINT ${q({ name: `${jt.tableName}_B_fkey` })} FOREIGN KEY ("B") REFERENCES ${q({ name: tableB })} ("${pkFieldB}") ON DELETE CASCADE ON UPDATE CASCADE;`,
|
|
656
|
+
].join("\n"),
|
|
657
|
+
isDestructive: false,
|
|
658
|
+
description: `Create join table "${jt.tableName}" for ${jt.modelA} ↔ ${jt.modelB}`,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
for (const jt of prevJoinTables) {
|
|
663
|
+
if (currJoinSet.has(jt.tableName)) continue;
|
|
664
|
+
ops.push({
|
|
665
|
+
type: "dropJoinTable",
|
|
666
|
+
sql: `DROP TABLE IF EXISTS ${q({ name: jt.tableName })} CASCADE;`,
|
|
667
|
+
isDestructive: true,
|
|
668
|
+
description: `Drop join table "${jt.tableName}" for ${jt.modelA} ↔ ${jt.modelB}`,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return ops;
|
|
673
|
+
}
|