@vibeorm/migrate 1.1.9 → 1.3.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/package.json +2 -2
- package/src/cascade-actions.ts +88 -0
- package/src/ddl-builder.ts +3 -1
- package/src/introspector.ts +91 -27
- package/src/schema-differ.ts +66 -15
- package/src/schema-printer.ts +39 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibeorm/migrate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Migration, introspection, and schema diff toolkit for VibeORM",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"bun": ">=1.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@vibeorm/parser": "1.
|
|
40
|
+
"@vibeorm/parser": "1.3.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascade-action resolution and SQL serialisation.
|
|
3
|
+
*
|
|
4
|
+
* Translates a `CascadeAction` from the parser IR into a Postgres
|
|
5
|
+
* `ON DELETE`/`ON UPDATE` clause, applying Prisma-compatible defaults when
|
|
6
|
+
* the schema doesn't specify an action explicitly.
|
|
7
|
+
*
|
|
8
|
+
* Defaults (matching Prisma's Postgres behaviour):
|
|
9
|
+
* - `onDelete`: required FK → `Restrict`, optional FK → `SetNull`
|
|
10
|
+
* - `onUpdate`: always → `Cascade`
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CascadeAction, RelationField } from "@vibeorm/parser";
|
|
14
|
+
|
|
15
|
+
const ACTION_TO_SQL: Record<CascadeAction, string> = {
|
|
16
|
+
Cascade: "CASCADE",
|
|
17
|
+
Restrict: "RESTRICT",
|
|
18
|
+
NoAction: "NO ACTION",
|
|
19
|
+
SetNull: "SET NULL",
|
|
20
|
+
SetDefault: "SET DEFAULT",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Inverse of {@link ACTION_TO_SQL} — used by the introspector when reading
|
|
25
|
+
* `pg_constraint.confdeltype`/`confupdtype`. The single-char codes are
|
|
26
|
+
* Postgres's catalog representation.
|
|
27
|
+
*/
|
|
28
|
+
export const PG_CHAR_TO_CASCADE_ACTION: Record<string, CascadeAction> = {
|
|
29
|
+
a: "NoAction",
|
|
30
|
+
r: "Restrict",
|
|
31
|
+
c: "Cascade",
|
|
32
|
+
n: "SetNull",
|
|
33
|
+
d: "SetDefault",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the effective onDelete action for a relation, applying the Prisma
|
|
38
|
+
* default when the schema didn't say anything explicit.
|
|
39
|
+
*
|
|
40
|
+
* `isRequired` is the FK column's nullability (Prisma's "required relation").
|
|
41
|
+
*/
|
|
42
|
+
export function resolveOnDelete(params: {
|
|
43
|
+
explicit: CascadeAction | undefined;
|
|
44
|
+
isRequired: boolean;
|
|
45
|
+
}): CascadeAction {
|
|
46
|
+
if (params.explicit) return params.explicit;
|
|
47
|
+
return params.isRequired ? "Restrict" : "SetNull";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Resolve the effective onUpdate action. Prisma's Postgres default is Cascade. */
|
|
51
|
+
export function resolveOnUpdate(params: {
|
|
52
|
+
explicit: CascadeAction | undefined;
|
|
53
|
+
}): CascadeAction {
|
|
54
|
+
return params.explicit ?? "Cascade";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Convenience: serialise a CascadeAction to its Postgres clause body. */
|
|
58
|
+
export function cascadeActionSql(params: { action: CascadeAction }): string {
|
|
59
|
+
return ACTION_TO_SQL[params.action];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the full `ON DELETE X ON UPDATE Y` clause for a relation field,
|
|
64
|
+
* applying defaults as needed.
|
|
65
|
+
*/
|
|
66
|
+
export function buildFkActionClause(params: { rel: RelationField }): string {
|
|
67
|
+
const { rel } = params;
|
|
68
|
+
const onDelete = resolveOnDelete({
|
|
69
|
+
explicit: rel.relation.onDelete,
|
|
70
|
+
isRequired: rel.isRequired,
|
|
71
|
+
});
|
|
72
|
+
const onUpdate = resolveOnUpdate({ explicit: rel.relation.onUpdate });
|
|
73
|
+
return `ON DELETE ${ACTION_TO_SQL[onDelete]} ON UPDATE ${ACTION_TO_SQL[onUpdate]}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Stable comparison key for whether two relations have the same effective
|
|
78
|
+
* cascade behaviour. Used by the differ to detect drift even when the schema
|
|
79
|
+
* was written without explicit actions (defaults must still be considered).
|
|
80
|
+
*/
|
|
81
|
+
export function fkActionFingerprint(params: { rel: RelationField }): string {
|
|
82
|
+
const onDelete = resolveOnDelete({
|
|
83
|
+
explicit: params.rel.relation.onDelete,
|
|
84
|
+
isRequired: params.rel.isRequired,
|
|
85
|
+
});
|
|
86
|
+
const onUpdate = resolveOnUpdate({ explicit: params.rel.relation.onUpdate });
|
|
87
|
+
return `${onDelete}|${onUpdate}`;
|
|
88
|
+
}
|
package/src/ddl-builder.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Schema, Model, Field, ScalarField, EnumField, RelationField, DefaultValue, Enum, EnumValue } from "@vibeorm/parser";
|
|
9
9
|
import { PRISMA_TO_PG } from "@vibeorm/parser";
|
|
10
|
+
import { buildFkActionClause } from "./cascade-actions.ts";
|
|
10
11
|
|
|
11
12
|
export type DDLResult = {
|
|
12
13
|
/** The full DDL SQL wrapped in BEGIN/COMMIT */
|
|
@@ -271,10 +272,11 @@ function buildForeignKeyStatements(params: { model: Model; allModels: Model[] })
|
|
|
271
272
|
.join(", ");
|
|
272
273
|
|
|
273
274
|
const constraintName = `${tableName}_${field.relation.fields.join("_")}_fkey`;
|
|
275
|
+
const actionClause = buildFkActionClause({ rel: field });
|
|
274
276
|
statements.push(
|
|
275
277
|
`ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} ` +
|
|
276
278
|
`FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ` +
|
|
277
|
-
|
|
279
|
+
`${actionClause};`
|
|
278
280
|
);
|
|
279
281
|
}
|
|
280
282
|
|
package/src/introspector.ts
CHANGED
|
@@ -20,9 +20,11 @@ import type {
|
|
|
20
20
|
PrismaScalarType,
|
|
21
21
|
PostgresType,
|
|
22
22
|
DefaultValue,
|
|
23
|
+
CascadeAction,
|
|
23
24
|
} from "@vibeorm/parser";
|
|
24
25
|
import { PRISMA_TO_TS, PRISMA_TO_PG } from "@vibeorm/parser";
|
|
25
26
|
import type { SqlExecutor } from "./types.ts";
|
|
27
|
+
import { PG_CHAR_TO_CASCADE_ACTION } from "./cascade-actions.ts";
|
|
26
28
|
|
|
27
29
|
// ─── Reverse Type Mapping ─────────────────────────────────────────
|
|
28
30
|
|
|
@@ -83,6 +85,10 @@ type ForeignKeyInfo = {
|
|
|
83
85
|
columnName: string;
|
|
84
86
|
foreignTableName: string;
|
|
85
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;
|
|
86
92
|
};
|
|
87
93
|
|
|
88
94
|
type EnumInfo = {
|
|
@@ -138,23 +144,36 @@ const CONSTRAINTS_QUERY = `
|
|
|
138
144
|
ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position;
|
|
139
145
|
`;
|
|
140
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
|
+
*/
|
|
141
155
|
const FOREIGN_KEYS_QUERY = `
|
|
142
156
|
SELECT
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
JOIN
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
|
158
177
|
`;
|
|
159
178
|
|
|
160
179
|
const ENUMS_QUERY = `
|
|
@@ -289,13 +308,22 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
|
|
|
289
308
|
columnName: r.column_name as string,
|
|
290
309
|
}));
|
|
291
310
|
|
|
292
|
-
const foreignKeys: ForeignKeyInfo[] = foreignKeysRaw.map((r) =>
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
+
});
|
|
299
327
|
|
|
300
328
|
const enumInfos: EnumInfo[] = enumsRaw.map((r) => ({
|
|
301
329
|
typeName: r.type_name as string,
|
|
@@ -387,14 +415,23 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
|
|
|
387
415
|
const tableUniques = uniqueConstraints.get(tableName) ?? new Map<string, string[]>();
|
|
388
416
|
const tableFKs = foreignKeys.filter((fk) => fk.tableName === tableName);
|
|
389
417
|
|
|
390
|
-
// Group FKs by constraint for multi-column FK support
|
|
391
|
-
|
|
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
|
+
}>();
|
|
392
427
|
for (const fk of tableFKs) {
|
|
393
428
|
if (!fkGroups.has(fk.constraintName)) {
|
|
394
429
|
fkGroups.set(fk.constraintName, {
|
|
395
430
|
localCols: [],
|
|
396
431
|
foreignTable: fk.foreignTableName,
|
|
397
432
|
foreignCols: [],
|
|
433
|
+
onDelete: fk.onDelete,
|
|
434
|
+
onUpdate: fk.onUpdate,
|
|
398
435
|
});
|
|
399
436
|
}
|
|
400
437
|
const group = fkGroups.get(fk.constraintName)!;
|
|
@@ -410,7 +447,7 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
|
|
|
410
447
|
const isUnique = uniqueColumns.has(`${tableName}.${col.columnName}`);
|
|
411
448
|
const parsedDefault = parseDefault({ columnDefault: col.columnDefault, dataType: col.dataType });
|
|
412
449
|
|
|
413
|
-
//
|
|
450
|
+
// Branch 1: scalar enum column (data_type=USER-DEFINED, udt_name=EnumName).
|
|
414
451
|
if (col.dataType === "USER-DEFINED" && enumNames.has(col.udtName)) {
|
|
415
452
|
const enumField: EnumField = {
|
|
416
453
|
kind: "enum",
|
|
@@ -425,8 +462,33 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
|
|
|
425
462
|
documentation: undefined,
|
|
426
463
|
};
|
|
427
464
|
fields.push(enumField);
|
|
428
|
-
}
|
|
429
|
-
|
|
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 {
|
|
430
492
|
const pgType = col.dataType === "ARRAY" ? col.udtName.replace(/^_/, "") : col.udtName;
|
|
431
493
|
const prismaType = PG_TO_PRISMA[pgType] ?? PG_TO_PRISMA[col.dataType] ?? "String";
|
|
432
494
|
const isArray = col.dataType === "ARRAY";
|
|
@@ -473,6 +535,8 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
|
|
|
473
535
|
relatedModel: group.foreignTable,
|
|
474
536
|
type: isOneToOne ? "oneToOne" : "manyToOne",
|
|
475
537
|
isForeignKey: true,
|
|
538
|
+
onDelete: group.onDelete,
|
|
539
|
+
onUpdate: group.onUpdate,
|
|
476
540
|
},
|
|
477
541
|
};
|
|
478
542
|
fields.push(relationField);
|
package/src/schema-differ.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
IndexDefinition,
|
|
18
18
|
} from "@vibeorm/parser";
|
|
19
19
|
import { PRISMA_TO_PG } from "@vibeorm/parser";
|
|
20
|
+
import { buildFkActionClause, fkActionFingerprint } from "./cascade-actions.ts";
|
|
20
21
|
|
|
21
22
|
// ─── Types ────────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -71,8 +72,11 @@ function getRelationFields(params: { model: Model }): RelationField[] {
|
|
|
71
72
|
function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
|
|
72
73
|
const { field, enums } = params;
|
|
73
74
|
if (field.kind === "enum") {
|
|
75
|
+
// Enum lists must emit `EnumName[]`, not the scalar enum type. Mirrors
|
|
76
|
+
// `ddl-builder.ts:135-141`. Bug 2 — see .ai/bug-report-2026-05-24.md.
|
|
74
77
|
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
75
|
-
|
|
78
|
+
const enumType = q({ name: enumDef?.dbName ?? field.enumName });
|
|
79
|
+
return field.isList ? `${enumType}[]` : enumType;
|
|
76
80
|
}
|
|
77
81
|
if (field.default?.kind === "autoincrement") {
|
|
78
82
|
return field.prismaType === "BigInt" ? "BIGSERIAL" : "SERIAL";
|
|
@@ -86,8 +90,10 @@ function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum
|
|
|
86
90
|
function resolveBaseColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
|
|
87
91
|
const { field, enums } = params;
|
|
88
92
|
if (field.kind === "enum") {
|
|
93
|
+
// Same enum-list handling as resolveColumnType (Bug 2).
|
|
89
94
|
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
90
|
-
|
|
95
|
+
const enumType = q({ name: enumDef?.dbName ?? field.enumName });
|
|
96
|
+
return field.isList ? `${enumType}[]` : enumType;
|
|
91
97
|
}
|
|
92
98
|
if (field.nativeType) return field.nativeType.toLowerCase();
|
|
93
99
|
if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
|
|
@@ -426,9 +432,10 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
|
|
|
426
432
|
}).join(", ");
|
|
427
433
|
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
|
|
428
434
|
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
435
|
+
const actionClause = buildFkActionClause({ rel });
|
|
429
436
|
ops.push({
|
|
430
437
|
type: "addForeignKey",
|
|
431
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols})
|
|
438
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
|
|
432
439
|
downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
433
440
|
isDestructive: false,
|
|
434
441
|
isReversible: true,
|
|
@@ -554,11 +561,25 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
|
|
|
554
561
|
|
|
555
562
|
// Type change — reverse is setting back to the previous type
|
|
556
563
|
if (prevType !== currType) {
|
|
564
|
+
// Detect scalar -> array transition of same base type. PG can't implicit-cast,
|
|
565
|
+
// so we add a USING clause that wraps the existing value in a 1-element array
|
|
566
|
+
// (or NULL if the column is NULL). This heals databases broken by an earlier
|
|
567
|
+
// enum-array bug (Bug 2) where the column was created as the scalar type.
|
|
568
|
+
// We intentionally do NOT handle the reverse direction (array -> scalar) — that
|
|
569
|
+
// is lossy and the user must decide which element to keep.
|
|
570
|
+
const isScalarToArray =
|
|
571
|
+
currType.endsWith("[]") && !prevType.endsWith("[]") &&
|
|
572
|
+
currType.slice(0, -2) === prevType;
|
|
573
|
+
|
|
574
|
+
const usingClause = isScalarToArray
|
|
575
|
+
? ` USING CASE WHEN ${q({ name: colName })} IS NULL THEN NULL ELSE ARRAY[${q({ name: colName })}] END`
|
|
576
|
+
: "";
|
|
577
|
+
|
|
557
578
|
ops.push({
|
|
558
579
|
type: "alterColumnType",
|
|
559
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType};`,
|
|
580
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType}${usingClause};`,
|
|
560
581
|
downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${prevType};`,
|
|
561
|
-
isDestructive:
|
|
582
|
+
isDestructive: !isScalarToArray, // scalar -> array is non-destructive (data preserved as 1-element arrays)
|
|
562
583
|
isReversible: true,
|
|
563
584
|
description: `Change type of "${fieldName}" in "${name}" from ${prevType} to ${currType}`,
|
|
564
585
|
});
|
|
@@ -709,7 +730,7 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
|
|
|
709
730
|
);
|
|
710
731
|
|
|
711
732
|
for (const [key, rel] of currFKs) {
|
|
712
|
-
|
|
733
|
+
const prev = prevFKs.get(key);
|
|
713
734
|
const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
|
|
714
735
|
const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
|
|
715
736
|
const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
|
|
@@ -719,14 +740,43 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
|
|
|
719
740
|
}).join(", ");
|
|
720
741
|
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
|
|
721
742
|
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
743
|
+
const actionClause = buildFkActionClause({ rel });
|
|
744
|
+
|
|
745
|
+
if (!prev) {
|
|
746
|
+
ops.push({
|
|
747
|
+
type: "addForeignKey",
|
|
748
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
|
|
749
|
+
downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
750
|
+
isDestructive: false,
|
|
751
|
+
isReversible: true,
|
|
752
|
+
description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
|
|
753
|
+
});
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// FK exists on both sides — detect cascade-action drift. Postgres has no
|
|
758
|
+
// `ALTER CONSTRAINT ... ON DELETE` for FKs, so we emit a DROP + ADD pair.
|
|
759
|
+
const prevFingerprint = fkActionFingerprint({ rel: prev });
|
|
760
|
+
const currFingerprint = fkActionFingerprint({ rel });
|
|
761
|
+
if (prevFingerprint !== currFingerprint) {
|
|
762
|
+
const prevActionClause = buildFkActionClause({ rel: prev });
|
|
763
|
+
ops.push({
|
|
764
|
+
type: "dropForeignKey",
|
|
765
|
+
sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
766
|
+
downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${prevActionClause};`,
|
|
767
|
+
isDestructive: false,
|
|
768
|
+
isReversible: true,
|
|
769
|
+
description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) to change cascade actions`,
|
|
770
|
+
});
|
|
771
|
+
ops.push({
|
|
772
|
+
type: "addForeignKey",
|
|
773
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
|
|
774
|
+
downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
775
|
+
isDestructive: false,
|
|
776
|
+
isReversible: true,
|
|
777
|
+
description: `Re-add foreign key on "${name}" (${rel.relation.fields.join(", ")}) with new cascade actions (${currFingerprint.replace("|", " / ")})`,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
730
780
|
}
|
|
731
781
|
|
|
732
782
|
for (const [key, rel] of prevFKs) {
|
|
@@ -740,10 +790,11 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
|
|
|
740
790
|
}).join(", ");
|
|
741
791
|
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
|
|
742
792
|
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
793
|
+
const actionClause = buildFkActionClause({ rel });
|
|
743
794
|
ops.push({
|
|
744
795
|
type: "dropForeignKey",
|
|
745
796
|
sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
746
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols})
|
|
797
|
+
downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
|
|
747
798
|
isDestructive: true,
|
|
748
799
|
isReversible: true,
|
|
749
800
|
description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
|
package/src/schema-printer.ts
CHANGED
|
@@ -14,7 +14,9 @@ import type {
|
|
|
14
14
|
RelationField,
|
|
15
15
|
Enum,
|
|
16
16
|
DefaultValue,
|
|
17
|
+
CascadeAction,
|
|
17
18
|
} from "@vibeorm/parser";
|
|
19
|
+
import { resolveOnDelete, resolveOnUpdate } from "./cascade-actions.ts";
|
|
18
20
|
|
|
19
21
|
// ─── Helpers ──────────────────────────────────────────────────────
|
|
20
22
|
|
|
@@ -105,12 +107,21 @@ function printRelationField(params: { field: RelationField }): string {
|
|
|
105
107
|
if (field.relation.isForeignKey && field.relation.fields.length > 0) {
|
|
106
108
|
const fieldsStr = field.relation.fields.map((f: string) => f).join(", ");
|
|
107
109
|
const refsStr = field.relation.references.map((f: string) => f).join(", ");
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
|
|
111
|
+
// Only emit cascade actions when they differ from the Prisma default for
|
|
112
|
+
// this relation's optionality. Keeps `db pull` output minimal and matches
|
|
113
|
+
// what users would have hand-written.
|
|
114
|
+
const explicitOnDelete = nonDefaultOnDelete({ field });
|
|
115
|
+
const explicitOnUpdate = nonDefaultOnUpdate({ field });
|
|
116
|
+
|
|
117
|
+
const segments: string[] = [];
|
|
118
|
+
if (field.relation.name) segments.push(`"${field.relation.name}"`);
|
|
119
|
+
segments.push(`fields: [${fieldsStr}]`);
|
|
120
|
+
segments.push(`references: [${refsStr}]`);
|
|
121
|
+
if (explicitOnDelete) segments.push(`onDelete: ${explicitOnDelete}`);
|
|
122
|
+
if (explicitOnUpdate) segments.push(`onUpdate: ${explicitOnUpdate}`);
|
|
123
|
+
|
|
124
|
+
parts.push(`@relation(${segments.join(", ")})`);
|
|
114
125
|
} else if (field.relation.name) {
|
|
115
126
|
parts.push(`@relation("${field.relation.name}")`);
|
|
116
127
|
}
|
|
@@ -118,6 +129,28 @@ function printRelationField(params: { field: RelationField }): string {
|
|
|
118
129
|
return indent({ text: parts.join(" ") });
|
|
119
130
|
}
|
|
120
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Returns the onDelete action only if it differs from the Prisma default for
|
|
134
|
+
* this relation's optionality. Returns `undefined` when the action is the
|
|
135
|
+
* default (in which case we omit it from the printed `@relation(...)`).
|
|
136
|
+
*/
|
|
137
|
+
function nonDefaultOnDelete(params: { field: RelationField }): CascadeAction | undefined {
|
|
138
|
+
const { field } = params;
|
|
139
|
+
if (field.relation.onDelete === undefined) return undefined;
|
|
140
|
+
const effectiveDefault = resolveOnDelete({
|
|
141
|
+
explicit: undefined,
|
|
142
|
+
isRequired: field.isRequired,
|
|
143
|
+
});
|
|
144
|
+
return field.relation.onDelete === effectiveDefault ? undefined : field.relation.onDelete;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function nonDefaultOnUpdate(params: { field: RelationField }): CascadeAction | undefined {
|
|
148
|
+
const { field } = params;
|
|
149
|
+
if (field.relation.onUpdate === undefined) return undefined;
|
|
150
|
+
const effectiveDefault = resolveOnUpdate({ explicit: undefined });
|
|
151
|
+
return field.relation.onUpdate === effectiveDefault ? undefined : field.relation.onUpdate;
|
|
152
|
+
}
|
|
153
|
+
|
|
121
154
|
function printField(params: { field: Field }): string {
|
|
122
155
|
switch (params.field.kind) {
|
|
123
156
|
case "scalar":
|