Skip to content

Commit 61470b8

Browse files
authored
Merge pull request #371 from metaobjectsdev/fix/368-assoc-ref-disambiguation
fix: two identity.reference nodes onto one entity made every association join the first FK (#368)
2 parents f204127 + 7dafb05 commit 61470b8

65 files changed

Lines changed: 8150 additions & 288 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
7070
- **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.
7171

7272
**Cross-port conformance corpora** (every port runs the shared corpus):
73-
- Metamodel: `fixtures/conformance/` (325 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.
73+
- 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.
7474
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
7575
- 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".)
7676
- 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.
@@ -525,7 +525,7 @@ Preserve the following contracts exactly across all language ports:
525525
- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `<Name>Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.)
526526
- Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`.
527527
- Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210).
528-
- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side). `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N → `ERR_INVALID_RELATIONSHIP`.
528+
- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N → `ERR_INVALID_RELATIONSHIP`.
529529
- Index subtypes: `index.lookup` (non-unique retrieval index; uniqueness is encoded in the **type**: `identity.secondary` = unique alternate key, `index.lookup` = non-unique; `@unique` is REMOVED from `identity.secondary` — `ERR_UNKNOWN_ATTR` on any legacy `@unique`). RDB-physical escapes `@using`/`@expr`/`@where`/`@orders` are registered by the db provider on **both** `identity.secondary` and `index.lookup`. `index.fulltext` / `index.vector` / `index.spatial` are reserved on the subtype axis — documented, NOT registered (YAGNI + 1.0 vocab freeze). See [ADR-0040](spec/decisions/ADR-0040-index-type-and-secondary-key-purity.md).
530530
- Layout subtypes: `dataGrid`
531531
- API subtypes: `api.base` / `api.operational` (request/response surface; subtype axis = interaction model, NEVER protocol — protocol lives in `binding.*` per operation: `rest` now, `messaging`/`grpc` reserved). Children: `operation.query` (outputRef → `object.projection`) / `operation.command` (inputRef → `object.value`, may also outputRef). Derived CRUD (FR-008/009) stays the zero-config default; declared `api` extends it. Org-tier modeling (application/service/network/deployment) stays OUT of core — provider SPI, FQN references. See [ADR-0030](spec/decisions/ADR-0030-declared-api-surface-and-org-tier-boundary.md). (FR-024 declared-API — planned; not yet in `expected-registry.json`; the remaining third of FR-024 after the projection/value taxonomy + validation parity.)

‎CHANGELOG.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,62 @@ here.**
174174
you."*, spelled once in `sidecarLine` (TypeScript) and `generated_header` (Python)
175175
rather than copy-pasted to eleven emitters.
176176

177+
- **Two `identity.reference` nodes onto the same entity no longer make every
178+
`@cardinality: one` relationship join the first one's FK column ([#368]).** An entity
179+
may legitimately declare more than one FK to the same target — `Match.homeTeamRef`
180+
and `Match.awayTeamRef` both `-> Team` — but a relationship names only its target via
181+
`@objectRef`, never which reference it means. Four call sites took the first matching
182+
reference and never noticed the second: the TypeScript codegen `relations()` block
183+
wired the relationship to one FK column regardless, the runtime relation traversal
184+
resolved every such navigation through it, a projection's `@via` join hop picked it
185+
even when the hop explicitly named the other reference, and the docs-site link graph
186+
drew the wrong edge. All four produce a join that typechecks, emits correct DDL, and
187+
passes `meta verify` — the only symptom is wrong rows. The referential-actions
188+
correlation (TypeScript's `migrate-ts`, the C# port, and the JVM tree's Kotlin
189+
Exposed table generator; Python does not implement this correlation) had the same
190+
defect one level over: every FK past the first silently inherited the *first*
191+
relationship's `@onDelete` / `@onUpdate` instead of its own, so a model mixing
192+
`restrict` and `cascade` across two references to the same target emitted the wrong
193+
action on whichever FK wasn't examined first. All three are now correlated by
194+
inverting the same ladder, and fail closed rather than guessing.
195+
196+
Resolution is now explicit and identical across TypeScript, Python, C# and Java: an
197+
ambiguous `@cardinality: one` reference set resolves by a ladder — the sole
198+
candidate, else a declared `@sourceRefField` naming the candidate's FK field, else a
199+
name-pairing match between the relationship's name and a candidate's name/FK field,
200+
else `ERR_INVALID_RELATIONSHIP` at load, naming every candidate
201+
([ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md)
202+
Amendment 1). `@sourceRefField` is now legal on a `@cardinality: one` relationship —
203+
it previously failed to load there as an M:N-only attribute. Three limits are
204+
documented rather than fixed here: the ladder matches a candidate's first FK field
205+
only, so two composite references sharing a first column stay indistinguishable (and
206+
resolve to the first, rather than being refused); the loader gate covers
207+
`@cardinality: one` relationships only — a `many`-cardinality relationship, or a bare
208+
`identity.reference` pair with no relationship wrapper, still reaches codegen
209+
unvalidated; and the gate needs at least one candidate on the holder, so an inverted
210+
shape with both FKs on the far side loads clean and codegen then silently drops the
211+
relation. See [`docs/features/relationships.md`](docs/features/relationships.md).
212+
213+
Two consequences worth stating outright. **Java's relationship validation changed
214+
from eager-throw-on-first-violation to collect-all-findings** —
215+
`validateRelationshipsM2M` now returns a `List<MetaDataException>` rather than
216+
throwing, and the new rule-(e) pass collects the same way, so a Java loader run
217+
reports every broken relationship where it previously reported only the first; that
218+
is a change to Java loader OUTPUT, not an internal refactor. And **adopters with the
219+
affected shapes will see FK referential-action diffs on their next `meta migrate`**:
220+
a second FK to the same target now resolves its OWN `@onDelete` / `@onUpdate`
221+
instead of inheriting the first relationship's, so the generated `ON DELETE` /
222+
`ON UPDATE` (and a Kotlin Exposed table's `ReferenceOption` arguments) legitimately
223+
change.
224+
225+
No vocabulary was added, removed or retyped — the only change to
226+
`expected-registry.json` corrects `@sourceRefField`'s own description, which no
227+
longer claims the attribute is M:N-only. Per
228+
[`docs/RELEASING.md`](docs/RELEASING.md), any change to that file forces all four
229+
registries (npm / PyPI / NuGet / Maven) to publish together at the next release,
230+
changed product files or not — that consequence is recorded here so it isn't a
231+
surprise at release time. `metamodelVersion` stays `1.0`.
232+
177233
### Added
178234

179235
- **The auth seam is printed in the generated routes handler's JSDoc ([#367]).** Stock
@@ -194,6 +250,7 @@ here.**
194250
inconsistency, not a policy. Read-only is not public.
195251

196252
[#367]: https://github.com/metaobjectsdev/metaobjects/issues/367
253+
[#368]: https://github.com/metaobjectsdev/metaobjects/issues/368
197254

198255
## [1.0.3] — 2026-09-12
199256

‎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) | 325 | ✓ | ✓ | 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 (325)
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 325 + 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

0 commit comments

Comments
 (0)