Skip to content

Commit 509799c

Browse files
dmealingclaude
andcommitted
feat(migrate-ts): resolveReferentialActions (subtype default + explicit override + no-action omission)
Adds `resolveReferentialActions(entity, ref)` in migrate-ts — correlates a MetaReferenceIdentity with its sibling MetaRelationship (matched on objectRef === targetEntity) and returns the { onDelete, onUpdate } pair. Subtype defaults: composition→cascade, aggregation→set-null, association→restrict; onUpdate defaults to cascade. Explicit @onDelete / @onUpdate override defaults. Normalized "no-action" → undefined to keep expected-schema round-trip diffs clean (mirrors introspect behavior). No correlated relationship → both undefined (preserves current no-clause behavior). All metadata barrel exports were already present (no index.ts changes needed). FkAction == REFERENTIAL_ACTIONS invariant confirmed: identical four-value set. 5 new unit tests; migrate-ts suite 222 pass / 0 fail; full server suite 2371 pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1d380d commit 509799c

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
ON_DELETE_DEFAULT_BY_SUBTYPE,
3+
ON_UPDATE_DEFAULT,
4+
type MetaObject,
5+
type MetaReferenceIdentity,
6+
} from "@metaobjectsdev/metadata";
7+
import type { FkAction } from "./types.js";
8+
9+
/**
10+
* Resolve the referential actions for a foreign key inferred from an
11+
* identity.reference, by correlating it with a sibling relationship on the
12+
* same entity (matched on target-entity name).
13+
*
14+
* - No correlated relationship → both undefined (no ON DELETE / ON UPDATE clause).
15+
* - With a relationship: onDelete defaults from the relationship subtype
16+
* (composition→cascade, aggregation→set-null, association→restrict);
17+
* onUpdate defaults to "cascade". Explicit @onDelete / @onUpdate override.
18+
* - Resolved "no-action" → undefined: introspection in introspect/{postgres,sqlite}.ts
19+
* omits actions when the DB value is "no-action", so the expected side does the same
20+
* to keep round-trip diffs clean.
21+
*
22+
* If multiple relationships target the same entity (rare), the first one is used.
23+
*
24+
* The single `as FkAction` cast in normalize() is safe because REFERENTIAL_ACTIONS
25+
* (metadata package) and FkAction (migrate-ts/src/types.ts) are the same four-value
26+
* set: "cascade" | "set-null" | "restrict" | "no-action". The comment in
27+
* relationship-constants.ts documents this invariant; a test in this file guards it
28+
* at runtime.
29+
*/
30+
export function resolveReferentialActions(
31+
entity: MetaObject,
32+
ref: MetaReferenceIdentity,
33+
): { onDelete: FkAction | undefined; onUpdate: FkAction | undefined } {
34+
const target = ref.targetEntity;
35+
if (target === undefined) return { onDelete: undefined, onUpdate: undefined };
36+
37+
const rel = entity.relationships().find((r) => r.objectRef === target);
38+
if (rel === undefined) return { onDelete: undefined, onUpdate: undefined };
39+
40+
const onDeleteRaw = rel.onDelete ?? ON_DELETE_DEFAULT_BY_SUBTYPE[rel.subType];
41+
const onUpdateRaw = rel.onUpdate ?? ON_UPDATE_DEFAULT;
42+
return {
43+
onDelete: normalize(onDeleteRaw),
44+
onUpdate: normalize(onUpdateRaw),
45+
};
46+
}
47+
48+
function normalize(a: string | undefined): FkAction | undefined {
49+
if (a === undefined || a === "no-action") return undefined;
50+
return a as FkAction;
51+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { resolveReferentialActions } from "../../src/referential-actions.js";
3+
import { MetaDataLoader, InMemorySource } from "@metaobjectsdev/metadata";
4+
5+
async function loadDoc(doc: unknown) {
6+
return new MetaDataLoader().load([new InMemorySource(JSON.stringify(doc))]);
7+
}
8+
9+
function weekDoc(rel: Record<string, unknown> | undefined) {
10+
return { "metadata.root": { package: "acme", children: [
11+
{ "object.entity": { name: "Program", children: [
12+
{ "field.long": { name: "id" } },
13+
{ "identity.primary": { "@fields": "id" } },
14+
] } },
15+
{ "object.entity": { name: "Week", children: [
16+
{ "field.long": { name: "id" } },
17+
{ "field.long": { name: "programId" } },
18+
...(rel ? [rel] : []),
19+
{ "identity.reference": { name: "ref_program", "@fields": ["programId"], "@references": "Program" } },
20+
{ "identity.primary": { "@fields": "id" } },
21+
] } },
22+
] } };
23+
}
24+
25+
async function loadWeek(rel: Record<string, unknown> | undefined) {
26+
const { root, errors } = await loadDoc(weekDoc(rel));
27+
expect(errors).toHaveLength(0);
28+
const week = root.objects().find((o) => o.name === "Week")!;
29+
const ref = week.referenceIdentities()[0]!;
30+
return { week, ref };
31+
}
32+
33+
describe("resolveReferentialActions", () => {
34+
test("composition → cascade / cascade(default)", async () => {
35+
const { week, ref } = await loadWeek({
36+
"relationship.composition": { name: "program", "@objectRef": "Program", "@cardinality": "one" }
37+
});
38+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: "cascade", onUpdate: "cascade" });
39+
});
40+
41+
test("aggregation → set-null / cascade(default)", async () => {
42+
const { week, ref } = await loadWeek({
43+
"relationship.aggregation": { name: "program", "@objectRef": "Program", "@cardinality": "one" }
44+
});
45+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: "set-null", onUpdate: "cascade" });
46+
});
47+
48+
test("association → restrict / cascade(default)", async () => {
49+
const { week, ref } = await loadWeek({
50+
"relationship.association": { name: "program", "@objectRef": "Program", "@cardinality": "one" }
51+
});
52+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: "restrict", onUpdate: "cascade" });
53+
});
54+
55+
test("explicit override wins; no-action normalizes to undefined", async () => {
56+
const { week, ref } = await loadWeek({
57+
"relationship.composition": { name: "program", "@objectRef": "Program",
58+
"@onDelete": "set-null", "@onUpdate": "no-action" }
59+
});
60+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: "set-null", onUpdate: undefined });
61+
});
62+
63+
test("no correlated relationship → both undefined", async () => {
64+
const { week, ref } = await loadWeek(undefined);
65+
expect(resolveReferentialActions(week, ref)).toEqual({ onDelete: undefined, onUpdate: undefined });
66+
});
67+
});

0 commit comments

Comments
 (0)