Skip to content

Commit 7e2d4ec

Browse files
dmealingclaude
andcommitted
feat(python): relationship @onDelete/@onUpdate + REFERENTIAL_ACTIONS
Adds the cross-language referential-action vocabulary to the relationship subtypes (association, aggregation, composition): - REFERENTIAL_ACTIONS = ("cascade", "set-null", "restrict", "no-action") — kebab-case, matches the TS migrate-ts FkAction set byte-for-byte. - RELATIONSHIP_ATTR_ON_DELETE = "onDelete" - RELATIONSHIP_ATTR_ON_UPDATE = "onUpdate" - ON_DELETE_DEFAULT_BY_SUBTYPE: composition=cascade, aggregation=set-null, association=restrict (applied at consumption time, not in validation). - ON_UPDATE_DEFAULT = "cascade" (subtype-independent). Registered as optional AttrSchemas on every relationship subtype with the allowed_values gate. Out-of-set values → ERR_BAD_ATTR_VALUE. MetaRelationship gains on_delete() / on_update() accessors (return None when not authored, so callers can apply per-subtype defaults), plus typed accessors for the existing @objectref / @Cardinality / @joinEntity / @joinFields attrs. Tests: +10 in tests/unit/test_relationship_referential_actions.py covering constants, accessors, and the loader allowed-values gate for both attrs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 43b186f commit 7e2d4ec

4 files changed

Lines changed: 248 additions & 3 deletions

File tree

server/python/src/metaobjects/core_types.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@
2929
from .meta.core.object.meta_object import MetaObject
3030
from .meta.core.object.object_constants import OBJECT_SUBTYPES
3131
from .meta.core.relationship.meta_relationship import MetaRelationship
32-
from .meta.core.relationship.relationship_constants import RELATIONSHIP_SUBTYPES
32+
from .meta.core.relationship.relationship_constants import (
33+
REFERENTIAL_ACTIONS,
34+
RELATIONSHIP_ATTR_ON_DELETE,
35+
RELATIONSHIP_ATTR_ON_UPDATE,
36+
RELATIONSHIP_SUBTYPES,
37+
)
3338
from .meta.meta_root import MetaRoot
3439
from .meta.persistence.origin.meta_origin import MetaOrigin
3540
from .meta.persistence.origin.origin_constants import (
@@ -215,13 +220,31 @@ def _register_subtypes(
215220
)
216221
)
217222

218-
# relationship.* (base, association, aggregation, composition)
223+
# relationship.* (base, association, aggregation, composition).
224+
# @onDelete / @onUpdate are validated against REFERENTIAL_ACTIONS — kebab-case
225+
# values (cascade / set-null / restrict / no-action). Defaults derive from the
226+
# relationship subtype at consumption time, not at validation time.
227+
_RELATIONSHIP_ATTRS = [
228+
AttrSchema(
229+
name=RELATIONSHIP_ATTR_ON_DELETE,
230+
value_type=ATTR_SUBTYPE_STRING,
231+
required=False,
232+
allowed_values=REFERENTIAL_ACTIONS,
233+
),
234+
AttrSchema(
235+
name=RELATIONSHIP_ATTR_ON_UPDATE,
236+
value_type=ATTR_SUBTYPE_STRING,
237+
required=False,
238+
allowed_values=REFERENTIAL_ACTIONS,
239+
),
240+
]
219241
_register_subtypes(
220242
core_provider,
221243
TYPE_RELATIONSHIP,
222244
RELATIONSHIP_SUBTYPES,
223245
factory=MetaRelationship,
224246
child_rules=[ChildRule(TYPE_ATTR, "*")],
247+
attrs=_RELATIONSHIP_ATTRS,
225248
)
226249

227250
# source.* — base (no attrs) + rdb (paradigm subtype with @table/@kind/@role/@schema).

server/python/src/metaobjects/meta/core/relationship/meta_relationship.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,48 @@
22
from __future__ import annotations
33

44
from ...meta_data import MetaData
5+
from .relationship_constants import (
6+
RELATIONSHIP_ATTR_CARDINALITY,
7+
RELATIONSHIP_ATTR_JOIN_ENTITY,
8+
RELATIONSHIP_ATTR_JOIN_FIELDS,
9+
RELATIONSHIP_ATTR_OBJECT_REF,
10+
RELATIONSHIP_ATTR_ON_DELETE,
11+
RELATIONSHIP_ATTR_ON_UPDATE,
12+
)
513

614

715
class MetaRelationship(MetaData):
8-
pass
16+
"""A relationship.* node. Accessors mirror the TS reference / Java port —
17+
own-attr reads; defaults for @onDelete/@onUpdate are resolved at consumption
18+
time (codegen / migration) not here."""
19+
20+
def cardinality(self) -> str | None:
21+
v = self.attr(RELATIONSHIP_ATTR_CARDINALITY)
22+
return v if isinstance(v, str) else None
23+
24+
def object_ref(self) -> str | None:
25+
"""FQN of the target object (e.g., ``"acme::vehicle::Car"``)."""
26+
v = self.attr(RELATIONSHIP_ATTR_OBJECT_REF)
27+
return v if isinstance(v, str) else None
28+
29+
def join_entity(self) -> str | None:
30+
"""Join-table entity name for N:M relationships."""
31+
v = self.attr(RELATIONSHIP_ATTR_JOIN_ENTITY)
32+
return v if isinstance(v, str) else None
33+
34+
def join_fields(self) -> list[str]:
35+
"""Join-table column names for N:M relationships."""
36+
v = self.attr(RELATIONSHIP_ATTR_JOIN_FIELDS)
37+
return list(v) if isinstance(v, list) else []
38+
39+
def on_delete(self) -> str | None:
40+
"""Referential action on parent delete. ``None`` when not explicitly set
41+
(default derives from subtype at consumption time)."""
42+
v = self.attr(RELATIONSHIP_ATTR_ON_DELETE)
43+
return v if isinstance(v, str) and v else None
44+
45+
def on_update(self) -> str | None:
46+
"""Referential action on key update. ``None`` when not explicitly set
47+
(default: cascade at consumption time)."""
48+
v = self.attr(RELATIONSHIP_ATTR_ON_UPDATE)
49+
return v if isinstance(v, str) and v else None

server/python/src/metaobjects/meta/core/relationship/relationship_constants.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,22 @@
1818
RELATIONSHIP_ATTR_PARENT_FIELD = "parentField"
1919
RELATIONSHIP_ATTR_JOIN_ENTITY = "joinEntity"
2020
RELATIONSHIP_ATTR_JOIN_FIELDS = "joinFields"
21+
22+
# --- Referential action attrs (@onDelete / @onUpdate) ----------------------
23+
24+
RELATIONSHIP_ATTR_ON_DELETE = "onDelete"
25+
RELATIONSHIP_ATTR_ON_UPDATE = "onUpdate"
26+
27+
# Canonical cross-language referential-action set (kebab-case, no "setDefault").
28+
# MUST equal TS migrate-ts FkAction; mirrors Java REFERENTIAL_ACTIONS.
29+
REFERENTIAL_ACTIONS = ("cascade", "set-null", "restrict", "no-action")
30+
31+
# Default @onDelete per relationship subtype (rollout-decided defaults).
32+
ON_DELETE_DEFAULT_BY_SUBTYPE: dict[str, str] = {
33+
RELATIONSHIP_SUBTYPE_COMPOSITION: "cascade",
34+
RELATIONSHIP_SUBTYPE_AGGREGATION: "set-null",
35+
RELATIONSHIP_SUBTYPE_ASSOCIATION: "restrict",
36+
}
37+
38+
# Default @onUpdate (subtype-independent).
39+
ON_UPDATE_DEFAULT = "cascade"
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Unit tests for relationship @onDelete / @onUpdate (referential actions).
2+
3+
Tier-1 cross-language contract:
4+
- Attr names: 'onDelete', 'onUpdate'
5+
- Action value-set: ('cascade', 'set-null', 'restrict', 'no-action') — kebab-case
6+
- Per-subtype @onDelete defaults: composition→cascade, aggregation→set-null,
7+
association→restrict (defaults applied at consumption time; the loader does
8+
not synthesize them, only validates explicit values).
9+
- @onUpdate default: cascade (subtype-independent).
10+
11+
The loader rejects any value outside the action set with ERR_BAD_ATTR_VALUE
12+
(implemented via the allowed_values schema in core_types.py — single source of
13+
truth).
14+
"""
15+
from __future__ import annotations
16+
17+
import json
18+
import os
19+
import tempfile
20+
from pathlib import Path
21+
22+
from metaobjects.core_types import core_provider
23+
from metaobjects.loader.meta_data_loader import load_directory
24+
from metaobjects.meta.core.relationship.meta_relationship import MetaRelationship
25+
from metaobjects.meta.core.relationship.relationship_constants import (
26+
ON_DELETE_DEFAULT_BY_SUBTYPE,
27+
ON_UPDATE_DEFAULT,
28+
REFERENTIAL_ACTIONS,
29+
RELATIONSHIP_ATTR_ON_DELETE,
30+
RELATIONSHIP_ATTR_ON_UPDATE,
31+
RELATIONSHIP_SUBTYPE_AGGREGATION,
32+
RELATIONSHIP_SUBTYPE_ASSOCIATION,
33+
RELATIONSHIP_SUBTYPE_COMPOSITION,
34+
)
35+
from metaobjects.shared.base_types import TYPE_RELATIONSHIP
36+
37+
38+
# ---------------------------------------------------------------------------
39+
# Constants — Tier-1 cross-language contract
40+
# ---------------------------------------------------------------------------
41+
42+
43+
def test_referential_attr_name_constants() -> None:
44+
"""Attr names must equal TS + Java byte-for-byte."""
45+
assert RELATIONSHIP_ATTR_ON_DELETE == "onDelete"
46+
assert RELATIONSHIP_ATTR_ON_UPDATE == "onUpdate"
47+
48+
49+
def test_referential_actions_value_set() -> None:
50+
"""Action set must be kebab-case (no 'setDefault'); order pins migrate-ts FkAction."""
51+
assert REFERENTIAL_ACTIONS == ("cascade", "set-null", "restrict", "no-action")
52+
53+
54+
def test_on_delete_defaults_by_subtype() -> None:
55+
"""Per-subtype @onDelete defaults (consumption-time, not validation-time)."""
56+
assert ON_DELETE_DEFAULT_BY_SUBTYPE[RELATIONSHIP_SUBTYPE_COMPOSITION] == "cascade"
57+
assert ON_DELETE_DEFAULT_BY_SUBTYPE[RELATIONSHIP_SUBTYPE_AGGREGATION] == "set-null"
58+
assert ON_DELETE_DEFAULT_BY_SUBTYPE[RELATIONSHIP_SUBTYPE_ASSOCIATION] == "restrict"
59+
60+
61+
def test_on_update_default_is_cascade() -> None:
62+
"""@onUpdate default is subtype-independent: cascade."""
63+
assert ON_UPDATE_DEFAULT == "cascade"
64+
65+
66+
# ---------------------------------------------------------------------------
67+
# Accessors
68+
# ---------------------------------------------------------------------------
69+
70+
71+
def test_on_delete_accessor_none_when_unset() -> None:
72+
"""meta-relationship.on_delete() returns None when not authored (default applied later)."""
73+
r = MetaRelationship(TYPE_RELATIONSHIP, RELATIONSHIP_SUBTYPE_COMPOSITION, "items")
74+
assert r.on_delete() is None
75+
assert r.on_update() is None
76+
77+
78+
def test_on_delete_accessor_returns_authored_value() -> None:
79+
r = MetaRelationship(TYPE_RELATIONSHIP, RELATIONSHIP_SUBTYPE_ASSOCIATION, "owner")
80+
r.set_attr(RELATIONSHIP_ATTR_ON_DELETE, "cascade", sub_type="string")
81+
r.set_attr(RELATIONSHIP_ATTR_ON_UPDATE, "no-action", sub_type="string")
82+
assert r.on_delete() == "cascade"
83+
assert r.on_update() == "no-action"
84+
85+
86+
# ---------------------------------------------------------------------------
87+
# Loader integration — allowed-values gate
88+
# ---------------------------------------------------------------------------
89+
90+
91+
def _load(doc: dict) -> list[str]:
92+
with tempfile.TemporaryDirectory() as tmpdir:
93+
path = os.path.join(tmpdir, "meta.test.json")
94+
Path(path).write_text(json.dumps(doc))
95+
result = load_directory(tmpdir, providers=[core_provider])
96+
return [e.code.name for e in result.errors]
97+
98+
99+
def _two_entity_doc(child_attrs: dict[str, str]) -> dict:
100+
"""A two-entity doc: Program -> Week composition with overridable referential actions."""
101+
return {
102+
"metadata.root": {
103+
"package": "acme",
104+
"children": [
105+
{
106+
"object.entity": {
107+
"name": "Program",
108+
"children": [
109+
{"source.rdb": {"@table": "programs"}},
110+
{"field.long": {"name": "id"}},
111+
{"identity.primary": {"@fields": "id"}},
112+
{
113+
"relationship.composition": {
114+
"name": "weeks",
115+
"@objectRef": "Week",
116+
"@cardinality": "one-to-many",
117+
**{f"@{k}": v for k, v in child_attrs.items()},
118+
}
119+
},
120+
],
121+
}
122+
},
123+
{
124+
"object.entity": {
125+
"name": "Week",
126+
"children": [
127+
{"source.rdb": {"@table": "weeks"}},
128+
{"field.long": {"name": "id"}},
129+
{"field.long": {"name": "programId"}},
130+
{"identity.primary": {"@fields": "id"}},
131+
],
132+
}
133+
},
134+
],
135+
}
136+
}
137+
138+
139+
def test_valid_on_delete_action_loads_ok() -> None:
140+
"""Every member of REFERENTIAL_ACTIONS is accepted as @onDelete."""
141+
for action in REFERENTIAL_ACTIONS:
142+
codes = _load(_two_entity_doc({"onDelete": action}))
143+
assert "ERR_BAD_ATTR_VALUE" not in codes, f"{action!r} should be valid; got {codes}"
144+
145+
146+
def test_valid_on_update_action_loads_ok() -> None:
147+
"""Every member of REFERENTIAL_ACTIONS is accepted as @onUpdate."""
148+
for action in REFERENTIAL_ACTIONS:
149+
codes = _load(_two_entity_doc({"onUpdate": action}))
150+
assert "ERR_BAD_ATTR_VALUE" not in codes, f"{action!r} should be valid; got {codes}"
151+
152+
153+
def test_invalid_on_delete_rejected() -> None:
154+
"""Out-of-set @onDelete value → ERR_BAD_ATTR_VALUE."""
155+
codes = _load(_two_entity_doc({"onDelete": "setDefault"}))
156+
assert "ERR_BAD_ATTR_VALUE" in codes, f"Expected ERR_BAD_ATTR_VALUE; got {codes}"
157+
158+
159+
def test_invalid_on_update_rejected() -> None:
160+
"""Out-of-set @onUpdate value → ERR_BAD_ATTR_VALUE."""
161+
codes = _load(_two_entity_doc({"onUpdate": "RESTRICT"})) # wrong case — should reject
162+
assert "ERR_BAD_ATTR_VALUE" in codes, f"Expected ERR_BAD_ATTR_VALUE; got {codes}"

0 commit comments

Comments
 (0)