Skip to content

Commit e793402

Browse files
dmealingclaude
andcommitted
feat(migrate-ts): thread relationship @onDelete/@onUpdate into FK descriptors
buildForeignKeys now correlates each identity.reference with its sibling relationship to derive onDelete/onUpdate per the rollout-decided defaults (composition->cascade, aggregation->set-null, association->restrict; onUpdate default cascade). Explicit @onDelete/@onUpdate on the relationship override. Resolved 'no-action' is omitted from the FkDescriptor to match the introspectors and keep round-trip diffs clean. Existing FK-shape assertions updated to include the derived defaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 509799c commit e793402

3 files changed

Lines changed: 98 additions & 3 deletions

File tree

server/typescript/packages/migrate-ts/src/expected-schema.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type { SqlType } from "./sql-type.js";
3434
import type {
3535
SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
3636
} from "./types.js";
37+
import { resolveReferentialActions } from "./referential-actions.js";
3738

3839
export interface BuildExpectedSchemaOptions {
3940
/**
@@ -242,12 +243,16 @@ function buildForeignKeys(
242243
? explicitTargetFields.map(toSnake)
243244
: [toSnake(refChild.resolvedTargetPkField(root) ?? "id")];
244245

245-
fks.push({
246+
const { onDelete, onUpdate } = resolveReferentialActions(entity, refChild);
247+
const fk: FkDescriptor = {
246248
name: `${tableName}_${fkCols[0]}_fk`,
247249
columns: fkCols,
248250
refTable,
249251
refColumns,
250-
});
252+
};
253+
if (onDelete !== undefined) fk.onDelete = onDelete;
254+
if (onUpdate !== undefined) fk.onUpdate = onUpdate;
255+
fks.push(fk);
251256
}
252257
return fks;
253258
}

server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,16 @@ describe("buildExpectedSchema — indexes + FKs", () => {
111111
expect(programs?.indexes.find((i) => i.name === "programs_pk")).toBeUndefined();
112112
});
113113

114-
test("many-to-one relationship → FK on child table", () => {
114+
test("many-to-one relationship → FK on child table (association → restrict/cascade defaults)", () => {
115115
const weeks = snapshot.tables.find((t) => t.name === "weeks");
116116
expect(weeks?.foreignKeys).toEqual([
117117
{
118118
name: "weeks_program_id_fk",
119119
columns: ["program_id"],
120120
refTable: "programs",
121121
refColumns: ["id"],
122+
onDelete: "restrict",
123+
onUpdate: "cascade",
122124
},
123125
]);
124126
});

server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, test } from "bun:test";
22
import { resolveReferentialActions } from "../../src/referential-actions.js";
3+
import { buildExpectedSchema } from "../../src/expected-schema.js";
4+
import { diff } from "../../src/diff/index.js";
5+
import { emit } from "../../src/emit/index.js";
36
import { MetaDataLoader, InMemorySource } from "@metaobjectsdev/metadata";
47

58
async function loadDoc(doc: unknown) {
@@ -65,3 +68,88 @@ describe("resolveReferentialActions", () => {
6568
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: undefined, onUpdate: undefined });
6669
});
6770
});
71+
72+
// ---------------------------------------------------------------------------
73+
// Round-trip: buildExpectedSchema → diff (against empty) → emit
74+
//
75+
// Asserts that the relationship-derived defaults (and explicit overrides)
76+
// flow all the way through to the emitted DDL — for both Postgres
77+
// (ALTER TABLE … ADD CONSTRAINT) and SQLite (inline FOREIGN KEY clause).
78+
// ---------------------------------------------------------------------------
79+
80+
const EMPTY_SCHEMA = { tables: [], views: [] };
81+
82+
function makeDoc(rel: Record<string, unknown>) {
83+
return {
84+
"metadata.root": {
85+
package: "acme",
86+
children: [
87+
{ "object.entity": { name: "Program", children: [
88+
{ "field.long": { name: "id" } },
89+
{ "identity.primary": { "@fields": "id" } },
90+
] } },
91+
{ "object.entity": { name: "Week", children: [
92+
{ "field.long": { name: "id" } },
93+
{ "field.long": { name: "programId" } },
94+
rel,
95+
{ "identity.reference": { name: "ref_program", "@fields": ["programId"], "@references": "Program" } },
96+
{ "identity.primary": { "@fields": "id" } },
97+
] } },
98+
],
99+
},
100+
};
101+
}
102+
103+
async function buildSnapshotForRel(rel: Record<string, unknown>) {
104+
const { root, errors } = await new MetaDataLoader().load([
105+
new InMemorySource(JSON.stringify(makeDoc(rel))),
106+
]);
107+
expect(errors).toHaveLength(0);
108+
return buildExpectedSchema(root);
109+
}
110+
111+
describe("end-to-end FK actions in emitted DDL", () => {
112+
test("composition default → ON DELETE CASCADE / ON UPDATE CASCADE (Postgres ADD CONSTRAINT)", async () => {
113+
const snapshot = await buildSnapshotForRel({
114+
"relationship.composition": { name: "program", "@objectRef": "Program", "@cardinality": "one" },
115+
});
116+
const { changes } = await diff(snapshot, EMPTY_SCHEMA);
117+
const { up } = emit(changes, { dialect: "postgres" });
118+
expect(up).toContain('ADD CONSTRAINT "weeks_program_id_fk"');
119+
expect(up).toContain('REFERENCES "programs" ("id") ON DELETE CASCADE ON UPDATE CASCADE');
120+
});
121+
122+
test("composition default → inline FOREIGN KEY … ON DELETE CASCADE ON UPDATE CASCADE (SQLite CREATE TABLE)", async () => {
123+
const snapshot = await buildSnapshotForRel({
124+
"relationship.composition": { name: "program", "@objectRef": "Program", "@cardinality": "one" },
125+
});
126+
const { changes } = await diff(snapshot, EMPTY_SCHEMA);
127+
const { up } = emit(changes, { dialect: "sqlite" });
128+
expect(up).toContain('FOREIGN KEY ("program_id") REFERENCES "programs" ("id") ON DELETE CASCADE ON UPDATE CASCADE');
129+
});
130+
131+
test("explicit @onDelete: set-null overrides composition default; @onUpdate stays cascade (Postgres)", async () => {
132+
const snapshot = await buildSnapshotForRel({
133+
"relationship.composition": {
134+
name: "program", "@objectRef": "Program", "@cardinality": "one",
135+
"@onDelete": "set-null", "@onUpdate": "cascade",
136+
},
137+
});
138+
const { changes } = await diff(snapshot, EMPTY_SCHEMA);
139+
const { up } = emit(changes, { dialect: "postgres" });
140+
expect(up).toContain('REFERENCES "programs" ("id") ON DELETE SET NULL ON UPDATE CASCADE');
141+
expect(up).not.toContain("ON DELETE CASCADE");
142+
});
143+
144+
test("explicit @onDelete: set-null overrides composition default (SQLite inline)", async () => {
145+
const snapshot = await buildSnapshotForRel({
146+
"relationship.composition": {
147+
name: "program", "@objectRef": "Program", "@cardinality": "one",
148+
"@onDelete": "set-null", "@onUpdate": "cascade",
149+
},
150+
});
151+
const { changes } = await diff(snapshot, EMPTY_SCHEMA);
152+
const { up } = emit(changes, { dialect: "sqlite" });
153+
expect(up).toContain('FOREIGN KEY ("program_id") REFERENCES "programs" ("id") ON DELETE SET NULL ON UPDATE CASCADE');
154+
});
155+
});

0 commit comments

Comments
 (0)