Skip to content

Commit e613da2

Browse files
authored
Merge pull request #372 from metaobjectsdev/followup/python-dotted-and-docs
fix(python): complete the dotted @references head-parse in router_generator and M:N derivation
2 parents 61470b8 + 9930741 commit e613da2

7 files changed

Lines changed: 173 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ metaobjects/
201201
├── CLAUDE.md # project instructions for Claude
202202
├── spec/ # canonical metamodel docs, ADRs, roadmap
203203
├── fixtures/ # 22 cross-language conformance corpora — the oracle
204-
│ ├── conformance/ # metamodel (loader + serializer + navigation), 314 fixtures
204+
│ ├── conformance/ # metamodel (loader + serializer + navigation), 329 fixtures
205205
│ ├── yaml-conformance/ # YAML authoring desugar
206206
│ ├── render-conformance/ # FR-004 byte-identical render oracle
207207
│ ├── verify-conformance/ # FR-004 template-drift gate

server/python/src/metaobjects/codegen/generators/router_generator.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@
6060
from metaobjects.meta.persistence.db import db_constants as dbc
6161
from metaobjects.meta.core.identity.identity_constants import (
6262
IDENTITY_ATTR_FIELDS,
63-
IDENTITY_REFERENCE_ATTR_REFERENCES,
6463
IDENTITY_SUBTYPE_REFERENCE,
6564
)
6665
from metaobjects.meta.core.object.meta_object import MetaObject
66+
from metaobjects.meta.core.relationship.relationship_references import (
67+
reference_target_entity,
68+
)
6769
from metaobjects.meta.persistence.source.source_constants import SOURCE_KIND_TABLE
6870
from metaobjects.naming import DEFAULT_COLUMN_NAMING
6971
from metaobjects.shared.base_types import TYPE_IDENTITY
@@ -269,13 +271,15 @@ def reverse_fks_for(entity: MetaObject) -> list[ReverseFk]:
269271
fk_field = fields[0]
270272
elif isinstance(fields, str) and fields:
271273
fk_field = fields.split(",")[0].strip() or None
272-
references = c.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving (identity attr)
273-
target = (
274-
references[references.rfind(PACKAGE_SEP) + len(PACKAGE_SEP):]
275-
if isinstance(references, str) and PACKAGE_SEP in references
276-
else references
277-
)
278-
if not fk_field or not isinstance(target, str) or not target:
274+
# #368 fallout: @references may be the dotted `Entity.field` /
275+
# `Entity.fieldA,fieldB` explicit-fields form (e.g. "acme::sport::Team.id"),
276+
# not just a bare/qualified entity name. reference_target_entity() already
277+
# drops that dotted tail (ADR-0039: resolving); strip_package here then bares
278+
# the remaining qualified entity name, same as the pre-existing package strip.
279+
target = reference_target_entity(c)
280+
if isinstance(target, str) and PACKAGE_SEP in target:
281+
target = target[target.rfind(PACKAGE_SEP) + len(PACKAGE_SEP):]
282+
if not fk_field or not target:
279283
continue
280284
out.append(ReverseFk(fk_field=fk_field, target_entity=target))
281285
return out

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

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
from ....shared.separators import PACKAGE_SEP
2727
from ..identity.identity_constants import (
2828
IDENTITY_ATTR_FIELDS,
29-
IDENTITY_REFERENCE_ATTR_REFERENCES,
3029
IDENTITY_SUBTYPE_REFERENCE,
3130
)
3231
from .meta_relationship import MetaRelationship
32+
from .relationship_references import reference_target_entity
3333

3434

3535
class M2MDerivationError(Exception):
@@ -80,18 +80,18 @@ def _ref_fk_field(ref: MetaData) -> str | None:
8080
def _ref_target_entity(ref: MetaData) -> str | None:
8181
"""The @references target-entity name of a reference (bare, package-stripped).
8282
83-
KNOWN GAP (pre-dates #368, deliberately NOT fixed here): this compares the
84-
WHOLE @references value, so the dotted ``Entity.field`` form ("Team.id")
85-
never matches a bare entity name — a junction whose references are authored
86-
dotted derives no M:N fields on this port, where TS's derive-m2m-fields.ts
87-
(which reads ``ref.targetEntity``) resolves them. The one-line repair is to
88-
delegate to ``relationship_references.reference_target_entity``; it is left
89-
alone because it would change M:N derivation behaviour, which is outside the
90-
#368 fix. Tracked separately from the rule-(e) ladder, whose copy of this
91-
blind spot IS fixed.
83+
GAP FIXED (was pre-existing, pre-dates #368; parked during that fix because
84+
repairing it changes M:N derivation behaviour, which was out of scope there):
85+
a junction reference authored with the dotted ``Entity.field`` explicit-fields
86+
form ("Team.id") used to compare the WHOLE @references value against a bare
87+
entity name and never match, so a M:N relationship through such a junction
88+
derived no fields at all. Delegates the dotted-tail split to
89+
``relationship_references.reference_target_entity`` (the #368 fix's canonical
90+
head-parse) and then strips any package prefix to keep this function's
91+
bare-name contract.
9292
"""
93-
v = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving (identity attr)
94-
return _strip_package(v) if isinstance(v, str) and v else None
93+
target = reference_target_entity(ref)
94+
return _strip_package(target) if target else None
9595

9696

9797
def derive_m2m_fields(

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ def reference_target_entity(ref: MetaData) -> str | None:
6969
The dot is searched only AFTER the last package separator so a ``::``-qualified
7070
name can never have a package segment mistaken for the field separator. Returns
7171
None when the attr is absent or empty.
72+
73+
Reused (not reimplemented) by ``codegen.generators.router_generator.reverse_fks_for``
74+
and ``derive_m2m_fields._ref_target_entity`` — both had their own copy of this exact
75+
blind spot before #368's follow-up fixed them onto this one. ``naming_refs.
76+
_split_child_tail`` runs the same character-level search but stays a separate,
77+
private implementation (desugar-phase, over raw pre-resolution strings, five
78+
attribute kinds, keeps the tail) — see its docstring for why it isn't merged here.
7279
"""
7380
raw = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving.
7481
if not isinstance(raw, str) or not raw:

server/python/src/metaobjects/naming_refs.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ def _split_child_tail(raw: str) -> tuple[str, str]:
101101
(package separators never follow a child dot), so the owner ends at the
102102
first ``.`` AFTER the last ``::``. Returns ``(owner, tail)`` where *tail*
103103
includes the leading ``.`` (or is "" when there is no child suffix).
104+
105+
Coincidentally the same character-level algorithm as
106+
``meta.core.relationship.relationship_references.reference_target_entity``'s
107+
dot search (#368) — deliberately NOT merged with it: this one is private to
108+
the desugar pass (runs pre-resolution, over raw authored strings, across five
109+
different ref-bearing attribute kinds, keeping the tail for reattachment);
110+
that one is a public accessor over a resolved ``identity.reference`` MetaData
111+
node, specific to ``@references``, discarding the tail. Unifying them would
112+
mean promoting a desugar-internal to public API (or inverting the layering —
113+
this module has no runtime dependency on ``meta.core.relationship`` today) for
114+
a one-line coincidence, not a shared contract.
104115
"""
105116
last_sep = raw.rfind(PACKAGE_SEP)
106117
seg_start = 0 if last_sep < 0 else last_sep + len(PACKAGE_SEP)

server/python/tests/codegen/test_m2m_codegen.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,25 @@ def _drop_source(data: dict[str, Any], entity_name: str) -> None:
9595
raise AssertionError(f"fixture has no object.entity named {entity_name!r}")
9696

9797

98+
def _dot_the_junction_references(data: dict[str, Any]) -> None:
99+
"""Rewrite PostTag's two identity.reference @references from a bare entity
100+
name to the normative dotted ``Entity.field`` explicit-fields form (spec/
101+
metamodel/identity.json) naming the SAME target — a no-op semantically,
102+
proving only that the dotted spelling itself must not change resolution."""
103+
for child in data["metadata.root"]["children"]:
104+
obj = child.get("object.entity")
105+
if obj is None or obj.get("name") != "PostTag":
106+
continue
107+
for c in obj["children"]:
108+
ref = c.get("identity.reference")
109+
if ref is not None and ref.get("name") == "fkPost":
110+
ref["@references"] = "Post.id"
111+
if ref is not None and ref.get("name") == "fkTag":
112+
ref["@references"] = "Tag.id"
113+
return
114+
raise AssertionError("fixture has no object.entity named 'PostTag'")
115+
116+
98117
_ENTITIES = _load_entities()
99118
_INDEX = build_object_index(list(_ENTITIES.values()))
100119

@@ -277,6 +296,28 @@ def test_sourced_junction_still_resolves_correctly() -> None:
277296
assert descs[0].target_table == "tags"
278297

279298

299+
def test_dotted_junction_reference_still_derives_m2m_fields() -> None:
300+
"""#368 fallout, previously a parked KNOWN GAP: a junction's
301+
identity.reference may be authored with the dotted ``Entity.field`` /
302+
``Entity.fieldA,fieldB`` explicit-fields form. Before the fix,
303+
``_ref_target_entity`` compared the WHOLE @references value ("Post.id")
304+
against the bare entity name ("Post") and never matched, so
305+
resolve_m2m_descriptors raised M2MDerivationError for a junction that
306+
should resolve exactly like the bare-name fixture. This is the newly-
307+
correct behaviour: same shape, same descriptor, dotted spelling."""
308+
entities = _load_entities_edited(_dot_the_junction_references)
309+
index = build_object_index(list(entities.values()))
310+
descs = resolve_m2m_descriptors(entities["Post"], index)
311+
assert len(descs) == 1
312+
d = descs[0]
313+
assert d.target_entity == "Tag"
314+
assert d.junction_table == "post_tags"
315+
assert d.target_table == "tags"
316+
assert d.source_column == "postId"
317+
assert d.target_column == "tagId"
318+
assert d.symmetric is False
319+
320+
280321
# ---------------------------------------------------------------------------
281322
# Column-naming strategy reaches the generator ENTRY POINTS — not just the
282323
# pure `resolve_m2m_descriptors` function.

server/python/tests/codegen/test_reverse_finders.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"""
2121
from __future__ import annotations
2222

23+
import json
2324
import shutil
2425
import tempfile
2526
from pathlib import Path
@@ -130,6 +131,94 @@ def test_scene_has_no_reverse_fks() -> None:
130131
assert reverse_fks_for(_ENTITIES["Scene"]) == []
131132

132133

134+
# ---------------------------------------------------------------------------
135+
# #368 fallout — a dotted @references ("Entity.field" / "Entity.a,b", the
136+
# normative explicit-fields form per spec/metamodel/identity.json) must still
137+
# resolve target_entity to the BARE entity name, not the raw dotted tail.
138+
# Deliberately not the shared reverse-finders-same-pair fixture — this shape
139+
# is narrow enough to inline and doesn't need a new cross-port corpus entry.
140+
# ---------------------------------------------------------------------------
141+
142+
143+
def _load_dotted_reference_entities() -> dict[str, MetaObject]:
144+
data = {
145+
"metadata.root": {
146+
"package": "acme::sport",
147+
"children": [
148+
{
149+
"object.entity": {
150+
"name": "Team",
151+
"children": [
152+
{"source.rdb": {"@table": "teams"}},
153+
{"field.long": {"name": "id"}},
154+
{"identity.primary": {"name": "id", "@fields": "id"}},
155+
],
156+
}
157+
},
158+
{
159+
"object.entity": {
160+
"name": "Match",
161+
"children": [
162+
{"source.rdb": {"@table": "matches"}},
163+
{"field.long": {"name": "id"}},
164+
{"field.long": {"name": "teamFk"}},
165+
{"identity.primary": {"name": "id", "@fields": "id"}},
166+
{
167+
"identity.reference": {
168+
"name": "teamRef",
169+
"@fields": "teamFk",
170+
"@references": "acme::sport::Team.id",
171+
}
172+
},
173+
],
174+
}
175+
},
176+
],
177+
}
178+
}
179+
tmp = Path(tempfile.mkdtemp(prefix="reverse-finders-dotted-"))
180+
try:
181+
(tmp / "meta.json").write_text(json.dumps(data))
182+
result = MetaDataLoader.from_directory(str(tmp))
183+
assert not result.errors, [f"{e.code}: {e.message}" for e in result.errors]
184+
return {
185+
c.name: c
186+
for c in result.root.children()
187+
if c.type == TYPE_OBJECT and isinstance(c, MetaObject)
188+
}
189+
finally:
190+
shutil.rmtree(tmp, ignore_errors=True)
191+
192+
193+
def test_reverse_fks_for_resolves_bare_target_entity_from_dotted_references() -> None:
194+
"""The USER-VISIBLE symptom this guards: ``reverse_fks_for()`` is a public,
195+
documented function (its ``ReverseFk.target_entity`` is asserted directly by
196+
``test_reverse_fks_for_game_session`` above) whose docstring promises "the bare
197+
target entity (T)". A dotted ``@references`` used to leak the raw tail
198+
("Team.id") into that field instead of bareing it to "Team" -- a correctness
199+
bug in a tested return value, independent of whether any current caller
200+
happens to consume the field (see the next test: today, none does)."""
201+
entities = _load_dotted_reference_entities()
202+
fks = reverse_fks_for(entities["Match"])
203+
assert [(f.fk_field, f.target_entity) for f in fks] == [("teamFk", "Team")]
204+
205+
206+
def test_router_reverse_finder_name_unaffected_by_dotted_references() -> None:
207+
"""The generated finder METHOD NAME derives only from the FK-holding entity's
208+
own name + FK field (never from target_entity), so it was already correct
209+
with a dotted @references even before the fix above -- the router_generator.py
210+
bug corrupted a returned data field, not the emitted router source."""
211+
entities = _load_dotted_reference_entities()
212+
index = build_object_index(list(entities.values()))
213+
src = render_router(entities["Match"], index)
214+
assert src is not None
215+
assert "def find_matches_by_team_fk(self, team_fk: Any) -> list[Any]: ..." in src
216+
assert (
217+
"def find_matches_by_team_fk_in(self, team_fk_values: list[Any]) -> list[Any]: ..."
218+
in src
219+
)
220+
221+
133222
# ---------------------------------------------------------------------------
134223
# Same-pair: THREE distinct GameSession→Scene finders — the collision case.
135224
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)