Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions server/python/src/metaobjects/naming_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions server/python/tests/codegen/test_m2m_codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))

Expand Down Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions server/python/tests/codegen/test_reverse_finders.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""
from __future__ import annotations

import json
import shutil
import tempfile
from pathlib import Path
Expand Down Expand Up @@ -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.
# ---------------------------------------------------------------------------
Expand Down
Loading