Skip to content

Commit 574d9e8

Browse files
authored
Merge pull request #65 from metaobjectsdev/fix/migrate-identity-reference-ondelete
fix(migrate): honor @onDelete/@onUpdate declared on identity.reference
2 parents f855ee6 + b0217f8 commit 574d9e8

4 files changed

Lines changed: 122 additions & 11 deletions

File tree

server/typescript/packages/metadata/src/core/identity/identity-constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export const IDENTITY_ATTR_UNIQUE = "unique";
2727
export const IDENTITY_REFERENCE_ATTR_REFERENCES = "references";
2828
/** Identity-reference attr: physical-enforcement flag. Default true → hard FK constraint; false → logical-only reference. */
2929
export const IDENTITY_REFERENCE_ATTR_ENFORCE = "enforce";
30+
/** Identity-reference attr: referential action on parent delete (cascade/set-null/restrict/no-action). */
31+
export const IDENTITY_REFERENCE_ATTR_ON_DELETE = "onDelete";
32+
/** Identity-reference attr: referential action on key update (cascade/set-null/restrict/no-action). */
33+
export const IDENTITY_REFERENCE_ATTR_ON_UPDATE = "onUpdate";
3034

3135
// NOTE: physical RDB-only identity attrs (@constraintName on identity.reference;
3236
// @orders / @where on identity.secondary) are NOT core — they are contributed by

server/typescript/packages/metadata/src/core/identity/meta-identity.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
IDENTITY_ATTR_UNIQUE,
1414
IDENTITY_REFERENCE_ATTR_REFERENCES,
1515
IDENTITY_REFERENCE_ATTR_ENFORCE,
16+
IDENTITY_REFERENCE_ATTR_ON_DELETE,
17+
IDENTITY_REFERENCE_ATTR_ON_UPDATE,
1618
} from "./identity-constants.js";
1719
import type { MetaRoot } from "../../shared/meta-root.js";
1820

@@ -104,6 +106,24 @@ export class MetaReferenceIdentity extends MetaIdentity {
104106
return this.ownAttr(IDENTITY_REFERENCE_ATTR_ENFORCE) !== false;
105107
}
106108

109+
/**
110+
* Referential action on parent delete, declared directly on the FK-defining
111+
* reference (cascade / set-null / restrict / no-action). Undefined when not
112+
* set — callers fall back to a correlated relationship's @onDelete, then the
113+
* relationship-subtype default. The FK is declared here, so the action may be
114+
* declared here too rather than only on a sibling relationship node.
115+
*/
116+
get onDelete(): string | undefined {
117+
const v = this.ownAttr(IDENTITY_REFERENCE_ATTR_ON_DELETE);
118+
return typeof v === "string" && v !== "" ? v : undefined;
119+
}
120+
121+
/** Referential action on key update, declared directly on the reference. Undefined when not set. */
122+
get onUpdate(): string | undefined {
123+
const v = this.ownAttr(IDENTITY_REFERENCE_ATTR_ON_UPDATE);
124+
return typeof v === "string" && v !== "" ? v : undefined;
125+
}
126+
107127
/**
108128
* Target field names. Empty array means "use the target's primary identity"
109129
* (the bare-entity form). For dotted forms, returns the field(s) after the

server/typescript/packages/migrate-ts/src/referential-actions.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,18 @@ export function isRequired(field: MetaData): boolean {
4141

4242
/**
4343
* Resolve the referential actions for a foreign key inferred from an
44-
* identity.reference, by correlating it with a sibling relationship on the
45-
* same entity (matched on target-entity name).
44+
* identity.reference.
4645
*
47-
* - No correlated relationship → both undefined (no ON DELETE / ON UPDATE clause).
48-
* - With a relationship: onDelete defaults from the relationship subtype
49-
* (composition→cascade, aggregation→set-null, association→restrict);
50-
* onUpdate defaults to "cascade". Explicit @onDelete / @onUpdate override.
46+
* Precedence (highest first):
47+
* 1. @onDelete / @onUpdate declared DIRECTLY on the identity.reference — the
48+
* reference IS the FK, so the action may be declared right where the FK is.
49+
* 2. A correlated sibling relationship on the same entity (matched on
50+
* target-entity name): its explicit @onDelete, else its subtype default
51+
* (composition→cascade, aggregation→set-null, association→restrict);
52+
* onUpdate defaults to "cascade".
53+
* 3. Neither → undefined (no ON DELETE / ON UPDATE clause).
54+
*
55+
* - "setnull" is accepted as an alias for the canonical "set-null".
5156
* - Resolved "no-action" → undefined: introspection in introspect/{postgres,sqlite}.ts
5257
* omits actions when the DB value is "no-action", so the expected side does the same
5358
* to keep round-trip diffs clean.
@@ -75,20 +80,35 @@ export function resolveReferentialActions(
7580
// resolve to undefined (no clause emitted) — surfacing the mismatch as a
7681
// silent loss of intent rather than a wrong action. Cross-language ports
7782
// should match the same correlation rule.
83+
// (1) Actions declared directly on the FK-defining reference win.
84+
const refOnDelete = ref.onDelete;
85+
const refOnUpdate = ref.onUpdate;
86+
87+
// (2) Otherwise correlate with a sibling relationship and use its action /
88+
// subtype default. onUpdate's "cascade" default only applies when a
89+
// relationship is present, so a reference-only FK with no explicit
90+
// @onUpdate emits no ON UPDATE clause.
7891
const rel = entity.relationships().find((r) => r.objectRef === target);
79-
if (rel === undefined) return { onDelete: undefined, onUpdate: undefined };
8092

81-
const onDeleteRaw = rel.onDelete ?? ON_DELETE_DEFAULT_BY_SUBTYPE[rel.subType];
82-
const onUpdateRaw = rel.onUpdate ?? ON_UPDATE_DEFAULT;
93+
const onDeleteRaw =
94+
refOnDelete ??
95+
(rel ? (rel.onDelete ?? ON_DELETE_DEFAULT_BY_SUBTYPE[rel.subType]) : undefined);
96+
const onUpdateRaw =
97+
refOnUpdate ??
98+
(rel ? (rel.onUpdate ?? ON_UPDATE_DEFAULT) : undefined);
99+
83100
return {
84101
onDelete: normalize(onDeleteRaw),
85102
onUpdate: normalize(onUpdateRaw),
86103
};
87104
}
88105

89106
function normalize(a: string | undefined): FkAction | undefined {
90-
if (a === undefined || a === "no-action") return undefined;
91-
return a as FkAction;
107+
if (a === undefined) return undefined;
108+
// Accept the hyphen-less spelling as an alias for the canonical kebab-case form.
109+
const canonical = a === "setnull" ? "set-null" : a;
110+
if (canonical === "no-action") return undefined;
111+
return canonical as FkAction;
92112
}
93113

94114
// ---------------------------------------------------------------------------

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,70 @@ describe("end-to-end FK actions in emitted DDL", () => {
142142
expect(up).toContain('FOREIGN KEY ("program_id") REFERENCES "programs" ("id") ON DELETE SET NULL ON UPDATE CASCADE');
143143
});
144144
});
145+
146+
// ---------------------------------------------------------------------------
147+
// Referential actions declared DIRECTLY on the identity.reference (the FK).
148+
//
149+
// Regression: an @onDelete / @onUpdate authored on the identity.reference was
150+
// previously ignored — only a correlated relationship node supplied actions —
151+
// so FKs declared with @onDelete: cascade emitted a bare FK (no ON DELETE),
152+
// silently dropping cascade intent.
153+
// ---------------------------------------------------------------------------
154+
155+
function weekDocRef(refAttrs: Record<string, unknown>, rel?: Record<string, unknown>) {
156+
return { "metadata.root": { package: "acme", children: [
157+
{ "object.entity": { name: "Program", children: [
158+
{ "field.long": { name: "id" } },
159+
{ "identity.primary": { "name": "id", "@fields": "id" } },
160+
] } },
161+
{ "object.entity": { name: "Week", children: [
162+
{ "field.long": { name: "id" } },
163+
{ "field.long": { name: "programId" } },
164+
...(rel ? [rel] : []),
165+
{ "identity.reference": { name: "ref_program", "@fields": ["programId"], "@references": "Program", ...refAttrs } },
166+
{ "identity.primary": { "name": "id", "@fields": "id" } },
167+
] } },
168+
] } };
169+
}
170+
171+
async function loadWeekRef(refAttrs: Record<string, unknown>, rel?: Record<string, unknown>) {
172+
const { root, errors } = await loadDoc(weekDocRef(refAttrs, rel));
173+
expect(errors).toHaveLength(0);
174+
const week = root.objects().find((o) => o.name === "Week")!;
175+
const ref = week.referenceIdentities()[0]!;
176+
return { root, week, ref };
177+
}
178+
179+
describe("reference-level @onDelete / @onUpdate (declared on identity.reference)", () => {
180+
test("reference @onDelete wins with no relationship; onUpdate stays absent", async () => {
181+
const { week, ref } = await loadWeekRef({ "@onDelete": "cascade" });
182+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: "cascade", onUpdate: undefined });
183+
});
184+
185+
test("reference @onUpdate is honored independently", async () => {
186+
const { week, ref } = await loadWeekRef({ "@onDelete": "restrict", "@onUpdate": "cascade" });
187+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: "restrict", onUpdate: "cascade" });
188+
});
189+
190+
test("'setnull' is accepted as an alias for the canonical 'set-null'", async () => {
191+
const { week, ref } = await loadWeekRef({ "@onDelete": "setnull" });
192+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: "set-null", onUpdate: undefined });
193+
});
194+
195+
test("reference @onDelete overrides a correlated relationship's action", async () => {
196+
const { week, ref } = await loadWeekRef(
197+
{ "@onDelete": "restrict" },
198+
{ "relationship.composition": { name: "program", "@objectRef": "Program", "@cardinality": "one" } },
199+
);
200+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: "restrict", onUpdate: "cascade" });
201+
});
202+
203+
test("end-to-end: reference @onDelete: cascade → ON DELETE CASCADE (Postgres), no relationship node", async () => {
204+
const { root } = await loadWeekRef({ "@onDelete": "cascade" });
205+
const snapshot = buildExpectedSchema(root);
206+
const { changes } = await diff(snapshot, EMPTY_SCHEMA);
207+
const { up } = emit(changes, { dialect: "postgres" });
208+
expect(up).toContain('ADD CONSTRAINT "weeks_program_id_fk"');
209+
expect(up).toContain('REFERENCES "programs" ("id") ON DELETE CASCADE');
210+
});
211+
});

0 commit comments

Comments
 (0)