diff --git a/README.md b/README.md index 2ee7f90f9..7b1ae809c 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ metaobjects/ ├── CLAUDE.md # project instructions for Claude ├── spec/ # canonical metamodel docs, ADRs, roadmap ├── fixtures/ # 22 cross-language conformance corpora — the oracle -│ ├── conformance/ # metamodel (loader + serializer + navigation), 314 fixtures +│ ├── conformance/ # metamodel (loader + serializer + navigation), 329 fixtures │ ├── yaml-conformance/ # YAML authoring desugar │ ├── render-conformance/ # FR-004 byte-identical render oracle │ ├── verify-conformance/ # FR-004 template-drift gate diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index 3ac91da0e..dbd0c79ee 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -60,10 +60,12 @@ from metaobjects.meta.persistence.db import db_constants as dbc from metaobjects.meta.core.identity.identity_constants import ( IDENTITY_ATTR_FIELDS, - IDENTITY_REFERENCE_ATTR_REFERENCES, IDENTITY_SUBTYPE_REFERENCE, ) from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.meta.core.relationship.relationship_references import ( + reference_target_entity, +) from metaobjects.meta.persistence.source.source_constants import SOURCE_KIND_TABLE from metaobjects.naming import DEFAULT_COLUMN_NAMING from metaobjects.shared.base_types import TYPE_IDENTITY @@ -269,13 +271,15 @@ def reverse_fks_for(entity: MetaObject) -> list[ReverseFk]: fk_field = fields[0] elif isinstance(fields, str) and fields: fk_field = fields.split(",")[0].strip() or None - references = c.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving (identity attr) - target = ( - references[references.rfind(PACKAGE_SEP) + len(PACKAGE_SEP):] - if isinstance(references, str) and PACKAGE_SEP in references - else references - ) - if not fk_field or not isinstance(target, str) or not target: + # #368 fallout: @references may be the dotted `Entity.field` / + # `Entity.fieldA,fieldB` explicit-fields form (e.g. "acme::sport::Team.id"), + # not just a bare/qualified entity name. reference_target_entity() already + # drops that dotted tail (ADR-0039: resolving); strip_package here then bares + # the remaining qualified entity name, same as the pre-existing package strip. + target = reference_target_entity(c) + if isinstance(target, str) and PACKAGE_SEP in target: + target = target[target.rfind(PACKAGE_SEP) + len(PACKAGE_SEP):] + if not fk_field or not target: continue out.append(ReverseFk(fk_field=fk_field, target_entity=target)) return out diff --git a/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py b/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py index c389a0856..abc6dc062 100644 --- a/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py +++ b/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py @@ -26,10 +26,10 @@ from ....shared.separators import PACKAGE_SEP from ..identity.identity_constants import ( IDENTITY_ATTR_FIELDS, - IDENTITY_REFERENCE_ATTR_REFERENCES, IDENTITY_SUBTYPE_REFERENCE, ) from .meta_relationship import MetaRelationship +from .relationship_references import reference_target_entity class M2MDerivationError(Exception): @@ -80,18 +80,18 @@ def _ref_fk_field(ref: MetaData) -> str | None: def _ref_target_entity(ref: MetaData) -> str | None: """The @references target-entity name of a reference (bare, package-stripped). - KNOWN GAP (pre-dates #368, deliberately NOT fixed here): this compares the - WHOLE @references value, so the dotted ``Entity.field`` form ("Team.id") - never matches a bare entity name — a junction whose references are authored - dotted derives no M:N fields on this port, where TS's derive-m2m-fields.ts - (which reads ``ref.targetEntity``) resolves them. The one-line repair is to - delegate to ``relationship_references.reference_target_entity``; it is left - alone because it would change M:N derivation behaviour, which is outside the - #368 fix. Tracked separately from the rule-(e) ladder, whose copy of this - blind spot IS fixed. + GAP FIXED (was pre-existing, pre-dates #368; parked during that fix because + repairing it changes M:N derivation behaviour, which was out of scope there): + a junction reference authored with the dotted ``Entity.field`` explicit-fields + form ("Team.id") used to compare the WHOLE @references value against a bare + entity name and never match, so a M:N relationship through such a junction + derived no fields at all. Delegates the dotted-tail split to + ``relationship_references.reference_target_entity`` (the #368 fix's canonical + head-parse) and then strips any package prefix to keep this function's + bare-name contract. """ - v = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving (identity attr) - return _strip_package(v) if isinstance(v, str) and v else None + target = reference_target_entity(ref) + return _strip_package(target) if target else None def derive_m2m_fields( diff --git a/server/python/src/metaobjects/meta/core/relationship/relationship_references.py b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py index ecc1f9cbe..f7b3f83b2 100644 --- a/server/python/src/metaobjects/meta/core/relationship/relationship_references.py +++ b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py @@ -69,6 +69,13 @@ def reference_target_entity(ref: MetaData) -> str | None: The dot is searched only AFTER the last package separator so a ``::``-qualified name can never have a package segment mistaken for the field separator. Returns None when the attr is absent or empty. + + Reused (not reimplemented) by ``codegen.generators.router_generator.reverse_fks_for`` + and ``derive_m2m_fields._ref_target_entity`` — both had their own copy of this exact + blind spot before #368's follow-up fixed them onto this one. ``naming_refs. + _split_child_tail`` runs the same character-level search but stays a separate, + private implementation (desugar-phase, over raw pre-resolution strings, five + attribute kinds, keeps the tail) — see its docstring for why it isn't merged here. """ raw = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving. if not isinstance(raw, str) or not raw: diff --git a/server/python/src/metaobjects/naming_refs.py b/server/python/src/metaobjects/naming_refs.py index f47a903ee..159214ddc 100644 --- a/server/python/src/metaobjects/naming_refs.py +++ b/server/python/src/metaobjects/naming_refs.py @@ -101,6 +101,17 @@ def _split_child_tail(raw: str) -> tuple[str, str]: (package separators never follow a child dot), so the owner ends at the first ``.`` AFTER the last ``::``. Returns ``(owner, tail)`` where *tail* includes the leading ``.`` (or is "" when there is no child suffix). + + Coincidentally the same character-level algorithm as + ``meta.core.relationship.relationship_references.reference_target_entity``'s + dot search (#368) — deliberately NOT merged with it: this one is private to + the desugar pass (runs pre-resolution, over raw authored strings, across five + different ref-bearing attribute kinds, keeping the tail for reattachment); + that one is a public accessor over a resolved ``identity.reference`` MetaData + node, specific to ``@references``, discarding the tail. Unifying them would + mean promoting a desugar-internal to public API (or inverting the layering — + this module has no runtime dependency on ``meta.core.relationship`` today) for + a one-line coincidence, not a shared contract. """ last_sep = raw.rfind(PACKAGE_SEP) seg_start = 0 if last_sep < 0 else last_sep + len(PACKAGE_SEP) diff --git a/server/python/tests/codegen/test_m2m_codegen.py b/server/python/tests/codegen/test_m2m_codegen.py index 74fa63f61..95bc330f5 100644 --- a/server/python/tests/codegen/test_m2m_codegen.py +++ b/server/python/tests/codegen/test_m2m_codegen.py @@ -95,6 +95,25 @@ def _drop_source(data: dict[str, Any], entity_name: str) -> None: raise AssertionError(f"fixture has no object.entity named {entity_name!r}") +def _dot_the_junction_references(data: dict[str, Any]) -> None: + """Rewrite PostTag's two identity.reference @references from a bare entity + name to the normative dotted ``Entity.field`` explicit-fields form (spec/ + metamodel/identity.json) naming the SAME target — a no-op semantically, + proving only that the dotted spelling itself must not change resolution.""" + for child in data["metadata.root"]["children"]: + obj = child.get("object.entity") + if obj is None or obj.get("name") != "PostTag": + continue + for c in obj["children"]: + ref = c.get("identity.reference") + if ref is not None and ref.get("name") == "fkPost": + ref["@references"] = "Post.id" + if ref is not None and ref.get("name") == "fkTag": + ref["@references"] = "Tag.id" + return + raise AssertionError("fixture has no object.entity named 'PostTag'") + + _ENTITIES = _load_entities() _INDEX = build_object_index(list(_ENTITIES.values())) @@ -277,6 +296,28 @@ def test_sourced_junction_still_resolves_correctly() -> None: assert descs[0].target_table == "tags" +def test_dotted_junction_reference_still_derives_m2m_fields() -> None: + """#368 fallout, previously a parked KNOWN GAP: a junction's + identity.reference may be authored with the dotted ``Entity.field`` / + ``Entity.fieldA,fieldB`` explicit-fields form. Before the fix, + ``_ref_target_entity`` compared the WHOLE @references value ("Post.id") + against the bare entity name ("Post") and never matched, so + resolve_m2m_descriptors raised M2MDerivationError for a junction that + should resolve exactly like the bare-name fixture. This is the newly- + correct behaviour: same shape, same descriptor, dotted spelling.""" + entities = _load_entities_edited(_dot_the_junction_references) + index = build_object_index(list(entities.values())) + descs = resolve_m2m_descriptors(entities["Post"], index) + assert len(descs) == 1 + d = descs[0] + assert d.target_entity == "Tag" + assert d.junction_table == "post_tags" + assert d.target_table == "tags" + assert d.source_column == "postId" + assert d.target_column == "tagId" + assert d.symmetric is False + + # --------------------------------------------------------------------------- # Column-naming strategy reaches the generator ENTRY POINTS — not just the # pure `resolve_m2m_descriptors` function. diff --git a/server/python/tests/codegen/test_reverse_finders.py b/server/python/tests/codegen/test_reverse_finders.py index 8fbb90683..e7b7ce39a 100644 --- a/server/python/tests/codegen/test_reverse_finders.py +++ b/server/python/tests/codegen/test_reverse_finders.py @@ -20,6 +20,7 @@ """ from __future__ import annotations +import json import shutil import tempfile from pathlib import Path @@ -130,6 +131,94 @@ def test_scene_has_no_reverse_fks() -> None: assert reverse_fks_for(_ENTITIES["Scene"]) == [] +# --------------------------------------------------------------------------- +# #368 fallout — a dotted @references ("Entity.field" / "Entity.a,b", the +# normative explicit-fields form per spec/metamodel/identity.json) must still +# resolve target_entity to the BARE entity name, not the raw dotted tail. +# Deliberately not the shared reverse-finders-same-pair fixture — this shape +# is narrow enough to inline and doesn't need a new cross-port corpus entry. +# --------------------------------------------------------------------------- + + +def _load_dotted_reference_entities() -> dict[str, MetaObject]: + data = { + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + {"source.rdb": {"@table": "teams"}}, + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ], + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + {"source.rdb": {"@table": "matches"}}, + {"field.long": {"name": "id"}}, + {"field.long": {"name": "teamFk"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + { + "identity.reference": { + "name": "teamRef", + "@fields": "teamFk", + "@references": "acme::sport::Team.id", + } + }, + ], + } + }, + ], + } + } + tmp = Path(tempfile.mkdtemp(prefix="reverse-finders-dotted-")) + try: + (tmp / "meta.json").write_text(json.dumps(data)) + result = MetaDataLoader.from_directory(str(tmp)) + assert not result.errors, [f"{e.code}: {e.message}" for e in result.errors] + return { + c.name: c + for c in result.root.children() + if c.type == TYPE_OBJECT and isinstance(c, MetaObject) + } + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def test_reverse_fks_for_resolves_bare_target_entity_from_dotted_references() -> None: + """The USER-VISIBLE symptom this guards: ``reverse_fks_for()`` is a public, + documented function (its ``ReverseFk.target_entity`` is asserted directly by + ``test_reverse_fks_for_game_session`` above) whose docstring promises "the bare + target entity (T)". A dotted ``@references`` used to leak the raw tail + ("Team.id") into that field instead of bareing it to "Team" -- a correctness + bug in a tested return value, independent of whether any current caller + happens to consume the field (see the next test: today, none does).""" + entities = _load_dotted_reference_entities() + fks = reverse_fks_for(entities["Match"]) + assert [(f.fk_field, f.target_entity) for f in fks] == [("teamFk", "Team")] + + +def test_router_reverse_finder_name_unaffected_by_dotted_references() -> None: + """The generated finder METHOD NAME derives only from the FK-holding entity's + own name + FK field (never from target_entity), so it was already correct + with a dotted @references even before the fix above -- the router_generator.py + bug corrupted a returned data field, not the emitted router source.""" + entities = _load_dotted_reference_entities() + index = build_object_index(list(entities.values())) + src = render_router(entities["Match"], index) + assert src is not None + assert "def find_matches_by_team_fk(self, team_fk: Any) -> list[Any]: ..." in src + assert ( + "def find_matches_by_team_fk_in(self, team_fk_values: list[Any]) -> list[Any]: ..." + in src + ) + + # --------------------------------------------------------------------------- # Same-pair: THREE distinct GameSession→Scene finders — the collision case. # ---------------------------------------------------------------------------