Skip to content

Commit 423959e

Browse files
dmealingclaude
andcommitted
fix(python): #368 — the ladder was blind to the dotted @references form
reference_candidates_for compared the WHOLE @references value, so a dotted "Team.id" never matched the target entity "Team". TypeScript, C# and Java all take the segment before the first "." (MetaReferenceIdentity.targetEntity / ReferenceIdentity.getTargetEntity), and the dotted form is normative — spec/metamodel/identity.json documents "Program.id". The divergence ran in BOTH directions, confirmed by running both loaders on identical models: Python refused a valid dotted model (zero candidates made a declared @sourceRefField look unsatisfiable — "names no identity.reference targeting Team. Candidates: .") and Python failed to refuse a genuinely ambiguous dotted model that TypeScript correctly rejects (rule (e)'s `len(candidates) <= 1` skips zero as well as one). Python has no MetaReferenceIdentity subclass to hang the accessor on, so the head-parse lives in relationship_references as reference_target_entity(). The dot is searched after the last "::" so a package separator can never be mistaken for the field separator. derive_m2m_fields._ref_target_entity has the same blind spot (the new module copied it) and is DELIBERATELY left alone — repairing it would change M:N derivation behaviour, which is outside this fix. It now carries a comment naming the gap and the one-line repair. Nothing in the 328-fixture corpus used the dotted form, which is why this divergence stayed green: fixtures/conformance/relationship-one-two-refs-dotted-references is the sibling of relationship-one-two-refs-sourcerefield with dotted @references, and all four ports run it (corpus 328 -> 329; counts updated in docs/CONFORMANCE.md, AGENTS.md and site-payload.json). Verified it FAILS on the pre-fix Python loader. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfd8nat1WcSpXffd8YetiC
1 parent 308e50f commit 423959e

8 files changed

Lines changed: 233 additions & 9 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
6868
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.
6969

7070
**Cross-port conformance corpora** (every port runs the shared corpus):
71-
- Metamodel: `fixtures/conformance/` (328 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
71+
- Metamodel: `fixtures/conformance/` (329 fixtures; 22 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
7272
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
7373
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
7474
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed.

‎docs/CONFORMANCE.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l`.
2525

2626
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2727
|---|---|---|---|---|---|---|
28-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 328 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
28+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 329 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
2929
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 |
3030
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
3131
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
@@ -119,7 +119,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
119119

120120
## Fixture-to-doc mapping
121121

122-
### `fixtures/conformance/` — metamodel loader + canonical serializer (328)
122+
### `fixtures/conformance/` — metamodel loader + canonical serializer (329)
123123

124124
| Fixture prefix | Feature doc |
125125
|---|---|
@@ -271,7 +271,7 @@ Phase 1a is TypeScript + Python only; those three ports arrive in Phase 2.
271271
272272
## Orphaned fixtures (tested but not yet documented)
273273

274-
The fixtures in the nine corpora mapped above (metamodel 328 + yaml 16 + verify 31
274+
The fixtures in the nine corpora mapped above (metamodel 329 + yaml 16 + verify 31
275275
+ render 15 + persistence 33 + api-contract 41 + source-resolution 25 + scope 10 +
276276
dependency 23) each map to a feature doc. None are orphaned today. The remaining
277277
corpora in the totals table gate tooling contracts (registry manifests, provider

‎examples/showcase/site-payload.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"metamodel": "1.0"
88
},
99
"counts": {
10-
"fixtures": 328,
10+
"fixtures": 329,
1111
"corpora": 22,
1212
"baseTypes": 14
1313
},
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::sport",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Team",
8+
"children": [
9+
{
10+
"field.long": {
11+
"name": "id"
12+
}
13+
},
14+
{
15+
"identity.primary": {
16+
"name": "id",
17+
"@fields": [
18+
"id"
19+
]
20+
}
21+
}
22+
]
23+
}
24+
},
25+
{
26+
"object.entity": {
27+
"name": "Match",
28+
"children": [
29+
{
30+
"field.long": {
31+
"name": "id"
32+
}
33+
},
34+
{
35+
"field.long": {
36+
"name": "alphaFk"
37+
}
38+
},
39+
{
40+
"field.long": {
41+
"name": "betaFk"
42+
}
43+
},
44+
{
45+
"identity.primary": {
46+
"name": "id",
47+
"@fields": [
48+
"id"
49+
]
50+
}
51+
},
52+
{
53+
"identity.reference": {
54+
"name": "alphaRef",
55+
"@fields": [
56+
"alphaFk"
57+
],
58+
"@references": "acme::sport::Team.id"
59+
}
60+
},
61+
{
62+
"identity.reference": {
63+
"name": "betaRef",
64+
"@fields": [
65+
"betaFk"
66+
],
67+
"@references": "acme::sport::Team.id"
68+
}
69+
},
70+
{
71+
"relationship.association": {
72+
"name": "winner",
73+
"@cardinality": "one",
74+
"@objectRef": "acme::sport::Team",
75+
"@sourceRefField": "alphaFk"
76+
}
77+
},
78+
{
79+
"relationship.association": {
80+
"name": "loser",
81+
"@cardinality": "one",
82+
"@objectRef": "acme::sport::Team",
83+
"@sourceRefField": "betaFk"
84+
}
85+
}
86+
]
87+
}
88+
}
89+
]
90+
}
91+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::sport",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Team",
8+
"children": [
9+
{ "field.long": { "name": "id" } },
10+
{ "identity.primary": { "name": "id", "@fields": "id" } }
11+
]
12+
}
13+
},
14+
{
15+
"object.entity": {
16+
"name": "Match",
17+
"children": [
18+
{ "field.long": { "name": "id" } },
19+
{ "field.long": { "name": "alphaFk" } },
20+
{ "field.long": { "name": "betaFk" } },
21+
{ "identity.primary": { "name": "id", "@fields": "id" } },
22+
{ "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team.id" } },
23+
{ "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team.id" } },
24+
{ "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "alphaFk" } },
25+
{ "relationship.association": { "name": "loser", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "betaFk" } }
26+
]
27+
}
28+
}
29+
]
30+
}
31+
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,18 @@ def _ref_fk_field(ref: MetaData) -> str | None:
7878

7979

8080
def _ref_target_entity(ref: MetaData) -> str | None:
81-
"""The @references target-entity name of a reference (bare, package-stripped)."""
81+
"""The @references target-entity name of a reference (bare, package-stripped).
82+
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.
92+
"""
8293
v = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving (identity attr)
8394
return _strip_package(v) if isinstance(v, str) and v else None
8495

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from ...meta_data import MetaData
2020
from ....naming import strip_package
2121
from ....shared.base_types import TYPE_IDENTITY
22+
from ....shared.separators import PACKAGE_SEP
2223
from ..identity.identity_constants import (
2324
IDENTITY_ATTR_FIELDS,
2425
IDENTITY_REFERENCE_ATTR_REFERENCES,
@@ -53,6 +54,31 @@ def reference_fields(ref: MetaData) -> list[str]:
5354
return []
5455

5556

57+
def reference_target_entity(ref: MetaData) -> str | None:
58+
"""The TARGET-ENTITY half of an ``identity.reference``'s ``@references``.
59+
60+
``@references`` is either a bare entity name (``Team`` / ``acme::sport::Team``,
61+
meaning "the target's primary identity") or the dotted ``Entity.field`` /
62+
``Entity.fieldA,fieldB`` form (``Team.id``) naming explicit target fields.
63+
Both forms name the same entity, so the entity half is the segment BEFORE the
64+
first ``.`` — mirroring the other three ports' ``targetEntity`` accessor
65+
(TS ``MetaReferenceIdentity.targetEntity``, C# ``MetaReferenceIdentity.TargetEntity``,
66+
Java ``ReferenceIdentity.getTargetEntity()``), which is the authoritative shape.
67+
Python has no MetaReferenceIdentity subclass to hang it on, so it lives here.
68+
69+
The dot is searched only AFTER the last package separator so a ``::``-qualified
70+
name can never have a package segment mistaken for the field separator. Returns
71+
None when the attr is absent or empty.
72+
"""
73+
raw = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving.
74+
if not isinstance(raw, str) or not raw:
75+
return None
76+
sep = raw.rfind(PACKAGE_SEP)
77+
start = sep + len(PACKAGE_SEP) if sep >= 0 else 0
78+
dot = raw.find(".", start)
79+
return raw if dot == -1 else raw[:dot]
80+
81+
5682
def _first_fk_field(ref: MetaData) -> str | None:
5783
"""The FK field a reference is anchored on (first field; composite FKs
5884
pair on their first column)."""
@@ -80,16 +106,17 @@ def _add(value: str | None) -> None:
80106
def reference_candidates_for(holder: MetaData, target_entity: str) -> list[MetaData]:
81107
"""Every identity.reference on ``holder`` whose @references targets
82108
``target_entity``. Package-insensitive on both sides: @references and
83-
@objectRef may each be bare or fully qualified.
109+
@objectRef may each be bare or fully qualified, and @references may use the
110+
dotted ``Entity.field`` form (see :func:`reference_target_entity`).
84111
"""
85112
target = strip_package(target_entity)
86113
candidates: list[MetaData] = []
87114
# ADR-0039: resolving -- children() honors references inherited via extends.
88115
for child in holder.children():
89116
if child.type != TYPE_IDENTITY or child.sub_type != IDENTITY_SUBTYPE_REFERENCE:
90117
continue
91-
references = child.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES)
92-
if not isinstance(references, str) or not references:
118+
references = reference_target_entity(child)
119+
if references is None:
93120
continue
94121
if strip_package(references) != target:
95122
continue

‎server/python/tests/unit/test_relationship_references.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,67 @@ def test_suffix_stripping_never_applies_to_the_relationship_name() -> None:
137137
assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"]
138138
match = _find_object(result.root, "Match")
139139
assert resolve_relationship_reference(match, "valid", "Team") is None
140+
141+
142+
# ---------------------------------------------------------------------------
143+
# The dotted `@references` form — "Team.id" names the same target entity as a
144+
# bare "Team" (spec/metamodel/identity.json documents it). This port used to
145+
# compare the WHOLE attr value, so a dotted reference matched no target: a valid
146+
# dotted model was refused (zero candidates made a declared @sourceRefField look
147+
# unsatisfiable) and a genuinely ambiguous dotted model loaded clean. The other
148+
# three ports head-parse at the first "." and always agreed; only Python did not.
149+
# ---------------------------------------------------------------------------
150+
151+
def _dotted_model(relationship: dict) -> dict:
152+
return {"metadata.root": {"package": "repro", "children": [
153+
{"object.entity": {"name": "Team", "children": [
154+
{"field.long": {"name": "id"}},
155+
{"identity.primary": {"name": "id", "@fields": ["id"]}},
156+
]}},
157+
{"object.entity": {"name": "Match", "children": [
158+
{"field.long": {"name": "id"}},
159+
{"field.long": {"name": "alphaFk"}},
160+
{"field.long": {"name": "betaFk"}},
161+
{"identity.primary": {"name": "id", "@fields": ["id"]}},
162+
{"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"],
163+
"@references": "Team.id"}},
164+
{"identity.reference": {"name": "betaRef", "@fields": ["betaFk"],
165+
"@references": "repro::Team.id"}},
166+
{"relationship.association": relationship},
167+
]}},
168+
]}}
169+
170+
171+
def test_dotted_references_are_enumerated_as_candidates() -> None:
172+
doc = _dotted_model({"name": "winner", "@objectRef": "Team",
173+
"@cardinality": "one", "@sourceRefField": "alphaFk"})
174+
match = _load_object(doc, "Match")
175+
# Both the bare-dotted "Team.id" and the FQN-dotted "repro::Team.id" forms
176+
# resolve to the entity "Team" — the package separator is never mistaken
177+
# for the field separator.
178+
assert [c.name for c in reference_candidates_for(match, "Team")] == ["alphaRef", "betaRef"]
179+
assert [c.name for c in reference_candidates_for(match, "repro::Team")] == ["alphaRef", "betaRef"]
180+
181+
182+
def test_dotted_references_resolve_through_source_ref_field() -> None:
183+
doc = _dotted_model({"name": "winner", "@objectRef": "Team",
184+
"@cardinality": "one", "@sourceRefField": "betaFk"})
185+
# Loads clean: the declared FK names a real candidate. Before the head-parse
186+
# this raised ERR_INVALID_RELATIONSHIP ("names no identity.reference
187+
# targeting Team. Candidates: .") on a model TypeScript accepted.
188+
match = _load_object(doc, "Match")
189+
resolved = resolve_relationship_reference(match, "winner", "Team", "betaFk")
190+
assert resolved is not None and resolved.name == "betaRef"
191+
192+
193+
def test_dotted_references_are_still_ambiguous_without_disambiguation() -> None:
194+
# The other direction: "winner" pairs with neither candidate, so the load
195+
# must FAIL. Before the head-parse there were zero candidates, rule (e)'s
196+
# `len(candidates) <= 1` skipped, and the model loaded clean while
197+
# TypeScript refused it.
198+
doc = _dotted_model({"name": "winner", "@objectRef": "Team", "@cardinality": "one"})
199+
result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))])
200+
assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"]
201+
assert "alphaRef(alphaFk), betaRef(betaFk)" in result.errors[0].message
202+
match = _find_object(result.root, "Match")
203+
assert resolve_relationship_reference(match, "winner", "Team") is None

0 commit comments

Comments
 (0)