diff --git a/AGENTS.md b/AGENTS.md index 799e0b9e8..e34e7ebbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **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. **Cross-port conformance corpora** (every port runs the shared corpus): -- 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. +- 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. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - 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".) - 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. @@ -523,7 +523,7 @@ Preserve the following contracts exactly across all language ports: - 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 `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) - 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`. - 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). -- 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`. +- 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`. - 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). - Layout subtypes: `dataGrid` - 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.) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbca9520..b961a208c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,62 @@ here.** you."*, spelled once in `sidecarLine` (TypeScript) and `generated_header` (Python) rather than copy-pasted to eleven emitters. +- **Two `identity.reference` nodes onto the same entity no longer make every + `@cardinality: one` relationship join the first one's FK column ([#368]).** An entity + may legitimately declare more than one FK to the same target — `Match.homeTeamRef` + and `Match.awayTeamRef` both `-> Team` — but a relationship names only its target via + `@objectRef`, never which reference it means. Four call sites took the first matching + reference and never noticed the second: the TypeScript codegen `relations()` block + wired the relationship to one FK column regardless, the runtime relation traversal + resolved every such navigation through it, a projection's `@via` join hop picked it + even when the hop explicitly named the other reference, and the docs-site link graph + drew the wrong edge. All four produce a join that typechecks, emits correct DDL, and + passes `meta verify` — the only symptom is wrong rows. The referential-actions + correlation (TypeScript's `migrate-ts`, the C# port, and the JVM tree's Kotlin + Exposed table generator; Python does not implement this correlation) had the same + defect one level over: every FK past the first silently inherited the *first* + relationship's `@onDelete` / `@onUpdate` instead of its own, so a model mixing + `restrict` and `cascade` across two references to the same target emitted the wrong + action on whichever FK wasn't examined first. All three are now correlated by + inverting the same ladder, and fail closed rather than guessing. + + Resolution is now explicit and identical across TypeScript, Python, C# and Java: an + ambiguous `@cardinality: one` reference set resolves by a ladder — the sole + candidate, else a declared `@sourceRefField` naming the candidate's FK field, else a + name-pairing match between the relationship's name and a candidate's name/FK field, + else `ERR_INVALID_RELATIONSHIP` at load, naming every candidate + ([ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) + Amendment 1). `@sourceRefField` is now legal on a `@cardinality: one` relationship — + it previously failed to load there as an M:N-only attribute. Three limits are + documented rather than fixed here: the ladder matches a candidate's first FK field + only, so two composite references sharing a first column stay indistinguishable (and + resolve to the first, rather than being refused); the loader gate covers + `@cardinality: one` relationships only — a `many`-cardinality relationship, or a bare + `identity.reference` pair with no relationship wrapper, still reaches codegen + unvalidated; and the gate needs at least one candidate on the holder, so an inverted + shape with both FKs on the far side loads clean and codegen then silently drops the + relation. See [`docs/features/relationships.md`](docs/features/relationships.md). + + Two consequences worth stating outright. **Java's relationship validation changed + from eager-throw-on-first-violation to collect-all-findings** — + `validateRelationshipsM2M` now returns a `List` rather than + throwing, and the new rule-(e) pass collects the same way, so a Java loader run + reports every broken relationship where it previously reported only the first; that + is a change to Java loader OUTPUT, not an internal refactor. And **adopters with the + affected shapes will see FK referential-action diffs on their next `meta migrate`**: + a second FK to the same target now resolves its OWN `@onDelete` / `@onUpdate` + instead of inheriting the first relationship's, so the generated `ON DELETE` / + `ON UPDATE` (and a Kotlin Exposed table's `ReferenceOption` arguments) legitimately + change. + + No vocabulary was added, removed or retyped — the only change to + `expected-registry.json` corrects `@sourceRefField`'s own description, which no + longer claims the attribute is M:N-only. Per + [`docs/RELEASING.md`](docs/RELEASING.md), any change to that file forces all four + registries (npm / PyPI / NuGet / Maven) to publish together at the next release, + changed product files or not — that consequence is recorded here so it isn't a + surprise at release time. `metamodelVersion` stays `1.0`. + ### Added - **The auth seam is printed in the generated routes handler's JSDoc ([#367]).** Stock @@ -105,6 +161,7 @@ here.** inconsistency, not a policy. Read-only is not public. [#367]: https://github.com/metaobjectsdev/metaobjects/issues/367 +[#368]: https://github.com/metaobjectsdev/metaobjects/issues/368 ## [1.0.3] — 2026-09-12 diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 48f32e008..de6416012 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -25,7 +25,7 @@ regenerate with `ls -d fixtures//*/ | wc -l`. | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 325 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 329 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`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 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`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. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (325) +### `fixtures/conformance/` — metamodel loader + canonical serializer (329) | Fixture prefix | Feature doc | |---|---| @@ -271,7 +271,7 @@ Phase 1a is TypeScript + Python only; those three ports arrive in Phase 2. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 325 + yaml 16 + verify 31 +The fixtures in the nine corpora mapped above (metamodel 329 + yaml 16 + verify 31 + render 15 + persistence 33 + api-contract 41 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/features/relationships.md b/docs/features/relationships.md index cf8ccd309..b4ee52e8a 100644 --- a/docs/features/relationships.md +++ b/docs/features/relationships.md @@ -163,6 +163,83 @@ is valid in a `passthrough` and rejected in an `aggregate`. Inverse navigation FK has no inverse edge. Explicit `@via` resolves either kind; single-hop-unique inference stays relationship-only. +## When one entity has two references to the same target + +An entity may legitimately declare more than one `identity.reference` onto the same +target — a `Match` entity with both `alphaRef` and `betaRef` pointing at `Team`. A +`@cardinality: one` relationship names only the target, via `@objectRef`; it does not +say which reference it means. With two candidates, the target alone is not enough to +pick the FK ([#368](https://github.com/metaobjectsdev/metaobjects/issues/368)). + +Resolution follows a ladder, checked in order: + +1. **Exactly one candidate** `identity.reference` targeting the relationship's + `@objectRef` → that one. This is the common case (one reference per target) and + needs no extra authoring. +2. **`@sourceRefField` declared** on the relationship → the candidate whose first FK + field it names. This is a short-circuit: a declared value that names no candidate's + FK field is a load error regardless of how many candidates exist — it never falls + through to name-pairing. +3. **Exactly one candidate name-pairs with the relationship** → that one. A candidate + pairs when its own name or its FK field — lowercased, with one trailing suffix from + `reference` / `ref` / `id` / `key` optionally stripped — equals the relationship's + name (lowercased, never stripped). A relationship named `awayTeam` pairs with a + reference named `awayTeamRef` or with one whose FK field is `awayTeamId`, with no + extra authoring. +4. **Otherwise** → `ERR_INVALID_RELATIONSHIP` at load, naming every candidate. Fix it + by declaring `@sourceRefField` with the FK field this relationship means, or by + naming the relationship so it pairs with exactly one candidate. + +```yaml +# Match declares TWO references onto Team: alphaRef (alphaFk) and betaRef (betaFk). +# "winner" pairs with neither name, so it must be disambiguated explicitly. +- relationship.association: + name: winner + objectRef: Team + cardinality: one + sourceRefField: alphaFk # picks alphaRef; drop this and the load fails, + # naming alphaRef(alphaFk) and betaRef(betaFk) +- relationship.association: + name: loser + objectRef: Team + cardinality: one + sourceRefField: betaFk +``` + +**Limitations, documented rather than fixed:** + +- The ladder matches a candidate's **first** FK field only, so two composite + references sharing a first column are indistinguishable from each other — and this + does **not** refuse. Rule (e) accepts a `@sourceRefField` that matches *any* + candidate's first column (`candidates.some(c => c.fields[0] === declared)`), and the + ladder's `.find()` then returns the **first** such candidate. The model loads clean + and resolves to whichever composite reference is declared first, which may not be the + one meant. Where an error *is* raised, it still renders each candidate's full field + tuple (`name(fieldA, fieldB)`) so the collision is at least visible. +- The load-time gate covers `@cardinality: one` relationships only. A + `many`-cardinality relationship, and a bare `identity.reference` pair with no + relationship wrapper at all, reach codegen unvalidated — an ambiguous reference set + in either shape is not caught at load. +- The load-time gate covers a `@cardinality: one` relationship only when its **holder + declares at least one `identity.reference` at the target**. Rule (e)'s + `candidates.length <= 1` skip (`validation-passes.ts`, and its three ports) skips + **zero** as well as one, so an inverted shape — the relationship on one entity, both + FKs on the far side — loads clean, and codegen then **silently drops the relation**: + no join, no error, no diagnostic. +- A projection's `@via` hop (above) resolves the identical ambiguity for the hop it + names, but `origin.first`'s own `@via` is never consulted for its base↔child + correlation — an ambiguous target there has no `@via`-based fix; the only escape is + removing the second reference. +- `@via` and `@sourceRefField` are different mechanisms on different node types: + `@via` lives on `origin.*` and names a projection join hop; `@sourceRefField` lives + on `relationship.*` and names an FK field. A projection ambiguity error points you at + `@via`; a relationship ambiguity error points you at `@sourceRefField` — don't reach + for one to fix the other. + +See [ADR-0029](../../spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) +Amendment 1 for the full ladder specification, including why suffix-stripping applies +to candidates only. + ## What each port generates ### TypeScript @@ -286,6 +363,10 @@ The following conformance fixtures gate this feature's behavior across ports: - [`fixtures/conformance/source-rdb-referential-actions/`](../../fixtures/conformance/source-rdb-referential-actions/) — `@onDelete` / `@onUpdate` on relationships - [`fixtures/conformance/identity-reference-referential-actions/`](../../fixtures/conformance/identity-reference-referential-actions/) — the parent-side `@cardinality: many` composition (subtype-default cascade) + `@onDelete` / `@onUpdate` declared directly on `identity.reference` (ADR-0047) - [`fixtures/conformance/error-unknown-relationship-subtype/`](../../fixtures/conformance/error-unknown-relationship-subtype/) — unknown `relationship.` rejected +- [`fixtures/conformance/relationship-one-two-refs-sourcerefield/`](../../fixtures/conformance/relationship-one-two-refs-sourcerefield/) — two `identity.reference` nodes onto the same target, disambiguated by `@sourceRefField` (ladder stage 2) +- [`fixtures/conformance/relationship-one-two-refs-name-pairing/`](../../fixtures/conformance/relationship-one-two-refs-name-pairing/) — the same shape resolved by name-pairing alone (ladder stage 3) +- [`fixtures/conformance/error-relationship-one-refs-ambiguous/`](../../fixtures/conformance/error-relationship-one-refs-ambiguous/) — neither `@sourceRefField` nor a pairing name given: `ERR_INVALID_RELATIONSHIP` at load (#368, ADR-0029 Amendment 1) +- [`fixtures/conformance/relationship-one-two-refs-dotted-references/`](../../fixtures/conformance/relationship-one-two-refs-dotted-references/) — the same two-reference shape with `@references` in the dotted `Entity.field` form: the entity half is the segment before the first `.`, so the ladder resolves identically to the bare form Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these via their respective conformance runners. See [`docs/CONFORMANCE.md`](../CONFORMANCE.md) diff --git a/docs/superpowers/plans/2026-09-13-issue-368-association-reference-disambiguation.md b/docs/superpowers/plans/2026-09-13-issue-368-association-reference-disambiguation.md new file mode 100644 index 000000000..c5f182ccf --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-issue-368-association-reference-disambiguation.md @@ -0,0 +1,1337 @@ +# Issue #368 — Association→Reference Disambiguation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When an entity declares two or more `identity.reference` nodes targeting the same entity, each `relationship.*` with `@cardinality: one` must resolve to its *own* reference — explicitly via `@sourceRefField`, implicitly by name pairing, or fail to load naming the candidates — instead of silently taking the first match. + +**Architecture:** One shared resolution helper in `@metaobjectsdev/metadata` implements a three-stage ladder (unique-candidate → `@sourceRefField` → name pairing). A new loader validation rule makes an unresolvable model a **load error** in all four ports, so `meta verify` catches it (ADR-0029 §5: "introducing a second path later is a load error naming the candidates"). The TypeScript consumers that currently do their own first-match lookup are re-pointed at the helper. + +**Tech Stack:** TypeScript (Bun test runner), Java (JUnit/Maven), C# (xUnit/dotnet), Python (pytest); cross-port fixtures in `fixtures/conformance/`. + +**Spec:** GitHub issue [#368](https://github.com/metaobjectsdev/metaobjects/issues/368) plus the design rulings recorded in "Design decisions" below. + +--- + +## Global Constraints + +- **No new metamodel vocabulary.** `@sourceRefField` is already registered on all four `relationship.*` subtypes in `fixtures/registry-conformance/expected-registry.json`. The attr *set* must not change, so `metamodelVersion` stays `"1.0"` (currently frozen). Task 9 asserts this. +- **ADR-0023 (strict provenance):** no invented attributes. Anything the loader accepts must already come from a registered provider. +- **ADR-0029 §5:** ambiguity is a **load error naming the candidates**, not a codegen-time guess and not silent inference beyond the specified rule. +- **ADR-0037 §65:** no same-name-different-meaning attrs. `@via` is reserved to `origin.*` and must NOT be used here. +- **ADR-0039 (own-accessor discipline):** use *resolving* accessors (`attr()`, `children()`, `referenceIdentities()`) everywhere. The one exception is validation iterating `ownChildren()` to validate the declaring entity — matching the existing rule-(d) loop. +- **Public repo hygiene:** no private project names, no absolute home paths in any committed file, including fixtures and commit messages. +- **TDD:** every task writes a failing test first and runs it to confirm the failure before implementing. +- **Error code:** `ERR_INVALID_RELATIONSHIP` (already defined at `server/typescript/packages/metadata/src/errors.ts:120`). + +--- + +## Design decisions + +These were settled before planning; executors must not re-litigate them. + +**1. Why not derive it?** The FK *is* derived whenever exactly one `identity.reference` targets the relationship's `@objectRef` — that is today's behaviour and it stays. When two references target the same entity, `homeTeam` and `awayTeam` are *identical declarations* (`{@objectRef: "Team", @cardinality: "one"}`); the only distinguishing signal is the node's own name. Declaration-order pairing was rejected: it is the `views()[0]` failure of #356, and `meta fmt` (#304) would silently re-pair every join on a format run. + +**2. Why `@sourceRefField` and not a new attr?** It is already registered on `relationship.association`/`aggregation`/`composition`/`base`, and on a `@cardinality: one` relationship it is currently a hard error ("sets @sourceRefField but is not a M:N relationship", `validation-passes.ts:2011`). Giving it meaning there is **strictly additive** — input that never had a valid meaning gains one — which is the `docs/compatibility-policy.md` correction bar, not a breaking change. + +**3. The name-pairing rule (normative).** It is cross-port conformance surface, so it is specified exactly: + +> Let `A` be the relationship's name. For each candidate reference `R` with first FK field `F`, build the **pairing key set**: +> `{ lower(R.name), strip(lower(R.name)), lower(F), strip(lower(F)) }` +> where `strip(s)` removes **one** trailing suffix from the ordered list `["reference", "ref", "id", "key"]` (first match wins; returns `s` unchanged if none match, and never returns the empty string — if stripping would empty the string, `s` is returned unchanged). +> `R` **name-matches** `A` iff `lower(A)` is in that set. +> The rule resolves **only** when exactly one candidate name-matches. Zero or two-or-more → error. + +Suffixes are stripped from the *candidate* side only, never from `A`. This is deliberate: stripping `A` would let `valid` → `val` falsely pair with a `valRef`. Failing closed is correct — the rule may only ever *select*, never guess. + +**4. Where the error surfaces.** Load/validation time in all four ports, so `meta verify` reports it. The issue's core complaint was that `meta verify` was clean while the output was wrong. + +**5. Ports.** *Validation* is cross-port (TS/Java/C#/Python — Kotlin uses the Java metadata layer). *Resolution* is TypeScript-only, because only TS emits a forward `one()` navigation from the association node; Java/C#/Python enumerate references individually for their ADR-0038 finders. Task 8 verifies that claim rather than assuming it. + +--- + +## File Structure + +**Created:** +- `server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts` — the resolution ladder + candidate enumeration. Single responsibility: given an entity and a 1:N relationship, say which `identity.reference` it navigates through. +- `server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts` — unit tests for the ladder and the pairing rule. +- `fixtures/conformance/relationship-one-two-refs-sourcerefield/` — positive fixture, explicit disambiguation. +- `fixtures/conformance/relationship-one-two-refs-name-pairing/` — positive fixture, implicit pairing. +- `fixtures/conformance/error-relationship-one-refs-ambiguous/` — error envelope. + +**Modified:** +- `server/typescript/packages/metadata/src/index.ts:117` — export the new helper. +- `server/typescript/packages/metadata/src/loader/validation-passes.ts:1994-2024` — widen rule (d); add rule (e). +- `server/typescript/packages/codegen-ts/src/relation-resolver.ts:95-101` — use the helper. +- `server/typescript/packages/runtime-ts/src/relation-resolver.ts:32-50` — use the helper. +- `server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts:760,1093` — ambiguity-aware lookup. +- `server/typescript/packages/metadata/src/core/relationship/find-reference.ts` — add plural `findReferencesBetween`. +- `server/typescript/packages/docs-site/src/link-graph.ts:105` — render all edges, not the first. +- `server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java:1741-1765` +- `server/csharp/MetaObjects/Loader/ValidationPasses.cs:3141-3160` +- `server/python/src/metaobjects/loader/validation_passes.py:2650-2670` +- `CHANGELOG.md`, `spec/decisions/ADR-0029-…md` (amendment note) + +--- + +## Task 1: The resolution helper + +**Files:** +- Create: `server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts` +- Test: `server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts` +- Modify: `server/typescript/packages/metadata/src/index.ts` (add exports after line 118) + +**Interfaces:** +- Consumes: `MetaObject.referenceIdentities()`, `MetaReferenceIdentity.{name, fields, targetEntity}`, `stripPackage` from `../../naming.js`. +- Produces: + - `referenceCandidatesFor(holder: MetaObject, targetEntity: string): MetaReferenceIdentity[]` + - `resolveRelationshipReference(holder: MetaObject, relationshipName: string, targetEntity: string, sourceRefField?: string): MetaReferenceIdentity | undefined` + - `referencePairingKeys(ref: MetaReferenceIdentity): Set` + +- [ ] **Step 1: Write the failing test** + +```ts +// server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader } from "../src/loader/loader.js"; +import { + referenceCandidatesFor, + resolveRelationshipReference, +} from "../src/core/relationship/resolve-relationship-reference.js"; +import type { MetaObject } from "../src/core/object/meta-object.js"; + +const MATCH_MODEL = { + "metadata.root": { + package: "repro", + children: [ + { + "object.entity": { + name: "Team", + children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ], + }, + }, + { + "object.entity": { + name: "Match", + children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }, + ], + }, + }, + ], + }, +}; + +function loadMatch(): MetaObject { + const root = new MetaDataLoader().loadFromObject(MATCH_MODEL, "meta.repro.json"); + return root.findObject("Match")! as MetaObject; +} + +describe("resolveRelationshipReference", () => { + test("enumerates every candidate reference for the target", () => { + const candidates = referenceCandidatesFor(loadMatch(), "Team"); + expect(candidates.map((r) => r.name)).toEqual(["homeTeamRef", "awayTeamRef"]); + }); + + test("name pairing resolves each association to its own reference", () => { + const match = loadMatch(); + expect(resolveRelationshipReference(match, "homeTeam", "Team")?.name).toBe("homeTeamRef"); + expect(resolveRelationshipReference(match, "awayTeam", "Team")?.name).toBe("awayTeamRef"); + }); + + test("@sourceRefField wins over name pairing", () => { + const match = loadMatch(); + expect( + resolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")?.name, + ).toBe("awayTeamRef"); + }); + + test("a single candidate resolves regardless of name", () => { + const root = new MetaDataLoader().loadFromObject( + { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "winnerFk" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "anythingAtAll", "@fields": ["winnerFk"], "@references": "Team" } }, + { "relationship.association": { name: "champion", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }, + "meta.repro.json", + ); + const match = root.findObject("Match")! as MetaObject; + expect(resolveRelationshipReference(match, "champion", "Team")?.name).toBe("anythingAtAll"); + }); + + test("unpairable names return undefined rather than guessing", () => { + const root = new MetaDataLoader().loadFromObject( + { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "alphaFk" } }, + { "field.int": { name: "betaFk" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } }, + { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } }, + { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }, + "meta.repro.json", + ); + const match = root.findObject("Match")! as MetaObject; + expect(resolveRelationshipReference(match, "winner", "Team")).toBeUndefined(); + }); + + test("suffix stripping never applies to the relationship name", () => { + // "valid" must NOT be stripped to "val" and pair with valRef. + const root = new MetaDataLoader().loadFromObject( + { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "valFk" } }, + { "field.int": { name: "otherFk" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "valRef", "@fields": ["valFk"], "@references": "Team" } }, + { "identity.reference": { name: "otherRef", "@fields": ["otherFk"], "@references": "Team" } }, + { "relationship.association": { name: "valid", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }, + "meta.repro.json", + ); + const match = root.findObject("Match")! as MetaObject; + expect(resolveRelationshipReference(match, "valid", "Team")).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server/typescript/packages/metadata && bun test test/resolve-relationship-reference.test.ts` +Expected: FAIL — `Cannot find module '../src/core/relationship/resolve-relationship-reference.js'` + +> If `loadFromObject` is not the loader's in-memory entry point, find the one the neighbouring tests in `server/typescript/packages/metadata/test/` use (grep for `new MetaDataLoader()`), and use that in every test in this plan. Do not invent a loader API. + +- [ ] **Step 3: Write the implementation** + +```ts +// server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts +// Association -> identity.reference resolution (issue #368). +// +// An entity may declare more than one identity.reference onto the SAME target +// entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A +// `@cardinality: one` relationship names only its target, so when two +// references match, the target alone cannot say which FK the navigation uses. +// Taking the first match emits a join on the wrong column that typechecks, has +// correct DDL and passes verify — so the ladder below resolves it explicitly or +// not at all. ADR-0029 §5: ambiguity is a load error naming the candidates. + +import type { MetaObject } from "../object/meta-object.js"; +import type { MetaReferenceIdentity } from "../identity/meta-identity.js"; +import { stripPackage } from "../../naming.js"; + +/** + * Trailing suffixes stripped from a CANDIDATE's name/FK field when building its + * pairing keys. Ordered — first match wins, so "reference" is tested before + * "ref". Never applied to the relationship name (see referencePairingKeys). + */ +const PAIRING_SUFFIXES = ["reference", "ref", "id", "key"] as const; + +function stripOneSuffix(value: string): string { + for (const suffix of PAIRING_SUFFIXES) { + if (value.length > suffix.length && value.endsWith(suffix)) { + return value.slice(0, value.length - suffix.length); + } + } + return value; +} + +/** The FK field a reference is anchored on (first field; composite FKs pair on their first column). */ +function refFkField(ref: MetaReferenceIdentity): string | undefined { + return ref.fields.length > 0 ? ref.fields[0] : undefined; +} + +/** + * The set of lowercased names a candidate reference answers to: its own name + * and its FK field, each with and without one stripped suffix. + */ +export function referencePairingKeys(ref: MetaReferenceIdentity): Set { + const keys = new Set(); + const add = (value: string | undefined): void => { + if (!value) return; + const lower = value.toLowerCase(); + keys.add(lower); + keys.add(stripOneSuffix(lower)); + }; + add(ref.name); + add(refFkField(ref)); + return keys; +} + +/** + * Every identity.reference on `holder` whose @references targets `targetEntity`. + * Package-insensitive on both sides: @references and @objectRef may each be bare + * or fully qualified. + */ +export function referenceCandidatesFor( + holder: MetaObject, + targetEntity: string, +): MetaReferenceIdentity[] { + const target = stripPackage(targetEntity); + // ADR-0039: resolving — referenceIdentities() honors references inherited via extends. + return holder + .referenceIdentities() + .filter((ref) => stripPackage(ref.targetEntity ?? "") === target) + .filter((ref) => refFkField(ref) !== undefined); +} + +/** + * Which identity.reference does this `@cardinality: one` relationship navigate + * through? The ladder, in order: + * + * 1. exactly one candidate -> that one (the common case; unchanged behaviour) + * 2. `@sourceRefField` declared -> the candidate whose FK field it names + * 3. exactly one candidate name-pairs -> that one + * 4. otherwise -> undefined (caller reports the ambiguity) + * + * Returns undefined for "no candidate" and "cannot choose" alike; callers that + * need to tell them apart use referenceCandidatesFor(). + */ +export function resolveRelationshipReference( + holder: MetaObject, + relationshipName: string, + targetEntity: string, + sourceRefField?: string, +): MetaReferenceIdentity | undefined { + const candidates = referenceCandidatesFor(holder, targetEntity); + if (candidates.length === 0) return undefined; + if (candidates.length === 1) return candidates[0]; + + if (sourceRefField !== undefined && sourceRefField !== "") { + return candidates.find((ref) => refFkField(ref) === sourceRefField); + } + + const wanted = relationshipName.toLowerCase(); + const paired = candidates.filter((ref) => referencePairingKeys(ref).has(wanted)); + return paired.length === 1 ? paired[0] : undefined; +} +``` + +- [ ] **Step 4: Export from the package barrel** + +In `server/typescript/packages/metadata/src/index.ts`, immediately after line 118 (`export type { ReferenceLookup } …`): + +```ts +export { + referenceCandidatesFor, + referencePairingKeys, + resolveRelationshipReference, +} from "./core/relationship/resolve-relationship-reference.js"; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd server/typescript/packages/metadata && bun test test/resolve-relationship-reference.test.ts` +Expected: PASS — 6 tests + +- [ ] **Step 6: Typecheck** + +Run: `cd server/typescript/packages/metadata && bun run build && bun run typecheck` +Expected: exit 0 + +- [ ] **Step 7: Commit** + +```bash +git add server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts \ + server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts \ + server/typescript/packages/metadata/src/index.ts +git commit -m "feat(metadata): association->reference resolution ladder (#368)" +``` + +--- + +## Task 2: Widen rule (d) — `@sourceRefField` legal on `@cardinality: one` + +**Files:** +- Modify: `server/typescript/packages/metadata/src/loader/validation-passes.ts:2010-2016` +- Test: `server/typescript/packages/metadata/test/relationship-m2m-validation.test.ts` (append; if that file does not exist, grep `test/` for the file asserting "is not a M:N relationship" and append there) + +**Interfaces:** +- Consumes: `RELATIONSHIP_ATTR_SOURCE_REF_FIELD`, `CARDINALITY_ONE` (already imported in this file). +- Produces: no new exports. Behavioural change only: `@sourceRefField` + `@cardinality: one` loads clean; `@sourceRefField` with any other non-M:N cardinality still errors. + +- [ ] **Step 1: Write the failing test** + +```ts +test("@sourceRefField is legal on a cardinality:one relationship (#368)", () => { + const result = loadExpectingErrors({ + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } }, + ] } }, + ], + }, + }); + expect(result.errors).toEqual([]); +}); + +test("@sourceRefField on a non-M:N, non-one relationship still errors", () => { + const result = loadExpectingErrors({ + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "relationship.association": { name: "teams", "@objectRef": "Team", "@cardinality": "many", "@sourceRefField": "whatever" } }, + ] } }, + ], + }, + }); + expect(result.errors.map((e) => e.code)).toContain("ERR_INVALID_RELATIONSHIP"); +}); +``` + +> Match the surrounding file's loader helper (grep it for `loadExpectingErrors` or equivalent) rather than introducing a new one. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server/typescript/packages/metadata && bun test -t "sourceRefField is legal"` +Expected: FAIL — one `ERR_INVALID_RELATIONSHIP` ("sets @sourceRefField but is not a M:N relationship") per association + +- [ ] **Step 3: Implement** + +In the `if (!isM2M) { … }` block, replace the `if (hasSourceRefField) { … }` clause with: + +```ts + // #368: @sourceRefField also disambiguates a `@cardinality: one` + // relationship when the entity holds more than one identity.reference + // onto the same target. Only the M:N *junction* reading is rejected + // here; rule (e) below checks that it names a real local reference. + if (hasSourceRefField && cardinality !== CARDINALITY_ONE) { + errors.push( + new ParseError( + `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.`, + { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, + ), + ); + } +``` + +Then change the `continue;` that closes the `!isM2M` block so a `cardinality: one` relationship falls through to rule (e) in Task 3 rather than skipping validation. The simplest shape that preserves existing behaviour: keep `continue;` for now and add rule (e) as its own loop in Task 3. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd server/typescript/packages/metadata && bun test test/relationship-m2m-validation.test.ts` +Expected: PASS, including every pre-existing test in the file + +- [ ] **Step 5: Commit** + +```bash +git add server/typescript/packages/metadata/src/loader/validation-passes.ts \ + server/typescript/packages/metadata/test/relationship-m2m-validation.test.ts +git commit -m "feat(loader): @sourceRefField is legal on cardinality:one (#368)" +``` + +--- + +## Task 3: Rule (e) — unresolvable 1:N reference is a load error (TypeScript) + +**Files:** +- Modify: `server/typescript/packages/metadata/src/loader/validation-passes.ts` (new pass, placed immediately after the M:N slim-vocabulary pass) +- Test: same file as Task 2 + +**Interfaces:** +- Consumes: `referenceCandidatesFor`, `resolveRelationshipReference` from Task 1. +- Produces: `ERR_INVALID_RELATIONSHIP` whose message names the entity, the relationship, and every candidate as `name(fkField)`. + +- [ ] **Step 1: Write the failing test** + +```ts +test("two references to one target with an unpairable relationship name is a load error (#368)", () => { + const result = loadExpectingErrors({ + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "alphaFk" } }, + { "field.int": { name: "betaFk" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } }, + { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } }, + { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }); + expect(result.errors.map((e) => e.code)).toContain("ERR_INVALID_RELATIONSHIP"); + const message = result.errors.map((e) => e.message).join("\n"); + expect(message).toContain("Match.winner"); + expect(message).toContain("alphaRef(alphaFk)"); + expect(message).toContain("betaRef(betaFk)"); +}); + +test("the issue #368 repro loads clean via name pairing", () => { + const result = loadExpectingErrors({ + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }); + expect(result.errors).toEqual([]); +}); + +test("@sourceRefField naming no local reference is a load error", () => { + const result = loadExpectingErrors({ + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "nonesuch" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } }, + ] } }, + ], + }, + }); + expect(result.errors.map((e) => e.code)).toContain("ERR_INVALID_RELATIONSHIP"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server/typescript/packages/metadata && bun test -t "#368"` +Expected: FAIL — `result.errors` is empty for the ambiguous cases (that is the bug) + +- [ ] **Step 3: Implement rule (e)** + +Add this pass alongside the M:N pass in `validation-passes.ts`, and register it wherever the M:N pass is registered (grep for the function name of the pass containing rule (d) and mirror its registration): + +```ts +// Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly one +// identity.reference. Two references onto the same target are indistinguishable +// from the relationship's @objectRef alone, so the resolver would silently emit +// the first one's FK column. ADR-0029 §5: a second path is a load error naming +// the candidates. +function validateOneSideReferenceResolution(root: MetaRoot): ParseError[] { + const errors: ParseError[] = []; + for (const obj of root.objects()) { + // ADR-0039: own — a relationship is validated on the entity that DECLARES it. + for (const rel of obj.ownChildren().filter((c) => c.type === TYPE_RELATIONSHIP)) { + // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends. + if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_ONE) continue; + const objectRef = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF); + if (typeof objectRef !== "string" || objectRef === "") continue; + + const candidates = referenceCandidatesFor(obj as MetaObject, objectRef); + if (candidates.length <= 1) continue; + + const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); + const declared = typeof sourceRefField === "string" && sourceRefField !== "" + ? sourceRefField + : undefined; + const resolved = resolveRelationshipReference( + obj as MetaObject, rel.name, objectRef, declared, + ); + if (resolved) continue; + + const listed = candidates + .map((c) => `${c.name}(${c.fields[0]})`) + .join(", "); + errors.push( + new ParseError( + declared !== undefined + ? `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} ` + + `"${declared}", which names no identity.reference targeting "${objectRef}". ` + + `Candidates: ${listed}.` + : `relationship "${obj.name}.${rel.name}" is ambiguous: "${obj.name}" declares ` + + `${candidates.length} identity.reference nodes targeting "${objectRef}" and the ` + + `relationship name does not pair with exactly one. Candidates: ${listed}. ` + + `Set @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this relationship navigates.`, + { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, + ), + ); + } + } + return errors; +} +``` + +Add the imports at the top of `validation-passes.ts`: + +```ts +import { + referenceCandidatesFor, + resolveRelationshipReference, +} from "../core/relationship/resolve-relationship-reference.js"; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd server/typescript/packages/metadata && bun test` +Expected: PASS — the whole metadata suite, not just the new tests + +- [ ] **Step 5: Commit** + +```bash +git add server/typescript/packages/metadata/src/loader/validation-passes.ts \ + server/typescript/packages/metadata/test/relationship-m2m-validation.test.ts +git commit -m "feat(loader): ambiguous 1:N reference is a load error (#368)" +``` + +--- + +## Task 4: codegen-ts relation resolver — the reported defect + +**Files:** +- Modify: `server/typescript/packages/codegen-ts/src/relation-resolver.ts:95-101` +- Test: `server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts` (create) + +**Interfaces:** +- Consumes: `resolveRelationshipReference` from Task 1 (via `@metaobjectsdev/metadata`). +- Produces: `RelationEntry.fkField` now the relationship's *own* FK. + +- [ ] **Step 1: Write the failing test** + +```ts +// server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader } from "@metaobjectsdev/metadata"; +import { buildRelationMap } from "../src/relation-resolver.js"; + +describe("buildRelationMap with two references onto one target (#368)", () => { + test("each association joins on its own FK column", () => { + const root = new MetaDataLoader().loadFromObject( + { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "source.rdb": { "@table": "team" } }, + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "source.rdb": { "@table": "match" } }, + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId", "@column": "home_team_id" } }, + { "field.int": { name: "awayTeamId", "@column": "away_team_id" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }, + "meta.repro.json", + ); + + const relations = buildRelationMap(root).get("Match")!; + const byName = Object.fromEntries(relations.map((r) => [r.name, r.fkField])); + expect(byName.homeTeam).toBe("homeTeamId"); + expect(byName.awayTeam).toBe("awayTeamId"); // was "homeTeamId" — the #368 defect + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server/typescript/packages/codegen-ts && bun test test/relation-resolver-two-refs.test.ts` +Expected: FAIL — `expect(byName.awayTeam).toBe("awayTeamId")` receives `"homeTeamId"` + +- [ ] **Step 3: Implement** + +Replace lines 95-101 of `relation-resolver.ts`: + +```ts + // #368: an entity may hold more than one identity.reference onto the same + // target, so the target alone does not identify the FK. Resolve through the + // shared ladder (unique candidate -> @sourceRefField -> name pairing); the + // loader has already refused anything it cannot resolve, so a miss here + // means an unloadable model reached codegen — skip rather than guess. + // ADR-0039: resolving — @sourceRefField may be inherited via extends. + const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; + const matching = resolveRelationshipReference( + obj, child.name, targetEntity, declaredRefField, + ); + if (!matching) continue; + + const fkField = matching.fields[0]; + if (!fkField) continue; +``` + +Add to the existing import block from `@metaobjectsdev/metadata`: + +```ts + RELATIONSHIP_ATTR_SOURCE_REF_FIELD, + resolveRelationshipReference, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd server/typescript/packages/codegen-ts && bun test` +Expected: PASS — full codegen-ts suite + +- [ ] **Step 5: Commit** + +```bash +git add server/typescript/packages/codegen-ts/src/relation-resolver.ts \ + server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts +git commit -m "fix(codegen-ts): relations() joined every association on the first FK (#368)" +``` + +--- + +## Task 5: runtime-ts relation resolver + +**Files:** +- Modify: `server/typescript/packages/runtime-ts/src/relation-resolver.ts:32-50,80` +- Test: `server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts` (create) + +**Interfaces:** +- Consumes: `resolveRelationshipReference` from Task 1. +- Produces: `RelationDescriptor.sourceField` now the relationship's own FK. + +- [ ] **Step 1: Write the failing test** + +```ts +// server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader } from "@metaobjectsdev/metadata"; +import { resolveRelationDescriptor } from "../src/relation-resolver.js"; + +const MODEL = { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "source.rdb": { "@table": "team" } }, + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "source.rdb": { "@table": "match" } }, + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, +}; + +describe("resolveRelationDescriptor with two references onto one target (#368)", () => { + test("each relation reads its own FK field", () => { + const root = new MetaDataLoader().loadFromObject(MODEL, "meta.repro.json"); + const match = root.findObject("Match")!; + expect(resolveRelationDescriptor(match, "homeTeam", root).sourceField).toBe("homeTeamId"); + expect(resolveRelationDescriptor(match, "awayTeam", root).sourceField).toBe("awayTeamId"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server/typescript/packages/runtime-ts && bun test test/relation-resolver-two-refs.test.ts` +Expected: FAIL — `awayTeam` resolves `sourceField` to `"homeTeamId"` + +- [ ] **Step 3: Implement** + +Replace the body of `findReferenceFkField` (lines 32-50) so it delegates, and pass the relationship through. Change its signature and both call sites: + +```ts +/** + * The FK field a `@cardinality: one` relationship navigates through. + * #368: an entity may declare several identity.reference nodes onto the same + * target, so the target alone is not enough — resolve through the shared ladder. + */ +function findReferenceFkField( + holder: MetaData, + targetName: string, + relationshipName: string, + sourceRefField?: string, +): string | undefined { + const ref = resolveRelationshipReference( + holder as unknown as MetaObject, relationshipName, targetName, sourceRefField, + ); + return ref?.fields[0]; +} +``` + +At the one-side call site (line 80): + +```ts + // ADR-0039: resolving — @sourceRefField may be inherited via extends. + const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; + const fkField = findReferenceFkField(sourceEntity, targetEntityName, child.name, declaredRefField); +``` + +At the many-side call site (line 117), the FK lives on the *other* entity and belongs to that entity's relationship `child`: + +```ts + const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; + const fkField = findReferenceFkField(other, sourceEntity.name, child.name, declaredRefField); +``` + +Add `MetaObject`, `RELATIONSHIP_ATTR_SOURCE_REF_FIELD` and `resolveRelationshipReference` to the `@metaobjectsdev/metadata` import block. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd server/typescript/packages/runtime-ts && bun test` +Expected: PASS — full runtime-ts suite + +- [ ] **Step 5: Commit** + +```bash +git add server/typescript/packages/runtime-ts/src/relation-resolver.ts \ + server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts +git commit -m "fix(runtime-ts): relation traversal read the first FK for every relation (#368)" +``` + +--- + +## Task 6: `findReferenceBetween` ambiguity + docs link graph + +**Files:** +- Modify: `server/typescript/packages/metadata/src/core/relationship/find-reference.ts` +- Modify: `server/typescript/packages/metadata/src/index.ts` (export the plural) +- Modify: `server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts:760,1093` +- Modify: `server/typescript/packages/docs-site/src/link-graph.ts:105` +- Test: `server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts` (create) + +**Interfaces:** +- Produces: `findReferencesBetween(a: MetaObject, b: MetaObject): ReferenceLookup[]` — every match, `a` walked first. `findReferenceBetween` keeps its exact signature and first-match behaviour for compatibility. + +- [ ] **Step 1: Write the failing test** + +```ts +// server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader } from "../src/loader/loader.js"; +import { findReferenceBetween, findReferencesBetween } from "../src/core/relationship/find-reference.js"; +import type { MetaObject } from "../src/core/object/meta-object.js"; + +describe("findReferencesBetween (#368)", () => { + test("returns every reference, not just the first", () => { + const root = new MetaDataLoader().loadFromObject( + { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + ] } }, + ], + }, + }, + "meta.repro.json", + ); + const match = root.findObject("Match")! as MetaObject; + const team = root.findObject("Team")! as MetaObject; + + const all = findReferencesBetween(match, team); + expect(all.map((r) => r.referenceIdentity.name)).toEqual(["homeTeamRef", "awayTeamRef"]); + + // Back-compat: the singular still answers with the first. + expect(findReferenceBetween(match, team)?.referenceIdentity.name).toBe("homeTeamRef"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server/typescript/packages/metadata && bun test test/find-reference-ambiguity.test.ts` +Expected: FAIL — `findReferencesBetween` is not exported + +- [ ] **Step 3: Implement** + +In `find-reference.ts`, add above `findReferenceBetween`: + +```ts +/** + * Every identity.reference on `a` or `b` targeting the other side, `a` walked + * first. #368: two references onto the same entity are legal, so callers that + * must not guess enumerate here and report ambiguity themselves. + */ +export function findReferencesBetween( + a: MetaObject, + b: MetaObject, +): ReferenceLookup[] { + const found: ReferenceLookup[] = []; + for (const [holder, other] of [[a, b], [b, a]] as const) { + for (const ref of holder.referenceIdentities()) { + if (stripPackage(ref.targetEntity) === other.name) { + found.push({ holder, other, referenceIdentity: ref }); + } + } + } + return found; +} +``` + +and rewrite `findReferenceBetween` to delegate, keeping its documented first-match contract: + +```ts +export function findReferenceBetween( + a: MetaObject, + b: MetaObject, +): ReferenceLookup | undefined { + return findReferencesBetween(a, b)[0]; +} +``` + +Export `findReferencesBetween` from `src/index.ts` beside the existing `findReferenceBetween` export (line 117). + +At `extract-view-spec.ts:760` and `:1093`, replace each `findReferenceBetween(...)` call with the plural form and refuse ambiguity instead of silently taking `[0]`: + +```ts + const refs = findReferencesBetween(currentObj as MetaObject, target); + if (refs.length > 1) { + throw new Error( + `projection join from "${(currentObj as MetaObject).name}" to "${target.name}" is ambiguous: ` + + `${refs.map((r) => r.referenceIdentity.name).join(", ")}. ` + + `Declare the hop explicitly with @via.`, + ); + } + const ref = refs[0]; +``` + +At `link-graph.ts:105`, replace the `.find(...)` with a filter so the docs graph draws every edge: + +```ts + const matches = obj.referenceIdentities().filter((r) => stripPackage(r.targetEntity ?? "") === target); +``` + +then emit one edge per match (adapt the surrounding loop; the existing single-`match` body becomes the loop body). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd server/typescript && bun run --filter '*' build && bun test` +Expected: PASS across packages + +- [ ] **Step 5: Commit** + +```bash +git add server/typescript/packages/metadata/src/core/relationship/find-reference.ts \ + server/typescript/packages/metadata/src/index.ts \ + server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts \ + server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts \ + server/typescript/packages/docs-site/src/link-graph.ts +git commit -m "fix(metadata,codegen-ts,docs): enumerate references instead of taking the first (#368)" +``` + +--- + +## Task 7: Cross-port validation parity (Java, C#, Python) + +Each port gets the *same two changes* as Tasks 2+3: widen the `@sourceRefField` rejection to spare `@cardinality: one`, and add rule (e). Port the ladder from Task 1 verbatim — same suffix list, same order, same "candidate side only" stripping. + +**Files:** +- Modify: `server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java:1741-1765` +- Create: `server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java` +- Modify: `server/csharp/MetaObjects/Loader/ValidationPasses.cs:3141-3160` +- Create: `server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs` +- Modify: `server/python/src/metaobjects/loader/validation_passes.py:2650-2670` +- Create: `server/python/src/metaobjects/meta/core/relationship/relationship_references.py` +- Test: one test per port, mirroring Task 3's three cases (ambiguous → error; repro → clean; bad `@sourceRefField` → error). Place them beside each port's existing M:N validation tests — Java `server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java` is the model. + +**Interfaces:** +- Produces, in each port, the equivalents of `referenceCandidatesFor` and `resolveRelationshipReference` with identical semantics. Message text must match the TS wording so the conformance error envelopes agree on `code` (envelopes assert `code` + `source`, not message text — but keep them aligned anyway). + +- [ ] **Step 1: Write the failing test in each port** + +Mirror Task 3's three test cases. Use each port's existing loader-error test helper; do not introduce a new one. + +- [ ] **Step 2: Run them to verify they fail** + +```bash +cd server/java && mvn -q -pl metadata test -Dtest=M2MSlimVocabularyTest +cd server/csharp && dotnet test --filter FullyQualifiedName~Relationship +cd server/python && python -m pytest tests/ -k "relationship and (ambiguous or sourceref)" -v +``` +Expected: FAIL — no error is raised for the ambiguous model + +- [ ] **Step 3: Implement the ladder + rule (e) in each port** + +Port `resolve-relationship-reference.ts` (Task 1, Step 3) to each language, keeping `PAIRING_SUFFIXES = ["reference", "ref", "id", "key"]` in that order and stripping only on the candidate side. Then apply the Task 2 widening and add the Task 3 pass. + +Python note (ADR-0039 naming inversion): Python's `attr()` is OWN — use `get_meta_attr()` for resolving reads, matching the surrounding code in `validation_passes.py`. + +- [ ] **Step 4: Run the per-port suites to verify they pass** + +```bash +cd server/java && mvn -q -pl metadata test +cd server/csharp && dotnet test +cd server/python && python -m pytest tests/ -q +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/java server/csharp server/python +git commit -m "feat(java,csharp,python): 1:N reference resolution parity (#368)" +``` + +--- + +## Task 8: Verify no other port resolves forward 1:N by target alone + +**Files:** none modified unless the audit finds a defect. + +- [ ] **Step 1: Audit each port's forward-relation emit** + +```bash +cd +grep -rn "referenceIdentities\|reference_identities\|ReferenceIdentities" \ + --include=*.java --include=*.cs --include=*.py server/ | grep -v test +``` + +For every hit that resolves a FK from a *relationship* (as opposed to enumerating references for ADR-0038 finders), check whether it filters by target only and takes the first. `server/python/src/metaobjects/codegen/generators/router_generator.py` (`reverse_fks_for`) and `server/java/.../M2MFields.java` were already reviewed during planning and are **correct** — they enumerate each reference individually. + +- [ ] **Step 2: Record the finding** + +If every port is clean, add one line to the PR description saying so. If a port is not clean, fix it with the same ladder and a test, in its own commit. + +- [ ] **Step 3: Commit (only if a fix was needed)** + +```bash +git commit -m "fix(): forward 1:N reference resolution (#368)" +``` + +--- + +## Task 9: Conformance fixtures + registry invariant + +**Files:** +- Create: `fixtures/conformance/relationship-one-two-refs-sourcerefield/{input/meta.sport.json,expected.json}` +- Create: `fixtures/conformance/relationship-one-two-refs-name-pairing/{input/meta.sport.json,expected.json}` +- Create: `fixtures/conformance/error-relationship-one-refs-ambiguous/{input/meta.sport.json,expected-errors.json}` + +**Interfaces:** +- Consumes: the fixture format at `fixtures/conformance/error-relationship-symmetric-and-sourceref/` (error) and `fixtures/conformance/relationship-m2m-hetero/` (positive). + +- [ ] **Step 1: Write the error fixture** + +`fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json`: + +```json +{ + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "alphaFk" } }, + { "field.long": { "name": "betaFk" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team" } }, + { "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team" } }, + { "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team" } } + ] + } + } + ] + } +} +``` + +`expected-errors.json`: + +```json +{ + "errors": [ + { + "code": "ERR_INVALID_RELATIONSHIP", + "source": { + "format": "json", + "files": ["meta.sport.json"], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[6]['relationship.association']" + } + } + ], + "warnings": [] +} +``` + +- [ ] **Step 2: Write the two positive fixtures** + +Same two entities. For `relationship-one-two-refs-sourcerefield`, `Match` declares both references plus two associations carrying `"@sourceRefField": "alphaFk"` and `"@sourceRefField": "betaFk"`. For `relationship-one-two-refs-name-pairing`, use the issue's `homeTeamId`/`awayTeamId` + `homeTeamRef`/`awayTeamRef` + `homeTeam`/`awayTeam` shape with no `@sourceRefField`. Generate each `expected.json` the way the other positive fixtures do — run the TS conformance runner in update mode if one exists (`grep -rn "update\|--write" server/typescript/packages/metadata/test/conformance*`), otherwise hand-write it to match the canonical serializer output of a neighbouring fixture. + +- [ ] **Step 3: Run the conformance corpus in every port** + +```bash +cd server/typescript && bun test -t conformance +cd server/java && mvn -q -pl metadata test -Dtest=*Conformance* +cd server/csharp && dotnet test --filter FullyQualifiedName~Conformance +cd server/python && python -m pytest tests/conformance -q +``` +Expected: PASS in all four + +- [ ] **Step 4: Assert the registry did NOT change** + +```bash +git diff --exit-code fixtures/registry-conformance/expected-registry.json +``` +Expected: exit 0, no output. **If this prints a diff, stop** — the change has grown a vocabulary cost it was designed to avoid, and that needs a ruling before going further. + +- [ ] **Step 5: Commit** + +```bash +git add fixtures/conformance/relationship-one-two-refs-sourcerefield \ + fixtures/conformance/relationship-one-two-refs-name-pairing \ + fixtures/conformance/error-relationship-one-refs-ambiguous +git commit -m "test(conformance): 1:N reference disambiguation fixtures (#368)" +``` + +--- + +## Task 10: Documentation + +**Files:** +- Modify: `CHANGELOG.md` +- Modify: `spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md` (amendment) +- Modify: `CLAUDE.md:526` (the relationship-subtypes bullet) +- Modify: `docs/features/` — whichever page documents relationships (grep for `@sourceRefField`) + +- [ ] **Step 1: Add the ADR-0029 amendment** + +Append to ADR-0029: + +```markdown +## Amendment 1 (2026-09-13) — the ambiguity rule extends to 1:N FK selection + +§5's contract ("a second path is a load error naming the candidates") was stated +for `@via` on `origin.*`. Issue #368 found the same ambiguity one level down: two +`identity.reference` nodes onto the same target make a `@cardinality: one` +relationship's FK unresolvable from `@objectRef` alone, and the TypeScript +resolvers silently took the first. + +The rule now also covers 1:N FK selection, resolved by this ladder: + +1. exactly one candidate reference → that one; +2. `@sourceRefField` declared → the candidate whose FK field it names; +3. exactly one candidate name-pairs with the relationship name → that one; +4. otherwise → `ERR_INVALID_RELATIONSHIP` naming every candidate. + +Stage 3 is a **local string rule**, not a graph walk: a candidate's pairing keys +are its own name and its FK field, each lowercased with and without one trailing +suffix from `["reference", "ref", "id", "key"]`. Suffixes are stripped from the +candidate side only. This keeps §5's "trivially portable" bar — it is pure string +comparison over one entity's own children, not the multi-hop path inference §5 +declined to attempt. + +No new vocabulary: `@sourceRefField` was already registered on every +`relationship.*` subtype, and on `@cardinality: one` it previously failed to +load, so giving it meaning is additive under the `docs/compatibility-policy.md` +correction bar. `metamodelVersion` is unchanged. +``` + +- [ ] **Step 2: Update `CLAUDE.md:526`** + +Extend the `@sourceRefField` sentence in the relationship-subtypes bullet: + +``` +`@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 names which of several `identity.reference` nodes onto the same target this relationship navigates (#368, ADR-0029 Amendment 1) — an unresolvable 1:N reference is `ERR_INVALID_RELATIONSHIP` at load. +``` + +- [ ] **Step 3: Add the CHANGELOG entry** + +Under the unreleased heading, in the style of the surrounding entries: + +```markdown +### Fixed +- **Two `identity.reference` nodes onto the same entity no longer make every + `@cardinality: one` relationship join the first one's FK column** (#368). The + TypeScript codegen `relations()` block, the runtime relation traversal, the + projection join lookup and the docs link graph each took the first + target-matching reference, so a second association emitted a wrong-column join + that compiled, typechecked, produced correct DDL and passed `meta verify` — + surfacing only as wrong rows. Resolution is now explicit: unique candidate → + `@sourceRefField` → name pairing → `ERR_INVALID_RELATIONSHIP` at load, in all + four ports. `@sourceRefField` is now legal on `@cardinality: one` (it + previously failed to load there). No vocabulary change; `metamodelVersion` + unchanged. +``` + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md CLAUDE.md spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md docs/features +git commit -m "docs: ADR-0029 Amendment 1 + changelog for #368" +``` + +--- + +## Task 11: End-to-end verification against the issue's repro + +- [ ] **Step 1: Build the whole TS workspace** + +```bash +cd server/typescript && bun run --filter '*' build && bun run --filter '*' typecheck +``` +Expected: exit 0 (this is the same gate `.githooks/pre-push` runs) + +- [ ] **Step 2: Reproduce the issue exactly** + +Create a scratch project outside the repo, paste the `metaobjects/meta.json` and `metaobjects.config.ts` from issue #368 verbatim, then: + +```bash +meta eject entity && meta gen +``` + +Expected in `src/generated/Match.ts`: + +```ts +export const matchesRelations = relations(matches, ({ one }) => ({ + homeTeam: one(teams, { fields: [matches.homeTeamId], references: [teams.id] }), + awayTeam: one(teams, { fields: [matches.awayTeamId], references: [teams.id] }), +})); +``` + +- [ ] **Step 3: Confirm the ambiguous model is now refused** + +Rename `awayTeamRef`→`betaRef` and `awayTeamId`→`betaFk` in the scratch model so nothing pairs, then run `meta verify`. +Expected: `ERR_INVALID_RELATIONSHIP` naming `Match.awayTeam` and both candidates. + +- [ ] **Step 4: Run the local CI gate** + +```bash +cd && scripts/ci-local.sh --quick +``` +Expected: PASS (this is what CLAUDE.md asks for before opening a PR) + +- [ ] **Step 5: Open the PR** + +```bash +npx -y gh-axi pr create \ + --title "fix: two identity.reference nodes onto one entity made every association join the first FK (#368)" \ + --body-file \ + --repo=metaobjectsdev/metaobjects +``` + +The body must state: the four surfaces fixed, the resolution ladder, that `expected-registry.json` and `metamodelVersion` are unchanged, and the Task 8 audit result. + +--- + +## Self-Review + +**Spec coverage:** +- Issue "Proposed fix" 1 (declarable) → Tasks 2, 3, 7 via `@sourceRefField` +- Issue "Proposed fix" 2 (name pairing) → Task 1 stage 3, fixtured in Task 9 +- Issue "Proposed fix" 3 (fail loudly) → Task 3 rule (e), cross-port in Task 7 +- Issue "Root cause" (`relation-resolver.ts:95-101`) → Task 4 +- Issue "anti-pattern check for existing estates" → covered by rule (e) firing at load, so `meta verify` reports it without regenerating (Task 11 Step 3 proves it) +- Extra surfaces beyond the issue → Tasks 5, 6, 8 + +**Open decision for the maintainer:** `@sourceRefField`'s registered *description* still reads "names the source-side FK field on the junction". Updating it to cover the 1:N meaning changes `expected-registry.json`, which per `CLAUDE.md` forces all four registries to publish at the next release even though the attr set — and so `metamodelVersion` — is unchanged. This plan **leaves the description alone** and documents the widened meaning in ADR-0029 Amendment 1, `CLAUDE.md` and the feature docs instead. Task 9 Step 4 enforces that choice. Flip it only on an explicit ruling. + +**Type consistency:** `resolveRelationshipReference(holder, relationshipName, targetEntity, sourceRefField?)` and `referenceCandidatesFor(holder, targetEntity)` are used with those exact signatures in Tasks 3, 4, 5 and 7. `findReferencesBetween(a, b): ReferenceLookup[]` is used with that signature in Task 6 only. diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 1b3ee0ff9..cf6a44b48 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.0" }, "counts": { - "fixtures": 325, + "fixtures": 329, "corpora": 22, "baseTypes": 14 }, diff --git a/fixtures/conformance/error-relationship-one-refs-ambiguous/expected-errors.json b/fixtures/conformance/error-relationship-one-refs-ambiguous/expected-errors.json new file mode 100644 index 000000000..aaef4c2e3 --- /dev/null +++ b/fixtures/conformance/error-relationship-one-refs-ambiguous/expected-errors.json @@ -0,0 +1,13 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_RELATIONSHIP", + "source": { + "format": "json", + "files": ["meta.sport.json"], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[6]['relationship.association']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json b/fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json new file mode 100644 index 000000000..a2ce8f14b --- /dev/null +++ b/fixtures/conformance/error-relationship-one-refs-ambiguous/input/meta.sport.json @@ -0,0 +1,30 @@ +{ + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "alphaFk" } }, + { "field.long": { "name": "betaFk" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team" } }, + { "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team" } }, + { "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/relationship-one-two-refs-dotted-references/expected.json b/fixtures/conformance/relationship-one-two-refs-dotted-references/expected.json new file mode 100644 index 000000000..d6134c0fd --- /dev/null +++ b/fixtures/conformance/relationship-one-two-refs-dotted-references/expected.json @@ -0,0 +1,91 @@ +{ + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "alphaFk" + } + }, + { + "field.long": { + "name": "betaFk" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "alphaRef", + "@fields": [ + "alphaFk" + ], + "@references": "acme::sport::Team.id" + } + }, + { + "identity.reference": { + "name": "betaRef", + "@fields": [ + "betaFk" + ], + "@references": "acme::sport::Team.id" + } + }, + { + "relationship.association": { + "name": "winner", + "@cardinality": "one", + "@objectRef": "acme::sport::Team", + "@sourceRefField": "alphaFk" + } + }, + { + "relationship.association": { + "name": "loser", + "@cardinality": "one", + "@objectRef": "acme::sport::Team", + "@sourceRefField": "betaFk" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/relationship-one-two-refs-dotted-references/input/meta.sport.json b/fixtures/conformance/relationship-one-two-refs-dotted-references/input/meta.sport.json new file mode 100644 index 000000000..c6c1bef6f --- /dev/null +++ b/fixtures/conformance/relationship-one-two-refs-dotted-references/input/meta.sport.json @@ -0,0 +1,31 @@ +{ + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "alphaFk" } }, + { "field.long": { "name": "betaFk" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team.id" } }, + { "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team.id" } }, + { "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "alphaFk" } }, + { "relationship.association": { "name": "loser", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "betaFk" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/relationship-one-two-refs-name-pairing/expected.json b/fixtures/conformance/relationship-one-two-refs-name-pairing/expected.json new file mode 100644 index 000000000..278c4e410 --- /dev/null +++ b/fixtures/conformance/relationship-one-two-refs-name-pairing/expected.json @@ -0,0 +1,89 @@ +{ + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "homeTeamId" + } + }, + { + "field.long": { + "name": "awayTeamId" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "homeTeamRef", + "@fields": [ + "homeTeamId" + ], + "@references": "acme::sport::Team" + } + }, + { + "identity.reference": { + "name": "awayTeamRef", + "@fields": [ + "awayTeamId" + ], + "@references": "acme::sport::Team" + } + }, + { + "relationship.association": { + "name": "homeTeam", + "@cardinality": "one", + "@objectRef": "acme::sport::Team" + } + }, + { + "relationship.association": { + "name": "awayTeam", + "@cardinality": "one", + "@objectRef": "acme::sport::Team" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/relationship-one-two-refs-name-pairing/input/meta.sport.json b/fixtures/conformance/relationship-one-two-refs-name-pairing/input/meta.sport.json new file mode 100644 index 000000000..7f5921310 --- /dev/null +++ b/fixtures/conformance/relationship-one-two-refs-name-pairing/input/meta.sport.json @@ -0,0 +1,31 @@ +{ + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "field.long": { "name": "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": "homeTeamId", "@references": "acme::sport::Team" } }, + { "identity.reference": { "name": "awayTeamRef", "@fields": "awayTeamId", "@references": "acme::sport::Team" } }, + { "relationship.association": { "name": "homeTeam", "@cardinality": "one", "@objectRef": "acme::sport::Team" } }, + { "relationship.association": { "name": "awayTeam", "@cardinality": "one", "@objectRef": "acme::sport::Team" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/relationship-one-two-refs-sourcerefield/expected.json b/fixtures/conformance/relationship-one-two-refs-sourcerefield/expected.json new file mode 100644 index 000000000..d5ceff8af --- /dev/null +++ b/fixtures/conformance/relationship-one-two-refs-sourcerefield/expected.json @@ -0,0 +1,91 @@ +{ + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "alphaFk" + } + }, + { + "field.long": { + "name": "betaFk" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "alphaRef", + "@fields": [ + "alphaFk" + ], + "@references": "acme::sport::Team" + } + }, + { + "identity.reference": { + "name": "betaRef", + "@fields": [ + "betaFk" + ], + "@references": "acme::sport::Team" + } + }, + { + "relationship.association": { + "name": "winner", + "@cardinality": "one", + "@objectRef": "acme::sport::Team", + "@sourceRefField": "alphaFk" + } + }, + { + "relationship.association": { + "name": "loser", + "@cardinality": "one", + "@objectRef": "acme::sport::Team", + "@sourceRefField": "betaFk" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/relationship-one-two-refs-sourcerefield/input/meta.sport.json b/fixtures/conformance/relationship-one-two-refs-sourcerefield/input/meta.sport.json new file mode 100644 index 000000000..21af0bdb0 --- /dev/null +++ b/fixtures/conformance/relationship-one-two-refs-sourcerefield/input/meta.sport.json @@ -0,0 +1,31 @@ +{ + "metadata.root": { + "package": "acme::sport", + "children": [ + { + "object.entity": { + "name": "Team", + "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "Match", + "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "alphaFk" } }, + { "field.long": { "name": "betaFk" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "alphaRef", "@fields": "alphaFk", "@references": "acme::sport::Team" } }, + { "identity.reference": { "name": "betaRef", "@fields": "betaFk", "@references": "acme::sport::Team" } }, + { "relationship.association": { "name": "winner", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "alphaFk" } }, + { "relationship.association": { "name": "loser", "@cardinality": "one", "@objectRef": "acme::sport::Team", "@sourceRefField": "betaFk" } } + ] + } + } + ] + } +} diff --git a/fixtures/metamodel-docs/expected/types/relationship.md b/fixtures/metamodel-docs/expected/types/relationship.md index 9341957c6..fb5ef64eb 100644 --- a/fixtures/metamodel-docs/expected/types/relationship.md +++ b/fixtures/metamodel-docs/expected/types/relationship.md @@ -16,7 +16,7 @@ A shared/independent containment — the parent groups the target but does not o **Owning provider:** metaobjects-core-types -**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null). +**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null). **When to use:** One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership. @@ -28,7 +28,7 @@ A shared/independent containment — the parent groups the target but does not o | `@objectRef` | string | no | | | metaobjects-core-types | Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car'). | | `@onDelete` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict). | | `@onUpdate` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on key update. Default cascade. | -| `@sourceRefField` | string | no | | | metaobjects-core-types | Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. | +| `@sourceRefField` | string | no | | | metaobjects-core-types | Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. | | `@symmetric` | boolean | no | | | metaobjects-core-types | Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField. | | `@through` | string | no | | | metaobjects-core-types | Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references. | @@ -42,7 +42,7 @@ A plain reference to another entity — no ownership; the target has an independ **Owning provider:** metaobjects-core-types -**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict). +**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict). **When to use:** A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity. @@ -54,7 +54,7 @@ A plain reference to another entity — no ownership; the target has an independ | `@objectRef` | string | no | | | metaobjects-core-types | Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car'). | | `@onDelete` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict). | | `@onUpdate` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on key update. Default cascade. | -| `@sourceRefField` | string | no | | | metaobjects-core-types | Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. | +| `@sourceRefField` | string | no | | | metaobjects-core-types | Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. | | `@symmetric` | boolean | no | | | metaobjects-core-types | Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField. | | `@through` | string | no | | | metaobjects-core-types | Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references. | @@ -68,7 +68,7 @@ Abstract relationship base — shared shape for the concrete association/aggrega **Owning provider:** metaobjects-core-types -**Rules:** @cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action). +**Rules:** @cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action). **Attributes** @@ -78,7 +78,7 @@ Abstract relationship base — shared shape for the concrete association/aggrega | `@objectRef` | string | no | | | metaobjects-core-types | Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car'). | | `@onDelete` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict). | | `@onUpdate` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on key update. Default cascade. | -| `@sourceRefField` | string | no | | | metaobjects-core-types | Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. | +| `@sourceRefField` | string | no | | | metaobjects-core-types | Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. | | `@symmetric` | boolean | no | | | metaobjects-core-types | Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField. | | `@through` | string | no | | | metaobjects-core-types | Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references. | @@ -92,7 +92,7 @@ An owned containment — the parent owns the target's lifecycle; deleting the pa **Owning provider:** metaobjects-core-types -**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade). +**Rules:** M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade). **When to use:** You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins. @@ -104,7 +104,7 @@ An owned containment — the parent owns the target's lifecycle; deleting the pa | `@objectRef` | string | no | | | metaobjects-core-types | Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car'). | | `@onDelete` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict). | | `@onUpdate` | string | no | | `cascade`, `set-null`, `restrict`, `no-action` | metaobjects-core-types | Referential action on key update. Default cascade. | -| `@sourceRefField` | string | no | | | metaobjects-core-types | Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric. | +| `@sourceRefField` | string | no | | | metaobjects-core-types | Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely. | | `@symmetric` | boolean | no | | | metaobjects-core-types | Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField. | | `@through` | string | no | | | metaobjects-core-types | Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references. | diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index 706701432..8e7fbb2ec 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -3517,7 +3517,7 @@ "type": "relationship", "subType": "aggregation", "description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", "whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.", "attrs": [ { @@ -3565,7 +3565,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." + "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "name": "symmetric", @@ -3588,7 +3588,7 @@ "type": "relationship", "subType": "association", "description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", "whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.", "attrs": [ { @@ -3636,7 +3636,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." + "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "name": "symmetric", @@ -3659,7 +3659,7 @@ "type": "relationship", "subType": "base", "description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.", - "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", + "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", "attrs": [ { "name": "cardinality", @@ -3706,7 +3706,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." + "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "name": "symmetric", @@ -3729,7 +3729,7 @@ "type": "relationship", "subType": "composition", "description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", "whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.", "attrs": [ { @@ -3777,7 +3777,7 @@ "valueType": "string", "isArray": false, "required": false, - "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." + "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "name": "symmetric", diff --git a/server/csharp/MetaObjects.Conformance.Tests/Issue368ReferentialActionsTests.cs b/server/csharp/MetaObjects.Conformance.Tests/Issue368ReferentialActionsTests.cs new file mode 100644 index 000000000..4feedd2ef --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/Issue368ReferentialActionsTests.cs @@ -0,0 +1,137 @@ +// Issue #368 round 2 — the same "matches on target alone" defect the ladder +// (MetaObjects.Core.Relationship.RelationshipReferences) fixed for relationship +// navigation also existed in MetaObjects.Persistence.Db.ReferentialActions: +// correlating an identity.reference to the relationship.* that supplies its +// @onDelete / @onUpdate matched on the TARGET ENTITY ALONE, so when an entity +// declares more than one relationship to the same target (Match.homeTeam / +// awayTeam, both -> Team), every FK past the first silently inherited the +// FIRST relationship's referential actions. +// +// C# port of the TS suite +// (server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts, +// describe("#368 round 2: ...")) — that file is the authoritative spec; this +// mirrors it directly against ReferentialActions.Resolve. + +using MetaObjects.Loader; +using MetaObjects.Meta; +using MetaObjects.Persistence.Db; +using Xunit; + +namespace MetaObjects.Conformance.Tests; + +public class Issue368ReferentialActionsTests +{ + private static LoadResult LoadInline(string json) => + new MetaDataLoader().Load([new InMemoryStringSource(json, id: "inline.json")]); + + // Not an interpolated raw string: the model is dense in `}}`, which an + // interpolated raw string would read as an interpolation hole (see the + // comment on TwoEntityTemplate in Issue294ReferentialActionTests.cs). + private const string MatchTeamTemplate = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "field.long": { "name": "awayTeamId" } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + /*RELS*/, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}} + ]}} + """; + + private static string MatchTeamDoc(string rels) => MatchTeamTemplate.Replace("/*RELS*/", rels); + + private static (MetaObject Match, MetaReferenceIdentity HomeTeamRef, MetaReferenceIdentity AwayTeamRef) + LoadMatchTeam(string rels) + { + var result = LoadInline(MatchTeamDoc(rels)); + Assert.Empty(result.Errors); + var match = result.Root.FindObject("Match")!; + var homeTeamRef = match.ReferenceIdentities().Single(r => r.Name == "homeTeamRef"); + var awayTeamRef = match.ReferenceIdentities().Single(r => r.Name == "awayTeamRef"); + return (match, homeTeamRef, awayTeamRef); + } + + [Fact] + public void REGRESSION_name_pairing_each_FK_gets_its_own_relationships_action() + { + var (match, homeTeamRef, awayTeamRef) = LoadMatchTeam(""" + { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } }, + { "relationship.composition": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } } + """); + Assert.Equal(new ResolvedReferentialActions("restrict", "cascade"), ReferentialActions.Resolve(match, homeTeamRef)); + Assert.Equal(new ResolvedReferentialActions("cascade", "cascade"), ReferentialActions.Resolve(match, awayTeamRef)); + } + + [Fact] + public void REGRESSION_source_ref_field_each_FK_gets_its_own_relationships_action_even_when_names_dont_pair() + { + // Relationship names deliberately don't name-pair with either reference — + // only @sourceRefField can route these correctly. + var (match, homeTeamRef, awayTeamRef) = LoadMatchTeam(""" + { "relationship.association": { "name": "primary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict", "@sourceRefField": "homeTeamId" } }, + { "relationship.composition": { "name": "secondary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade", "@sourceRefField": "awayTeamId" } } + """); + Assert.Equal(new ResolvedReferentialActions("restrict", "cascade"), ReferentialActions.Resolve(match, homeTeamRef)); + Assert.Equal(new ResolvedReferentialActions("cascade", "cascade"), ReferentialActions.Resolve(match, awayTeamRef)); + } + + [Fact] + public void No_regression_a_single_relationship_to_a_target_still_resolves_regardless_of_its_name() + { + // Only one reference to Team exists here (winnerRef) — the relationship + // name "champion" pairs with neither reference's name, but the ladder's + // "exactly one candidate" tier means naming never mattered for this, by + // far the most common, shape. + const string doc = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "winnerId" } }, + { "identity.reference": { "name": "winnerRef", "@fields": ["winnerId"], "@references": "Team" } }, + { "relationship.composition": { "name": "champion", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Empty(result.Errors); + var match = result.Root.FindObject("Match")!; + var reference = match.ReferenceIdentities().Single(); + Assert.Equal(new ResolvedReferentialActions("cascade", "cascade"), ReferentialActions.Resolve(match, reference)); + } + + [Fact] + public void Unresolvable_correlation_emits_the_default_not_the_first_relationships_action() + { + // Neither relationship name pairs with either reference and no + // @sourceRefField disambiguates — the ladder can't choose. This is the + // SAME ambiguity rule (e) already refuses at load time (ADR-0029 §5), + // mirroring how Issue368RelationshipReferenceLadderTests asserts the + // load error directly rather than requiring a clean load. Resolve() + // still fails closed (no action on either FK) as defense in depth, + // rather than either one inheriting the first relationship's action. + var result = LoadInline(MatchTeamDoc(""" + { "relationship.association": { "name": "primary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } }, + { "relationship.composition": { "name": "secondary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } } + """)); + Assert.Equal( + [ErrorCode.ERR_INVALID_RELATIONSHIP, ErrorCode.ERR_INVALID_RELATIONSHIP], + result.Errors.Select(e => e.Code)); + var match = result.Root.FindObject("Match")!; + var homeTeamRef = match.ReferenceIdentities().Single(r => r.Name == "homeTeamRef"); + var awayTeamRef = match.ReferenceIdentities().Single(r => r.Name == "awayTeamRef"); + Assert.Equal(new ResolvedReferentialActions(null, null), ReferentialActions.Resolve(match, homeTeamRef)); + Assert.Equal(new ResolvedReferentialActions(null, null), ReferentialActions.Resolve(match, awayTeamRef)); + } +} diff --git a/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs new file mode 100644 index 000000000..8bffebb5e --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs @@ -0,0 +1,154 @@ +// Issue #368 — direct unit tests for the reference-resolution ladder +// (MetaObjects.Core.Relationship.RelationshipReferences). +// +// C# port of the TS reference suite +// (server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts) +// and its Python port (server/python/tests/unit/test_relationship_references.py) — +// exercises ReferenceCandidatesFor / ResolveRelationshipReference directly against +// a loaded model, rather than only through the loader's error output (see +// Issue368RelationshipReferenceValidationTests.cs for the loader-integration tests +// covering rule (d) / rule (e)). + +using MetaObjects.Core.Relationship; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Conformance.Tests; + +public class Issue368RelationshipReferenceLadderTests +{ + private static LoadResult LoadInline(string json) => + new MetaDataLoader().Load([new InMemoryStringSource(json, id: "inline.json")]); + + private const string MatchModel = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "field.long": { "name": "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one" } } + ]}} + ]}} + """; + + private static MetaObject LoadMatch() + { + var result = LoadInline(MatchModel); + Assert.Empty(result.Errors); + return result.Root.FindObject("Match")!; + } + + [Fact] + public void Enumerates_every_candidate_reference_for_the_target() + { + var candidates = RelationshipReferences.ReferenceCandidatesFor(LoadMatch(), "Team"); + Assert.Equal(["homeTeamRef", "awayTeamRef"], candidates.Select(c => c.Name)); + } + + [Fact] + public void Name_pairing_resolves_each_association_to_its_own_reference() + { + var match = LoadMatch(); + Assert.Equal("homeTeamRef", RelationshipReferences.ResolveRelationshipReference(match, "homeTeam", "Team")?.Name); + Assert.Equal("awayTeamRef", RelationshipReferences.ResolveRelationshipReference(match, "awayTeam", "Team")?.Name); + } + + [Fact] + public void Source_ref_field_wins_over_name_pairing() + { + var match = LoadMatch(); + Assert.Equal( + "awayTeamRef", + RelationshipReferences.ResolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")?.Name); + } + + [Fact] + public void A_single_candidate_resolves_regardless_of_name() + { + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "winnerFk" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "identity.reference": { "name": "anythingAtAll", "@fields": ["winnerFk"], "@references": "Team" } }, + { "relationship.association": { "name": "champion", "@objectRef": "Team", "@cardinality": "one" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Empty(result.Errors); + var match = result.Root.FindObject("Match")!; + Assert.Equal("anythingAtAll", RelationshipReferences.ResolveRelationshipReference(match, "champion", "Team")?.Name); + } + + [Fact] + public void Unpairable_names_return_null_rather_than_guessing() + { + // #368 rule (e) flags this fixture as a load error -- it's the exact + // ambiguity the ladder returning null exists to surface. Load with the + // merge/validation pipeline still run (errors non-empty is expected here), + // then assert the ladder's return value directly. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "alphaFk" } }, + { "field.long": { "name": "betaFk" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "identity.reference": { "name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } }, + { "identity.reference": { "name": "betaRef", "@fields": ["betaFk"], "@references": "Team" } }, + { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code)); + var match = result.Root.FindObject("Match")!; + Assert.Null(RelationshipReferences.ResolveRelationshipReference(match, "winner", "Team")); + } + + [Fact] + public void Suffix_stripping_never_applies_to_the_relationship_name() + { + // "valid" must NOT be stripped to "val" and pair with valRef. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "valFk" } }, + { "field.long": { "name": "otherFk" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "identity.reference": { "name": "valRef", "@fields": ["valFk"], "@references": "Team" } }, + { "identity.reference": { "name": "otherRef", "@fields": ["otherFk"], "@references": "Team" } }, + { "relationship.association": { "name": "valid", "@objectRef": "Team", "@cardinality": "one" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code)); + var match = result.Root.FindObject("Match")!; + Assert.Null(RelationshipReferences.ResolveRelationshipReference(match, "valid", "Team")); + } +} diff --git a/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs new file mode 100644 index 000000000..d8c526926 --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceValidationTests.cs @@ -0,0 +1,584 @@ +// Issue #368 — loader validation for relationship.* M:N slim vocabulary + the +// 1:N reference-disambiguation rules (the resolution ladder in +// MetaObjects.Core.Relationship.RelationshipReferences). +// +// C# port of the TS reference suite (relationship-m2m.test.ts) and its Python +// port (test_relationship_m2m_validation.py), covering the #368 additions: +// +// (B) @sourceRefField becomes legal on @cardinality: "one" (previously +// rejected on any non-M:N relationship). +// (C) Rule (e) -- a @cardinality: one relationship must resolve to exactly +// one identity.reference candidate; ambiguity is a load error. +// (D) Both rule (d) (the M:N slim-vocabulary pass) and rule (e) iterate the +// EFFECTIVE relationship set (own + inherited via extends), not just +// own-declared relationships -- with rule (d) deduping on the +// relationship node's identity (an inherited, unmodified relationship +// must not be reported once per inheriting entity). +// +// Also regression-covers two latent "obj vs. declaring entity" bugs the TS/ +// Python authors flagged when they switched rule (d) to resolving iteration +// (see d49c3d921 / 7fab449a2 commit messages) -- neither had a fixture in +// either port, so they are new here: ADR-0042 package resolution for a bare +// @through must use the DECLARING entity's package, and rule (a)'s self-join +// comparison must use the DECLARING entity, not whichever entity's effective +// view reached the inherited relationship first. +// +// See server/typescript/packages/metadata/src/core/relationship/ +// resolve-relationship-reference.ts and .../src/loader/validation-passes.ts +// (validateRelationships / validateOneSideReferenceResolution) for the +// authoritative spec these tests mirror. + +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Conformance.Tests; + +public class Issue368RelationshipReferenceValidationTests +{ + private static LoadResult LoadInline(string json) => + new MetaDataLoader().Load([new InMemoryStringSource(json, id: "inline.json")]); + + private static LoadResult LoadInlineMulti(params string[] jsons) => + new MetaDataLoader().Load( + jsons.Select((json, i) => new InMemoryStringSource(json, id: $"inline-{i}.json")) + .Cast() + .ToArray()); + + // ------------------------------------------------------------------------- + // (B) @sourceRefField becomes legal on @cardinality: "one" + // ------------------------------------------------------------------------- + + [Fact] + public void Source_ref_field_on_cardinality_one_loads_clean() + { + // The issue #368 repro: two 1:N relationships, each disambiguated by + // @sourceRefField, load with no errors. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "field.long": { "name": "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } }, + { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } + ]}} + ]}} + """; + Assert.Empty(LoadInline(doc).Errors); + } + + [Fact] + public void Source_ref_field_on_many_without_through_still_errors() + { + // @sourceRefField on @cardinality: many with no @through is still not + // M:N -- the widening only spares @cardinality: one. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "relationship.association": { "name": "teams", "@objectRef": "Team", "@cardinality": "many", "@sourceRefField": "whatever" } } + ]}} + ]}} + """; + Assert.Contains(LoadInline(doc).Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + [Fact] + public void Through_on_cardinality_one_still_errors() + { + // @through still requires @cardinality: many -- only @sourceRefField was widened. + const string doc = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Week", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "relationship.composition": { "name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X" } } + ]}}, + { "object.entity": { "name": "Program", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}} + ]}} + """; + Assert.Contains(LoadInline(doc).Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + [Fact] + public void Symmetric_on_cardinality_one_still_errors() + { + // @symmetric still requires M:N -- only @sourceRefField was widened. + const string doc = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Week", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "relationship.association": { "name": "program", "@objectRef": "Program", "@symmetric": true } } + ]}}, + { "object.entity": { "name": "Program", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}} + ]}} + """; + Assert.Contains(LoadInline(doc).Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + // ------------------------------------------------------------------------- + // (A) The resolution ladder, exercised end-to-end through the loader. + // ------------------------------------------------------------------------- + + [Fact] + public void Issue_repro_loads_clean_via_name_pairing() + { + // Two references onto the same target, no @sourceRefField -- resolved by + // name-pairing (homeTeamRef <-> homeTeam, awayTeamRef <-> awayTeam). + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "field.long": { "name": "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one" } } + ]}} + ]}} + """; + Assert.Empty(LoadInline(doc).Errors); + } + + [Fact] + public void Unpairable_names_error_naming_both_candidates() + { + // Two references whose names don't pair with the relationship name -- + // ambiguous, and the error names both candidates as name(fkField). + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "alphaFk" } }, + { "field.long": { "name": "betaFk" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } }, + { "identity.reference": { "name": "betaRef", "@fields": ["betaFk"], "@references": "Team" } }, + { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Contains(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP); + string joined = string.Join("\n", result.Errors.Select(e => e.Message)); + Assert.Contains("Match.winner", joined); + Assert.Contains("alphaRef(alphaFk)", joined); + Assert.Contains("betaRef(betaFk)", joined); + } + + [Fact] + public void Declared_but_unmatched_errors_at_single_candidate_count() + { + // A declared @sourceRefField naming nothing must error even with exactly + // one candidate -- the ladder's step 1 ("exactly one candidate -> that + // one") must not silently short-circuit past a bad declared value. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code)); + string joined = string.Join("\n", result.Errors.Select(e => e.Message)); + Assert.Contains("Match.awayTeam", joined); + Assert.Contains("\"awayTeamId\"", joined); + } + + [Fact] + public void Declared_but_unmatched_errors_at_zero_candidate_count() + { + // A declared @sourceRefField naming nothing must also error with ZERO + // candidates (no identity.reference targets the objectRef at all) -- the + // declared value is read BEFORE any candidate-count guard. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code)); + string joined = string.Join("\n", result.Errors.Select(e => e.Message)); + Assert.Contains("Match.homeTeam", joined); + Assert.Contains("\"homeTeamId\"", joined); + } + + [Fact] + public void Source_ref_field_correctly_naming_single_candidate_loads_clean() + { + // Regression: a correctly-declared @sourceRefField over a single + // candidate stays clean. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } } + ]}} + ]}} + """; + Assert.Empty(LoadInline(doc).Errors); + } + + [Fact] + public void Composite_reference_candidates_render_full_field_tuple() + { + // Two composite references sharing a first column must still print + // distinguishably in the candidate list (fields[0] alone would collide). + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "tenantId" } }, + { "field.long": { "name": "homeTeamId" } }, + { "field.long": { "name": "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "aRef", "@fields": ["tenantId", "homeTeamId"], "@references": "Team" } }, + { "identity.reference": { "name": "bRef", "@fields": ["tenantId", "awayTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Contains(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP); + string joined = string.Join("\n", result.Errors.Select(e => e.Message)); + Assert.Contains("aRef(tenantId, homeTeamId)", joined); + Assert.Contains("bRef(tenantId, awayTeamId)", joined); + } + + // ------------------------------------------------------------------------- + // Cross-relationship state-leakage regression: a declared-but-unmatched + // @sourceRefField at 2+ candidates on one relationship must not affect a + // SIBLING relationship on the same entity whose declared field DOES match. + // Backfilled from the Java port (Issue368RelationshipReferenceValidationTest + // .siblingRelationshipWithMatchingSourceRefFieldProducesNoErrorWhileTheBadOneDoes) -- + // the case most likely to catch per-relationship state accidentally shared + // across a loop iteration. + // ------------------------------------------------------------------------- + + [Fact] + public void Sibling_relationship_with_matching_source_ref_field_produces_no_error_while_the_bad_one_does() + { + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Venue", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "venueId" } }, + { "field.long": { "name": "alphaFk" } }, + { "field.long": { "name": "betaFk" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "venueRef", "@fields": ["venueId"], "@references": "Venue" } }, + { "relationship.association": { "name": "venue", "@objectRef": "Venue", "@cardinality": "one", "@sourceRefField": "venueId" } }, + { "identity.reference": { "name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } }, + { "identity.reference": { "name": "betaRef", "@fields": ["betaFk"], "@references": "Team" } }, + { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "doesNotExist" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code)); + string joined = string.Join("\n", result.Errors.Select(e => e.Message)); + Assert.Contains("Match.winner", joined); + Assert.Contains("\"doesNotExist\"", joined); + // The good sibling must never be named in any error -- no state leaked + // from evaluating "winner" into (or out of) evaluating "venue". + Assert.DoesNotContain("Match.venue", joined); + } + + // ------------------------------------------------------------------------- + // (D) Rule (e) must iterate the EFFECTIVE relationship set. + // ------------------------------------------------------------------------- + + [Fact] + public void Inherited_relationship_ambiguity_errors() + { + // A extends cleanly; B extends A and adds a second reference onto the + // same target -- the inherited relationship becomes ambiguous on B even + // though A (and the relationship's own declaration) are untouched. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "A", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "winner", "@objectRef": "Team", "@cardinality": "one" } } + ]}}, + { "object.entity": { "name": "B", "extends": "A", "children": [ + { "field.long": { "name": "awayTeamId" } }, + { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code)); + string joined = string.Join("\n", result.Errors.Select(e => e.Message)); + Assert.Contains("B.winner", joined); + Assert.Contains("homeTeamRef(homeTeamId)", joined); + Assert.Contains("awayTeamRef(awayTeamId)", joined); + } + + [Fact] + public void Inherited_relationship_resolved_by_child_added_reference_loads_clean() + { + // A child entity's added reference that name-pairs with the inherited + // relationship resolves cleanly. + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "A", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", "@cardinality": "one" } } + ]}}, + { "object.entity": { "name": "B", "extends": "A", "children": [ + { "field.long": { "name": "awayTeamId" } }, + { "identity.reference": { "name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } } + ]}} + ]}} + """; + Assert.Empty(LoadInline(doc).Errors); + } + + // ------------------------------------------------------------------------- + // (D) Rule (d) dedupe -- own attrs never change per inheriting entity, so an + // inherited unmodified relationship must be reported exactly once. + // ------------------------------------------------------------------------- + + [Fact] + public void Rule_d_dedupe_single_error_for_one_inheriting_child() + { + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Program", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "A", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.composition": { "name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "B", "extends": "A" } } + ]}} + """; + Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], LoadInline(doc).Errors.Select(e => e.Code)); + } + + [Fact] + public void Rule_d_dedupe_single_error_across_several_inheriting_children() + { + const string doc = """ + { "metadata.root": { "package": "repro", "children": [ + { "object.entity": { "name": "Program", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "A", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.composition": { "name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "B", "extends": "A" } }, + { "object.entity": { "name": "C", "extends": "A" } }, + { "object.entity": { "name": "D", "extends": "A" } } + ]}} + """; + Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], LoadInline(doc).Errors.Select(e => e.Code)); + } + + // ------------------------------------------------------------------------- + // Two latent "obj vs. declaring entity" bugs the TS/Python authors flagged + // (but did not cover with a fixture) when rule (d) switched to resolving + // iteration -- see d49c3d921 / 7fab449a2. Regression-covered here. + // ------------------------------------------------------------------------- + + [Fact] + public void Inherited_self_join_relationship_is_not_misflagged_as_non_self_join() + { + // Node extends NodeBase, which declares a @symmetric self-join + // relationship onto NodeBase itself (@objectRef: "NodeBase"). Node is + // declared BEFORE NodeBase (extends is resolved order-independently by + // a deferred pass, so this is legal) so that the outer validation loop + // visits `obj = Node` FIRST -- if rule (a)'s self-join comparison used + // the visiting `obj` instead of the relationship's DECLARING entity + // (NodeBase, via rel.Parent), it would wrongly conclude @objectRef + // "NodeBase" is not the (visiting) declaring entity "Node" and misfire + // ERR_BAD_ATTR_VALUE -- and dedupe would then lock in that wrong result + // when NodeBase's own turn came second. + const string doc = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Node", "extends": "NodeBase", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "NodeBase", "@isAbstract": true, "children": [ + { "relationship.association": { "name": "peers", "@cardinality": "many", "@objectRef": "NodeBase", + "@through": "NodeLink", "@symmetric": true } } + ]}}, + { "object.entity": { "name": "NodeLink", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "aId" } }, + { "field.long": { "name": "bId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "a", "@fields": ["aId"], "@references": "NodeBase" } }, + { "identity.reference": { "name": "b", "@fields": ["bId"], "@references": "NodeBase" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_BAD_ATTR_VALUE); + Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + [Fact] + public void Inherited_bare_through_resolves_in_the_declaring_entity_package_not_the_visiting_one() + { + // WeekBase (package "base") declares a M:N relationship with a BARE + // @through "Tag" -- ADR-0042 says a bare ref resolves in the DECLARING + // entity's package ("base::Tag"), never the package of whichever entity + // inherits and visits it. Week extends WeekBase from a DIFFERENT package + // ("acme") that also happens to declare its own unrelated "Tag" entity. + // The acme source is loaded FIRST so the outer validation loop visits + // `obj = Week` before `obj = WeekBase` -- if @through resolution used + // the visiting entity's package it would wrongly bind to "acme::Tag" + // (which has zero identity.reference children) instead of "base::Tag" + // (which correctly has two), and dedupe would lock in that wrong result + // before WeekBase's own (correct) turn ever came. + const string baseDoc = """ + { "metadata.root": { "package": "base", "children": [ + { "object.entity": { "name": "WeekBase", "@isAbstract": true, "children": [ + { "relationship.association": { "name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "Tag" } } + ]}}, + { "object.entity": { "name": "Tag", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "weekId" } }, + { "field.long": { "name": "labelId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "w", "@fields": ["weekId"], "@references": "base::WeekBase" } }, + { "identity.reference": { "name": "l", "@fields": ["labelId"], "@references": "base::Tag" } } + ]}} + ]}} + """; + const string acmeDoc = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Week", "extends": "base::WeekBase", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Tag", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}} + ]}} + """; + var result = LoadInlineMulti(acmeDoc, baseDoc); + Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + // ------------------------------------------------------------------------- + // Regression: valid M:N still loads clean. + // ------------------------------------------------------------------------- + + [Fact] + public void Valid_hetero_m2m_produces_no_relationship_errors() + { + const string doc = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Post", "children": [ + { "field.long": { "name": "id" } }, + { "relationship.association": { "name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "PostTag" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "Tag", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "PostTag", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "postId" } }, + { "field.long": { "name": "tagId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "p", "@fields": ["postId"], "@references": "Post" } }, + { "identity.reference": { "name": "t", "@fields": ["tagId"], "@references": "Tag" } } + ]}} + ]}} + """; + var result = LoadInline(doc); + Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_INVALID_RELATIONSHIP); + Assert.DoesNotContain(result.Errors, e => e.Code == ErrorCode.ERR_BAD_ATTR_VALUE); + } +} diff --git a/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs b/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs new file mode 100644 index 000000000..595b31cff --- /dev/null +++ b/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs @@ -0,0 +1,127 @@ +// Association -> identity.reference resolution (issue #368). +// +// An entity may declare more than one identity.reference onto the SAME target +// entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A +// `@cardinality: one` relationship names only its target, so when two +// references match, the target alone cannot say which FK the navigation uses. +// Taking the first match emits a join on the wrong column that typechecks, has +// correct DDL and passes verify — so the ladder below resolves it explicitly or +// not at all. ADR-0029 §5: ambiguity is a load error naming the candidates. +// +// Ported 1:1 from +// typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts +// (see also the Python port, relationship_references.py) — that file is the +// authoritative spec; this module mirrors it exactly (same suffix list, same +// order, same "candidate side only" stripping). + +using MetaObjects.Meta; + +namespace MetaObjects.Core.Relationship; + +/// +/// Which identity.reference does a @cardinality: one relationship +/// navigate through, when its target entity carries more than one +/// identity.reference candidate onto the same target? See +/// for the four-step ladder. +/// +public static class RelationshipReferences +{ + /// + /// Trailing suffixes stripped from a CANDIDATE's name/FK field when building its + /// pairing keys. Ordered — first match wins, so "reference" is tested before + /// "ref". Never applied to the relationship name (see ReferencePairingKeys). + /// + private static readonly string[] PairingSuffixes = ["reference", "ref", "id", "key"]; + + private static string StripOneSuffix(string value) + { + foreach (var suffix in PairingSuffixes) + { + if (value.Length > suffix.Length && value.EndsWith(suffix, StringComparison.Ordinal)) + { + return value[..^suffix.Length]; + } + } + return value; + } + + /// The FK field a reference is anchored on (first field; composite FKs pair on their first column). + private static string? RefFkField(MetaReferenceIdentity reference) + { + var fields = reference.Fields; + return fields.Count > 0 ? fields[0] : null; + } + + /// Last ::-segment of a (possibly package-qualified, possibly null/empty) name. + private static string StripPackage(string? name) + { + if (string.IsNullOrEmpty(name)) return ""; + int idx = name.LastIndexOf(PACKAGE_SEPARATOR, StringComparison.Ordinal); + return idx < 0 ? name : name[(idx + PACKAGE_SEPARATOR.Length)..]; + } + + /// + /// The set of lowercased names a candidate reference answers to: its own + /// name and its FK field, each with and without one stripped suffix. + /// Ordinal, culture-invariant lowercasing — behaviour must not vary by locale. + /// + public static HashSet ReferencePairingKeys(MetaReferenceIdentity reference) + { + var keys = new HashSet(); + void Add(string? value) + { + if (string.IsNullOrEmpty(value)) return; + string lower = value.ToLowerInvariant(); + keys.Add(lower); + keys.Add(StripOneSuffix(lower)); + } + Add(reference.Name); + Add(RefFkField(reference)); + return keys; + } + + /// + /// Every identity.reference on whose @references targets + /// . Package-insensitive on both sides: @references + /// and @objectRef may each be bare or fully qualified. + /// + public static List ReferenceCandidatesFor(MetaObject holder, string targetEntity) + { + string target = StripPackage(targetEntity); + // ADR-0039: resolving — ReferenceIdentities() honors references inherited via extends. + return holder.ReferenceIdentities() + .Where(r => StripPackage(r.TargetEntity) == target) + .Where(r => RefFkField(r) is not null) + .ToList(); + } + + /// + /// Which identity.reference does this @cardinality: one relationship + /// navigate through? The ladder, in order: + /// + /// exactly one candidate -> that one (the common case; unchanged behaviour) + /// @sourceRefField declared -> the candidate whose FK field it names, + /// SHORT-CIRCUITING (does not fall through to name-pairing on a miss) + /// exactly one candidate name-pairs -> that one + /// otherwise -> null (caller reports the ambiguity) + /// + /// Returns null for "no candidate" and "cannot choose" alike; callers that need to + /// tell them apart use . + /// + public static MetaReferenceIdentity? ResolveRelationshipReference( + MetaObject holder, string relationshipName, string targetEntity, string? sourceRefField = null) + { + var candidates = ReferenceCandidatesFor(holder, targetEntity); + if (candidates.Count == 0) return null; + if (candidates.Count == 1) return candidates[0]; + + if (!string.IsNullOrEmpty(sourceRefField)) + { + return candidates.FirstOrDefault(r => RefFkField(r) == sourceRefField); + } + + string wanted = relationshipName.ToLowerInvariant(); + var paired = candidates.Where(r => ReferencePairingKeys(r).Contains(wanted)).ToList(); + return paired.Count == 1 ? paired[0] : null; + } +} diff --git a/server/csharp/MetaObjects/Core/Relationship/RelationshipSchema.cs b/server/csharp/MetaObjects/Core/Relationship/RelationshipSchema.cs index 909377f55..9b72d51ab 100644 --- a/server/csharp/MetaObjects/Core/Relationship/RelationshipSchema.cs +++ b/server/csharp/MetaObjects/Core/Relationship/RelationshipSchema.cs @@ -38,7 +38,7 @@ public static class RelationshipSchema Name: RelationshipConstants.RELATIONSHIP_ATTR_SOURCE_REF_FIELD, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, Required: false, - Description: "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric."), + Description: "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely."), new AttrSchema( Name: RelationshipConstants.RELATIONSHIP_ATTR_SYMMETRIC, diff --git a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs index 8c3c0506d..196a1a410 100644 --- a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs +++ b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs @@ -548,9 +548,20 @@ public LoadResult Load(IReadOnlyList sources) // Pass 14 (FR-017): M:N relationship slim-vocabulary validation — // symmetric-self-join-only / symmetric⊕sourceRefField (ERR_BAD_ATTR_VALUE); // junction-two-references / sourceRefField-match / M:N-attr-on-1:N - // (ERR_INVALID_RELATIONSHIP). Deferred-resolution (own-relationships only). + // (ERR_INVALID_RELATIONSHIP). Deferred-resolution, iterating the + // EFFECTIVE relationship set (own + inherited via extends), deduped by + // the relationship node's own identity (ADR-0039 / #368). errors.AddRange(ValidationPasses.ValidateRelationships(root)); + // Rule (e) (#368) — registered alongside ValidateRelationships (the + // M:N slim-vocabulary pass, above): a @cardinality: one relationship + // must resolve to exactly one identity.reference candidate on the + // EFFECTIVE entity; ambiguity is a load error naming the candidates + // (ERR_INVALID_RELATIONSHIP). No dedupe — the candidate set is a + // property of the effective entity, so a parent and a child can both + // be genuinely, independently ambiguous. + errors.AddRange(ValidationPasses.ValidateOneSideReferenceResolution(root)); + // index.lookup @fields resolution — every index.lookup must name ≥1 field, // and each must exist in the entity's effective field set (ERR_INVALID_INDEX). errors.AddRange(ValidationPasses.ValidateIndexLookupFields(root)); diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index dcfb535f9..01eefa9fd 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -10,6 +10,7 @@ using System.Text.RegularExpressions; using MetaObjects.Core.Attr; +using MetaObjects.Core.Relationship; using MetaObjects.Core.Requirement; using MetaObjects.Meta; using MetaObjects.Persistence.Source; @@ -3059,10 +3060,7 @@ public static IReadOnlyList ValidateFieldMap(MetaData root) // Pass 14 (FR-017): ValidateRelationships — M:N slim-vocabulary rules. // // Deferred-resolution validation (runs after all files load + extends: - // resolution, like origin paths), enforcing the cross-port M:N contract. - // Iterates OWN relationships (a relationship is validated on the entity that - // declares it — a declaration-structure walk), but reads each relationship's - // inheritable M:N attrs via the RESOLVING Attr accessor (ADR-0039; TS parity): + // resolution, like origin paths), enforcing the cross-port M:N contract: // // (a) @symmetric:true is valid only on a self-join (@objectRef == declaring // entity). Otherwise ERR_BAD_ATTR_VALUE. @@ -3071,8 +3069,29 @@ public static IReadOnlyList ValidateFieldMap(MetaData root) // must exist and declare exactly two identity.reference children; // @sourceRefField (if present) must match one of those references' FK // fields -> ERR_INVALID_RELATIONSHIP. - // (d) @through / @sourceRefField / @symmetric are invalid on a non-M:N - // relationship (@cardinality != "many", or no @through) -> ERR_INVALID_RELATIONSHIP. + // (d) @through / @symmetric are invalid on a non-M:N relationship + // (@cardinality != "many", or no @through) -> ERR_INVALID_RELATIONSHIP. + // @sourceRefField is also invalid there, EXCEPT on @cardinality: "one" + // (#368: it then names which of several identity.reference nodes onto + // the same target the relationship navigates). + // + // ADR-0039: resolving, not own-only (#368) — every rule above validates a + // property of the relationship's OWN declaration (its attrs, plus for rule + // (c) the @through target resolved against the root), so a relationship + // inherited via extends must be validated wherever it's visible, or a child + // entity that only SEES the relationship through inheritance could carry a + // violation no pass ever examines. That makes an inherited, UNMODIFIED + // relationship visited once per inheriting entity — checked once below + // (keyed on the relationship node's own identity), not once per entity, + // using the DECLARING entity (rel.Parent) rather than whichever entity's + // effective view got there first for every piece of context a rule reads + // (message text, @through's package per ADR-0042, rule (a)'s self-join + // comparison). That keeps each check's result independent of which entity + // triggered it, which is what makes "checked once" both sufficient and + // correct. An override replaces the node in place (effective-children), so + // it is never the same object as what it overrides and is never skipped + // against it — a genuinely different declaration is always independently + // checked. // // Ported from validateRelationships in // typescript/packages/metadata/src/loader/validation-passes.ts. @@ -3108,15 +3127,45 @@ public static IReadOnlyList ValidateRelationships(MetaData root) { var errors = new List(); - foreach (var obj in root.OwnChildren().Where(c => c.Type == TYPE_OBJECT)) + // #368 — the inner loop below iterates the EFFECTIVE relationship set + // (obj.Relationships(), resolving), so a relationship inherited unmodified + // by N entities is reached N times. Its own attrs never change based on + // who inherits it, so re-validating it more than once would report the + // identical finding N times — pure noise. `checkedRels` is keyed on the + // relationship NODE's own object identity: effective-children reuses the + // super's child object in place for an unmodified inherited child, so the + // same physical declaration IS the same object everywhere it's visible, + // while an override replaces it with a genuinely different object + // (correctly NOT deduped — a distinct declaration is a distinct finding). + // Explicit ReferenceEqualityComparer — MetaData carries no Equals/ + // GetHashCode override today, but identity-keyed dedupe must not + // silently degrade to value equality if one is added later. + var checkedRels = new HashSet(ReferenceEqualityComparer.Instance); + + // ADR-0039: root has no super; OwnChildren()==Children(), but resolving + // is still the correct default (mirrors the TS/Python root.children()). + foreach (var obj in root.OwnChildren().Where(c => c.Type == TYPE_OBJECT).Cast()) { - // ADR-0042 — a bare @through / @objectRef resolves in the declaring entity's package. - string referrerPkg = NamingRefs.EffectivePackage(obj); - foreach (var rel in obj.OwnChildren().Where(c => c.Type == TYPE_RELATIONSHIP)) + // ADR-0039: resolving — rule (d) (like rule (e)) must see a + // relationship inherited via extends, not just this entity's own + // declarations. `checkedRels` absorbs the resulting revisits. + foreach (var rel in obj.Relationships()) { - // ADR-0039: resolving — a relationship may inherit its M:N attrs via extends - // (TS validation-passes.ts:1320-1324). Iterated via OwnChildren above (a rel is - // validated on the entity that declares it), but its attrs may still be inherited. + if (!checkedRels.Add(rel)) continue; + + // Every rule below validates a property of the relationship's OWN + // declaration (its attrs, plus for rule (c) the @through target), so + // context — the entity name in messages, the package a bare @through + // resolves in (ADR-0042), and rule (a)'s self-join comparison — is + // always the entity that DECLARES `rel` (rel.Parent), never `obj` + // (the entity whose effective view happened to reach it first). This + // keeps the check's result independent of iteration order/ + // inheritance depth, which is what makes checking each node exactly + // once correct. + var declaringEntity = rel.Parent ?? obj; + string referrerPkg = NamingRefs.EffectivePackage(declaringEntity); + + // ADR-0039: resolving — a relationship may inherit its M:N attrs via extends. var through = rel.Attr(RELATIONSHIP_ATTR_THROUGH); var sourceRefField = rel.Attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); bool symmetric = rel.Attr(RELATIONSHIP_ATTR_SYMMETRIC) is true; @@ -3127,6 +3176,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root) bool hasSourceRefField = sourceRefField is string srs && srs.Length > 0; bool isMany = cardinality is string cs && cs == CARDINALITY_MANY; bool isM2M = hasThrough && isMany; + bool isCardinalityOne = cardinality is string co && co == CARDINALITY_ONE; // NOTE: @objectRef existence resolution moved to the validation registry // (a declarative ReferenceDescriptor on relationship.* TypeDefinitions, @@ -3138,22 +3188,30 @@ public static IReadOnlyList ValidateRelationships(MetaData root) if (hasThrough) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_THROUGH} but is not a M:N " + + $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_THROUGH} but is not a M:N " + $"relationship (requires @{RELATIONSHIP_ATTR_CARDINALITY}: \"{CARDINALITY_MANY}\").", ErrorCode.ERR_INVALID_RELATIONSHIP, Envelope: rel.Source)); } - if (hasSourceRefField) + // #368: @sourceRefField also disambiguates a `@cardinality: one` + // relationship when the entity holds more than one + // identity.reference onto the same target. Only the M:N + // *junction* reading is rejected here; rule (e) — + // ValidateOneSideReferenceResolution, below in this file — + // checks that it names a real local reference. + if (hasSourceRefField && !isCardinalityOne) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.", + $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is neither a M:N " + + $"relationship (requires @{RELATIONSHIP_ATTR_THROUGH} with @{RELATIONSHIP_ATTR_CARDINALITY}: \"{CARDINALITY_MANY}\") " + + $"nor a @{RELATIONSHIP_ATTR_CARDINALITY}: \"{CARDINALITY_ONE}\" relationship.", ErrorCode.ERR_INVALID_RELATIONSHIP, Envelope: rel.Source)); } if (symmetric) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.", + $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.", ErrorCode.ERR_INVALID_RELATIONSHIP, Envelope: rel.Source)); } @@ -3164,7 +3222,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root) if (symmetric && hasSourceRefField) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" sets both @{RELATIONSHIP_ATTR_SYMMETRIC} and " + + $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets both @{RELATIONSHIP_ATTR_SYMMETRIC} and " + $"@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD}; they are mutually exclusive.", ErrorCode.ERR_BAD_ATTR_VALUE, Envelope: rel.Source)); @@ -3175,12 +3233,12 @@ public static IReadOnlyList ValidateRelationships(MetaData root) // package is self, but an FQN "other::Widget" (a different same-short-name entity) // is NOT (comparing stripped short names would misclassify it). bool isSelfJoin = objectRef is string objRefStr && - ReferenceEquals(NamingRefs.ResolveObjectRef(root, objRefStr, referrerPkg), obj); + ReferenceEquals(NamingRefs.ResolveObjectRef(root, objRefStr, referrerPkg), declaringEntity); if (symmetric && !isSelfJoin) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SYMMETRIC} but @{RELATIONSHIP_ATTR_OBJECT_REF} " + - $"\"{objectRef}\" is not the declaring entity \"{obj.Name}\"; @{RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.", + $"relationship \"{declaringEntity.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SYMMETRIC} but @{RELATIONSHIP_ATTR_OBJECT_REF} " + + $"\"{objectRef}\" is not the declaring entity \"{declaringEntity.Name}\"; @{RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.", ErrorCode.ERR_BAD_ATTR_VALUE, Envelope: rel.Source)); } @@ -3191,9 +3249,9 @@ public static IReadOnlyList ValidateRelationships(MetaData root) if (junction is null) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" does not resolve to an entity.", + $"relationship \"{declaringEntity.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" does not resolve to an entity.", ErrorCode.ERR_INVALID_RELATIONSHIP, - Envelope: ResolvedSource.From(rel.Source, $"{obj.Fqn()}::{rel.Name}", (string)through!))); + Envelope: ResolvedSource.From(rel.Source, $"{declaringEntity.Fqn()}::{rel.Name}", (string)through!))); continue; } // A junction is a physical join table — it MUST be an object.entity. ADR-0046 @@ -3203,7 +3261,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root) if (junction.SubType != OBJECT_SUBTYPE_ENTITY) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" resolves to " + + $"relationship \"{declaringEntity.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" resolves to " + $"{junction.Type}.{junction.SubType}, not an entity — a junction is a persisted join table " + "and must be object.entity.", ErrorCode.ERR_INVALID_RELATIONSHIP, @@ -3214,7 +3272,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root) if (refCount != 2) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" must declare exactly two " + + $"relationship \"{declaringEntity.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_THROUGH} \"{through}\" must declare exactly two " + $"identity.reference children (one per FK side); found {refCount}.", ErrorCode.ERR_INVALID_RELATIONSHIP, Envelope: rel.Source)); @@ -3227,7 +3285,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root) if (!fkFields.Contains((string)sourceRefField!, StringComparer.Ordinal)) { errors.Add(new MetaError( - $"relationship \"{obj.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} \"{sourceRefField}\" does not match " + + $"relationship \"{declaringEntity.Name}.{rel.Name}\" @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} \"{sourceRefField}\" does not match " + $"any identity.reference FK field on junction \"{through}\". Available: {(fkFields.Count > 0 ? string.Join(", ", fkFields) : "(none)")}.", ErrorCode.ERR_INVALID_RELATIONSHIP, Envelope: rel.Source)); @@ -3239,6 +3297,110 @@ public static IReadOnlyList ValidateRelationships(MetaData root) return errors.AsReadOnly(); } + // --------------------------------------------------------------------------- + // Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly + // one identity.reference. Two references onto the same target are + // indistinguishable from the relationship's @objectRef alone, so the resolver + // would silently emit the first one's FK column. ADR-0029 §5: ambiguity is a + // load error naming the candidates. + // + // Registered alongside ValidateRelationships (the M:N slim-vocabulary pass, + // above) — same deferred-resolution timing (after all files load + extends + // resolution). + // + // Scope differs deliberately from rule (d) — in SUBJECT, not in which + // relationships each pass walks (both walk the EFFECTIVE set; rule (d) is + // no longer own-scoped, or an M:N declaration reached only via extends + // would go unchecked). Rule (d) validates attrs that travel with the + // relationship's OWN declaration (@through/@symmetric/@sourceRefField), so + // it checks each declaration EXACTLY ONCE — deduped by node identity, and + // reported against the entity that declares it — because those attrs don't + // change meaning depending on who inherits the relationship. Rule (e) + // instead validates whether THIS entity's reference set resolves the + // relationship uniquely, which is a property of the EFFECTIVE entity, not of + // wherever the relationship happens to be declared. A child entity that + // extends a clean parent and adds a second identity.reference onto the same + // target makes an INHERITED relationship ambiguous on the child even though + // the parent (and the relationship's own declaration) are untouched — own- + // scoping this pass would leave that case unchecked, and codegen/runtime + // (which resolve against the effective entity) would silently drop the + // relation. If a parent and a child are both genuinely ambiguous, both are + // reported — two entities are broken, not one error duplicated. (No dedupe + // here, unlike rule (d): rule (e)'s candidate set genuinely differs per entity.) + // --------------------------------------------------------------------------- + + public static IReadOnlyList ValidateOneSideReferenceResolution(MetaRoot root) + { + var errors = new List(); + foreach (var obj in root.Objects()) + { + // ADR-0039: resolving — see the scope note above: rule (e) checks THIS + // entity's effective reference set against every relationship it can see, + // including one only inherited via extends. + foreach (var rel in obj.Relationships()) + { + // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends. + if (rel.Attr(RELATIONSHIP_ATTR_CARDINALITY) is not string cardinality || cardinality != CARDINALITY_ONE) continue; + if (rel.Attr(RELATIONSHIP_ATTR_OBJECT_REF) is not string objectRef || objectRef.Length == 0) continue; + + var candidates = RelationshipReferences.ReferenceCandidatesFor(obj, objectRef); + + var sourceRefField = rel.Attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); + string? declared = sourceRefField is string srf && srf.Length > 0 ? srf : null; + + if (declared is not null) + { + // A declared @sourceRefField short-circuits the ladder at ANY + // candidate count — checked independently of + // RelationshipReferences.ResolveRelationshipReference, whose step 1 + // ("exactly one candidate -> that one") would otherwise silently + // return the lone candidate even when it disagrees with the + // declared field. The author named a specific FK; it must exist, + // whether there are zero, one, or many candidates. + bool matchesDeclared = candidates.Any(c => c.Fields.Count > 0 && c.Fields[0] == declared); + if (matchesDeclared) continue; + errors.Add(new MetaError( + $"relationship \"{obj.Name}.{rel.Name}\" sets @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} " + + $"\"{declared}\", which names no identity.reference targeting \"{objectRef}\". " + + $"Candidates: {FormatReferenceCandidates(candidates)}.", + ErrorCode.ERR_INVALID_RELATIONSHIP, + Envelope: rel.Source)); + continue; + } + + // No @sourceRefField declared: ambiguity only exists with 2+ + // candidates — ResolveRelationshipReference's name-pairing step + // (ladder step 3) decides. + if (candidates.Count <= 1) continue; + var resolved = RelationshipReferences.ResolveRelationshipReference(obj, rel.Name, objectRef); + if (resolved is not null) continue; + + errors.Add(new MetaError( + $"relationship \"{obj.Name}.{rel.Name}\" is ambiguous: \"{obj.Name}\" declares " + + $"{candidates.Count} identity.reference nodes targeting \"{objectRef}\" and the " + + "relationship name does not pair with exactly one. Candidates: " + + $"{FormatReferenceCandidates(candidates)}. Set @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this " + + "relationship navigates.", + ErrorCode.ERR_INVALID_RELATIONSHIP, + Envelope: rel.Source)); + } + } + return errors.AsReadOnly(); + } + + /// + /// Render a candidate reference as name(fkField), or name(fieldA, fieldB) + /// for a composite reference — so two composite references sharing a first column still + /// print distinguishably. + /// + /// NOTE: this is display only. Matching (both here and in + /// RelationshipReferences.ResolveRelationshipReference) still keys on the first field + /// alone — a composite reference cannot actually be disambiguated by @sourceRefField. + /// That's a documented limitation, not fixed by this rendering. + /// + private static string FormatReferenceCandidates(IReadOnlyList candidates) => + string.Join(", ", candidates.Select(c => $"{c.Name}({string.Join(", ", c.Fields)})")); + // NOTE: identity.reference @references resolution moved to the validation registry // (a declarative ReferenceDescriptor with dottedFieldPath on the identity.reference // TypeDefinition, resolved by RegisteredValidation). diff --git a/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs b/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs index b9ba23498..67d3383e8 100644 --- a/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs +++ b/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs @@ -7,6 +7,7 @@ // EF Core model can agree with that DDL instead of falling back to EF's own convention // (#294). Any change to the precedence belongs in BOTH files. +using MetaObjects.Core.Relationship; using MetaObjects.Meta; namespace MetaObjects.Persistence.Db; @@ -50,7 +51,12 @@ public static class ReferentialActions /// would add a clause that changes nothing (and, on the TS side, would dirty /// introspection round-trips). /// - /// If multiple relationships target the same entity (rare), the first is used. + /// When more than one relationship targets the same entity (Match.homeTeam / + /// awayTeam both -> Team), each is correlated to its OWN identity.reference via the + /// shared relationship<->reference ladder (#368 round 2) — never the first + /// match. When the correlation is genuinely ambiguous, it contributes nothing rather + /// than an arbitrary — possibly wrong — action (see the tier-2/tier-3 bodies below for + /// the exact rules). /// public static ResolvedReferentialActions Resolve(MetaObject entity, MetaReferenceIdentity reference) { @@ -76,14 +82,30 @@ public static ResolvedReferentialActions Resolve(MetaObject entity, MetaReferenc // direct FK. When the target does not resolve (dangling @references — normally // a load error), fall back to an exact-string match so behavior on // partially-valid trees is unchanged. + // + // #368 (round 2): `entity` may declare MORE THAN ONE relationship to this + // SAME target (Match.homeTeam / awayTeam both -> Team). Matching `r` on the + // target alone can't say which FK `r` supplies actions FOR — every FK past + // the first silently inherited the first relationship's actions. Resolve it + // with the INVERSE of the relationship->reference ladder + // (RelationshipReferences.ResolveRelationshipReference): `r` belongs to + // `reference` iff the ladder, applied to `r`, resolves back to `reference` + // itself — not merely "resolves to *some* reference on this target". `r` and + // `reference` are always declared on the same `entity`, the exact shape the + // ladder is built for, so this is a direct inversion, not a second parallel + // rule. var rel = entity.Relationships().FirstOrDefault(r => { if (r.Through is not null) return false; var objectRef = r.ObjectRef; if (objectRef is null) return false; if (targetObj is null) return objectRef == target; - return NamingRefs.RefMatchesObject( - targetObj, objectRef, NamingRefs.EffectivePackage(r.Parent ?? entity)); + if (!NamingRefs.RefMatchesObject( + targetObj, objectRef, NamingRefs.EffectivePackage(r.Parent ?? entity))) + return false; + return ReferenceEquals( + RelationshipReferences.ResolveRelationshipReference(entity, r.Name, objectRef, r.SourceRefField), + reference); }); // (3) Failing that, the REVERSE relationship declared on the TARGET entity. @@ -137,6 +159,20 @@ reverse.OnDelete is null /// target, the reverse relationship cannot say WHICH FK carries the ownership edge, /// so it contributes to none of them (arming every FK could cascade through an edge /// the author never designated). + /// When the TARGET entity declares more than one non-@through + /// relationship back at (rare — e.g. a "posts" + /// composition and a separate "latestPost" association both @objectRef-ing + /// Post), no candidate is preferred: this is the tier-2 ambiguity's mirror image + /// (multiple RELATIONSHIPS rather than multiple REFERENCES), and it cannot be + /// resolved by the relationship->reference ladder — that ladder picks among + /// references declared on the SAME object as the relationship, whereas here the + /// candidate relationships live on targetObj while + /// lives on , a different object, so the ladder has no + /// candidates to apply to. Previously this used the FIRST match (the same + /// silently-wrong-schema defect as tier 2); it now fails closed like the guard + /// above, once the ambiguity guard above has already established + /// is the entity's only candidate reference to this + /// target. /// /// private static MetaRelationship? FindReverseRelationship( @@ -160,14 +196,19 @@ reverse.OnDelete is null // The reverse relationship's bare @objectRef resolves in ITS declaring owner's // package (normally the target entity's own package). - return targetObj.Relationships().FirstOrDefault(r => + // + // Fail closed on ambiguity rather than taking the first match: if more than one + // non-@through relationship on targetObj resolves back to `entity`, none of them + // is preferred (see the class doc above). + var reverseCandidates = targetObj.Relationships().Where(r => { if (r.Through is not null) return false; // M:N — junction path, not this FK var objectRef = r.ObjectRef; if (objectRef is null) return false; return NamingRefs.RefMatchesObject( entity, objectRef, NamingRefs.EffectivePackage(r.Parent ?? targetObj)); - }); + }).ToList(); + return reverseCandidates.Count == 1 ? reverseCandidates[0] : null; } /// diff --git a/server/csharp/MetaObjects/SpecMetamodel/relationship.json b/server/csharp/MetaObjects/SpecMetamodel/relationship.json index bc6e78d64..0fd2d5b1a 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/relationship.json +++ b/server/csharp/MetaObjects/SpecMetamodel/relationship.json @@ -5,12 +5,12 @@ "type": "relationship", "subType": "base", "description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.", - "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", + "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -21,12 +21,12 @@ "subType": "association", "description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).", "whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -37,12 +37,12 @@ "subType": "aggregation", "description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).", "whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -53,12 +53,12 @@ "subType": "composition", "description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).", "whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index 1d5223a5d..0fcaa50df 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -16,6 +16,7 @@ import com.metaobjects.loader.MetaDataLoader import com.metaobjects.`object`.MetaObject import com.metaobjects.relationship.CompositionRelationship import com.metaobjects.relationship.MetaRelationship +import com.metaobjects.relationship.RelationshipReferences import com.metaobjects.source.MetaSource import com.metaobjects.source.RdbSource import com.squareup.kotlinpoet.ClassName @@ -1576,9 +1577,13 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase + // reference ladder (not by target alone, which let a second FK inherit + // the first relationship's actions); and the reverse relationship + // contributes nothing when this entity holds more than one enforced + // reference to the same target, or when the target declares more than + // one relationship back at this entity. val (resolvedOnDelete, resolvedOnUpdate) = resolveDecorationActions(loader, entity, child, target) val refSuffix = referentialActionSuffix(resolvedOnDelete, resolvedOnUpdate) @@ -1609,9 +1614,24 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase Team). Matching `rel` on the + // target ALONE cannot say which of those references `rel` supplies actions FOR, + // so every FK past the first silently inherited the FIRST relationship's + // @onDelete / @onUpdate — homeTeamRef's `restrict` landing on awayTeamRef's + // `cascade` FK, in a table Exposed compiles and the database accepts. Resolve it + // with the INVERSE of the relationship->reference ladder: `rel` belongs to `ref` + // iff the ladder, applied to `rel`, resolves back to `ref` ITSELF — not merely + // "to some reference on this target". `rel` and `ref` are both declared on (or + // inherited into) `entity`, the exact shape the ladder is built for, so this is + // a direct inversion rather than a second parallel rule. Mirrors the TS migrate + // engine's resolveReferentialActions and the C# ReferentialActions port. val childSide = entity.relationships.firstOrNull { rel -> rel.through == null && rel.objectRef != null && - KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === target + KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === target && + RelationshipReferences.resolveRelationshipReference( + entity, rel.shortName, rel.objectRef, rel.sourceRefField) === ref } var rel = childSide // When the tier-3 satisfiability guard fires, the reverse relationship's @@ -1651,10 +1671,11 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase + // #368: fail closed on ambiguity rather than taking the first match — when more + // than one non-@through relationship on [target] resolves back to [entity] (e.g. + // a "posts" composition and a separate "latestPost" association both pointing at + // Post), none of them is preferred. This is the tier-2 ambiguity's mirror image + // (multiple RELATIONSHIPS rather than multiple REFERENCES) and the ladder cannot + // resolve it: the ladder picks among references declared on the SAME object as + // the relationship, whereas here the candidates live on [target] while [ref] + // lives on [entity]. Mirrors the TS findReverseRelationship. + return target.relationships.filter { rel -> rel.through == null && rel.objectRef != null && KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === entity - } + }.singleOrNull() } /** diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableTwoRefsTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableTwoRefsTest.kt new file mode 100644 index 000000000..d25d03f0c --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableTwoRefsTest.kt @@ -0,0 +1,129 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.metadata.ktx.loadString +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * #368 — an entity may legally declare more than one `identity.reference` onto the + * SAME target (`Match.homeTeamRef` and `Match.awayTeamRef`, both -> `Team`). + * + * `KotlinExposedTableGenerator`'s ADR-0047 referential-action correlation had the + * defect the TS `migrate-ts` engine and the C# `ReferentialActions` port were fixed + * for: tier 2 matched a sibling relationship on the TARGET ALONE, so every FK past + * the first silently inherited the FIRST relationship's `@onDelete` / `@onUpdate`, + * and tier 3 took the first reverse relationship instead of failing closed. The + * emitted Exposed table compiles and the database accepts the DDL — the only symptom + * is the wrong referential action on the wrong FK. + */ +class KotlinExposedTableTwoRefsTest { + + /** + * Tier 2 (child-side correlation). `homeTeam` declares `@onDelete: restrict`, + * `awayTeam` declares `@onDelete: cascade`; each must land on ITS OWN FK column. + * Before the fix both columns carried RESTRICT — the first relationship's action. + * + * The relationships resolve by ladder step 3 (name pairing: `homeTeam` pairs with + * `homeTeamRef` / `homeTeamId`), so the model loads clean with no `@sourceRefField`. + */ + @Test fun `each FK carries its own relationship's referential actions`() { + val model = """{ + "metadata.root": { "package": "x", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "source.rdb": { "@table": "teams" } }, + { "identity.primary": { "@fields": "id" } } + ] } }, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "field.long": { "name": "awayTeamId" } }, + { "source.rdb": { "@table": "matches" } }, + { "identity.primary": { "@fields": "id" } }, + { "identity.reference": { "name": "homeTeamRef", + "@fields": "homeTeamId", "@references": "Team" } }, + { "identity.reference": { "name": "awayTeamRef", + "@fields": "awayTeamId", "@references": "Team" } }, + { "relationship.association": { "name": "homeTeam", "@objectRef": "Team", + "@cardinality": "one", "@onDelete": "restrict" } }, + { "relationship.association": { "name": "awayTeam", "@objectRef": "Team", + "@cardinality": "one", "@onDelete": "cascade" } } + ] } } + ] } + }""".trimIndent() + val outDir = Files.createTempDirectory("ktbl-368-tier2-") + try { + val gen = KotlinExposedTableGenerator() + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loadString("issue368-tier2", model)) + + val src = Files.readString(outDir.resolve("x/MatchTable.kt")) + assertTrue( + ("val homeTeamId = long(\"home_team_id\").references(TeamTable.id, " + + "onDelete = ReferenceOption.RESTRICT, onUpdate = ReferenceOption.CASCADE)") in src, + "expected homeTeamId to carry homeTeam's @onDelete: restrict; saw:\n$src", + ) + assertTrue( + ("val awayTeamId = long(\"away_team_id\").references(TeamTable.id, " + + "onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)") in src, + "expected awayTeamId to carry awayTeam's OWN @onDelete: cascade, not " + + "homeTeam's restrict (the #368 defect); saw:\n$src", + ) + } finally { + outDir.toFile().deleteRecursively() + } + } + + /** + * Tier 3 (parent-side reverse correlation) fails closed. `Team` declares TWO + * non-`@through` relationships back at `Match` — a `cascade` composition and a + * `restrict` association — and `Match` declares no relationship of its own, so + * tier 2 contributes nothing and tier 3 must choose. It cannot: neither candidate + * is preferred, so the FK is emitted BARE rather than arbitrarily armed with the + * first one's CASCADE. + */ + @Test fun `an ambiguous reverse relationship contributes no referential action`() { + val model = """{ + "metadata.root": { "package": "x", "children": [ + { "object.entity": { "name": "Team", "children": [ + { "field.long": { "name": "id" } }, + { "source.rdb": { "@table": "teams" } }, + { "identity.primary": { "@fields": "id" } }, + { "relationship.composition": { "name": "matches", "@objectRef": "Match", + "@cardinality": "many", "@onDelete": "cascade" } }, + { "relationship.association": { "name": "playedMatches", "@objectRef": "Match", + "@cardinality": "many", "@onDelete": "restrict" } } + ] } }, + { "object.entity": { "name": "Match", "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "teamId" } }, + { "source.rdb": { "@table": "matches" } }, + { "identity.primary": { "@fields": "id" } }, + { "identity.reference": { "name": "teamRef", + "@fields": "teamId", "@references": "Team" } } + ] } } + ] } + }""".trimIndent() + val outDir = Files.createTempDirectory("ktbl-368-tier3-") + try { + val gen = KotlinExposedTableGenerator() + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loadString("issue368-tier3", model)) + + val src = Files.readString(outDir.resolve("x/MatchTable.kt")) + assertTrue( + "val teamId = long(\"team_id\").references(TeamTable.id).nullable()" in src || + "val teamId = long(\"team_id\").references(TeamTable.id)" in src, + "expected a bare .references(TeamTable.id) FK; saw:\n$src", + ) + assertTrue( + "ReferenceOption" !in src, + "an ambiguous reverse relationship must contribute NO referential action " + + "rather than the first candidate's; saw:\n$src", + ) + } finally { + outDir.toFile().deleteRecursively() + } + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index e6dcdcb29..b37b0e553 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -52,6 +52,7 @@ import com.metaobjects.origin.MetaOrigin; import com.metaobjects.origin.PassthroughOrigin; import com.metaobjects.relationship.MetaRelationship; +import com.metaobjects.relationship.RelationshipReferences; import com.metaobjects.requirement.MetaRequirement; import com.metaobjects.attr.MetaAttribute; import com.metaobjects.registry.ChildRequirement; @@ -195,7 +196,30 @@ public static void run(MetaRoot root, MetaDataLoader loader) { // Sibling of the source-role pass above; must run after it. pass(collected, () -> validateSourceEscapes(root, loader)); pass(collected, () -> validateRelationshipReferentialActions(root)); - pass(collected, () -> validateRelationshipsM2M(root)); + // Pass 14 (FR-017): M:N relationship slim-vocabulary validation — collects + // EVERY violation across the effective relationship set (own + inherited via + // extends), deduped by the relationship node's own identity (#368 / ADR-0039). + // Routed through pass(...) (not a bare loop) so a MetaDataException escaping + // the list-building call itself — as opposed to one already safely collected + // into the returned list — is still folded into `collected` rather than + // propagating straight out of run(), which would skip every remaining pass, + // dedupe(), and loader.addError(). + pass(collected, () -> { + for (MetaDataException e : validateRelationshipsM2M(root)) { + collected.add(e); + } + }); + // Rule (e) (#368) — registered alongside validateRelationshipsM2M above: a + // @cardinality: one relationship must resolve to exactly one identity.reference + // candidate on the EFFECTIVE entity; ambiguity is a load error naming the + // candidates (ERR_INVALID_RELATIONSHIP). No dedupe — the candidate set is a + // property of the effective entity, so a parent and a child can both be + // genuinely, independently ambiguous. + pass(collected, () -> { + for (MetaDataException e : validateOneSideReferenceResolution(root)) { + collected.add(e); + } + }); // ADR-0042 — the cross-package ambiguity pass (ERR_AMBIGUOUS_REF) is RETIRED. A bare // reference now resolves package-locally (referrer's package, else root-level) at every // ref site (SymbolTable / resolveRootObject), so cross-package ambiguity is unreachable; @@ -1670,44 +1694,76 @@ private static void validateRelationshipNode(MetaData node) { // (c) When @through is present (M:N): the named entity must exist and declare // exactly two identity.reference children; @sourceRefField (if present) // must match one of those references' FK fields → ERR_INVALID_RELATIONSHIP. - // (d) @through / @sourceRefField / @symmetric are invalid on a non-M:N - // relationship (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP. + // (d) @through / @symmetric are invalid on a non-M:N relationship + // (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP. + // @sourceRefField is also invalid there, EXCEPT on @cardinality: "one" + // (#368: it then names which of several identity.reference nodes onto + // the same target the relationship navigates — rule (e), below, checks + // that it names a real local reference). // - // Own-relationships only: a relationship is validated on the entity that - // declares it (matching the own-attrs policy of the other passes). Eager-throw - // on the first violation, like the rest of this phase. The thrown source is the - // relationship node's own JsonSource, so the cross-port envelope jsonPath points - // at the relationship node — matching the shared error fixtures. + // ADR-0039: resolving, not own-only (#368) — every rule above validates a + // property of the relationship's OWN declaration (its attrs, plus for rule + // (c) the @through target resolved against the root), so a relationship + // inherited via extends must be validated wherever it's visible, or a child + // entity that only SEES the relationship through inheritance could carry a + // violation no pass ever examines. That makes an inherited, UNMODIFIED + // relationship visited once per inheriting entity — checked once below + // (keyed on the relationship node's own identity via an IdentityHashMap- + // backed set, not a plain HashSet — MetaData may override equals/hashCode + // and dedup here must be by reference), not once per entity, using the + // DECLARING entity (rel.getParent()) rather than whichever entity's + // effective view got there first for every piece of context a rule reads + // (message text, @through's package per ADR-0042, rule (a)'s self-join + // comparison). That keeps each check's result independent of which entity + // triggered it, which is what makes "checked once" both sufficient and + // correct. An override REPLACES the node in the overriding entity's own + // children (a genuinely different object), so it is never skipped against + // the node it overrides — a distinct declaration is always independently + // checked. This relies on Java's effective-children walk reusing the SAME + // physical child object for an unmodified inherited relationship (see + // MetaData.addParentChildren, which walks up to the super's own children + // collection rather than cloning) — verified against + // M2MSlimVocabularyTest#junctionWithInheritedReferencesLoadsCleanly's + // sibling mechanism and the new dedupe tests below. + // + // Collects EVERY violation (not eager-throw-and-stop) so a genuinely + // distinct defect on a sibling relationship is never masked by an earlier + // one — mirrors the TS/C#/Python ParseError[]/MetaError[] collection. // ========================================================================= - static void validateRelationshipsM2M(MetaRoot root) { - for (MetaData rootChild : root.getChildren(MetaData.class, false)) { - walkRelationshipsM2M(root, rootChild); - } - } - - private static void walkRelationshipsM2M(MetaRoot root, MetaData node) { - if (node instanceof MetaObject) { - validateObjectRelationshipsM2M(root, (MetaObject) node); - } - for (MetaData child : node.getChildren(MetaData.class, false)) { - walkRelationshipsM2M(root, child); - } - } - - private static void validateObjectRelationshipsM2M(MetaRoot root, MetaObject obj) { - for (MetaData child : obj.getChildren(MetaData.class, false)) { - if (!(child instanceof MetaRelationship)) continue; - MetaRelationship rel = (MetaRelationship) child; - validateRelationshipM2MNode(root, obj, rel); + static List validateRelationshipsM2M(MetaRoot root) { + List errors = new java.util.ArrayList<>(); + java.util.Set checkedRels = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + // ADR-0039: root has no super; root.objects() is the complete top-level set. + for (MetaObject obj : root.objects()) { + // ADR-0039: resolving — rule (d) (like rule (e)) must see a relationship + // inherited via extends, not just this entity's own declarations. + // `checkedRels` absorbs the resulting revisits. + for (MetaRelationship rel : obj.getRelationships()) { + if (!checkedRels.add(rel)) continue; + validateRelationshipM2MNode(root, obj, rel, errors); + } } + return errors; } private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj, - MetaRelationship rel) { + MetaRelationship rel, + List errors) { + // Every rule below validates a property of the relationship's OWN + // declaration (its attrs, plus for rule (c) the @through target), so + // context — the entity name in messages, the package a bare @through + // resolves in (ADR-0042), and rule (a)'s self-join comparison — is + // always the entity that DECLARES `rel` (rel.getParent()), never `obj` + // (the entity whose effective view happened to reach it first). This + // keeps the check's result independent of iteration order/inheritance + // depth, which is what makes checking each node exactly once correct. + MetaData relParent = rel.getParent(); + MetaObject declaringEntity = (relParent instanceof MetaObject) ? (MetaObject) relParent : obj; // ADR-0042 — a bare @through / @objectRef self-join resolves in the declaring // entity's package (an FQN resolves exactly). - String referrerPkg = obj.getPackage() == null ? "" : obj.getPackage(); + String referrerPkg = declaringEntity.getPackage() == null ? "" : declaringEntity.getPackage(); // ADR-0039: resolving — a relationship may inherit its M:N slim-vocabulary // attrs (@through/@sourceRefField/@objectRef/@cardinality/@symmetric) via // extends. Mirrors TS validateRelationships (validation-passes.ts:1320-1324), @@ -1719,13 +1775,23 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj, String sourceRefField = rel.getSourceRefField(); boolean symmetric = rel.isSymmetric(); String objectRef = rel.getObjectRef(); - // getCardinality() defaults to "one" when absent — matches the TS isMany check. - String cardinality = rel.getCardinality(); + // Cross-port divergence guard: MetaRelationship.getCardinality() DEFAULTS to + // "one" when the attr is absent, but TS (validation-passes.ts:2058/:2196), + // C# (ValidationPasses.cs:3179) and Python (validation_passes.py:2676) all + // compare the RAW attribute — an absent @cardinality is neither "many" nor + // "one" there. Using the defaulting getter here would spare @sourceRefField + // on a cardinality-less relationship (Java-only) and, in rule (e) below, + // wrongly apply the one-side-resolution check to it too. Read the raw own + // attr instead, mirroring the established hopCardinality(...) idiom elsewhere + // in this file (FR-024 B5/B6, a few hundred lines down) — defaulting is + // deliberately NOT reproduced here so all four ports treat "absent" alike. + String cardinality = rawCardinality(rel); boolean hasThrough = through != null && !through.isEmpty(); boolean hasSourceRefField = sourceRefField != null && !sourceRefField.isEmpty(); boolean isMany = MetaRelationship.CARDINALITY_MANY.equals(cardinality); boolean isM2M = hasThrough && isMany; + boolean isCardinalityOne = MetaRelationship.CARDINALITY_ONE.equals(cardinality); // NOTE: @objectRef existence resolution moved to the validation registry // (RegisteredValidation.defaultRegistry → a declarative reference descriptor). @@ -1734,42 +1800,52 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj, // Rule (d): M:N-only attrs on a non-M:N relationship. if (!isM2M) { if (hasThrough) { - throw new MetaDataException( + errors.add(new MetaDataException( ErrorMessageConstants.ERR_INVALID_RELATIONSHIP - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" sets @" + MetaRelationship.ATTR_THROUGH + " but is not a M:N relationship (requires @" + MetaRelationship.ATTR_CARDINALITY + ": \"" + MetaRelationship.CARDINALITY_MANY + "\").", - ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()); - } - if (hasSourceRefField) { - throw new MetaDataException( + ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource())); + } + // #368: @sourceRefField also disambiguates a `@cardinality: one` + // relationship when the entity holds more than one identity.reference + // onto the same target. Only the M:N *junction* reading is rejected + // here; rule (e) — validateOneSideReferenceResolution, below — checks + // that it names a real local reference. + if (hasSourceRefField && !isCardinalityOne) { + errors.add(new MetaDataException( ErrorMessageConstants.ERR_INVALID_RELATIONSHIP - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" sets @" + MetaRelationship.ATTR_SOURCE_REF_FIELD - + " but is not a M:N relationship.", - ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()); + + " but is neither a M:N relationship (requires @" + + MetaRelationship.ATTR_THROUGH + " with @" + + MetaRelationship.ATTR_CARDINALITY + ": \"" + + MetaRelationship.CARDINALITY_MANY + "\") nor a @" + + MetaRelationship.ATTR_CARDINALITY + ": \"" + + MetaRelationship.CARDINALITY_ONE + "\" relationship.", + ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource())); } if (symmetric) { - throw new MetaDataException( + errors.add(new MetaDataException( ErrorMessageConstants.ERR_INVALID_RELATIONSHIP - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" sets @" + MetaRelationship.ATTR_SYMMETRIC + " but is not a M:N relationship.", - ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()); + ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource())); } return; } // Rule (b): @symmetric and @sourceRefField are mutually exclusive. if (symmetric && hasSourceRefField) { - throw new MetaDataException( + errors.add(new MetaDataException( ErrorMessageConstants.ERR_BAD_ATTR_VALUE - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" sets both @" + MetaRelationship.ATTR_SYMMETRIC + " and @" + MetaRelationship.ATTR_SOURCE_REF_FIELD + "; they are mutually exclusive.", - ErrorCode.ERR_BAD_ATTR_VALUE, rel.getSource()); + ErrorCode.ERR_BAD_ATTR_VALUE, rel.getSource())); } // Rule (a): @symmetric is valid only on a self-join (@objectRef == declaring entity). @@ -1777,16 +1853,16 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj, // package is self, but an FQN "other::Widget" (a different same-short-name entity) is // NOT (comparing stripped short names would misclassify it). boolean isSelfJoin = objectRef != null - && resolveRootObject(root, objectRef, referrerPkg) == obj; + && resolveRootObject(root, objectRef, referrerPkg) == declaringEntity; if (symmetric && !isSelfJoin) { - throw new MetaDataException( + errors.add(new MetaDataException( ErrorMessageConstants.ERR_BAD_ATTR_VALUE - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" sets @" + MetaRelationship.ATTR_SYMMETRIC + " but @" + MetaRelationship.ATTR_OBJECT_REF + " \"" + objectRef - + "\" is not the declaring entity \"" + obj.getShortName() + + "\" is not the declaring entity \"" + declaringEntity.getShortName() + "\"; @" + MetaRelationship.ATTR_SYMMETRIC + " is self-join-only.", - ErrorCode.ERR_BAD_ATTR_VALUE, rel.getSource()); + ErrorCode.ERR_BAD_ATTR_VALUE, rel.getSource())); } // Rule (c): @through must name an entity declaring exactly two identity.reference children. @@ -1794,53 +1870,185 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj, // cross-package @through no longer binds a junction in another package. MetaObject junction = resolveRootObject(root, through, referrerPkg); if (junction == null) { - throw new MetaDataException( + errors.add(new MetaDataException( ErrorMessageConstants.ERR_INVALID_RELATIONSHIP - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" @" + MetaRelationship.ATTR_THROUGH + " \"" + through + "\" does not resolve to an entity." + didYouMeanHint(root, through), ErrorCode.ERR_INVALID_RELATIONSHIP, - ResolvedSource.from(rel.getSource(), obj.getShortName() + "::" + rel.getShortName(), through)); + ResolvedSource.from(rel.getSource(), declaringEntity.getShortName() + "::" + rel.getShortName(), through))); + return; } // A junction is a physical join table — it MUST be an object.entity. ADR-0046 // lets a value carry navigation-only references, so value-purity no longer // implicitly guarantees a two-reference junction is an entity; assert it here. // (A value/projection has no table to join through.) if (!MetaObject.SUBTYPE_ENTITY.equals(junction.getSubType())) { - throw new MetaDataException( + errors.add(new MetaDataException( ErrorMessageConstants.ERR_INVALID_RELATIONSHIP - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" @" + MetaRelationship.ATTR_THROUGH + " \"" + through + "\" resolves to " + junction.getType() + "." + junction.getSubType() + ", not an entity — a junction is a persisted join table and must be object.entity.", - ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()); + ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource())); + return; } int refCount = countJunctionReferences(junction); if (refCount != 2) { - throw new MetaDataException( + errors.add(new MetaDataException( ErrorMessageConstants.ERR_INVALID_RELATIONSHIP - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" @" + MetaRelationship.ATTR_THROUGH + " \"" + through + "\" must declare exactly two identity.reference children" + " (one per FK side); found " + refCount + ".", - ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()); + ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource())); + return; } // @sourceRefField (if present) must match one of the junction's reference FK fields. if (hasSourceRefField) { List fkFields = junctionReferenceFkFields(junction); if (!fkFields.contains(sourceRefField)) { - throw new MetaDataException( + errors.add(new MetaDataException( ErrorMessageConstants.ERR_INVALID_RELATIONSHIP - + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + ": relationship \"" + declaringEntity.getShortName() + "." + rel.getShortName() + "\" @" + MetaRelationship.ATTR_SOURCE_REF_FIELD + " \"" + sourceRefField + "\" does not match any identity.reference FK field on junction \"" + through + "\". Available: " + (fkFields.isEmpty() ? "(none)" : String.join(", ", fkFields)) + ".", - ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource()); + ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource())); } } } + // ========================================================================= + // Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly + // one identity.reference. Two references onto the same target are + // indistinguishable from the relationship's @objectRef alone, so the resolver + // would silently emit the first one's FK column. ADR-0029 §5: ambiguity is a + // load error naming the candidates. + // + // Registered alongside validateRelationshipsM2M (the M:N slim-vocabulary pass, + // above) — same deferred-resolution timing (after all files load + extends + // resolution). + // + // Scope differs deliberately from rule (d) — in SUBJECT, not in which + // relationships each pass walks (both walk the EFFECTIVE set; rule (d) is + // no longer own-scoped, or an M:N declaration reached only via extends + // would go unchecked). Rule (d) validates attrs that travel with the + // relationship's OWN declaration (@through/@symmetric/@sourceRefField), so + // it checks each declaration EXACTLY ONCE — deduped by node identity, and + // reported against the entity that declares it — because those attrs don't + // change meaning depending on who inherits the relationship. Rule (e) + // instead validates whether THIS entity's reference set resolves the + // relationship uniquely, which is a property of the EFFECTIVE entity, not of + // wherever the relationship happens to be declared. A child entity that + // extends a clean parent and adds a second identity.reference onto the same + // target makes an INHERITED relationship ambiguous on the child even though + // the parent (and the relationship's own declaration) are untouched — own- + // scoping this pass would leave that case unchecked, and codegen/runtime + // (which resolve against the effective entity) would silently drop the + // relation. If a parent and a child are both genuinely ambiguous, both are + // reported — two entities are broken, not one error duplicated. (No dedupe + // here, unlike rule (d): rule (e)'s candidate set genuinely differs per entity.) + // ========================================================================= + + static List validateOneSideReferenceResolution(MetaRoot root) { + List errors = new java.util.ArrayList<>(); + for (MetaObject obj : root.objects()) { + // ADR-0039: resolving — see the scope note above: rule (e) checks THIS + // entity's effective reference set against every relationship it can + // see, including one only inherited via extends. + for (MetaRelationship rel : obj.getRelationships()) { + // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends. + // Raw read, not getCardinality() (see the cross-port divergence note in + // validateRelationshipM2MNode above) — an absent @cardinality must skip + // this pass entirely on Java, exactly as it does on TS/C#/Python. + if (!MetaRelationship.CARDINALITY_ONE.equals(rawCardinality(rel))) continue; + String objectRef = rel.getObjectRef(); + if (objectRef == null || objectRef.isEmpty()) continue; + + List candidates = + RelationshipReferences.referenceCandidatesFor(obj, objectRef); + + String sourceRefField = rel.getSourceRefField(); + String declared = (sourceRefField != null && !sourceRefField.isEmpty()) + ? sourceRefField : null; + + if (declared != null) { + // A declared @sourceRefField short-circuits the ladder at ANY + // candidate count — checked independently of + // RelationshipReferences.resolveRelationshipReference, whose + // step 1 ("exactly one candidate -> that one") would otherwise + // silently return the lone candidate even when it disagrees + // with the declared field. The author named a specific FK; it + // must exist, whether there are zero, one, or many candidates. + boolean matchesDeclared = candidates.stream().anyMatch(c -> { + List fields = c.getFields(); + return !fields.isEmpty() && declared.equals(fields.get(0)); + }); + if (matchesDeclared) continue; + errors.add(new MetaDataException( + ErrorMessageConstants.ERR_INVALID_RELATIONSHIP + + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + "\" sets @" + MetaRelationship.ATTR_SOURCE_REF_FIELD + " \"" + declared + + "\", which names no identity.reference targeting \"" + objectRef + + "\". Candidates: " + formatReferenceCandidates(candidates) + ".", + ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource())); + continue; + } + + // No @sourceRefField declared: ambiguity only exists with 2+ + // candidates — resolveRelationshipReference's name-pairing step + // (ladder step 3) decides. + if (candidates.size() <= 1) continue; + ReferenceIdentity resolved = RelationshipReferences.resolveRelationshipReference( + obj, rel.getShortName(), objectRef); + if (resolved != null) continue; + + errors.add(new MetaDataException( + ErrorMessageConstants.ERR_INVALID_RELATIONSHIP + + ": relationship \"" + obj.getShortName() + "." + rel.getShortName() + + "\" is ambiguous: \"" + obj.getShortName() + "\" declares " + candidates.size() + + " identity.reference nodes targeting \"" + objectRef + "\" and the relationship" + + " name does not pair with exactly one. Candidates: " + + formatReferenceCandidates(candidates) + ". Set @" + + MetaRelationship.ATTR_SOURCE_REF_FIELD + " to the FK field this relationship navigates.", + ErrorCode.ERR_INVALID_RELATIONSHIP, rel.getSource())); + } + } + return errors; + } + + /** Render a candidate reference as {@code name(fkField)}, or {@code name(fieldA, fieldB)} + * for a composite reference — so two composite references sharing a first column still + * print distinguishably. + * + *

NOTE: this is display only. Matching (both here and in + * {@link RelationshipReferences#resolveRelationshipReference}) still keys on the first + * field alone — a composite reference cannot actually be disambiguated by + * @sourceRefField. That's a documented limitation, not fixed by this rendering.

*/ + private static String formatReferenceCandidates(List candidates) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < candidates.size(); i++) { + if (i > 0) sb.append(", "); + ReferenceIdentity c = candidates.get(i); + sb.append(c.getShortName()).append('(').append(String.join(", ", c.getFields())).append(')'); + } + return sb.toString(); + } + + /** The relationship's own (effective, resolving) {@code @cardinality}, or + * {@code null} when absent — deliberately NOT {@link MetaRelationship#getCardinality()}, + * which defaults to {@code "one"}. TS/C#/Python all compare the raw attribute + * at the rule (d) sourceRefField-exemption check and the rule (e) gate, so an + * absent @cardinality must read as neither "many" nor "one" here too (matches + * the same raw-read idiom as {@code hopCardinality(...)} elsewhere in this file). */ + private static String rawCardinality(MetaRelationship rel) { + return rel.hasMetaAttr(MetaRelationship.ATTR_CARDINALITY) + ? rel.getMetaAttr(MetaRelationship.ATTR_CARDINALITY).getValueAsString() + : null; + } + /** Count a junction's {@code identity.reference} children. * ADR-0039: resolving — a junction may inherit an {@code identity.reference} * via extends, so the FK direction/count must be judged on the EFFECTIVE view. diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java index a28c4519c..16ef5aee0 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java +++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java @@ -217,10 +217,12 @@ private static List referenceIdentities(MetaObject junction) { return out; } - /** First {@code @fields} entry of a reference (the physical FK column on the junction). */ + // refFkField / stripPackage moved to the package-private ReferenceFkUtil (fix + // round 1, finding 5) — they were byte-for-byte duplicates of + // RelationshipReferences' own private copies, and both classes live in this + // same package. private static String refFkField(MetaIdentity ref) { - List fields = ref.getFields(); - return fields.isEmpty() ? null : fields.get(0); + return ReferenceFkUtil.refFkField(ref); } /** @@ -278,8 +280,6 @@ private static MetaObject findObject(MetaRoot root, String name) { } private static String stripPackage(String name) { - if (name == null) return null; - int idx = name.lastIndexOf(MetaData.PKG_SEPARATOR); - return (idx >= 0) ? name.substring(idx + MetaData.PKG_SEPARATOR.length()) : name; + return ReferenceFkUtil.stripPackage(name); } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/MetaRelationship.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/MetaRelationship.java index dde80bc8d..8f8363173 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/relationship/MetaRelationship.java +++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/MetaRelationship.java @@ -50,9 +50,12 @@ public abstract class MetaRelationship extends MetaData { * derived from those references (never restated). Renamed from the retired {@code @joinEntity}. */ public final static String ATTR_THROUGH = "through"; - /** Directed self-join disambiguator: names the source-side FK field on the junction - * (the other reference is the target side). Required only for directed/ambiguous self-join - * M:N. Mutually exclusive with {@code @symmetric}. */ + /** Disambiguates which reference/FK field a relationship uses when more than one candidate + * exists: on a directed self-join M:N it names the junction's source-side reference + * (mutually exclusive with {@code @symmetric}), and on a {@code @cardinality:'one'} + * relationship it names which of several {@code identity.reference} nodes onto the same + * target the relationship navigates when name-pairing does not resolve it uniquely + * (issue #368). */ public final static String ATTR_SOURCE_REF_FIELD = "sourceRefField"; /** Undirected self-join flag (union-on-read). Valid only when {@code @objectRef} == the diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/ReferenceFkUtil.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/ReferenceFkUtil.java new file mode 100644 index 000000000..6a7c66516 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/ReferenceFkUtil.java @@ -0,0 +1,52 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.relationship; + +import com.metaobjects.MetaData; +import com.metaobjects.identity.MetaIdentity; + +import java.util.List; + +/** + * Package-private helpers shared by {@link M2MFields} and + * {@link RelationshipReferences} — both independently needed "the FK field an + * {@code identity.reference} is anchored on" and "the bare (package-stripped) + * tail of a dotted name" before this class existed, and had grown byte-for-byte + * duplicate private copies of each (fix round 1, finding 5). Kept here rather + * than made public: the other language ports keep this logic in separate + * modules (no cross-file sharing to mirror), so there is no cross-port reason + * to widen visibility beyond this package. + */ +final class ReferenceFkUtil { + + private ReferenceFkUtil() { + // static utility class + } + + /** The FK field an identity carries (first {@code @fields} entry; composite FKs + * key on their first column), or {@code null} when it declares no {@code @fields}. */ + static String refFkField(MetaIdentity ref) { + List fields = ref.getFields(); + return fields.isEmpty() ? null : fields.get(0); + } + + /** Last {@code ::}-segment of a (possibly package-qualified, possibly null/empty) name. */ + static String stripPackage(String name) { + if (name == null || name.isEmpty()) return ""; + int idx = name.lastIndexOf(MetaData.PKG_SEPARATOR); + return idx < 0 ? name : name.substring(idx + MetaData.PKG_SEPARATOR.length()); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java new file mode 100644 index 000000000..e097afcdf --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java @@ -0,0 +1,161 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.relationship; + +import com.metaobjects.identity.MetaIdentity; +import com.metaobjects.identity.ReferenceIdentity; +import com.metaobjects.object.MetaObject; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Association -> identity.reference resolution (issue #368). + * + *

An entity may declare more than one identity.reference onto the SAME target + * entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A + * {@code @cardinality: one} relationship names only its target, so when two + * references match, the target alone cannot say which FK the navigation uses. + * Taking the first match emits a join on the wrong column that typechecks, has + * correct DDL and passes verify — so the ladder below resolves it explicitly or + * not at all. ADR-0029 §5: ambiguity is a load error naming the candidates.

+ * + *

Ported 1:1 from + * {@code typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts} + * (see also the Python port, {@code relationship_references.py}, and the C# port, + * {@code MetaObjects.Core.Relationship.RelationshipReferences}) — that file is the + * authoritative spec; this class mirrors it exactly (same suffix list, same order, + * same "candidate side only" stripping).

+ */ +public final class RelationshipReferences { + + private RelationshipReferences() { + // static utility class + } + + /** + * Trailing suffixes stripped from a CANDIDATE's name/FK field when building its + * pairing keys. Ordered — first match wins, so "reference" is tested before + * "ref". Never applied to the relationship name (see {@link #referencePairingKeys}). + */ + private static final List PAIRING_SUFFIXES = List.of("reference", "ref", "id", "key"); + + private static String stripOneSuffix(String value) { + for (String suffix : PAIRING_SUFFIXES) { + if (value.length() > suffix.length() && value.endsWith(suffix)) { + return value.substring(0, value.length() - suffix.length()); + } + } + return value; + } + + // refFkField / stripPackage moved to the package-private ReferenceFkUtil (fix + // round 1, finding 5) — they were byte-for-byte duplicates of M2MFields' own + // private copies, and both classes live in this same package. + + /** + * The set of lowercased names a candidate reference answers to: its own name + * (short name — a reference is never itself package-qualified) and its FK + * field, each with and without one stripped suffix. + * + *

{@code toLowerCase(Locale.ROOT)} — never the no-arg {@code toLowerCase()} — so + * behaviour does not depend on the JVM's default locale (a Turkish-locale JVM lower- + * cases {@code "I"} to {@code "ı"}, not {@code "i"}).

+ */ + public static Set referencePairingKeys(ReferenceIdentity ref) { + Set keys = new HashSet<>(); + addPairingKey(keys, ref.getShortName()); + addPairingKey(keys, ReferenceFkUtil.refFkField(ref)); + return keys; + } + + private static void addPairingKey(Set keys, String value) { + if (value == null || value.isEmpty()) return; + String lower = value.toLowerCase(Locale.ROOT); + keys.add(lower); + keys.add(stripOneSuffix(lower)); + } + + /** + * Every {@code identity.reference} on {@code holder} whose {@code @references} targets + * {@code targetEntity}. Package-insensitive on both sides: {@code @references} and + * {@code @objectRef} may each be bare or fully qualified. + * + *

ADR-0039: resolving — {@code holder.getIdentities()} (no-arg, includeParentData + * defaulting true) honors references inherited via extends.

+ */ + public static List referenceCandidatesFor(MetaObject holder, String targetEntity) { + String target = ReferenceFkUtil.stripPackage(targetEntity); + List out = new ArrayList<>(); + for (MetaIdentity id : holder.getIdentities()) { + if (!(id instanceof ReferenceIdentity)) continue; + ReferenceIdentity ref = (ReferenceIdentity) id; + if (!target.equals(ReferenceFkUtil.stripPackage(ref.getTargetEntity()))) continue; + if (ReferenceFkUtil.refFkField(ref) == null) continue; + out.add(ref); + } + return out; + } + + /** + * Which identity.reference does this {@code @cardinality: one} relationship navigate + * through? The ladder, in order: + *
    + *
  1. exactly one candidate -> that one (the common case; unchanged behaviour)
  2. + *
  3. {@code @sourceRefField} declared -> the candidate whose FK field it names, + * SHORT-CIRCUITING (does not fall through to name-pairing on a miss)
  4. + *
  5. exactly one candidate name-pairs -> that one
  6. + *
  7. otherwise -> {@code null} (caller reports the ambiguity)
  8. + *
+ * Returns {@code null} for "no candidate" and "cannot choose" alike; callers that need + * to tell them apart use {@link #referenceCandidatesFor}. + * + * @param holder the entity whose effective reference set is searched + * @param relationshipName the relationship's own (short) name, used for name-pairing + * @param targetEntity the relationship's {@code @objectRef} + * @param sourceRefField the relationship's declared {@code @sourceRefField}, or + * {@code null}/empty when absent + */ + public static ReferenceIdentity resolveRelationshipReference( + MetaObject holder, String relationshipName, String targetEntity, String sourceRefField) { + List candidates = referenceCandidatesFor(holder, targetEntity); + if (candidates.isEmpty()) return null; + if (candidates.size() == 1) return candidates.get(0); + + if (sourceRefField != null && !sourceRefField.isEmpty()) { + for (ReferenceIdentity ref : candidates) { + if (sourceRefField.equals(ReferenceFkUtil.refFkField(ref))) return ref; + } + return null; + } + + String wanted = relationshipName.toLowerCase(Locale.ROOT); + List paired = new ArrayList<>(); + for (ReferenceIdentity ref : candidates) { + if (referencePairingKeys(ref).contains(wanted)) paired.add(ref); + } + return paired.size() == 1 ? paired.get(0) : null; + } + + /** Overload for the common case with no declared {@code @sourceRefField}. */ + public static ReferenceIdentity resolveRelationshipReference( + MetaObject holder, String relationshipName, String targetEntity) { + return resolveRelationshipReference(holder, relationshipName, targetEntity, null); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/Issue368RuleDDedupeUnitTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/Issue368RuleDDedupeUnitTest.java new file mode 100644 index 000000000..0f9c6be93 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/Issue368RuleDDedupeUnitTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.loader; + +import com.metaobjects.MetaDataException; +import com.metaobjects.MetaRoot; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +/** + * Issue #368 fix round 1, finding 2 — a dedupe test that can actually fail. + * + *

{@link ValidationPhase#run(MetaRoot, MetaDataLoader)} already collapses findings + * by {@code code + envelope.toString()} ({@code ValidationPhase.dedupe}), and + * {@code JsonSource} is a record with value-based {@code toString()} — so four visits + * of the SAME physical relationship (one entity declaring it, three inheriting it + * unmodified) produce byte-identical exceptions (same code, same source instance, + * same message) regardless of whether {@code validateRelationshipsM2M}'s own + * {@code checkedRels} (an {@code IdentityHashMap}-backed dedupe set) does anything at + * all. A test that only calls {@code loader.load(...)} and checks the FINAL + * error count (as {@code Issue368RelationshipReferenceValidationTest}'s dedupe tests + * do) would pass identically with {@code checkedRels} deleted.

+ * + *

This test lives in {@code com.metaobjects.loader} (not + * {@code com.metaobjects.relationship}, where the rest of the #368 test suite lives) + * specifically to reach the package-private {@link ValidationPhase#validateRelationshipsM2M} + * directly and assert on the size of the RAW list it returns — bypassing {@code run()}'s + * own dedupe entirely. Deleting {@code checkedRels} (making every relationship node + * always pass the identity check) would make this assert 4, not 1, so it is a genuine + * regression guard for that mechanism specifically.

+ */ +public class Issue368RuleDDedupeUnitTest extends SharedRegistryTestBase { + + private MetaDataLoader newTestLoader() { + return createTestLoader("Issue368RuleDDedupeUnitTest", Collections.emptyList()); + } + + @Test + public void ruleDDedupeIsGenuineNotJustRunLevelEnvelopeCollapse() { + // A declares one broken M:N-only-attr-on-non-M:N relationship ("program", + // @through set but @cardinality: "one"). B, C and D each extend A WITHOUT + // overriding "program" -- so all four entities' effective relationship set + // (obj.getRelationships()) contains the literal SAME MetaRelationship object. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Program\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"A\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\" } }," + + " { \"object.entity\": { \"name\": \"C\", \"extends\": \"A\" } }," + + " { \"object.entity\": { \"name\": \"D\", \"extends\": \"A\" } }" + + "] } }"; + + MetaDataLoader loader = newTestLoader(); + MetaRoot root; + try { + loader.load(List.of(new InMemoryStringSource(doc, "dedupe-unit.json"))); + root = loader.getRoot(); + } catch (MetaDataException e) { + // Expected -- the model is deliberately invalid. The tree is already + // fully built (parse + extends resolution happen before validation), so + // getRoot() is still usable for a direct, fresh call below. + root = loader.getRoot(); + } + + // Call the pass directly -- a SECOND, INDEPENDENT invocation, not reading + // anything cached by the load() above -- and inspect its raw return value. + // run()'s dedupe(collected) is never involved here. + List raw = ValidationPhase.validateRelationshipsM2M(root); + assertEquals( + "checkedRels must dedupe A's single relationship across A + 3 inheriting " + + "children (B, C, D) to exactly one raw finding from " + + "validateRelationshipsM2M itself -- got: " + raw, + 1, raw.size()); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java new file mode 100644 index 000000000..4f2171143 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java @@ -0,0 +1,174 @@ +package com.metaobjects.relationship; + +import com.metaobjects.MetaData; +import com.metaobjects.identity.ReferenceIdentity; +import com.metaobjects.loader.InMemoryStringSource; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Issue #368 — direct unit tests for the reference-resolution ladder + * ({@link RelationshipReferences}). + * + *

Java port of the TS reference suite + * ({@code server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts}) + * and its Python/C# ports — exercises {@link RelationshipReferences#referenceCandidatesFor} + * / {@link RelationshipReferences#resolveRelationshipReference} directly against a loaded + * model, rather than only through the loader's error output (see + * {@link Issue368RelationshipReferenceValidationTest} for the loader-integration tests + * covering rule (d) / rule (e)).

+ */ +public class Issue368RelationshipReferenceLadderTest extends SharedRegistryTestBase { + + private MetaDataLoader newTestLoader() { + return createTestLoader("Issue368RelationshipReferenceLadderTest", java.util.Collections.emptyList()); + } + + private MetaDataLoader loadClean(String json, String id) { + MetaDataLoader loader = newTestLoader(); + loader.load(List.of(new InMemoryStringSource(json, id))); + return loader; + } + + private static final String MATCH_MODEL = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"homeTeamId\" } }," + + " { \"field.long\": { \"name\": \"awayTeamId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } }," + + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }" + + "] } }"; + + private MetaObject loadMatch() { + MetaDataLoader loader = loadClean(MATCH_MODEL, "match.json"); + assertTrue(loader.getErrors().isEmpty()); + return (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match"); + } + + @Test + public void enumeratesEveryCandidateReferenceForTheTarget() { + List candidates = RelationshipReferences.referenceCandidatesFor(loadMatch(), "Team"); + assertEquals(2, candidates.size()); + assertEquals("homeTeamRef", candidates.get(0).getShortName()); + assertEquals("awayTeamRef", candidates.get(1).getShortName()); + } + + @Test + public void namePairingResolvesEachAssociationToItsOwnReference() { + MetaObject match = loadMatch(); + assertEquals("homeTeamRef", + RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team").getShortName()); + assertEquals("awayTeamRef", + RelationshipReferences.resolveRelationshipReference(match, "awayTeam", "Team").getShortName()); + } + + @Test + public void sourceRefFieldWinsOverNamePairing() { + MetaObject match = loadMatch(); + assertEquals("awayTeamRef", + RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId") + .getShortName()); + } + + @Test + public void sourceRefFieldMissShortCircuitsRatherThanFallingThroughToNamePairing() { + // #368 ladder step 2: a declared @sourceRefField that matches NOTHING must + // return null -- it must NOT fall through to step 3 (name-pairing), even + // though "homeTeam" would otherwise pair cleanly with "homeTeamRef". + MetaObject match = loadMatch(); + assertNull(RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team", "doesNotExist")); + } + + @Test + public void aSingleCandidateResolvesRegardlessOfName() { + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"winnerFk\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"anythingAtAll\", \"@fields\": \"winnerFk\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"champion\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }" + + "] } }"; + MetaDataLoader loader = loadClean(doc, "single-candidate.json"); + assertTrue(loader.getErrors().isEmpty()); + MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match"); + assertEquals("anythingAtAll", + RelationshipReferences.resolveRelationshipReference(match, "champion", "Team").getShortName()); + } + + @Test + public void unpairableNamesReturnNullRatherThanGuessing() { + // #368 rule (e) flags this fixture as a load error -- it's the exact + // ambiguity the ladder returning null exists to surface. The loader throws; + // catch it and assert the ladder's return value directly against the + // already-loaded (partially-validated) root. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"alphaFk\" } }," + + " { \"field.long\": { \"name\": \"betaFk\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"alphaRef\", \"@fields\": \"alphaFk\", \"@references\": \"Team\" } }," + + " { \"identity.reference\": { \"name\": \"betaRef\", \"@fields\": \"betaFk\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }" + + "] } }"; + MetaDataLoader loader = newTestLoader(); + try { + loader.load(List.of(new InMemoryStringSource(doc, "unpairable.json"))); + fail("Expected the load to fail with an ambiguity error"); + } catch (com.metaobjects.MetaDataException expected) { + // expected -- rule (e) rejects this model + } + MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match"); + assertNull(RelationshipReferences.resolveRelationshipReference(match, "winner", "Team")); + } + + @Test + public void suffixStrippingNeverAppliesToTheRelationshipName() { + // "valid" must NOT be stripped to "val" and pair with valRef. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"valFk\" } }," + + " { \"field.long\": { \"name\": \"otherFk\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"valRef\", \"@fields\": \"valFk\", \"@references\": \"Team\" } }," + + " { \"identity.reference\": { \"name\": \"otherRef\", \"@fields\": \"otherFk\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"valid\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }" + + "] } }"; + MetaDataLoader loader = newTestLoader(); + try { + loader.load(List.of(new InMemoryStringSource(doc, "valid-valref.json"))); + fail("Expected the load to fail with an ambiguity error"); + } catch (com.metaobjects.MetaDataException expected) { + // expected -- "valid" must not pair with "valRef" + } + MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match"); + assertNull(RelationshipReferences.resolveRelationshipReference(match, "valid", "Team")); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java new file mode 100644 index 000000000..71bc31142 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceValidationTest.java @@ -0,0 +1,710 @@ +package com.metaobjects.relationship; + +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaDataException; +import com.metaobjects.loader.InMemoryStringSource; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.MetaDataSource; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Issue #368 — loader validation for relationship.* M:N slim vocabulary + the + * 1:N reference-disambiguation rules (the resolution ladder in + * {@link RelationshipReferences}). + * + *

Java port of the TS reference suite ({@code relationship-m2m.test.ts}) and its + * Python/C# ports, covering the #368 additions:

+ *
    + *
  • (B) {@code @sourceRefField} becomes legal on {@code @cardinality: "one"} + * (previously rejected on any non-M:N relationship).
  • + *
  • (C) Rule (e) — a {@code @cardinality: one} relationship must resolve to + * exactly one identity.reference candidate; ambiguity is a load error.
  • + *
  • (D) Both rule (d) (the M:N slim-vocabulary pass) and rule (e) iterate the + * EFFECTIVE relationship set (own + inherited via extends), not just + * own-declared relationships — with rule (d) deduping on the relationship + * node's identity (an inherited, unmodified relationship must not be + * reported once per inheriting entity).
  • + *
+ * + *

Also regression-covers two latent "obj vs. declaring entity" bugs (present in + * TS, Python and C# before their own #368 fixes): ADR-0042 package resolution for a + * bare {@code @through} must use the DECLARING entity's package, and rule (a)'s + * self-join comparison must use the DECLARING entity, not whichever entity's + * effective view reached the inherited relationship first.

+ * + *

See {@code server/typescript/packages/metadata/src/core/relationship/ + * resolve-relationship-reference.ts} and {@code .../src/loader/validation-passes.ts} + * ({@code validateRelationships} / {@code validateOneSideReferenceResolution}) for + * the authoritative spec these tests mirror.

+ */ +public class Issue368RelationshipReferenceValidationTest extends SharedRegistryTestBase { + + private MetaDataLoader newTestLoader() { + return createTestLoader("Issue368RelationshipReferenceValidationTest", java.util.Collections.emptyList()); + } + + /** Outcome of a load attempt: every error the loader saw, whether recorded via + * {@code getErrors()} (all-but-last) or thrown (the last one) — mirrors the C# + * {@code LoadResult.Errors} single collection so assertions read the same way. */ + private static final class Outcome { + final MetaDataLoader loader; + final MetaDataException thrown; + + Outcome(MetaDataLoader loader, MetaDataException thrown) { + this.loader = loader; + this.thrown = thrown; + } + + List allErrors() { + List all = new ArrayList<>(loader.getErrors()); + if (thrown != null) all.add(thrown); + return all; + } + + String joinedMessages() { + StringBuilder sb = new StringBuilder(); + for (MetaDataException e : allErrors()) { + if (sb.length() > 0) sb.append('\n'); + sb.append(e.getMessage()); + } + return sb.toString(); + } + } + + private Outcome attemptLoad(String... jsons) { + MetaDataLoader loader = newTestLoader(); + List sources = new ArrayList<>(); + for (int i = 0; i < jsons.length; i++) { + sources.add(new InMemoryStringSource(jsons[i], "inline-" + i + ".json")); + } + try { + loader.load(sources); + return new Outcome(loader, null); + } catch (MetaDataException e) { + return new Outcome(loader, e); + } + } + + private void assertLoadsClean(String... jsons) { + Outcome outcome = attemptLoad(jsons); + if (outcome.thrown != null || !outcome.loader.getErrors().isEmpty()) { + fail("Expected a clean load; got: " + outcome.joinedMessages()); + } + } + + private void assertHasError(Outcome outcome, ErrorCode expected) { + boolean found = outcome.allErrors().stream() + .anyMatch(e -> e.getCode().map(c -> c == expected).orElse(false)); + assertTrue("Expected an error with code " + expected + "; got: " + outcome.joinedMessages(), found); + } + + private void assertNoErrorOfCode(Outcome outcome, ErrorCode code) { + boolean found = outcome.allErrors().stream() + .anyMatch(e -> e.getCode().map(c -> c == code).orElse(false)); + assertFalse("Did not expect any " + code + "; got: " + outcome.joinedMessages(), found); + } + + // ------------------------------------------------------------------------- + // (B) @sourceRefField becomes legal on @cardinality: "one" + // ------------------------------------------------------------------------- + + @Test + public void sourceRefFieldOnCardinalityOneLoadsClean() { + // The issue #368 repro: two 1:N relationships, each disambiguated by + // @sourceRefField, load with no errors. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"homeTeamId\" } }," + + " { \"field.long\": { \"name\": \"awayTeamId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } }," + + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"homeTeamId\" } }," + + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"awayTeamId\" } } ] } }" + + "] } }"; + assertLoadsClean(doc); + } + + @Test + public void sourceRefFieldOnManyWithoutThroughStillErrors() { + // @sourceRefField on @cardinality: many with no @through is still not + // M:N -- the widening only spares @cardinality: one. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"relationship.association\": { \"name\": \"teams\", \"@objectRef\": \"Team\", \"@cardinality\": \"many\", \"@sourceRefField\": \"whatever\" } } ] } }" + + "] } }"; + assertHasError(attemptLoad(doc), ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + @Test + public void throughOnCardinalityOneStillErrors() { + // @through still requires @cardinality: many -- only @sourceRefField was widened. + String doc = + "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Week\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Program\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }" + + "] } }"; + assertHasError(attemptLoad(doc), ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + @Test + public void symmetricOnCardinalityOneStillErrors() { + // @symmetric still requires M:N -- only @sourceRefField was widened. The + // fixture sets @cardinality: "one" EXPLICITLY (not just omits it) -- an + // omitted @cardinality is the diverging case covered separately by + // sourceRefFieldOnAbsentCardinalityStillErrors below; this test must not + // rely on MetaRelationship.getCardinality()'s "one" default to exercise + // the genuine @cardinality: "one" path. + String doc = + "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Week\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"relationship.association\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@symmetric\": true } } ] } }," + + " { \"object.entity\": { \"name\": \"Program\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }" + + "] } }"; + assertHasError(attemptLoad(doc), ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + @Test + public void sourceRefFieldOnAbsentCardinalityStillErrors() { + // Cross-port divergence guard (fix round 1): MetaRelationship.getCardinality() + // defaults to "one" when the attribute is absent, but TS/C#/Python all compare + // the RAW attribute -- an absent @cardinality is neither "many" nor "one" on + // any of the other three ports, so @sourceRefField must still be rejected by + // RULE (D) there. This relationship declares NO @cardinality at all. + // + // The @sourceRefField ("programId") deliberately NAMES A REAL, MATCHING + // candidate (programRef) so that rule (e) -- which also gates on cardinality + // and would otherwise independently flag/clear this fixture -- cannot mask + // whether rule (d) itself was fixed: under the (buggy) defaulting behavior, + // rule (d) wrongly exempts @sourceRefField (isCardinalityOne defaults true) + // AND rule (e) wrongly applies but finds the declared field matches its one + // candidate, so the WHOLE load comes back clean -- only the raw-read fix + // makes rule (d) reject this relationship at all. + String doc = + "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Week\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"programId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"programRef\", \"@fields\": \"programId\", \"@references\": \"Program\" } }," + + " { \"relationship.association\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@sourceRefField\": \"programId\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Program\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + List all = outcome.allErrors(); + assertEquals(outcome.joinedMessages(), 1, all.size()); + assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + assertTrue(outcome.joinedMessages(), outcome.joinedMessages().contains("Week.program")); + assertTrue(outcome.joinedMessages(), outcome.joinedMessages() + .contains("sets @sourceRefField but is neither a M:N relationship")); + // The message names BOTH legal homes for the attribute -- M:N and + // @cardinality: "one" -- so it can't read as "never allowed here". + assertTrue(outcome.joinedMessages(), outcome.joinedMessages() + .contains("nor a @cardinality: \"one\" relationship")); + } + + @Test + public void ruleEDoesNotApplyToAbsentCardinality() { + // Cross-port divergence guard (fix round 1), rule (e) side: a relationship + // with NO @cardinality and 2+ same-target candidates must NOT be flagged as + // ambiguous by rule (e) on Java -- TS/C#/Python only apply rule (e) when the + // raw @cardinality attribute is exactly "one". With getCardinality()'s "one" + // default, this fixture would (wrongly, Java-only) fail to load. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"alphaFk\" } }," + + " { \"field.long\": { \"name\": \"betaFk\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"alphaRef\", \"@fields\": \"alphaFk\", \"@references\": \"Team\" } }," + + " { \"identity.reference\": { \"name\": \"betaRef\", \"@fields\": \"betaFk\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\" } } ] } }" + + "] } }"; + assertLoadsClean(doc); + } + + // ------------------------------------------------------------------------- + // (A) The resolution ladder, exercised end-to-end through the loader. + // ------------------------------------------------------------------------- + + @Test + public void issueReproLoadsCleanViaNamePairing() { + // Two references onto the same target, no @sourceRefField -- resolved by + // name-pairing (homeTeamRef <-> homeTeam, awayTeamRef <-> awayTeam). + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"homeTeamId\" } }," + + " { \"field.long\": { \"name\": \"awayTeamId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } }," + + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }" + + "] } }"; + assertLoadsClean(doc); + } + + @Test + public void unpairableNamesErrorNamingBothCandidates() { + // Two references whose names don't pair with the relationship name -- + // ambiguous, and the error names both candidates as name(fkField). + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"alphaFk\" } }," + + " { \"field.long\": { \"name\": \"betaFk\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"alphaRef\", \"@fields\": \"alphaFk\", \"@references\": \"Team\" } }," + + " { \"identity.reference\": { \"name\": \"betaRef\", \"@fields\": \"betaFk\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + String joined = outcome.joinedMessages(); + assertTrue(joined, joined.contains("Match.winner")); + assertTrue(joined, joined.contains("alphaRef(alphaFk)")); + assertTrue(joined, joined.contains("betaRef(betaFk)")); + } + + @Test + public void declaredButUnmatchedErrorsAtSingleCandidateCount() { + // A declared @sourceRefField naming nothing must error even with exactly + // one candidate -- the ladder's step 1 ("exactly one candidate -> that + // one") must not silently short-circuit past a bad declared value. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"homeTeamId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"awayTeamId\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + List all = outcome.allErrors(); + assertEquals(outcome.joinedMessages(), 1, all.size()); + assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + String joined = outcome.joinedMessages(); + assertTrue(joined, joined.contains("Match.awayTeam")); + assertTrue(joined, joined.contains("\"awayTeamId\"")); + } + + @Test + public void declaredButUnmatchedErrorsAtZeroCandidateCount() { + // A declared @sourceRefField naming nothing must also error with ZERO + // candidates (no identity.reference targets the objectRef at all) -- the + // declared value is read BEFORE any candidate-count guard. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"homeTeamId\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + List all = outcome.allErrors(); + assertEquals(outcome.joinedMessages(), 1, all.size()); + assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + String joined = outcome.joinedMessages(); + assertTrue(joined, joined.contains("Match.homeTeam")); + assertTrue(joined, joined.contains("\"homeTeamId\"")); + } + + @Test + public void sourceRefFieldCorrectlyNamingSingleCandidateLoadsClean() { + // Regression: a correctly-declared @sourceRefField over a single + // candidate stays clean. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"homeTeamId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"homeTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"homeTeamId\" } } ] } }" + + "] } }"; + assertLoadsClean(doc); + } + + @Test + public void compositeReferenceCandidatesRenderFullFieldTuple() { + // Two composite references sharing a first column must still print + // distinguishably in the candidate list (fields[0] alone would collide). + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"tenantId\" } }," + + " { \"field.long\": { \"name\": \"homeTeamId\" } }," + + " { \"field.long\": { \"name\": \"awayTeamId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"aRef\", \"@fields\": [\"tenantId\", \"homeTeamId\"], \"@references\": \"Team\" } }," + + " { \"identity.reference\": { \"name\": \"bRef\", \"@fields\": [\"tenantId\", \"awayTeamId\"], \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + String joined = outcome.joinedMessages(); + assertTrue(joined, joined.contains("aRef(tenantId, homeTeamId)")); + assertTrue(joined, joined.contains("bRef(tenantId, awayTeamId)")); + } + + // ------------------------------------------------------------------------- + // (D) Rule (e) must iterate the EFFECTIVE relationship set. + // ------------------------------------------------------------------------- + + @Test + public void inheritedRelationshipAmbiguityErrors() { + // A extends cleanly; B extends A and adds a second reference onto the + // same target -- the inherited relationship becomes ambiguous on B even + // though A (and the relationship's own declaration) are untouched. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"A\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"homeTeamId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }," + + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\", \"children\": [" + + " { \"field.long\": { \"name\": \"awayTeamId\" } }," + + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + List all = outcome.allErrors(); + assertEquals(outcome.joinedMessages(), 1, all.size()); + assertHasError(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + String joined = outcome.joinedMessages(); + assertTrue(joined, joined.contains("B.winner")); + assertTrue(joined, joined.contains("homeTeamRef(homeTeamId)")); + assertTrue(joined, joined.contains("awayTeamRef(awayTeamId)")); + } + + @Test + public void inheritedRelationshipResolvedByChildAddedReferenceLoadsClean() { + // A child entity's added reference that name-pairs with the inherited + // relationship resolves cleanly. + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"A\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"homeTeamId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"homeTeamRef\", \"@fields\": \"homeTeamId\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"awayTeam\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\" } } ] } }," + + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\", \"children\": [" + + " { \"field.long\": { \"name\": \"awayTeamId\" } }," + + " { \"identity.reference\": { \"name\": \"awayTeamRef\", \"@fields\": \"awayTeamId\", \"@references\": \"Team\" } } ] } }" + + "] } }"; + assertLoadsClean(doc); + } + + // ------------------------------------------------------------------------- + // (D) Rule (d) dedupe -- own attrs never change per inheriting entity, so an + // inherited unmodified relationship must be reported exactly once. + // ------------------------------------------------------------------------- + + @Test + public void ruleDDedupeSingleErrorForOneInheritingChild() { + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Program\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"A\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\" } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + List all = outcome.allErrors(); + assertEquals(outcome.joinedMessages(), 1, all.size()); + assertEquals(ErrorCode.ERR_INVALID_RELATIONSHIP, all.get(0).getCode().orElse(null)); + } + + @Test + public void ruleDDedupeSingleErrorAcrossSeveralInheritingChildren() { + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Program\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"A\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"B\", \"extends\": \"A\" } }," + + " { \"object.entity\": { \"name\": \"C\", \"extends\": \"A\" } }," + + " { \"object.entity\": { \"name\": \"D\", \"extends\": \"A\" } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + List all = outcome.allErrors(); + assertEquals(outcome.joinedMessages(), 1, all.size()); + assertEquals(ErrorCode.ERR_INVALID_RELATIONSHIP, all.get(0).getCode().orElse(null)); + } + + @Test + public void twoIndependentRelationshipViolationsBothSurface() { + // Fix round 1, finding 2: proves the collect-all change actually matters. + // Under the OLD eager-throw-on-first-violation style, only ONE of these two + // independent, unrelated relationship defects (different entities, different + // relationships, different JSON source locations) would ever be reported -- + // the walk would abort at whichever one it reached first, silently leaving + // the other entity's defect unexamined. run()'s own code+envelope dedupe + // (ValidationPhase.dedupe) does NOT collapse these two: different source + // envelopes, different messages. + String doc = + "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Program\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"WeekA\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"relationship.composition\": { \"name\": \"program\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@through\": \"X\" } } ] } }," + + " { \"object.entity\": { \"name\": \"WeekB\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"relationship.association\": { \"name\": \"sponsor\", \"@objectRef\": \"Program\", \"@cardinality\": \"one\", \"@symmetric\": true } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + List all = outcome.allErrors(); + assertEquals("Expected BOTH independent relationship violations to surface " + + "from one load: " + outcome.joinedMessages(), 2, all.size()); + String joined = outcome.joinedMessages(); + assertTrue(joined, joined.contains("WeekA.program")); + assertTrue(joined, joined.contains("WeekB.sponsor")); + } + + // ------------------------------------------------------------------------- + // Cross-relationship state-leakage regression: a declared-but-unmatched + // @sourceRefField at 2+ candidates on one relationship must not affect a + // SIBLING relationship on the same entity whose declared field DOES match. + // (Not present in the Python port; it is the case most likely to catch + // per-relationship state accidentally shared across a loop iteration.) + // ------------------------------------------------------------------------- + + @Test + public void siblingRelationshipWithMatchingSourceRefFieldProducesNoErrorWhileTheBadOneDoes() { + String doc = + "{ \"metadata.root\": { \"package\": \"repro\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Team\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Venue\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Match\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"venueId\" } }," + + " { \"field.long\": { \"name\": \"alphaFk\" } }," + + " { \"field.long\": { \"name\": \"betaFk\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + // The GOOD sibling: a single, correctly-matched candidate onto Venue. + + " { \"identity.reference\": { \"name\": \"venueRef\", \"@fields\": \"venueId\", \"@references\": \"Venue\" } }," + + " { \"relationship.association\": { \"name\": \"venue\", \"@objectRef\": \"Venue\", \"@cardinality\": \"one\", \"@sourceRefField\": \"venueId\" } }," + // The BAD one: 2+ candidates onto Team, declared @sourceRefField matches neither. + + " { \"identity.reference\": { \"name\": \"alphaRef\", \"@fields\": \"alphaFk\", \"@references\": \"Team\" } }," + + " { \"identity.reference\": { \"name\": \"betaRef\", \"@fields\": \"betaFk\", \"@references\": \"Team\" } }," + + " { \"relationship.association\": { \"name\": \"winner\", \"@objectRef\": \"Team\", \"@cardinality\": \"one\", \"@sourceRefField\": \"doesNotExist\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + List all = outcome.allErrors(); + assertEquals("Expected exactly one error (the bad 'winner' relationship only): " + + outcome.joinedMessages(), 1, all.size()); + String joined = outcome.joinedMessages(); + assertTrue(joined, joined.contains("Match.winner")); + assertTrue(joined, joined.contains("\"doesNotExist\"")); + // The good sibling must never be named in any error -- no state leaked + // from evaluating "winner" into (or out of) evaluating "venue". + assertFalse(joined, joined.contains("Match.venue\"")); + } + + // ------------------------------------------------------------------------- + // Two latent "obj vs. declaring entity" bugs -- present in TS, Python and C# + // before their own #368 fixes. Regression-covered here for Java. + // ------------------------------------------------------------------------- + + @Test + public void inheritedSelfJoinRelationshipIsNotMisflaggedAsNonSelfJoin() { + // Node extends NodeBase, which declares a @symmetric self-join + // relationship onto NodeBase itself (@objectRef: "NodeBase"). Node is + // declared BEFORE NodeBase (extends is resolved order-independently by + // a deferred pass, so this is legal) so that the outer validation loop + // visits `obj = Node` FIRST -- if rule (a)'s self-join comparison used + // the visiting `obj` instead of the relationship's DECLARING entity + // (NodeBase, via rel.getParent()), it would wrongly conclude @objectRef + // "NodeBase" is not the (visiting) declaring entity "Node" and misfire + // ERR_BAD_ATTR_VALUE. + String doc = + "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Node\", \"extends\": \"NodeBase\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"NodeBase\", \"@isAbstract\": true, \"children\": [" + + " { \"relationship.association\": { \"name\": \"peers\", \"@cardinality\": \"many\", \"@objectRef\": \"NodeBase\"," + + " \"@through\": \"NodeLink\", \"@symmetric\": true } } ] } }," + + " { \"object.entity\": { \"name\": \"NodeLink\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"aId\" } }," + + " { \"field.long\": { \"name\": \"bId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"a\", \"@fields\": \"aId\", \"@references\": \"NodeBase\" } }," + + " { \"identity.reference\": { \"name\": \"b\", \"@fields\": \"bId\", \"@references\": \"NodeBase\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + // Pin the iteration-order invariant this test's premise depends on: if + // root.objects() ever stopped iterating in declaration order (e.g. started + // sorting alphabetically), "Node" would no longer be visited before + // "NodeBase" and this test would keep passing for the wrong reason -- + // silently no longer exercising the bug at all. Fail loudly instead. + assertEquals("root.objects() must iterate in declaration order for this " + + "test's premise (Node visited before NodeBase) to hold", + List.of("acme::Node", "acme::NodeBase", "acme::NodeLink"), objectVisitOrder(outcome)); + assertNoErrorOfCode(outcome, ErrorCode.ERR_BAD_ATTR_VALUE); + assertNoErrorOfCode(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + @Test + public void inheritedBareThroughResolvesInTheDeclaringEntityPackageNotTheVisitingOne() { + // WeekBase (package "base") declares a M:N relationship with a BARE + // @through "Tag" -- ADR-0042 says a bare ref resolves in the DECLARING + // entity's package ("base::Tag"), never the package of whichever entity + // inherits and visits it. Week extends WeekBase from a DIFFERENT package + // ("acme") that also happens to declare its own unrelated "Tag" entity. + // The acme source is loaded FIRST so the outer validation loop visits + // `obj = Week` before `obj = WeekBase` -- if @through resolution used the + // visiting entity's package it would wrongly bind to "acme::Tag" (which + // has zero identity.reference children) instead of "base::Tag" (which + // correctly has two). + String acmeDoc = + "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Week\", \"extends\": \"base::WeekBase\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Tag\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }" + + "] } }"; + String baseDoc = + "{ \"metadata.root\": { \"package\": \"base\", \"children\": [" + + " { \"object.entity\": { \"name\": \"WeekBase\", \"@isAbstract\": true, \"children\": [" + + " { \"relationship.association\": { \"name\": \"tags\", \"@cardinality\": \"many\", \"@objectRef\": \"Tag\", \"@through\": \"Tag\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Tag\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"weekId\" } }," + + " { \"field.long\": { \"name\": \"labelId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"w\", \"@fields\": \"weekId\", \"@references\": \"base::WeekBase\" } }," + + " { \"identity.reference\": { \"name\": \"l\", \"@fields\": \"labelId\", \"@references\": \"base::Tag\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(acmeDoc, baseDoc); + // Pin the iteration-order invariant: the acme source must be fully visited + // (Week, then acme::Tag) before base's WeekBase/Tag, or this test's premise + // (obj = Week visited before obj = WeekBase) silently stops holding and the + // test would keep passing without ever exercising the bug. + assertEquals("root.objects() must iterate in source-then-declaration order " + + "for this test's premise (Week visited before WeekBase) to hold", + List.of("acme::Week", "acme::Tag", "base::WeekBase", "base::Tag"), objectVisitOrder(outcome)); + assertNoErrorOfCode(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + } + + /** The FQNs of {@code root.objects()} in iteration order, for pinning the + * declaration/source-order invariant the two order-dependence regression + * tests above rely on. */ + private static List objectVisitOrder(Outcome outcome) { + List names = new ArrayList<>(); + for (com.metaobjects.object.MetaObject obj : outcome.loader.getRoot().objects()) { + names.add(obj.getName()); + } + return names; + } + + // ------------------------------------------------------------------------- + // Regression: valid M:N still loads clean. + // ------------------------------------------------------------------------- + + @Test + public void validHeteroM2mProducesNoRelationshipErrors() { + String doc = + "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Post\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"relationship.association\": { \"name\": \"tags\", \"@cardinality\": \"many\", \"@objectRef\": \"Tag\", \"@through\": \"PostTag\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"Tag\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } } ] } }," + + " { \"object.entity\": { \"name\": \"PostTag\", \"children\": [" + + " { \"field.long\": { \"name\": \"id\" } }," + + " { \"field.long\": { \"name\": \"postId\" } }," + + " { \"field.long\": { \"name\": \"tagId\" } }," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": \"id\" } }," + + " { \"identity.reference\": { \"name\": \"p\", \"@fields\": \"postId\", \"@references\": \"Post\" } }," + + " { \"identity.reference\": { \"name\": \"t\", \"@fields\": \"tagId\", \"@references\": \"Tag\" } } ] } }" + + "] } }"; + Outcome outcome = attemptLoad(doc); + assertNoErrorOfCode(outcome, ErrorCode.ERR_INVALID_RELATIONSHIP); + assertNoErrorOfCode(outcome, ErrorCode.ERR_BAD_ATTR_VALUE); + } +} diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index e070a0bf8..367f1f934 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -136,6 +136,11 @@ ) from ..source import resolved_source from ..naming_refs import did_you_mean_hint, resolve_object_ref +from ..meta.core.relationship.relationship_references import ( + reference_candidates_for, + reference_fields, + resolve_relationship_reference, +) # A subtype-specific template attr is valid ONLY on the subtype(s) it is registered # for. The metamodel registers these per-subtype (see the core_types template block), @@ -193,6 +198,10 @@ def run_validations( # FR-024 B6 — an entity's origin-bearing field needs a read-capable source. _validate_derived_field_providability(root, errors) _validate_relationships(root, errors) + # Rule (e) (#368) — registered alongside _validate_relationships (the M:N + # slim-vocabulary pass, rule (d)): same deferred-resolution timing (after + # all files load + extends resolution). + _validate_one_side_reference_resolution(root, errors) # Phase 2 — validation DERIVED FROM THE TYPE REGISTRY: each node's TypeDefinition # carries its reference descriptors (relationship @objectRef, identity.reference # @references for core; a downstream provider's type carries its own) + validator, @@ -2614,17 +2623,44 @@ def _count_junction_references(junction: MetaData) -> int: def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: - # ADR-0039: a relationship is validated on the entity that DECLARES it — the - # M:N slim-vocabulary rules apply to own-declared relationships (obj.own_children()), - # but each relationship's @through/@sourceRefField/@symmetric/@cardinality/@objectRef - # is read RESOLVING (get_meta_attr), since those attrs may be inherited via extends. - # Mirrors the TS validateRelationships (root.children() + obj.ownChildren() + - # `rel.attr` which resolves — validation-passes.ts:1313-1324). (The junction's - # identity.reference fields are also read resolving — see _count_junction_references.) + # #368 — the inner loop below iterates the EFFECTIVE relationship set + # (obj.children(), resolving), so a relationship inherited unmodified by N + # entities is reached N times. Its own attrs never change based on who + # inherits it, so re-validating it more than once would report the + # identical finding N times — pure noise. `checked` is keyed on the + # relationship NODE's own object identity: MetaData.children()'s + # effective-children computation reuses the super's child object in place + # for an unmodified inherited child (see MetaData._effective_children_inner), + # so the same physical declaration IS the same object everywhere it's + # visible, while an override replaces it with a genuinely different object + # (correctly NOT deduped — a distinct declaration is a distinct finding). + checked: set[MetaData] = set() + # ADR-0039: root has no super; children()==own_children(), but resolving + # is still the correct default (mirrors the TS root.children()). for obj in (c for c in root.children() if c.type == TYPE_OBJECT): - # ADR-0042 — a bare @through / @objectRef resolves in the declaring entity's package. - referrer_pkg = obj.package or obj.file_default_package or "" - for rel in (c for c in obj.own_children() if c.type == TYPE_RELATIONSHIP): + # ADR-0039: resolving — rule (d) (like rule (e)) must see a + # relationship inherited via extends, not just this entity's own + # declarations. `checked` absorbs the resulting revisits. + for rel in (c for c in obj.children() if c.type == TYPE_RELATIONSHIP): + if rel in checked: + continue + checked.add(rel) + + # Every rule below validates a property of the relationship's OWN + # declaration (its attrs, plus for rule (c) the @through target), so + # context — the entity name in messages, the package a bare + # @through resolves in (ADR-0042), and rule (a)'s self-join + # comparison — is always the entity that DECLARES `rel` + # (rel.parent), never `obj` (the entity whose effective view + # happened to reach it first). This keeps the check's result + # independent of iteration order/inheritance depth, which is what + # makes checking each node exactly once correct. + declaring_entity = rel.parent if rel.parent is not None else obj + referrer_pkg = ( + declaring_entity.package or declaring_entity.file_default_package or "" + ) + + # ADR-0039: resolving — a relationship may inherit its M:N attrs via extends. through = rel.get_meta_attr(RELATIONSHIP_ATTR_THROUGH) source_ref_field = rel.get_meta_attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) symmetric = rel.get_meta_attr(RELATIONSHIP_ATTR_SYMMETRIC) is True @@ -2637,6 +2673,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: ) is_many = cardinality == CARDINALITY_MANY is_m2m = has_through and is_many + is_cardinality_one = cardinality == CARDINALITY_ONE # NOTE: @objectRef existence resolution moved to the validation registry # (a declarative ReferenceDescriptor on relationship.* TypeDefinitions, @@ -2646,22 +2683,31 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: if not is_m2m: if has_through: errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" sets ' + f'relationship "{declaring_entity.name}.{rel.name}" sets ' f'@{RELATIONSHIP_ATTR_THROUGH} but is not a M:N relationship ' f'(requires @{RELATIONSHIP_ATTR_CARDINALITY}: "{CARDINALITY_MANY}").', ErrorCode.ERR_INVALID_RELATIONSHIP, envelope=rel.source, )) - if has_source_ref_field: + # #368: @sourceRefField also disambiguates a `@cardinality: one` + # relationship when the entity holds more than one + # identity.reference onto the same target. Only the M:N + # *junction* reading is rejected here; rule (e) — + # _validate_one_side_reference_resolution, below in this file — + # checks that it names a real local reference. + if has_source_ref_field and not is_cardinality_one: errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" sets ' - f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.', + f'relationship "{declaring_entity.name}.{rel.name}" sets ' + f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is neither a M:N ' + f'relationship (requires @{RELATIONSHIP_ATTR_THROUGH} with ' + f'@{RELATIONSHIP_ATTR_CARDINALITY}: "{CARDINALITY_MANY}") nor a ' + f'@{RELATIONSHIP_ATTR_CARDINALITY}: "{CARDINALITY_ONE}" relationship.', ErrorCode.ERR_INVALID_RELATIONSHIP, envelope=rel.source, )) if symmetric: errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" sets ' + f'relationship "{declaring_entity.name}.{rel.name}" sets ' f'@{RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.', ErrorCode.ERR_INVALID_RELATIONSHIP, envelope=rel.source, @@ -2671,7 +2717,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: # Rule (b): @symmetric and @sourceRefField are mutually exclusive. if symmetric and has_source_ref_field: errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" sets both ' + f'relationship "{declaring_entity.name}.{rel.name}" sets both ' f'@{RELATIONSHIP_ATTR_SYMMETRIC} and ' f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD}; they are mutually exclusive.', ErrorCode.ERR_BAD_ATTR_VALUE, @@ -2684,13 +2730,13 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: # name entity) is NOT (comparing stripped short names would misclassify it). is_self_join = ( isinstance(object_ref, str) - and resolve_object_ref(root, object_ref, referrer_pkg) is obj + and resolve_object_ref(root, object_ref, referrer_pkg) is declaring_entity ) if symmetric and not is_self_join: errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" sets ' + f'relationship "{declaring_entity.name}.{rel.name}" sets ' f'@{RELATIONSHIP_ATTR_SYMMETRIC} but @{RELATIONSHIP_ATTR_OBJECT_REF} ' - f'"{object_ref}" is not the declaring entity "{obj.name}"; ' + f'"{object_ref}" is not the declaring entity "{declaring_entity.name}"; ' f'@{RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.', ErrorCode.ERR_BAD_ATTR_VALUE, envelope=rel.source, @@ -2704,12 +2750,12 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: junction = resolve_object_ref(root, str(through), referrer_pkg) if junction is None: errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" ' + f'relationship "{declaring_entity.name}.{rel.name}" ' f'@{RELATIONSHIP_ATTR_THROUGH} "{through}" does not resolve to an ' f"entity.{did_you_mean_hint(root, str(through))}", ErrorCode.ERR_INVALID_RELATIONSHIP, envelope=resolved_source( - rel.source, f"{obj.fqn()}::{rel.name}", str(through) + rel.source, f"{declaring_entity.fqn()}::{rel.name}", str(through) ), )) continue @@ -2719,7 +2765,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: # assert it here. (A value/projection has no table to join through.) if junction.sub_type != OBJECT_SUBTYPE_ENTITY: errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" ' + f'relationship "{declaring_entity.name}.{rel.name}" ' f'@{RELATIONSHIP_ATTR_THROUGH} "{through}" resolves to ' f"{junction.type}.{junction.sub_type}, not an entity — a junction is a " f"persisted join table and must be object.entity.", @@ -2730,7 +2776,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: ref_count = _count_junction_references(junction) if ref_count != 2: errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" ' + f'relationship "{declaring_entity.name}.{rel.name}" ' f'@{RELATIONSHIP_ATTR_THROUGH} "{through}" must declare exactly two ' f'identity.reference children (one per FK side); found {ref_count}.', ErrorCode.ERR_INVALID_RELATIONSHIP, @@ -2744,7 +2790,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: if source_ref_field not in fk_fields: available = ", ".join(fk_fields) or "(none)" errors.append(MetaError( - f'relationship "{obj.name}.{rel.name}" ' + f'relationship "{declaring_entity.name}.{rel.name}" ' f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "{source_ref_field}" ' f'does not match any identity.reference FK field on junction ' f'"{through}". Available: {available}.', @@ -2753,6 +2799,122 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: )) +# --------------------------------------------------------------------------- +# Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly +# one identity.reference. Two references onto the same target are +# indistinguishable from the relationship's @objectRef alone, so the resolver +# would silently emit the first one's FK column. ADR-0029 Section 5: ambiguity +# is a load error naming the candidates. +# +# Registered alongside _validate_relationships (the M:N slim-vocabulary pass, +# above) — same deferred-resolution timing (after all files load + extends +# resolution). +# +# Scope differs deliberately from rule (d) — in SUBJECT, not in which +# relationships each pass walks (both walk the EFFECTIVE set; rule (d) is +# no longer own-scoped, or an M:N declaration reached only via extends +# would go unchecked). Rule (d) validates attrs that travel with the +# relationship's OWN declaration (@through/@symmetric/@sourceRefField), so +# it checks each declaration EXACTLY ONCE — deduped by node identity, and +# reported against the entity that declares it — because those attrs don't +# change meaning depending on who inherits the relationship. Rule (e) +# instead validates whether THIS entity's reference set resolves the +# relationship uniquely, which is a property of the EFFECTIVE entity, not of +# wherever the relationship happens to be declared. A child entity that +# extends a clean parent and adds a second identity.reference onto the same +# target makes an INHERITED relationship ambiguous on the child even though +# the parent (and the relationship's own declaration) are untouched — own- +# scoping this pass would leave that case unchecked, and codegen/runtime +# (which resolve against the effective entity) would silently drop the +# relation. If a parent and a child are both genuinely ambiguous, both are +# reported — two entities are broken, not one error duplicated. (No dedupe +# here, unlike rule (d): rule (e)'s candidate set genuinely differs per +# entity.) +# --------------------------------------------------------------------------- + + +def _validate_one_side_reference_resolution( + root: MetaData, errors: list[MetaError] +) -> None: + # ADR-0039: root has no super; children()==own_children(). + for obj in (c for c in root.children() if c.type == TYPE_OBJECT): + # ADR-0039: resolving — see the scope note above: rule (e) checks THIS + # entity's effective reference set against every relationship it can + # see, including one only inherited via extends. + for rel in (c for c in obj.children() if c.type == TYPE_RELATIONSHIP): + # ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends. + if rel.get_meta_attr(RELATIONSHIP_ATTR_CARDINALITY) != CARDINALITY_ONE: + continue + object_ref = rel.get_meta_attr(RELATIONSHIP_ATTR_OBJECT_REF) + if not isinstance(object_ref, str) or object_ref == "": + continue + + candidates = reference_candidates_for(obj, object_ref) + + source_ref_field = rel.get_meta_attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) + declared = ( + source_ref_field + if isinstance(source_ref_field, str) and source_ref_field != "" + else None + ) + + if declared is not None: + # A declared @sourceRefField short-circuits the ladder at ANY + # candidate count — checked independently of + # resolve_relationship_reference, whose step 1 ("exactly one + # candidate -> that one") would otherwise silently return the + # lone candidate even when it disagrees with the declared + # field. The author named a specific FK; it must exist, + # whether there are zero, one, or many candidates. + matches_declared = any( + reference_fields(c)[:1] == [declared] for c in candidates + ) + if matches_declared: + continue + errors.append(MetaError( + f'relationship "{obj.name}.{rel.name}" sets ' + f'@{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "{declared}", which names ' + f'no identity.reference targeting "{object_ref}". ' + f'Candidates: {_format_reference_candidates(candidates)}.', + ErrorCode.ERR_INVALID_RELATIONSHIP, + envelope=rel.source, + )) + continue + + # No @sourceRefField declared: ambiguity only exists with 2+ + # candidates — resolve_relationship_reference's name-pairing step + # (ladder step 3) decides. + if len(candidates) <= 1: + continue + resolved = resolve_relationship_reference(obj, rel.name, object_ref) + if resolved is not None: + continue + + errors.append(MetaError( + f'relationship "{obj.name}.{rel.name}" is ambiguous: "{obj.name}" declares ' + f'{len(candidates)} identity.reference nodes targeting "{object_ref}" and ' + f'the relationship name does not pair with exactly one. ' + f'Candidates: {_format_reference_candidates(candidates)}. ' + f'Set @{RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this ' + f'relationship navigates.', + ErrorCode.ERR_INVALID_RELATIONSHIP, + envelope=rel.source, + )) + + +def _format_reference_candidates(candidates: list[MetaData]) -> str: + """Render a candidate reference as ``name(fkField)``, or + ``name(fieldA, fieldB)`` for a composite reference — so two composite + references sharing a first column still print distinguishably. + + NOTE: this is display only. Matching (both here and in + resolve_relationship_reference) still keys on the first field alone — a + composite reference cannot actually be disambiguated by @sourceRefField. + That's a documented limitation, not fixed by this rendering. + """ + return ", ".join(f"{c.name}({', '.join(reference_fields(c))})" for c in candidates) + + # NOTE: identity.reference @references resolution moved to the validation registry # (a declarative ReferenceDescriptor with dotted_field_path on the identity.reference # TypeDefinition, resolved by registered_validation). 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 dc64c4194..c389a0856 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 @@ -78,7 +78,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).""" + """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. + """ 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 diff --git a/server/python/src/metaobjects/meta/core/relationship/relationship_references.py b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py new file mode 100644 index 000000000..ecc1f9cbe --- /dev/null +++ b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py @@ -0,0 +1,159 @@ +"""Association -> identity.reference resolution (issue #368). + +An entity may declare more than one identity.reference onto the SAME target +entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A +``@cardinality: one`` relationship names only its target, so when two +references match, the target alone cannot say which FK the navigation uses. +Taking the first match emits a join on the wrong column that typechecks, has +correct DDL and passes verify -- so the ladder below resolves it explicitly or +not at all. ADR-0029 Section 5: ambiguity is a load error naming the +candidates. + +Python port of the TypeScript reference implementation +(``metadata/src/core/relationship/resolve-relationship-reference.ts``) -- +that file is the authoritative spec; this module mirrors it exactly (same +suffix list, same order, same "candidate side only" stripping). +""" +from __future__ import annotations + +from ...meta_data import MetaData +from ....naming import strip_package +from ....shared.base_types import TYPE_IDENTITY +from ....shared.separators import PACKAGE_SEP +from ..identity.identity_constants import ( + IDENTITY_ATTR_FIELDS, + IDENTITY_REFERENCE_ATTR_REFERENCES, + IDENTITY_SUBTYPE_REFERENCE, +) + +# Trailing suffixes stripped from a CANDIDATE's name/FK field when building its +# pairing keys. Ordered -- first match wins, so "reference" is tested before +# "ref". Never applied to the relationship name (see reference_pairing_keys). +PAIRING_SUFFIXES: tuple[str, ...] = ("reference", "ref", "id", "key") + + +def _strip_one_suffix(value: str) -> str: + for suffix in PAIRING_SUFFIXES: + if len(value) > len(suffix) and value.endswith(suffix): + return value[: len(value) - len(suffix)] + return value + + +def reference_fields(ref: MetaData) -> list[str]: + """The full @fields tuple of an identity.reference, in declared order. + + @fields may be authored as a JSON array (the normal identity.reference + shape) or as a single bare string (as identity.primary sometimes is) -- + both are normalized here so callers never branch on authoring shape. + """ + fields = ref.get_meta_attr(IDENTITY_ATTR_FIELDS) # ADR-0039: resolving. + if isinstance(fields, (list, tuple)): + return [f for f in fields if isinstance(f, str)] + if isinstance(fields, str) and fields: + return [f.strip() for f in fields.split(",") if f.strip()] + return [] + + +def reference_target_entity(ref: MetaData) -> str | None: + """The TARGET-ENTITY half of an ``identity.reference``'s ``@references``. + + ``@references`` is either a bare entity name (``Team`` / ``acme::sport::Team``, + meaning "the target's primary identity") or the dotted ``Entity.field`` / + ``Entity.fieldA,fieldB`` form (``Team.id``) naming explicit target fields. + Both forms name the same entity, so the entity half is the segment BEFORE the + first ``.`` — mirroring the other three ports' ``targetEntity`` accessor + (TS ``MetaReferenceIdentity.targetEntity``, C# ``MetaReferenceIdentity.TargetEntity``, + Java ``ReferenceIdentity.getTargetEntity()``), which is the authoritative shape. + Python has no MetaReferenceIdentity subclass to hang it on, so it lives here. + + 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. + """ + raw = ref.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES) # ADR-0039: resolving. + if not isinstance(raw, str) or not raw: + return None + sep = raw.rfind(PACKAGE_SEP) + start = sep + len(PACKAGE_SEP) if sep >= 0 else 0 + dot = raw.find(".", start) + return raw if dot == -1 else raw[:dot] + + +def _first_fk_field(ref: MetaData) -> str | None: + """The FK field a reference is anchored on (first field; composite FKs + pair on their first column).""" + fields = reference_fields(ref) + return fields[0] if fields else None + + +def reference_pairing_keys(ref: MetaData) -> set[str]: + """The set of lowercased names a candidate reference answers to: its own + name and its FK field, each with and without one stripped suffix.""" + keys: set[str] = set() + + def _add(value: str | None) -> None: + if not value: + return + lower = value.lower() + keys.add(lower) + keys.add(_strip_one_suffix(lower)) + + _add(ref.name) + _add(_first_fk_field(ref)) + return keys + + +def reference_candidates_for(holder: MetaData, target_entity: str) -> list[MetaData]: + """Every identity.reference on ``holder`` whose @references targets + ``target_entity``. Package-insensitive on both sides: @references and + @objectRef may each be bare or fully qualified, and @references may use the + dotted ``Entity.field`` form (see :func:`reference_target_entity`). + """ + target = strip_package(target_entity) + candidates: list[MetaData] = [] + # ADR-0039: resolving -- children() honors references inherited via extends. + for child in holder.children(): + if child.type != TYPE_IDENTITY or child.sub_type != IDENTITY_SUBTYPE_REFERENCE: + continue + references = reference_target_entity(child) + if references is None: + continue + if strip_package(references) != target: + continue + if _first_fk_field(child) is None: + continue + candidates.append(child) + return candidates + + +def resolve_relationship_reference( + holder: MetaData, + relationship_name: str, + target_entity: str, + source_ref_field: str | None = None, +) -> MetaData | None: + """Which identity.reference does this ``@cardinality: one`` relationship + navigate through? The ladder, in order: + + 1. exactly one candidate -> that one (the common case; unchanged behaviour) + 2. ``@sourceRefField`` declared -> the candidate whose FK field it names + 3. exactly one candidate name-pairs -> that one + 4. otherwise -> None (caller reports the ambiguity) + + Returns None for "no candidate" and "cannot choose" alike; callers that + need to tell them apart use :func:`reference_candidates_for`. + """ + candidates = reference_candidates_for(holder, target_entity) + if len(candidates) == 0: + return None + if len(candidates) == 1: + return candidates[0] + + if source_ref_field is not None and source_ref_field != "": + return next( + (c for c in candidates if _first_fk_field(c) == source_ref_field), None + ) + + wanted = relationship_name.lower() + paired = [c for c in candidates if wanted in reference_pairing_keys(c)] + return paired[0] if len(paired) == 1 else None diff --git a/server/python/src/metaobjects/spec_metamodel/relationship.json b/server/python/src/metaobjects/spec_metamodel/relationship.json index bc6e78d64..0fd2d5b1a 100644 --- a/server/python/src/metaobjects/spec_metamodel/relationship.json +++ b/server/python/src/metaobjects/spec_metamodel/relationship.json @@ -5,12 +5,12 @@ "type": "relationship", "subType": "base", "description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.", - "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", + "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -21,12 +21,12 @@ "subType": "association", "description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).", "whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -37,12 +37,12 @@ "subType": "aggregation", "description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).", "whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -53,12 +53,12 @@ "subType": "composition", "description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).", "whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } diff --git a/server/python/tests/unit/test_relationship_m2m_validation.py b/server/python/tests/unit/test_relationship_m2m_validation.py new file mode 100644 index 000000000..1c7d1649e --- /dev/null +++ b/server/python/tests/unit/test_relationship_m2m_validation.py @@ -0,0 +1,557 @@ +"""Loader validation for relationship.* M:N slim vocabulary + #368 1:N +reference-disambiguation rules. + +Python port of the TS reference suite +(``metadata/test/relationship-m2m.test.ts``) covering the #368 additions: + + (B) ``@sourceRefField`` becomes legal on ``@cardinality: "one"`` (previously + rejected on any non-M:N relationship). + (C) Rule (e) -- a `@cardinality: one` relationship must resolve to exactly + one identity.reference candidate; ambiguity is a load error. + (D) Both rule (d) (the M:N slim-vocabulary pass) and rule (e) iterate the + EFFECTIVE relationship set (own + inherited via extends), not just + own-declared relationships -- with rule (d) deduping on the + relationship node's identity (an inherited, unmodified relationship + must not be reported once per inheriting entity). + +See ``server/typescript/packages/metadata/src/core/relationship/ +resolve-relationship-reference.ts`` and ``.../src/loader/validation-passes.ts`` +(``validateRelationships`` / ``validateOneSideReferenceResolution``) for the +authoritative spec these tests mirror. +""" +from __future__ import annotations + +import json + +from metaobjects import InMemoryStringSource, MetaDataLoader +from metaobjects.shared.base_types import TYPE_OBJECT + + +def _load(doc: dict) -> tuple[list[str], list[str]]: + """Load *doc* and return (error codes, error messages).""" + result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))]) + codes = [e.code.value for e in result.errors] + messages = [e.message for e in result.errors] + return codes, messages + + +def _load_multi(*docs: dict) -> tuple[list[str], list[str], list[str]]: + """Load several docs as separate sources and return (error codes, error + messages, object visit order) -- the visit order is the resolution key + of each ``object.entity`` in ``root.children()`` iteration order, i.e. + the same order ``validate_relationships``'s outer loop sees them in.""" + sources = [ + InMemoryStringSource(json.dumps(doc), id=f"inline-{i}.json") + for i, doc in enumerate(docs) + ] + result = MetaDataLoader().load(sources) + codes = [e.code.value for e in result.errors] + messages = [e.message for e in result.errors] + visit_order = [c.resolution_key() for c in result.root.children() if c.type == TYPE_OBJECT] + return codes, messages, visit_order + + +# --------------------------------------------------------------------------- +# (B) @sourceRefField becomes legal on @cardinality: "one" +# --------------------------------------------------------------------------- + + +def test_source_ref_field_on_cardinality_one_loads_clean() -> None: + """The issue #368 repro: two 1:N relationships, each disambiguated by + @sourceRefField, load with no errors.""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "homeTeamId"}}, + {"field.long": {"name": "awayTeamId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}}, + {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId"}}, + {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId"}}, + ]}}, + ]}} + codes, _ = _load(doc) + assert codes == [] + + +def test_source_ref_field_on_many_without_through_still_errors() -> None: + """@sourceRefField on @cardinality: many with no @through is still not + M:N -- the widening only spares @cardinality: one.""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"relationship.association": {"name": "teams", "@objectRef": "Team", "@cardinality": "many", "@sourceRefField": "whatever"}}, + ]}}, + ]}} + codes, _ = _load(doc) + assert "ERR_INVALID_RELATIONSHIP" in codes + + +def test_through_on_cardinality_one_still_errors() -> None: + """@through still requires @cardinality: many -- only @sourceRefField + was widened.""" + doc = {"metadata.root": {"package": "acme", "children": [ + {"object.entity": {"name": "Week", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"relationship.composition": {"name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X"}}, + ]}}, + {"object.entity": {"name": "Program", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + ]}} + codes, _ = _load(doc) + assert "ERR_INVALID_RELATIONSHIP" in codes + + +def test_symmetric_on_cardinality_one_still_errors() -> None: + """@symmetric still requires M:N -- only @sourceRefField was widened.""" + doc = {"metadata.root": {"package": "acme", "children": [ + {"object.entity": {"name": "Week", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"relationship.association": {"name": "program", "@objectRef": "Program", "@symmetric": True}}, + ]}}, + {"object.entity": {"name": "Program", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + ]}} + codes, _ = _load(doc) + assert "ERR_INVALID_RELATIONSHIP" in codes + + +# --------------------------------------------------------------------------- +# (A) The resolution ladder, exercised end-to-end through the loader. +# --------------------------------------------------------------------------- + + +def test_issue_repro_loads_clean_via_name_pairing() -> None: + """Two references onto the same target, no @sourceRefField -- resolved by + name-pairing (homeTeamRef <-> homeTeam, awayTeamRef <-> awayTeam).""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "homeTeamId"}}, + {"field.long": {"name": "awayTeamId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one"}}, + {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, + ]}} + codes, _ = _load(doc) + assert codes == [] + + +def test_unpairable_names_error_naming_both_candidates() -> None: + """Two references whose names don't pair with the relationship name -- + ambiguous, and the error names both candidates as name(fkField).""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "alphaFk"}}, + {"field.long": {"name": "betaFk"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team"}}, + {"identity.reference": {"name": "betaRef", "@fields": ["betaFk"], "@references": "Team"}}, + {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, + ]}} + codes, messages = _load(doc) + assert "ERR_INVALID_RELATIONSHIP" in codes + joined = "\n".join(messages) + assert "Match.winner" in joined + assert "alphaRef(alphaFk)" in joined + assert "betaRef(betaFk)" in joined + + +def test_declared_but_unmatched_errors_at_single_candidate_count() -> None: + """A declared @sourceRefField naming nothing must error even with exactly + one candidate -- the ladder's step 1 ("exactly one candidate -> that + one") must not silently short-circuit past a bad declared value.""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "homeTeamId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId"}}, + ]}}, + ]}} + codes, messages = _load(doc) + assert codes == ["ERR_INVALID_RELATIONSHIP"] + joined = "\n".join(messages) + assert "Match.awayTeam" in joined + assert '"awayTeamId"' in joined + + +def test_declared_but_unmatched_errors_at_zero_candidate_count() -> None: + """A declared @sourceRefField naming nothing must also error with ZERO + candidates (no identity.reference targets the objectRef at all) -- the + declared value is read BEFORE any candidate-count guard.""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId"}}, + ]}}, + ]}} + codes, messages = _load(doc) + assert codes == ["ERR_INVALID_RELATIONSHIP"] + joined = "\n".join(messages) + assert "Match.homeTeam" in joined + assert '"homeTeamId"' in joined + + +def test_source_ref_field_correctly_naming_single_candidate_loads_clean() -> None: + """Regression: a correctly-declared @sourceRefField over a single + candidate stays clean.""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "homeTeamId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId"}}, + ]}}, + ]}} + codes, _ = _load(doc) + assert codes == [] + + +def test_composite_reference_candidates_render_full_field_tuple() -> None: + """Two composite references sharing a first column must still print + distinguishably in the candidate list (fields[0] alone would collide).""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "tenantId"}}, + {"field.long": {"name": "homeTeamId"}}, + {"field.long": {"name": "awayTeamId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "aRef", "@fields": ["tenantId", "homeTeamId"], "@references": "Team"}}, + {"identity.reference": {"name": "bRef", "@fields": ["tenantId", "awayTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, + ]}} + codes, messages = _load(doc) + assert "ERR_INVALID_RELATIONSHIP" in codes + joined = "\n".join(messages) + assert "aRef(tenantId, homeTeamId)" in joined + assert "bRef(tenantId, awayTeamId)" in joined + + +# --------------------------------------------------------------------------- +# (D) Rule (e) must iterate the EFFECTIVE relationship set. +# --------------------------------------------------------------------------- + + +def test_inherited_relationship_ambiguity_errors() -> None: + """A extends cleanly; B extends A and adds a second reference onto the + same target -- the inherited relationship becomes ambiguous on B even + though A (and the relationship's own declaration) are untouched.""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "A", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "homeTeamId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, + {"object.entity": {"name": "B", "extends": "A", "children": [ + {"field.long": {"name": "awayTeamId"}}, + {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}}, + ]}}, + ]}} + codes, messages = _load(doc) + assert codes == ["ERR_INVALID_RELATIONSHIP"] + joined = "\n".join(messages) + assert "B.winner" in joined + assert "homeTeamRef(homeTeamId)" in joined + assert "awayTeamRef(awayTeamId)" in joined + + +def test_inherited_relationship_resolved_by_child_added_reference_loads_clean() -> None: + """A child entity's added reference that name-pairs with the inherited + relationship resolves cleanly.""" + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "A", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "homeTeamId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, + {"object.entity": {"name": "B", "extends": "A", "children": [ + {"field.long": {"name": "awayTeamId"}}, + {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}}, + ]}}, + ]}} + codes, _ = _load(doc) + assert codes == [] + + +# --------------------------------------------------------------------------- +# (D) Rule (d) dedupe -- own attrs never change per inheriting entity, so an +# inherited unmodified relationship must be reported exactly once. +# --------------------------------------------------------------------------- + + +def test_rule_d_dedupe_single_error_for_one_inheriting_child() -> None: + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Program", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "A", "children": [ + {"field.long": {"name": "id"}}, + {"relationship.composition": {"name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "B", "extends": "A"}}, + ]}} + codes, _ = _load(doc) + assert codes == ["ERR_INVALID_RELATIONSHIP"] + + +def test_rule_d_dedupe_single_error_across_several_inheriting_children() -> None: + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Program", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "A", "children": [ + {"field.long": {"name": "id"}}, + {"relationship.composition": {"name": "program", "@objectRef": "Program", "@cardinality": "one", "@through": "X"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "B", "extends": "A"}}, + {"object.entity": {"name": "C", "extends": "A"}}, + {"object.entity": {"name": "D", "extends": "A"}}, + ]}} + codes, _ = _load(doc) + assert codes == ["ERR_INVALID_RELATIONSHIP"] + + +# --------------------------------------------------------------------------- +# Regression: valid M:N still loads clean. +# --------------------------------------------------------------------------- + + +def test_valid_hetero_m2m_produces_no_relationship_errors() -> None: + doc = {"metadata.root": {"package": "acme", "children": [ + {"object.entity": {"name": "Post", "children": [ + {"field.long": {"name": "id"}}, + {"relationship.association": {"name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "PostTag"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Tag", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "PostTag", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "postId"}}, + {"field.long": {"name": "tagId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "p", "@fields": ["postId"], "@references": "Post"}}, + {"identity.reference": {"name": "t", "@fields": ["tagId"], "@references": "Tag"}}, + ]}}, + ]}} + codes, _ = _load(doc) + assert "ERR_INVALID_RELATIONSHIP" not in codes + assert "ERR_BAD_ATTR_VALUE" not in codes + + +# --------------------------------------------------------------------------- +# Cross-relationship state-leakage regression: a declared-but-unmatched +# @sourceRefField at 2+ candidates on one relationship must not affect a +# SIBLING relationship on the same entity whose declared field DOES match. +# Backfilled from the Java port (Issue368RelationshipReferenceValidationTest +# .siblingRelationshipWithMatchingSourceRefFieldProducesNoErrorWhileTheBadOneDoes) -- +# the case most likely to catch per-relationship state accidentally shared +# across a loop iteration. +# --------------------------------------------------------------------------- + + +def test_sibling_relationship_with_matching_source_ref_field_produces_no_error_while_the_bad_one_does() -> None: + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Venue", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "venueId"}}, + {"field.long": {"name": "alphaFk"}}, + {"field.long": {"name": "betaFk"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + # The GOOD sibling: a single, correctly-matched candidate onto Venue. + {"identity.reference": {"name": "venueRef", "@fields": ["venueId"], "@references": "Venue"}}, + {"relationship.association": {"name": "venue", "@objectRef": "Venue", "@cardinality": "one", "@sourceRefField": "venueId"}}, + # The BAD one: 2+ candidates onto Team, declared @sourceRefField matches neither. + {"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team"}}, + {"identity.reference": {"name": "betaRef", "@fields": ["betaFk"], "@references": "Team"}}, + {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "doesNotExist"}}, + ]}}, + ]}} + codes, messages = _load(doc) + assert codes == ["ERR_INVALID_RELATIONSHIP"], f"Expected exactly one error (the bad 'winner' relationship only): {messages}" + joined = "\n".join(messages) + assert "Match.winner" in joined + assert '"doesNotExist"' in joined + # The good sibling must never be named in any error -- no state leaked + # from evaluating "winner" into (or out of) evaluating "venue". + assert "Match.venue" not in joined + + +# --------------------------------------------------------------------------- +# Two latent "obj vs. declaring entity" bugs, backfilled from the C#/Java +# ports (see Issue368RelationshipReferenceValidationTests.cs and +# Issue368RelationshipReferenceValidationTest.java). Both bugs are +# ORDER-DEPENDENT: they only manifest when an inheriting entity is visited +# by _validate_relationships's outer loop BEFORE its declaring base -- which +# is why neither was caught by the tests above when the #368 fix landed here +# (``declaring_entity = rel.parent if rel.parent is not None else obj`` in +# validation_passes.py). Each fixture pins the visit-order invariant it +# depends on via ``_load_multi``'s returned visit order, so a future change +# to iteration order fails loudly instead of silently making the test pass +# for the wrong reason. +# --------------------------------------------------------------------------- + + +def test_inherited_self_join_relationship_is_not_misflagged_as_non_self_join() -> None: + """Node extends NodeBase, which declares a @symmetric self-join + relationship onto NodeBase itself (@objectRef: "NodeBase"). Node is + declared BEFORE NodeBase (extends is resolved order-independently by a + deferred pass, so this is legal) so that the outer validation loop + visits `obj = Node` FIRST -- if rule (a)'s self-join comparison used the + visiting `obj` instead of the relationship's DECLARING entity (NodeBase, + via rel.parent), it would wrongly conclude @objectRef "NodeBase" is not + the (visiting) declaring entity "Node" and misfire ERR_BAD_ATTR_VALUE.""" + doc = {"metadata.root": {"package": "acme", "children": [ + {"object.entity": {"name": "Node", "extends": "NodeBase", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "NodeBase", "@isAbstract": True, "children": [ + {"relationship.association": {"name": "peers", "@cardinality": "many", "@objectRef": "NodeBase", + "@through": "NodeLink", "@symmetric": True}}, + ]}}, + {"object.entity": {"name": "NodeLink", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "aId"}}, + {"field.long": {"name": "bId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "a", "@fields": ["aId"], "@references": "NodeBase"}}, + {"identity.reference": {"name": "b", "@fields": ["bId"], "@references": "NodeBase"}}, + ]}}, + ]}} + codes, messages, visit_order = _load_multi(doc) + # Pin the iteration-order invariant this test's premise depends on: if + # root.children() ever stopped iterating in declaration order (e.g. + # started sorting alphabetically), "Node" would no longer be visited + # before "NodeBase" and this test would keep passing for the wrong + # reason -- silently no longer exercising the bug at all. Fail loudly. + assert visit_order == ["acme::Node", "acme::NodeBase", "acme::NodeLink"], ( + "root.children() must iterate in declaration order for this test's " + f"premise (Node visited before NodeBase) to hold; got {visit_order}" + ) + assert "ERR_BAD_ATTR_VALUE" not in codes, messages + assert "ERR_INVALID_RELATIONSHIP" not in codes, messages + + +def test_inherited_bare_through_resolves_in_the_declaring_entity_package_not_the_visiting_one() -> None: + """WeekBase (package "base") declares a M:N relationship with a BARE + @through "Tag" -- ADR-0042 says a bare ref resolves in the DECLARING + entity's package ("base::Tag"), never the package of whichever entity + inherits and visits it. Week extends WeekBase from a DIFFERENT package + ("acme") that also happens to declare its own unrelated "Tag" entity. + The acme source is loaded FIRST so the outer validation loop visits + `obj = Week` before `obj = WeekBase` -- if @through resolution used the + visiting entity's package it would wrongly bind to "acme::Tag" (which + has zero identity.reference children) instead of "base::Tag" (which + correctly has two).""" + acme_doc = {"metadata.root": {"package": "acme", "children": [ + {"object.entity": {"name": "Week", "extends": "base::WeekBase", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + {"object.entity": {"name": "Tag", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + ]}}, + ]}} + base_doc = {"metadata.root": {"package": "base", "children": [ + {"object.entity": {"name": "WeekBase", "@isAbstract": True, "children": [ + {"relationship.association": {"name": "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "Tag"}}, + ]}}, + {"object.entity": {"name": "Tag", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "weekId"}}, + {"field.long": {"name": "labelId"}}, + {"identity.primary": {"name": "id", "@fields": "id"}}, + {"identity.reference": {"name": "w", "@fields": ["weekId"], "@references": "base::WeekBase"}}, + {"identity.reference": {"name": "l", "@fields": ["labelId"], "@references": "base::Tag"}}, + ]}}, + ]}} + codes, messages, visit_order = _load_multi(acme_doc, base_doc) + # Pin the iteration-order invariant: the acme source must be fully + # visited (Week, then acme::Tag) before base's WeekBase/Tag, or this + # test's premise (obj = Week visited before obj = WeekBase) silently + # stops holding and the test would keep passing without ever exercising + # the bug. + assert visit_order == ["acme::Week", "acme::Tag", "base::WeekBase", "base::Tag"], ( + "root.children() must iterate in source-then-declaration order for " + f"this test's premise (Week visited before WeekBase) to hold; got {visit_order}" + ) + assert "ERR_INVALID_RELATIONSHIP" not in codes, messages diff --git a/server/python/tests/unit/test_relationship_references.py b/server/python/tests/unit/test_relationship_references.py new file mode 100644 index 000000000..826ad11fe --- /dev/null +++ b/server/python/tests/unit/test_relationship_references.py @@ -0,0 +1,203 @@ +"""Direct unit tests for the #368 resolution ladder +(``metaobjects.meta.core.relationship.relationship_references``). + +Python port of the TS reference suite +(``metadata/test/resolve-relationship-reference.test.ts``) -- exercises +``reference_candidates_for`` / ``resolve_relationship_reference`` directly +against a loaded model, rather than only through the loader's error output +(see ``test_relationship_m2m_validation.py`` for the loader-integration +tests covering rule (d) / rule (e)). +""" +from __future__ import annotations + +import json + +from metaobjects import InMemoryStringSource, MetaDataLoader +from metaobjects.meta.core.relationship.relationship_references import ( + reference_candidates_for, + resolve_relationship_reference, +) +from metaobjects.meta.meta_data import MetaData +from metaobjects.shared.base_types import TYPE_OBJECT + +_MATCH_MODEL = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "homeTeamId"}}, + {"field.long": {"name": "awayTeamId"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + {"identity.reference": {"name": "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "homeTeam", "@objectRef": "Team", "@cardinality": "one"}}, + {"identity.reference": {"name": "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team"}}, + {"relationship.association": {"name": "awayTeam", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, +]}} + + +def _find_object(root: MetaData, name: str) -> MetaData: + return next(c for c in root.children() if c.type == TYPE_OBJECT and c.name == name) + + +def _load_object(doc: dict, name: str) -> MetaData: + result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))]) + assert result.errors == [], [e.message for e in result.errors] + return _find_object(result.root, name) + + +def _load_match() -> MetaData: + return _load_object(_MATCH_MODEL, "Match") + + +def test_enumerates_every_candidate_reference_for_the_target() -> None: + candidates = reference_candidates_for(_load_match(), "Team") + assert [c.name for c in candidates] == ["homeTeamRef", "awayTeamRef"] + + +def test_name_pairing_resolves_each_association_to_its_own_reference() -> None: + match = _load_match() + home = resolve_relationship_reference(match, "homeTeam", "Team") + away = resolve_relationship_reference(match, "awayTeam", "Team") + assert home is not None and home.name == "homeTeamRef" + assert away is not None and away.name == "awayTeamRef" + + +def test_source_ref_field_wins_over_name_pairing() -> None: + match = _load_match() + resolved = resolve_relationship_reference(match, "homeTeam", "Team", "awayTeamId") + assert resolved is not None and resolved.name == "awayTeamRef" + + +def test_a_single_candidate_resolves_regardless_of_name() -> None: + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "winnerFk"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + {"identity.reference": {"name": "anythingAtAll", "@fields": ["winnerFk"], "@references": "Team"}}, + {"relationship.association": {"name": "champion", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, + ]}} + match = _load_object(doc, "Match") + resolved = resolve_relationship_reference(match, "champion", "Team") + assert resolved is not None and resolved.name == "anythingAtAll" + + +def test_unpairable_names_return_none_rather_than_guessing() -> None: + # #368 rule (e) flags this fixture as a load error -- it's the exact + # ambiguity the ladder returning None exists to surface. Load with the + # merge/validation pipeline still run (errors non-empty is expected + # here), then assert the ladder's return value directly. + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "alphaFk"}}, + {"field.long": {"name": "betaFk"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + {"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"], "@references": "Team"}}, + {"identity.reference": {"name": "betaRef", "@fields": ["betaFk"], "@references": "Team"}}, + {"relationship.association": {"name": "winner", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, + ]}} + result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))]) + assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"] + match = _find_object(result.root, "Match") + assert resolve_relationship_reference(match, "winner", "Team") is None + + +def test_suffix_stripping_never_applies_to_the_relationship_name() -> None: + # "valid" must NOT be stripped to "val" and pair with valRef. + doc = {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "valFk"}}, + {"field.long": {"name": "otherFk"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + {"identity.reference": {"name": "valRef", "@fields": ["valFk"], "@references": "Team"}}, + {"identity.reference": {"name": "otherRef", "@fields": ["otherFk"], "@references": "Team"}}, + {"relationship.association": {"name": "valid", "@objectRef": "Team", "@cardinality": "one"}}, + ]}}, + ]}} + result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))]) + assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"] + match = _find_object(result.root, "Match") + assert resolve_relationship_reference(match, "valid", "Team") is None + + +# --------------------------------------------------------------------------- +# The dotted `@references` form — "Team.id" names the same target entity as a +# bare "Team" (spec/metamodel/identity.json documents it). This port used to +# compare the WHOLE attr value, so a dotted reference matched no target: a valid +# dotted model was refused (zero candidates made a declared @sourceRefField look +# unsatisfiable) and a genuinely ambiguous dotted model loaded clean. The other +# three ports head-parse at the first "." and always agreed; only Python did not. +# --------------------------------------------------------------------------- + +def _dotted_model(relationship: dict) -> dict: + return {"metadata.root": {"package": "repro", "children": [ + {"object.entity": {"name": "Team", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + ]}}, + {"object.entity": {"name": "Match", "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "alphaFk"}}, + {"field.long": {"name": "betaFk"}}, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + {"identity.reference": {"name": "alphaRef", "@fields": ["alphaFk"], + "@references": "Team.id"}}, + {"identity.reference": {"name": "betaRef", "@fields": ["betaFk"], + "@references": "repro::Team.id"}}, + {"relationship.association": relationship}, + ]}}, + ]}} + + +def test_dotted_references_are_enumerated_as_candidates() -> None: + doc = _dotted_model({"name": "winner", "@objectRef": "Team", + "@cardinality": "one", "@sourceRefField": "alphaFk"}) + match = _load_object(doc, "Match") + # Both the bare-dotted "Team.id" and the FQN-dotted "repro::Team.id" forms + # resolve to the entity "Team" — the package separator is never mistaken + # for the field separator. + assert [c.name for c in reference_candidates_for(match, "Team")] == ["alphaRef", "betaRef"] + assert [c.name for c in reference_candidates_for(match, "repro::Team")] == ["alphaRef", "betaRef"] + + +def test_dotted_references_resolve_through_source_ref_field() -> None: + doc = _dotted_model({"name": "winner", "@objectRef": "Team", + "@cardinality": "one", "@sourceRefField": "betaFk"}) + # Loads clean: the declared FK names a real candidate. Before the head-parse + # this raised ERR_INVALID_RELATIONSHIP ("names no identity.reference + # targeting Team. Candidates: .") on a model TypeScript accepted. + match = _load_object(doc, "Match") + resolved = resolve_relationship_reference(match, "winner", "Team", "betaFk") + assert resolved is not None and resolved.name == "betaRef" + + +def test_dotted_references_are_still_ambiguous_without_disambiguation() -> None: + # The other direction: "winner" pairs with neither candidate, so the load + # must FAIL. Before the head-parse there were zero candidates, rule (e)'s + # `len(candidates) <= 1` skipped, and the model loaded clean while + # TypeScript refused it. + doc = _dotted_model({"name": "winner", "@objectRef": "Team", "@cardinality": "one"}) + result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))]) + assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"] + assert "alphaRef(alphaFk), betaRef(betaFk)" in result.errors[0].message + match = _find_object(result.root, "Match") + assert resolve_relationship_reference(match, "winner", "Team") is None diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts index fb6c8b5e6..73a1a8917 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts @@ -42,16 +42,19 @@ import { SORT_ORDER_DESC, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_CARDINALITY, + RELATIONSHIP_ATTR_SOURCE_REF_FIELD, CARDINALITY_ONE, IDENTITY_SUBTYPE_REFERENCE, IDENTITY_REFERENCE_ATTR_REFERENCES, FIELD_ATTR_COLUMN, OBJECT_PROJECTION_ATTR_FILTER, - findReferenceBetween, + findReferencesBetween, + resolveRelationshipReference, resolveObjectRef, type AggregateFunction, + type ReferenceLookup, } from "@metaobjectsdev/metadata"; -import { type MetaData, type MetaField, type MetaRoot, type MetaSource, MetaObject } from "@metaobjectsdev/metadata"; +import { type MetaData, type MetaField, type MetaReferenceIdentity, type MetaRoot, type MetaSource, MetaObject } from "@metaobjectsdev/metadata"; import { intValueMapOf } from "../enum-meta.js"; import { columnNameFromField, @@ -330,6 +333,43 @@ function resolveHop( return undefined; } +/** + * #368: which identity.reference does a resolved `@via` hop actually mean? + * + * `resolveHop` already found the EXACT node the hop segment named — a + * reference hop names the reference itself (nothing to disambiguate: two + * references onto the same target can coexist, but the hop picked one of them + * by name), and a relationship hop names a relationship whose backing + * reference `resolveRelationshipReference` resolves via the same SSOT ladder + * (unique candidate -> @sourceRefField -> unique name-pairing) that + * relation-resolver.ts already uses for the identical question elsewhere. + * Discarding `hop` and re-deriving purely from `holder`/`target` — as this + * file did before — throws away that specificity and reintroduces the exact + * ambiguity a named hop exists to resolve. + * + * Returns a single `ReferenceLookup` once resolved unambiguously, or the full + * candidate list when even the ladder cannot choose (a relationship hop whose + * name pairs with none of its candidates and no `@sourceRefField`) — the + * caller reports that list as a genuine ambiguity. + */ +function resolveHopReference( + holder: MetaObject, + hop: MetaData, + hopName: string, + target: MetaObject, +): ReferenceLookup | ReferenceLookup[] { + if (hop.type === TYPE_IDENTITY && hop.subType === IDENTITY_SUBTYPE_REFERENCE) { + return { holder, other: target, referenceIdentity: hop as unknown as MetaReferenceIdentity }; + } + // ADR-0039: resolving — @sourceRefField may be inherited via extends. + const sourceRefField = hop.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; + const matching = resolveRelationshipReference(holder, hopName, target.name, sourceRefField); + if (matching) { + return { holder, other: target, referenceIdentity: matching }; + } + return findReferencesBetween(holder, target); +} + function viewName(projection: MetaObject, ctx: ExtractContext): string { // The read-only source carries the physical view name. FR-016: physicalName // implements the four-step rule (kind-matching alias → legacy @table → @@ -750,14 +790,62 @@ function buildJoinTree( // a traversed relationship/reference may inherit its target via extends. const resolved = resolveHop(currentObj, relName); if (!resolved) break; - const { targetName, cardinality } = resolved; + const { hop, targetName, cardinality } = resolved; // @objectRef/@references may be package-qualified ("pkg::Entity"); resolve it // package-aware relative to the hop's source entity (the loader qualifies a // same-package ref even when authored bare), so the join binds the exact target. const target = resolveEntityRef(root, targetName, packageOf(currentObj)); if (!target) break; - const ref = findReferenceBetween(currentObj as MetaObject, target); + // #368: two identity.reference declarations onto the same target are legal + // (e.g. Match.homeTeamRef/awayTeamRef -> Team) — resolveHopReference prefers + // the SPECIFIC reference/relationship the hop already named over re-deriving + // one from the target alone, so an explicit `@via: "Match.homeTeamRef"` (or a + // relationship disambiguated by @sourceRefField/name-pairing) resolves cleanly. + // Only a relationship hop that even the ladder cannot choose reaches the throw. + const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target); + let ref: ReferenceLookup | undefined; + if (Array.isArray(resolvedRef)) { + if (resolvedRef.length > 1) { + // #368 round 2: @sourceRefField cannot fix this, but WHY differs by shape, and + // asserting the wrong reason for a given shape is itself a bug (fix round 1 of + // this cleanup caught exactly that). resolveRelationshipReference's ladder reads + // ONLY the hop's own entity's candidates (referenceCandidatesFor(currentObj, ...)); + // it never even looks at `target`'s references. So: + // - If `currentObj` itself holds one of the ambiguous candidates, resolution + // already tried @sourceRefField/name-pairing against it and failed — and that + // is only reachable at all when @cardinality isn't "one": a @cardinality "one" + // relationship with 2+ own-side candidates is rejected at LOAD by rule (e) + // (validateOneSideReferenceResolution) using this exact same ladder, so if we + // got this far with an own-side candidate, @cardinality is provably not "one", + // and @sourceRefField is provably illegal here (rule (d)). + // - If NONE of the candidates are `currentObj`'s own, @sourceRefField could not + // have mattered regardless of @cardinality — it only ever consults the hop's + // OWN identity.reference children, and it has none targeting `target`. This is + // rule (e)'s zero-candidate gap (validation-passes.ts:2226, `<= 1` skips 0 too): + // a @cardinality "one" relationship can reach here with the FK entirely on the + // far side, so @cardinality itself must NOT be asserted in this branch. + const holderName = (currentObj as MetaObject).name; + const holderOwnsACandidate = resolvedRef.some((r) => r.holder.name === holderName); + const whySourceRefFieldCannotHelp = holderOwnsACandidate + ? `it only disambiguates a @cardinality "${CARDINALITY_ONE}" relationship, and this ` + + `relationship's @cardinality is not "${CARDINALITY_ONE}" (declaring @sourceRefField on it ` + + `is itself a load error)` + : `it only consults "${holderName}"'s own identity.reference children, and "${holderName}" ` + + `declares none targeting "${target.name}" -- every candidate above belongs to the other side ` + + `of this join`; + throw new Error( + `projection join hop "${relName}" from "${holderName}" to "${target.name}" is ambiguous: ` + + `${resolvedRef.map((r) => r.referenceIdentity.name).join(", ")}. ` + + `@sourceRefField cannot resolve this: ${whySourceRefFieldCannotHelp}. There is no attribute ` + + `that disambiguates a hop like this -- remove the extra identity.reference between these two ` + + `entities, or restructure the model so only one remains.`, + ); + } + ref = resolvedRef[0]; + } else { + ref = resolvedRef; + } if (!ref) break; const fkField = ref.referenceIdentity.fields[0]; @@ -1090,7 +1178,40 @@ function buildSelectSpec( // The base↔child correlation FK — resolved exactly as buildJoinTree resolves a // single hop (the identity.reference is the FK-direction SSOT). Single-hop @via; // a multi-hop @via on origin.first is not lowered here (rare, and validated away). - const ref = findReferenceBetween(base, childEntity); + // #368: two identity.reference declarations onto the same target are legal, so + // this correlation cannot silently take the first — refuse rather than guess. + // Unlike buildJoinTree's @via hop (a named relationship/reference this file can + // resolve via resolveHopReference), origin.first's OWN @via (ORIGIN_FIRST_ATTR_VIA) + // is never read anywhere in this file — buildJoinTree explicitly skips it + // (ORIGIN_SUBTYPE_FIRST falls through to `continue` there) and this branch derives + // childEntity from @of alone, so there is no hop name here to prefer. Fixing that is + // a separate, larger change (wiring @via/single-hop-unique inference into this + // branch to match _validateViaPath/_inferViaSingleHop) — out of scope for #368's + // silent-first-match fix; the message below reflects the real, narrower remedy. + const refs = findReferencesBetween(base, childEntity); + // findReferencesBetween walks BOTH directions ([base,child] then [child,base]), + // so ">1 entry" is not by itself #368 ambiguity: a mutual 1:1 — + // Customer.primaryAddressRef -> Address PLUS Address.customerRef -> Customer — + // returns two entries pointing in OPPOSITE directions. That shape is legal + // (findReferenceBetween's own contract calls it "rare, but legal", and the + // referenceHolder: "source" | "target" branch below exists to serve it), and + // refusing it broke codegen for a model #368 says nothing about. The real + // ambiguity is two references declared by the SAME holder onto the other side — + // that is what the first-match below cannot choose between. + const ambiguousHolder = [base, childEntity].find( + (holder) => refs.filter((r) => r.holder === holder).length > 1, + ); + if (ambiguousHolder !== undefined) { + const ambiguous = refs.filter((r) => r.holder === ambiguousHolder); + throw new Error( + `origin.first correlation from "${base.name}" to "${childEntity.name}" is ambiguous: ` + + `"${ambiguousHolder.name}" declares ${ambiguous.length} identity.reference nodes ` + + `onto the other side (${ambiguous.map((r) => r.referenceIdentity.name).join(", ")}). ` + + `origin.first's own @via is not consulted for this correlation — reduce to a ` + + `single identity.reference between these two entities.`, + ); + } + const ref = refs[0]; if (!ref) continue; const fkField = ref.referenceIdentity.fields[0]; if (!fkField) continue; diff --git a/server/typescript/packages/codegen-ts/src/relation-resolver.ts b/server/typescript/packages/codegen-ts/src/relation-resolver.ts index 19682c4d7..fc7db0084 100644 --- a/server/typescript/packages/codegen-ts/src/relation-resolver.ts +++ b/server/typescript/packages/codegen-ts/src/relation-resolver.ts @@ -10,9 +10,11 @@ import { RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH, + RELATIONSHIP_ATTR_SOURCE_REF_FIELD, CARDINALITY_ONE, CARDINALITY_MANY, deriveM2MFields, + resolveRelationshipReference, stripPackage, } from "@metaobjectsdev/metadata"; import { variableNameFromEntity } from "./naming.js"; @@ -89,16 +91,20 @@ export function buildRelationMap(root: MetaRoot): RelationMap { if (!targetEntityRaw) continue; const targetEntity = stripPackage(targetEntityRaw); - // Find an identity.reference on `obj` whose @references targets this relationship's target. - // Compare against package-stripped names since both relationship @objectRef and - // identity.reference @references may carry package-qualified entity names. - const refs = obj.referenceIdentities(); - const matching = refs.find((r) => stripPackage(r.targetEntity ?? "") === targetEntity); + // #368: an entity may hold more than one identity.reference onto the same + // target, so the target alone does not identify the FK. Resolve through the + // shared ladder (unique candidate -> @sourceRefField -> name pairing); the + // loader has already refused anything it cannot resolve, so a miss here + // means an unloadable model reached codegen — skip rather than guess. + // ADR-0039: resolving — @sourceRefField may be inherited via extends. + const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; + const matching = resolveRelationshipReference( + obj, child.name, targetEntity, declaredRefField, + ); if (!matching) continue; - const fkFields = matching.fields; - if (fkFields.length === 0) continue; - const fkField = fkFields[0]!; + const fkField = matching.fields[0]; + if (!fkField) continue; ensure(obj.name).push({ name: child.name, diff --git a/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts new file mode 100644 index 000000000..1789ee1b8 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/projection/reference-ambiguity.test.ts @@ -0,0 +1,512 @@ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { extractViewSpec } from "../../src/projection/extract-view-spec.js"; + +// #368 — an entity may legally declare more than one identity.reference onto +// the same target (e.g. Match.homeTeamRef/awayTeamRef both -> Team). The +// loader's ambiguity gate (validateOneSideReferenceResolution) only fires for +// a `@cardinality: one` relationship node — a bare identity.reference pair or +// an origin.first correlation reaches codegen unvalidated, so extract-view-spec +// itself must refuse to silently guess. These tests are the regression +// coverage: a fix-round-1 review found the first cut of the throw was blind to +// an explicit @via hop's own name (it re-derived candidates by target entity +// alone even when the hop already named an exact reference or a disambiguated +// relationship), which made the throw's own suggested remedy impossible to +// satisfy. Test 1 is that regression test. +async function load(children: unknown[]) { + const json = JSON.stringify({ "metadata.root": { package: "test", children } }); + const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]); + if (result.errors.length > 0) { + throw new Error(`Loader errors:\n${result.errors.map((e) => e.message).join("\n")}`); + } + return result.root; +} + +const TEAM = { + "object.entity": { + name: "Team", + children: [ + { "source.rdb": { "@table": "teams" } }, + { "field.int": { name: "id" } }, + { "field.string": { name: "name" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + // Lets the loader's single-hop-@via inference succeed for origin.first + // below (exactly one relationship Team -> Match) — it does not affect + // the reference-count ambiguity check, which reads identity.reference + // declarations directly and ignores relationships entirely. + { "relationship.association": { name: "matches", "@objectRef": "Match", "@cardinality": "many" } }, + ], + }, +}; + +function matchEntity(extraChildren: unknown[] = []) { + return { + "object.entity": { + name: "Match", + children: [ + { "source.rdb": { "@table": "matches" } }, + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": "homeTeamId", "@references": "Team" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": "awayTeamId", "@references": "Team" } }, + ...extraChildren, + ], + }, + }; +} + +describe("extractViewSpec — reference ambiguity (#368)", () => { + test("an explicit @via naming one of two references to the same target resolves to THAT one, not the first", async () => { + // Regression test for fix-round-1 finding 1: before the fix, this threw + // "ambiguous: homeTeamRef, awayTeamRef" even though the author already + // disambiguated by naming the exact reference in @via. + const root = await load([ + TEAM, + matchEntity(), + { + "object.projection": { + name: "MatchView", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_match" } }, + { "field.int": { name: "id", extends: "Match.id" } }, + { "identity.primary": { name: "id", extends: "Match.id" } }, + { + "field.string": { + name: "away_team_name", + children: [ + { + "origin.passthrough": { + "@from": "Team.name", + // Names the exact reference, not the ambiguous target alone. + "@via": "Match.awayTeamRef", + }, + }, + ], + }, + }, + ], + }, + }, + ]); + + const projection = root.objects().find((o) => o.name === "MatchView")!; + const spec = extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" }); + + expect(spec.joinTree.joins.length).toBe(1); + const join = spec.joinTree.joins[0]!; + expect(join.relationship).toBe("awayTeamRef"); + expect(join.targetEntity).toBe("test::Team"); + // The disambiguating proof: the FK column matches the NAMED reference + // (away_team_id), never the declaration-order-first one (home_team_id). + expect(join.fkColumn).toBe("away_team_id"); + + const awayTeamName = spec.selectSpec.columns.find((c) => c.fieldName === "away_team_name"); + expect(awayTeamName).toBeDefined(); + }); + + test("a cardinality:many relationship hop whose candidates live on the target side throws naming both", async () => { + // A `@cardinality: "one"` relationship with an unresolvable candidate set + // is rejected by the LOADER itself (Task 3's validateOneSideReferenceResolution + // rule (e)) before codegen ever runs — so it cannot be used to exercise this + // throw. `@cardinality: "many"` is NOT gated by that rule (finding 2), so + // Team.matches loads fine even though Match holds two references back to + // Team. resolveRelationshipReference correctly reports zero candidates on + // the holder (Team) side — the FK physically lives on Match — and + // resolveHopReference falls back to findReferencesBetween, which walks + // both sides and finds both. + const root = await load([ + TEAM, + matchEntity(), + { + "object.projection": { + name: "TeamSummary", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_team_summary" } }, + { "field.int": { name: "id", extends: "Team.id" } }, + { "identity.primary": { name: "id", extends: "Team.id" } }, + { + "field.int": { + name: "matchCount", + children: [ + { "origin.aggregate": { "@agg": "count", "@of": "Match.id", "@via": "Team.matches" } }, + ], + }, + }, + ], + }, + }, + ]); + + const projection = root.objects().find((o) => o.name === "TeamSummary")!; + expect(() => extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" })).toThrow( + /projection join hop "matches".*is ambiguous:.*homeTeamRef.*awayTeamRef/s, + ); + }); + + test("the ambiguity message never recommends @sourceRefField as a fix (it is a dead end for this hop)", async () => { + // #368 round 2 (fix round 1): Team owns ZERO identity.reference children of + // its own here, so resolveRelationshipReference's ladder (which only ever + // consults the HOP'S OWN entity's candidates) had nothing to work with — + // @sourceRefField could not have mattered regardless of @cardinality, since + // it also only ever consults the same own-side candidate set. The message + // must say THAT (not assert a @cardinality value it can't guarantee is the + // reason in every shape reaching this throw — see the "reverse @cardinality + // one" test below for a shape where the @cardinality *is* "one"), and name + // the two remedies that ARE legal: remove the extra identity.reference, or + // restructure the model. + const root = await load([ + TEAM, + matchEntity(), + { + "object.projection": { + name: "TeamSummary", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_team_summary" } }, + { "field.int": { name: "id", extends: "Team.id" } }, + { "identity.primary": { name: "id", extends: "Team.id" } }, + { + "field.int": { + name: "matchCount", + children: [ + { "origin.aggregate": { "@agg": "count", "@of": "Match.id", "@via": "Team.matches" } }, + ], + }, + }, + ], + }, + }, + ]); + + const projection = root.objects().find((o) => o.name === "TeamSummary")!; + let message = ""; + try { + extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" }); + throw new Error("expected extractViewSpec to throw the ambiguity error"); + } catch (err) { + message = (err as Error).message; + } + + // The dead-end advice from before this fix must never come back. + expect(message).not.toContain("Declare @sourceRefField on the relationship"); + // Fix round 1: nor may the message claim a @cardinality value as the reason + // -- Team owns no candidates, so the real reason is scope (whose references + // @sourceRefField consults), independent of @cardinality. + expect(message).not.toContain("@cardinality"); + expect(message).toContain( + "@sourceRefField cannot resolve this: it only consults \"Team\"'s own identity.reference " + + 'children, and "Team" declares none targeting "Match" -- every candidate above belongs ' + + "to the other side of this join", + ); + // And states the two remedies that are actually legal. + expect(message).toContain( + "There is no attribute that disambiguates a hop like this -- remove the extra identity.reference " + + "between these two entities, or restructure the model so only one remains.", + ); + }); + + test("fix round 1: a reverse @cardinality \"one\" relationship (rule (e)'s zero-candidate gap) gets the same honest, cardinality-free message", async () => { + // Rule (e) (validateOneSideReferenceResolution, validation-passes.ts:2226) + // only fires when the HOLDER declares 2+ candidates of its own + // (`if (candidates.length <= 1) continue;` -- zero is silent, not just one). + // So a @cardinality "one" relationship whose FK is entirely on the FAR side + // (the holder itself declares no identity.reference at all) loads clean, + // and reaches this exact codegen throw exactly like the @cardinality "many" + // case above -- via findReferencesBetween's bidirectional walk finding the + // far side's 2 candidates. This is a DOCUMENTED, parked gap (not fixed here + // -- rule (e) is implemented in four language ports and broadening it in + // TypeScript alone would create cross-port divergence); this test only + // pins that the MESSAGE stays honest about it: @cardinality really is + // "one" here, so the message must not claim otherwise, or claim @cardinality + // is the reason @sourceRefField can't help. + const root = await load([ + { + "object.entity": { + name: "Owner", + children: [ + { "source.rdb": { "@table": "owners" } }, + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + // No identity.reference on Owner itself -- the FK lives on Pet. + { + "relationship.association": { + name: "primaryPet", + "@objectRef": "Pet", + "@cardinality": "one", + }, + }, + ], + }, + }, + { + "object.entity": { + name: "Pet", + children: [ + { "source.rdb": { "@table": "pets" } }, + { "field.int": { name: "id" } }, + { "field.string": { name: "name" } }, + { "field.int": { name: "primaryOwnerId" } }, + { "field.int": { name: "backupOwnerId" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { + "identity.reference": { + name: "primaryOwnerRef", + "@fields": "primaryOwnerId", + "@references": "Owner", + }, + }, + { + "identity.reference": { + name: "backupOwnerRef", + "@fields": "backupOwnerId", + "@references": "Owner", + }, + }, + ], + }, + }, + { + "object.projection": { + name: "OwnerSummary", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_owner_summary" } }, + { "field.int": { name: "id", extends: "Owner.id" } }, + { "identity.primary": { name: "id", extends: "Owner.id" } }, + { + "field.string": { + name: "primary_pet_name", + children: [ + { "origin.passthrough": { "@from": "Pet.name", "@via": "Owner.primaryPet" } }, + ], + }, + }, + ], + }, + }, + ]); + + const projection = root.objects().find((o) => o.name === "OwnerSummary")!; + let message = ""; + try { + extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" }); + throw new Error("expected extractViewSpec to throw the ambiguity error"); + } catch (err) { + message = (err as Error).message; + } + + expect(message).toMatch( + /projection join hop "primaryPet" from "Owner" to "Pet" is ambiguous:.*primaryOwnerRef.*backupOwnerRef/s, + ); + // The bug this test guards against: claiming @cardinality is not "one" when it IS "one". + expect(message).not.toContain("@cardinality"); + expect(message).not.toContain("Declare @sourceRefField on the relationship"); + expect(message).toContain( + '@sourceRefField cannot resolve this: it only consults "Owner"\'s own identity.reference ' + + 'children, and "Owner" declares none targeting "Pet" -- every candidate above belongs ' + + "to the other side of this join", + ); + }); + + test("fix round 1: a @cardinality \"many\" hop whose OWN entity holds the ambiguous candidates still gets a true message (this one MAY name @cardinality)", async () => { + // The mirror case: here Team itself declares two references onto Match, so + // resolveRelationshipReference's ladder DID look at Team's own candidates + // and failed to narrow them (name-pairing doesn't match "matches" to either). + // A @cardinality "one" relationship could never reach this throw in this + // shape -- rule (e) uses the identical own-side candidate ladder and would + // reject it at load first -- so @cardinality is PROVABLY not "one" whenever + // the hop's own entity owns one of the ambiguous candidates, and the message + // may safely say so (unlike the two tests above, where the candidates are + // on the far side and @cardinality could be either value). + const root = await load([ + { + "object.entity": { + name: "Team", + children: [ + { "source.rdb": { "@table": "teams" } }, + { "field.int": { name: "id" } }, + { "field.int": { name: "featuredMatchId" } }, + { "field.int": { name: "backupMatchId" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { + "identity.reference": { + name: "featuredMatchRef", + "@fields": "featuredMatchId", + "@references": "Match", + }, + }, + { + "identity.reference": { + name: "backupMatchRef", + "@fields": "backupMatchId", + "@references": "Match", + }, + }, + { "relationship.association": { name: "matches", "@objectRef": "Match", "@cardinality": "many" } }, + ], + }, + }, + { + "object.entity": { + name: "Match", + children: [ + { "source.rdb": { "@table": "matches" } }, + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + ], + }, + }, + { + "object.projection": { + name: "TeamSummary", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_team_summary" } }, + { "field.int": { name: "id", extends: "Team.id" } }, + { "identity.primary": { name: "id", extends: "Team.id" } }, + { + "field.int": { + name: "matchCount", + children: [ + { "origin.aggregate": { "@agg": "count", "@of": "Match.id", "@via": "Team.matches" } }, + ], + }, + }, + ], + }, + }, + ]); + + const projection = root.objects().find((o) => o.name === "TeamSummary")!; + let message = ""; + try { + extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" }); + throw new Error("expected extractViewSpec to throw the ambiguity error"); + } catch (err) { + message = (err as Error).message; + } + + expect(message).toMatch( + /projection join hop "matches" from "Team" to "Match" is ambiguous:.*featuredMatchRef.*backupMatchRef/s, + ); + expect(message).toContain('@sourceRefField cannot resolve this: it only disambiguates a @cardinality "one"'); + expect(message).toContain('this relationship\'s @cardinality is not "one"'); + }); + + test("origin.first correlation with two references and no relationship throws naming both", async () => { + const root = await load([ + TEAM, + matchEntity(), + { + "object.projection": { + name: "TeamSummary", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_team_summary" } }, + { "field.int": { name: "id", extends: "Team.id" } }, + { "identity.primary": { name: "id", extends: "Team.id" } }, + { + "field.int": { + name: "lastMatchId", + children: [ + { + "origin.first": { + "@of": "Match.id", + "@orderBy": ["id:desc"], + }, + }, + ], + }, + }, + ], + }, + }, + ]); + + const projection = root.objects().find((o) => o.name === "TeamSummary")!; + let message = ""; + try { + extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" }); + throw new Error("expected extractViewSpec to throw the ambiguity error"); + } catch (err) { + message = (err as Error).message; + } + + expect(message).toMatch(/origin\.first correlation from "Team" to "Match" is ambiguous:.*homeTeamRef.*awayTeamRef/s); + // The refusal names the HOLDER that declares both, because that is the shape it + // cannot choose within (see the mutual-1:1 test below for the shape it must not + // refuse). + expect(message).toContain('"Match" declares 2 identity.reference nodes'); + // #368 round 2 sibling check: origin.first has no relationship node to attach + // @sourceRefField to at all (the correlation is derived from @of alone), so this + // message must never suggest it — confirming it stays dead-end-free alongside the + // buildJoinTree fix above. + expect(message).not.toContain("@sourceRefField"); + }); + + test("a mutual 1:1 — one reference per holder, pointing opposite ways — is NOT ambiguous", async () => { + // findReferencesBetween walks BOTH directions ([a,b] then [b,a]), so a mutual 1:1 + // returns TWO entries. A bare `refs.length > 1` refusal therefore hard-failed + // codegen on a shape #368 says nothing about: the two references point in OPPOSITE + // directions, the surrounding code explicitly supports both (`referenceHolder: + // "source" | "target"`), and findReferenceBetween's own contract calls mutual 1:1 + // "rare, but legal". Only a SINGLE holder declaring two-or-more is #368 ambiguity. + const root = await load([ + { + "object.entity": { + name: "Customer", + children: [ + { "source.rdb": { "@table": "customers" } }, + { "field.int": { name: "id" } }, + { "field.int": { name: "primaryAddressId" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + // Customer -> Address + { "identity.reference": { name: "primaryAddressRef", "@fields": "primaryAddressId", "@references": "Address" } }, + // Lets the loader's single-hop-@via inference succeed for origin.first. + { "relationship.association": { name: "addresses", "@objectRef": "Address", "@cardinality": "many" } }, + ], + }, + }, + { + "object.entity": { + name: "Address", + children: [ + { "source.rdb": { "@table": "addresses" } }, + { "field.int": { name: "id" } }, + { "field.int": { name: "customerId" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + // Address -> Customer: the other half of the mutual 1:1. + { "identity.reference": { name: "customerRef", "@fields": "customerId", "@references": "Customer" } }, + ], + }, + }, + { + "object.projection": { + name: "CustomerSummary", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_customer_summary" } }, + { "field.int": { name: "id", extends: "Customer.id" } }, + { "identity.primary": { name: "id", extends: "Customer.id" } }, + { + "field.int": { + name: "latestAddressId", + children: [ + { "origin.first": { "@of": "Address.id", "@orderBy": ["id:desc"] } }, + ], + }, + }, + ], + }, + }, + ]); + + const projection = root.objects().find((o) => o.name === "CustomerSummary")!; + // The assertion is that this does NOT throw. + const spec = extractViewSpec(projection, root, { columnNamingStrategy: "snake_case" }); + const col = spec.selectSpec.columns.find((c) => c.fieldName === "latestAddressId"); + expect(col).toBeDefined(); + expect(col!.kind).toBe("first"); + // Documented first-match behaviour is preserved: `a` is walked first, so the + // base-side reference wins and the correlation is source-held. + expect((col as { referenceHolder: string }).referenceHolder).toBe("source"); + expect((col as { fkColumn: string }).fkColumn).toBe("primary_address_id"); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts b/server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts new file mode 100644 index 000000000..19f533078 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/relation-resolver-two-refs.test.ts @@ -0,0 +1,80 @@ +// #368 — an entity may declare two identity.reference nodes onto the same +// target entity (Match -> Team twice, via homeTeamRef/awayTeamRef). A +// `@cardinality: one` relationship names only its target, so +// `refs.find(r => target matches)` picked the FIRST identity.reference for +// every such relationship — every association joined on the same FK column. +// It compiles, typechecks, and the DDL is correct; the only symptom is wrong +// rows. This proves buildRelationMap resolves each association to its OWN +// reference: the implicit name-pairing case (the bug report) and an explicit +// `@sourceRefField` case (names that do not pair, so only the declared field +// can disambiguate). + +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { buildRelationMap } from "../src/relation-resolver.js"; + +const MODEL = { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + // Names that do NOT pair with either reference's name/FK field, so the + // relationship can only be resolved via an explicit @sourceRefField — + // proving that declaration reaches codegen and is not just tested at + // the metadata layer (Task 1). + { "object.entity": { name: "Player", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Game", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "p1Id" } }, + { "field.int": { name: "p2Id" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "p1Ref", "@fields": ["p1Id"], "@references": "Player" } }, + { "identity.reference": { name: "p2Ref", "@fields": ["p2Id"], "@references": "Player" } }, + { "relationship.association": { name: "winner", "@objectRef": "Player", "@cardinality": "one", "@sourceRefField": "p1Id" } }, + { "relationship.association": { name: "loser", "@objectRef": "Player", "@cardinality": "one", "@sourceRefField": "p2Id" } }, + ] } }, + ], + }, +}; + +async function loadRoot(): Promise { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(MODEL), { id: "meta.repro.json" }), + ]); + expect(errors).toEqual([]); + return root; +} + +describe("buildRelationMap with two references onto one target (#368)", () => { + test("each association joins on its own FK column (name pairing)", async () => { + const root = await loadRoot(); + const relations = buildRelationMap(root).get("Match")!; + const byName = Object.fromEntries(relations.map((r) => [r.name, r.fkField])); + expect(byName.homeTeam).toBe("homeTeamId"); + expect(byName.awayTeam).toBe("awayTeamId"); // was "homeTeamId" — the #368 defect + }); + + test("an explicit @sourceRefField also resolves to its own FK column", async () => { + const root = await loadRoot(); + const relations = buildRelationMap(root).get("Game")!; + const byName = Object.fromEntries(relations.map((r) => [r.name, r.fkField])); + expect(byName.winner).toBe("p1Id"); + expect(byName.loser).toBe("p2Id"); + }); +}); diff --git a/server/typescript/packages/docs-site/src/link-graph.ts b/server/typescript/packages/docs-site/src/link-graph.ts index f4630137e..fcfaacb02 100644 --- a/server/typescript/packages/docs-site/src/link-graph.ts +++ b/server/typescript/packages/docs-site/src/link-graph.ts @@ -1,5 +1,5 @@ import type { MetaData, MetaObject, MetaRelationship } from "@metaobjectsdev/metadata"; -import { deriveM2MFields, stripPackage } from "@metaobjectsdev/metadata"; +import { deriveM2MFields, resolveRelationshipReference, stripPackage } from "@metaobjectsdev/metadata"; import { type LoadedModel, treeOf } from "./load.js"; export interface DocNode { kind: "object" | "prompt" | "output"; name: string; pkg: string; pkgPath: string; href: string; node: MetaData; tree: string; } @@ -99,10 +99,18 @@ export class LinkGraph { addM2mEdge(fqn, to, rel, obj, dn.pkg, onDelete, subtype); // Task 3 helper continue; } - // belongs-to (1:N, one) — find the matching identity.reference to dedupe (mirrors - // relation-resolver: first reference whose target matches, package-stripped). + // belongs-to (1:N, one) — resolve the identity.reference this relationship + // actually navigates, so the FK loop below can dedupe the edge it supersedes. + // #368: an entity may declare more than one reference onto the same target + // (e.g. Match.homeTeamRef/awayTeamRef -> Team), so a bare "target matches" + // `.find()` picked the same candidate for every same-target relationship — + // resolveRelationshipReference is the SSOT ladder (unique candidate, + // @sourceRefField, or unique name-pairing) that relation-resolver itself now + // uses; ambiguous cases resolve to undefined and simply leave every candidate + // reference to draw its own "fk" edge, which is the safe (extra-info, not + // missing-edge) outcome for a docs graph. const target = stripPackage(objectRef); - const match = obj.referenceIdentities().find((r) => stripPackage(r.targetEntity ?? "") === target); + const match = resolveRelationshipReference(obj, rel.name, target, rel.sourceRefField); const fkField = match?.fields?.[0]; if (fkField) coveredFk.add(`${to}::${fkField}`); addRef({ from: fqn, to, via: rel.name, kind: "relationship", cardinality, onDelete, subtype }); diff --git a/server/typescript/packages/docs-site/test/fixture/input/repro368/meta.repro368.yaml b/server/typescript/packages/docs-site/test/fixture/input/repro368/meta.repro368.yaml new file mode 100644 index 000000000..c3f1d9230 --- /dev/null +++ b/server/typescript/packages/docs-site/test/fixture/input/repro368/meta.repro368.yaml @@ -0,0 +1,25 @@ +# #368 fixture — an entity with TWO identity.reference declarations onto the +# SAME target, one backed by a named relationship and one bare. Used only by +# link-graph.test.ts's own-fixture ambiguity test; deliberately kept out of +# the acme/ fixture tree so it never has to be reconciled against golden.test.ts's +# full-site snapshot. +metadata: + package: repro368 + children: + - object.entity: + name: Team + children: + - field.int: { name: id } + - field.string: { name: name } + - identity.primary: { name: id, "@fields": id } + - object.entity: + name: Match + children: + - field.int: { name: id } + - field.int: { name: homeTeamId } + - field.int: { name: awayTeamId } + - identity.primary: { name: id, "@fields": id } + - identity.reference: { name: homeTeamRef, fields: homeTeamId, references: Team } + - identity.reference: { name: awayTeamRef, fields: awayTeamId, references: Team } + - relationship.association: { name: homeTeam, "@objectRef": Team, cardinality: one } + - relationship.association: { name: awayTeam, "@objectRef": Team, cardinality: one } diff --git a/server/typescript/packages/docs-site/test/link-graph.test.ts b/server/typescript/packages/docs-site/test/link-graph.test.ts index 46a0d1877..1672ef12d 100644 --- a/server/typescript/packages/docs-site/test/link-graph.test.ts +++ b/server/typescript/packages/docs-site/test/link-graph.test.ts @@ -84,3 +84,21 @@ test("symmetric self-join (@symmetric) is flagged symmetric", async () => { expect(e!.through).toBe("acme::shop::CustomerFriend"); expect(e!.symmetric).toBe(true); }); + +// #368 — Match declares TWO identity.reference children onto the SAME target +// (Team), each backed by its own named `@cardinality: "one"` relationship +// (homeTeam/awayTeam). Before fix-round-1, the belongs-to dedup pass used +// `.find()` to guess which reference a relationship navigates, so BOTH +// relationships resolved to the same (first) candidate — the second reference +// (awayTeamId) was never marked covered and rendered an extra, redundant "fk" +// edge alongside its own "relationship" edge: 3 edges where 2 were correct. +// Kept in its own fixture dir (not acme/) so it never has to be reconciled +// against golden.test.ts's full-site snapshot. +test("two references to the same target render exactly one edge per relationship, no redundant fk edge (#368)", async () => { + const model = await loadModel([join(FIX, "repro368")]); + const g = new LinkGraph(model); + const toTeam = g.refsFrom("repro368::Match").filter((r) => r.to === "repro368::Team"); + expect(toTeam.map((r) => r.via).sort()).toEqual(["awayTeam", "homeTeam"]); + expect(toTeam.every((r) => r.kind === "relationship")).toBe(true); + expect(toTeam.length).toBe(2); +}); diff --git a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts index 1d9ce1c12..c85eb3efd 100644 --- a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts +++ b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts @@ -20,25 +20,48 @@ export interface ReferenceLookup { } /** - * Find an identity.reference on either `a` or `b` whose @references targets the - * other side. Returns undefined if neither side declares one. + * Every identity.reference on `a` or `b` whose @references targets the other + * side, `a` walked first. Comparison is package-insensitive: `@references` may + * be a bare entity name ("User") or a fully-qualified one ("pkg::User"); both + * match `other.name`. * - * Comparison is package-insensitive: `@references` may be a bare entity name - * ("User") or a fully-qualified one ("pkg::User"); both match `other.name`. - * - * If both sides declare references targeting each other (rare, but legal for - * mutual 1:1), returns the first found, walking `a` first. + * #368: an entity may legally declare more than one identity.reference onto + * the same target (e.g. Match.homeTeamRef and Match.awayTeamRef both -> Team). + * Callers that must not silently guess which one is meant enumerate here and + * report the ambiguity themselves; see also `findReferenceBetween`, which + * keeps the historical first-match contract for callers that legitimately + * expect at most one reference between the pair. */ -export function findReferenceBetween( +export function findReferencesBetween( a: MetaObject, b: MetaObject, -): ReferenceLookup | undefined { +): ReferenceLookup[] { + const found: ReferenceLookup[] = []; for (const [holder, other] of [[a, b], [b, a]] as const) { for (const ref of holder.referenceIdentities()) { if (stripPackage(ref.targetEntity) === other.name) { - return { holder, other, referenceIdentity: ref }; + found.push({ holder, other, referenceIdentity: ref }); } } } - return undefined; + return found; +} + +/** + * Find an identity.reference on either `a` or `b` whose @references targets the + * other side. Returns undefined if neither side declares one. + * + * Delegates to `findReferencesBetween` and returns its first entry — kept for + * backward compatibility (this is exported public API) and for the mutual-1:1 + * case where both sides declare references targeting each other. #368: two + * references onto the SAME target are also legal and indistinguishable from + * here — a caller that must not guess which one is meant should call + * `findReferencesBetween` directly and handle ambiguity explicitly rather than + * relying on this function's first-match behaviour. + */ +export function findReferenceBetween( + a: MetaObject, + b: MetaObject, +): ReferenceLookup | undefined { + return findReferencesBetween(a, b)[0]; } diff --git a/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts b/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts index 8b95173ba..40ab7f362 100644 --- a/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/core/relationship/relationship-definition.embedded.ts @@ -13,7 +13,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = { "type": "relationship", "subType": "base", "description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.", - "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", + "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", "children": [ { "type": "attr", @@ -45,7 +45,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = { "name": "sourceRefField", "min": 0, "max": 1, - "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." + "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", @@ -90,7 +90,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = { "subType": "association", "description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).", "whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", "children": [ { "type": "attr", @@ -122,7 +122,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = { "name": "sourceRefField", "min": 0, "max": 1, - "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." + "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", @@ -167,7 +167,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = { "subType": "aggregation", "description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).", "whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", "children": [ { "type": "attr", @@ -199,7 +199,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = { "name": "sourceRefField", "min": 0, "max": 1, - "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." + "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", @@ -244,7 +244,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = { "subType": "composition", "description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).", "whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", "children": [ { "type": "attr", @@ -276,7 +276,7 @@ export const RELATIONSHIP_DEFINITION: ProviderDefinition = { "name": "sourceRefField", "min": 0, "max": 1, - "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." + "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", diff --git a/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts b/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts new file mode 100644 index 000000000..bbf227de3 --- /dev/null +++ b/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts @@ -0,0 +1,99 @@ +// Association -> identity.reference resolution (issue #368). +// +// An entity may declare more than one identity.reference onto the SAME target +// entity (Match.homeTeamRef and Match.awayTeamRef both -> Team). A +// `@cardinality: one` relationship names only its target, so when two +// references match, the target alone cannot say which FK the navigation uses. +// Taking the first match emits a join on the wrong column that typechecks, has +// correct DDL and passes verify — so the ladder below resolves it explicitly or +// not at all. ADR-0029 §5: ambiguity is a load error naming the candidates. + +import type { MetaObject } from "../object/meta-object.js"; +import type { MetaReferenceIdentity } from "../identity/meta-identity.js"; +import { stripPackage } from "../../naming.js"; + +/** + * Trailing suffixes stripped from a CANDIDATE's name/FK field when building its + * pairing keys. Ordered — first match wins, so "reference" is tested before + * "ref". Never applied to the relationship name (see referencePairingKeys). + */ +const PAIRING_SUFFIXES = ["reference", "ref", "id", "key"] as const; + +function stripOneSuffix(value: string): string { + for (const suffix of PAIRING_SUFFIXES) { + if (value.length > suffix.length && value.endsWith(suffix)) { + return value.slice(0, value.length - suffix.length); + } + } + return value; +} + +/** The FK field a reference is anchored on (first field; composite FKs pair on their first column). */ +function refFkField(ref: MetaReferenceIdentity): string | undefined { + return ref.fields.length > 0 ? ref.fields[0] : undefined; +} + +/** + * The set of lowercased names a candidate reference answers to: its own name + * and its FK field, each with and without one stripped suffix. + */ +export function referencePairingKeys(ref: MetaReferenceIdentity): Set { + const keys = new Set(); + const add = (value: string | undefined): void => { + if (!value) return; + const lower = value.toLowerCase(); + keys.add(lower); + keys.add(stripOneSuffix(lower)); + }; + add(ref.name); + add(refFkField(ref)); + return keys; +} + +/** + * Every identity.reference on `holder` whose @references targets `targetEntity`. + * Package-insensitive on both sides: @references and @objectRef may each be bare + * or fully qualified. + */ +export function referenceCandidatesFor( + holder: MetaObject, + targetEntity: string, +): MetaReferenceIdentity[] { + const target = stripPackage(targetEntity); + // ADR-0039: resolving — referenceIdentities() honors references inherited via extends. + return holder + .referenceIdentities() + .filter((ref) => stripPackage(ref.targetEntity ?? "") === target) + .filter((ref) => refFkField(ref) !== undefined); +} + +/** + * Which identity.reference does this `@cardinality: one` relationship navigate + * through? The ladder, in order: + * + * 1. exactly one candidate -> that one (the common case; unchanged behaviour) + * 2. `@sourceRefField` declared -> the candidate whose FK field it names + * 3. exactly one candidate name-pairs -> that one + * 4. otherwise -> undefined (caller reports the ambiguity) + * + * Returns undefined for "no candidate" and "cannot choose" alike; callers that + * need to tell them apart use referenceCandidatesFor(). + */ +export function resolveRelationshipReference( + holder: MetaObject, + relationshipName: string, + targetEntity: string, + sourceRefField?: string, +): MetaReferenceIdentity | undefined { + const candidates = referenceCandidatesFor(holder, targetEntity); + if (candidates.length === 0) return undefined; + if (candidates.length === 1) return candidates[0]; + + if (sourceRefField !== undefined && sourceRefField !== "") { + return candidates.find((ref) => refFkField(ref) === sourceRefField); + } + + const wanted = relationshipName.toLowerCase(); + const paired = candidates.filter((ref) => referencePairingKeys(ref).has(wanted)); + return paired.length === 1 ? paired[0] : undefined; +} diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index 2822af835..ba674d705 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -114,8 +114,13 @@ export type { IdentityPassthroughResolution } from "./core/identity/validate-ide // Relationship export { MetaRelationship } from "./core/relationship/meta-relationship.js"; // Cross-entity reference lookup -export { findReferenceBetween } from "./core/relationship/find-reference.js"; +export { findReferenceBetween, findReferencesBetween } from "./core/relationship/find-reference.js"; export type { ReferenceLookup } from "./core/relationship/find-reference.js"; +export { + referenceCandidatesFor, + referencePairingKeys, + resolveRelationshipReference, +} from "./core/relationship/resolve-relationship-reference.js"; // FR-017 — M:N junction FK derivation (hetero / directed-self-join / symmetric) export { deriveM2MFields, M2MDerivationError } from "./core/relationship/derive-m2m-fields.js"; export type { M2MFields } from "./core/relationship/derive-m2m-fields.js"; diff --git a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts index 07c14dd9e..a7ab1dafe 100644 --- a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts +++ b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts @@ -25,7 +25,7 @@ import { ParseError } from "../errors.js"; import type { LoaderWarning } from "../source.js"; import { codeSource, resolvedSource } from "../source.js"; import { parseJson } from "../parser-json.js"; -import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js"; +import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateOneSideReferenceResolution, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js"; import { runRegisteredValidation } from "./validation-registry.js"; import { validateSourceRoles } from "../persistence/source/validate-source-roles.js"; import { validateSourceEscapes } from "../persistence/source/validate-source-escapes.js"; @@ -652,6 +652,12 @@ export class MetaDataLoader { // are invalid on a 1:N relationship. errors.push(...validateRelationships(root)); + // Rule (e) — #368: a `@cardinality: one` relationship must resolve to + // exactly one identity.reference when its declaring entity holds more + // than one onto the same target; an unresolvable case is a load error + // naming the candidates (ADR-0029 §5). + errors.push(...validateOneSideReferenceResolution(root)); + // index.lookup @fields resolution — each index.lookup must name ≥1 field, // and every field must exist in the entity's effective (resolved) field set // (ADR-0039: resolving accessor, so inherited fields via extends are visible). diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index a473a6aa0..a5d17f97b 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -129,6 +129,11 @@ import { CARDINALITY_ONE, CARDINALITY_MANY, } from "../core/relationship/relationship-constants.js"; +import { + referenceCandidatesFor, + resolveRelationshipReference, +} from "../core/relationship/resolve-relationship-reference.js"; +import type { MetaRoot } from "../shared/meta-root.js"; import { stripPackage } from "../naming.js"; import { FILTER_COMPOSE_OR, @@ -1938,11 +1943,29 @@ export function validateDataGridFilterValues(root: MetaData): ParseError[] { // (c) When @through is present: the named entity must exist and declare exactly // two identity.reference children; @sourceRefField (if present) must match // one of those references' FK fields → ERR_INVALID_RELATIONSHIP. -// (d) @through / @sourceRefField / @symmetric are invalid on a non-M:N -// relationship (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP. +// (d) @through / @symmetric are invalid on a non-M:N relationship +// (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP. +// @sourceRefField is also invalid there, EXCEPT on @cardinality: "one" +// (#368: it then names which of several identity.reference nodes onto +// the same target the relationship navigates). // -// Own-relationships only: a relationship is validated on the entity that declares -// it (matching the own-attrs policy of the other passes). +// ADR-0039: resolving, not own-only (#368 fix round 3) — every rule above +// validates a property of the relationship's OWN declaration (its attrs, +// plus for rule (c) the @through target resolved against the root), so a +// relationship inherited via extends must be validated wherever it's +// visible, or a child entity that only SEES the relationship through +// inheritance could carry a violation no pass ever examines. That makes an +// inherited, UNMODIFIED relationship visited once per inheriting entity — +// checked once below (keyed on the relationship node's own identity), not +// once per entity, using the DECLARING entity (rel.parent) rather than +// whichever entity's effective view got there first for every piece of +// context a rule reads (message text, @through's package per ADR-0042, rule +// (a)'s self-join comparison). That keeps each check's result independent of +// which entity triggered it, which is what makes "checked once" both +// sufficient and correct. An override replaces the node in place +// (MetaData._effectiveChildren), so it is never the same object as what it +// overrides and is never skipped against it — a genuinely different +// declaration is always independently checked. // --------------------------------------------------------------------------- // The junction's reference view: the validator and the runtime/codegen FK @@ -1970,14 +1993,36 @@ function _countJunctionReferences(junction: MetaData): number { export function validateRelationships(root: MetaData): ParseError[] { const errors: ParseError[] = []; + // #368 fix round 3 — the outer loop below is now resolving (obj.relationships()), + // so a relationship inherited unmodified by N entities is reached N times. Its + // own attrs never change based on who inherits it, so re-validating it more + // than once would report the identical finding N times — pure noise. Checked + // is keyed on the relationship NODE's own object identity: MetaData._effectiveChildren + // reuses the super's child object in place for an unmodified inherited child, + // so the same physical declaration IS the same object everywhere it's visible, + // while an override replaces it with a genuinely different object (correctly + // NOT skipped — a distinct declaration is a distinct finding). + const checked = new Set(); // ADR-0039: root has no super; children()==ownChildren() but resolving is the default. for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) { - // ADR-0042 — a bare @through resolves in the declaring entity's package. - const referrerPkg = obj.package ?? obj.fileDefaultPackage ?? ""; - // ADR-0039: own — a relationship is validated on the entity that DECLARES it - // (the M:N slim-vocabulary rules apply to own-declared relationships; its - // inheritable attrs are read resolving below). - for (const rel of obj.ownChildren().filter((c) => c.type === TYPE_RELATIONSHIP)) { + // ADR-0039: resolving — see the header comment above: rule (d) (like rule + // (e)) must see a relationship inherited via extends, not just this + // entity's own declarations. `checked` absorbs the resulting revisits. + for (const rel of (obj as MetaObject).relationships()) { + if (checked.has(rel)) continue; + checked.add(rel); + + // Every rule below validates a property of the relationship's OWN + // declaration (its attrs, plus for rule (c) the @through target), so + // context — the entity name in messages, the package a bare @through + // resolves in (ADR-0042), and rule (a)'s self-join comparison — is + // always the entity that DECLARES `rel` (rel.parent), never `obj` (the + // entity whose effective view happened to reach it first). This keeps + // the check's result independent of iteration order/inheritance depth, + // which is what makes checking each node exactly once correct. + const declaringEntity = rel.parent ?? obj; + const referrerPkg = declaringEntity.package ?? declaringEntity.fileDefaultPackage ?? ""; + // ADR-0039: resolving — a relationship may inherit its M:N attrs via extends. const through = rel.attr(RELATIONSHIP_ATTR_THROUGH); const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); @@ -1999,16 +2044,23 @@ export function validateRelationships(root: MetaData): ParseError[] { if (hasThrough) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_THROUGH} but is not a M:N ` + + `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_THROUGH} but is not a M:N ` + `relationship (requires @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_MANY}").`, { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, ), ); } - if (hasSourceRefField) { + // #368: @sourceRefField also disambiguates a `@cardinality: one` + // relationship when the entity holds more than one identity.reference + // onto the same target. Only the M:N *junction* reading is rejected + // here; rule (e) — validateOneSideReferenceResolution, below in this + // file — checks that it names a real local reference. + if (hasSourceRefField && cardinality !== CARDINALITY_ONE) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.`, + `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is neither a M:N ` + + `relationship (requires @${RELATIONSHIP_ATTR_THROUGH} with @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_MANY}") ` + + `nor a @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_ONE}" relationship.`, { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, ), ); @@ -2016,7 +2068,7 @@ export function validateRelationships(root: MetaData): ParseError[] { if (symmetric) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.`, + `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.`, { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, ), ); @@ -2028,7 +2080,7 @@ export function validateRelationships(root: MetaData): ParseError[] { if (symmetric && hasSourceRefField) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" sets both @${RELATIONSHIP_ATTR_SYMMETRIC} and ` + + `relationship "${declaringEntity.name}.${rel.name}" sets both @${RELATIONSHIP_ATTR_SYMMETRIC} and ` + `@${RELATIONSHIP_ATTR_SOURCE_REF_FIELD}; they are mutually exclusive.`, { code: "ERR_BAD_ATTR_VALUE", source: rel.source }, ), @@ -2040,12 +2092,13 @@ export function validateRelationships(root: MetaData): ParseError[] { // in this package is self, but an FQN "other::Widget" (a different same-short- // name entity) is NOT (comparing stripped short names would misclassify it). const isSelfJoin = - typeof objectRef === "string" && resolveObjectRef(root, objectRef, referrerPkg).node === obj; + typeof objectRef === "string" && + resolveObjectRef(root, objectRef, referrerPkg).node === declaringEntity; if (symmetric && !isSelfJoin) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but @${RELATIONSHIP_ATTR_OBJECT_REF} ` + - `"${String(objectRef)}" is not the declaring entity "${obj.name}"; @${RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.`, + `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but @${RELATIONSHIP_ATTR_OBJECT_REF} ` + + `"${String(objectRef)}" is not the declaring entity "${declaringEntity.name}"; @${RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.`, { code: "ERR_BAD_ATTR_VALUE", source: rel.source }, ), ); @@ -2056,8 +2109,8 @@ export function validateRelationships(root: MetaData): ParseError[] { if (!junction) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" does not resolve to an entity.${didYouMeanHint(root, String(through))}`, - { code: "ERR_INVALID_RELATIONSHIP", source: resolvedSource(rel.source, `${obj.fqn()}::${rel.name}`, String(through)) }, + `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" does not resolve to an entity.${didYouMeanHint(root, String(through))}`, + { code: "ERR_INVALID_RELATIONSHIP", source: resolvedSource(rel.source, `${declaringEntity.fqn()}::${rel.name}`, String(through)) }, ), ); continue; @@ -2069,7 +2122,7 @@ export function validateRelationships(root: MetaData): ParseError[] { if (junction.subType !== OBJECT_SUBTYPE_ENTITY) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" resolves to ` + + `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" resolves to ` + `${junction.type}.${junction.subType}, not an entity — a junction is a persisted join table ` + `and must be object.entity.`, { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, @@ -2081,7 +2134,7 @@ export function validateRelationships(root: MetaData): ParseError[] { if (refCount !== 2) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" must declare exactly two ` + + `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" must declare exactly two ` + `identity.reference children (one per FK side); found ${refCount}.`, { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, ), @@ -2094,7 +2147,7 @@ export function validateRelationships(root: MetaData): ParseError[] { if (!fkFields.includes(sourceRefField as string)) { errors.push( new ParseError( - `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "${sourceRefField}" does not match ` + + `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "${sourceRefField}" does not match ` + `any identity.reference FK field on junction "${through}". Available: ${fkFields.join(", ") || "(none)"}.`, { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, ), @@ -2106,6 +2159,111 @@ export function validateRelationships(root: MetaData): ParseError[] { return errors; } +// --------------------------------------------------------------------------- +// Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly +// one identity.reference. Two references onto the same target are +// indistinguishable from the relationship's @objectRef alone, so the resolver +// would silently emit the first one's FK column. ADR-0029 §5: a second path +// is a load error naming the candidates. +// +// Registered alongside validateRelationships (the M:N slim-vocabulary pass, +// above) — same deferred-resolution timing (after all files load + extends +// resolution). +// +// Scope differs deliberately from rule (d) — in SUBJECT, not in which +// relationships each pass walks (both walk the EFFECTIVE set; rule (d) is +// no longer own-scoped, or an M:N declaration reached only via extends +// would go unchecked). Rule (d) validates attrs that travel with the +// relationship's OWN declaration (@through/@symmetric/@sourceRefField), so +// it checks each declaration EXACTLY ONCE — deduped by node identity, and +// reported against the entity that declares it — because those attrs don't +// change meaning depending on who inherits the relationship. Rule (e) +// instead validates whether THIS entity's reference set resolves the +// relationship uniquely, which is a property of the EFFECTIVE entity, not of +// wherever the relationship happens to be declared. A child entity that +// extends a clean parent and adds a second identity.reference onto the same +// target makes an INHERITED relationship ambiguous on the child even though +// the parent (and the relationship's own declaration) are untouched — own- +// scoping this pass would leave that case unchecked, and codegen/runtime +// (which resolve against the effective entity) would silently drop the +// relation (#368 fix round 2). If a parent and a child are both genuinely +// ambiguous, both are reported — two entities are broken, not one error +// duplicated. +// --------------------------------------------------------------------------- + +export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[] { + const errors: ParseError[] = []; + for (const obj of root.objects()) { + // ADR-0039: resolving — see the scope note above: rule (e) checks THIS + // entity's effective reference set against every relationship it can see, + // including one only inherited via extends. + for (const rel of obj.relationships()) { + // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends. + if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_ONE) continue; + const objectRef = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF); + if (typeof objectRef !== "string" || objectRef === "") continue; + + const candidates = referenceCandidatesFor(obj, objectRef); + + const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); + const declared = typeof sourceRefField === "string" && sourceRefField !== "" + ? sourceRefField + : undefined; + + if (declared !== undefined) { + // A declared @sourceRefField short-circuits the ladder at ANY + // candidate count — checked independently of resolveRelationshipReference, + // whose step 1 ("exactly one candidate -> that one") would otherwise + // silently return the lone candidate even when it disagrees with the + // declared field. The author named a specific FK; it must exist, + // whether there are zero, one, or many candidates. + const matchesDeclared = candidates.some((c) => c.fields[0] === declared); + if (matchesDeclared) continue; + errors.push( + new ParseError( + `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} ` + + `"${declared}", which names no identity.reference targeting "${objectRef}". ` + + `Candidates: ${formatReferenceCandidates(candidates)}.`, + { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, + ), + ); + continue; + } + + // No @sourceRefField declared: ambiguity only exists with 2+ candidates — + // resolveRelationshipReference's name-pairing step (ladder step 3) decides. + if (candidates.length <= 1) continue; + const resolved = resolveRelationshipReference(obj, rel.name, objectRef); + if (resolved) continue; + + errors.push( + new ParseError( + `relationship "${obj.name}.${rel.name}" is ambiguous: "${obj.name}" declares ` + + `${candidates.length} identity.reference nodes targeting "${objectRef}" and the ` + + `relationship name does not pair with exactly one. Candidates: ${formatReferenceCandidates(candidates)}. ` + + `Set @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this relationship navigates.`, + { code: "ERR_INVALID_RELATIONSHIP", source: rel.source }, + ), + ); + } + } + return errors; +} + +/** + * Render a candidate reference as `name(fkField)`, or `name(fieldA, fieldB)` + * for a composite reference — so two composite references sharing a first + * column (e.g. both starting `tenantId`) still print distinguishably. + * + * NOTE: this is display only. Matching (both here and in + * resolveRelationshipReference) still keys on `fields[0]` alone — a + * composite reference cannot actually be disambiguated by @sourceRefField. + * That's a documented limitation, not fixed by this rendering change. + */ +function formatReferenceCandidates(candidates: readonly MetaReferenceIdentity[]): string { + return candidates.map((c) => `${c.name}(${c.fields.join(", ")})`).join(", "); +} + // NOTE: identity.reference @references resolution moved to the validation registry // (defaultValidationRegistry → a declarative reference descriptor with dottedFieldPath). diff --git a/server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts b/server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts new file mode 100644 index 000000000..fbafa1ce5 --- /dev/null +++ b/server/typescript/packages/metadata/test/find-reference-ambiguity.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader } from "../src/loader/meta-data-loader.js"; +import { InMemoryStringSource } from "../src/loader/meta-data-source.js"; +import { findReferenceBetween, findReferencesBetween } from "../src/core/relationship/find-reference.js"; +import type { MetaObject } from "../src/core/object/meta-object.js"; + +// #368 — Match declares two identity.reference children onto the same target +// (Team), with no relationship at all. That's legal (task 3's loader rule only +// gates a `@cardinality: one` relationship, not a bare reference pair), so it +// is exactly the model findReferencesBetween exists to enumerate honestly. +const MODEL = { + "metadata.root": { + package: "repro", + children: [ + { + "object.entity": { + name: "Team", + children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ], + }, + }, + { + "object.entity": { + name: "Match", + children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + ], + }, + }, + ], + }, +}; + +describe("findReferencesBetween (#368)", () => { + test("returns every reference, not just the first", async () => { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(MODEL), { id: "meta.repro.json" }), + ]); + expect(errors).toEqual([]); + const match = root.findObject("Match")! as MetaObject; + const team = root.findObject("Team")! as MetaObject; + + const all = findReferencesBetween(match, team); + expect(all.map((r) => r.referenceIdentity.name)).toEqual(["homeTeamRef", "awayTeamRef"]); + + // Back-compat: the singular still answers with the first — this is the + // documented contract public consumers (outside this repo) still get. + expect(findReferenceBetween(match, team)?.referenceIdentity.name).toBe("homeTeamRef"); + }); + + test("singular delegates to the plural's first entry even when there is only one match", async () => { + const SINGLE = { + "metadata.root": { + package: "repro2", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "teamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "teamRef", "@fields": ["teamId"], "@references": "Team" } }, + ] } }, + ], + }, + }; + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(SINGLE), { id: "meta.repro2.json" }), + ]); + expect(errors).toEqual([]); + const match = root.findObject("Match")! as MetaObject; + const team = root.findObject("Team")! as MetaObject; + + expect(findReferencesBetween(match, team).map((r) => r.referenceIdentity.name)).toEqual(["teamRef"]); + expect(findReferenceBetween(match, team)?.referenceIdentity.name).toBe("teamRef"); + }); + + test("returns empty/undefined when neither side references the other", async () => { + const NONE = { + "metadata.root": { + package: "repro3", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Stadium", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + ], + }, + }; + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(NONE), { id: "meta.repro3.json" }), + ]); + expect(errors).toEqual([]); + const team = root.findObject("Team")! as MetaObject; + const stadium = root.findObject("Stadium")! as MetaObject; + + expect(findReferencesBetween(team, stadium)).toEqual([]); + expect(findReferenceBetween(team, stadium)).toBeUndefined(); + }); +}); diff --git a/server/typescript/packages/metadata/test/relationship-m2m.test.ts b/server/typescript/packages/metadata/test/relationship-m2m.test.ts index b20651a80..6d0a45765 100644 --- a/server/typescript/packages/metadata/test/relationship-m2m.test.ts +++ b/server/typescript/packages/metadata/test/relationship-m2m.test.ts @@ -325,6 +325,41 @@ describe("FR-017 M:N validation rules", () => { expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP"); }); + // Rule (d) exception (#368): @sourceRefField also disambiguates a + // @cardinality:one relationship — which of several identity.reference nodes + // onto the same target it navigates (see resolve-relationship-reference.test.ts). + // Only the M:N *junction* reading of @sourceRefField still requires @cardinality:many. + test("sourceRefField on a cardinality:one relationship loads cleanly (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Match", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "homeTeamId" } }, + { "field.long": { name: "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } ] } }, + ] } }); + expect(errors).toHaveLength(0); + }); + + test("sourceRefField on a @cardinality:many relationship without @through still errors", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Match", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "relationship.association": { name: "teams", "@objectRef": "Team", "@cardinality": "many", "@sourceRefField": "whatever" } } ] } }, + ] } }); + expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP"); + }); + test("valid hetero M:N produces no relationship errors", async () => { const { errors } = await loadDoc({ "metadata.root": { package: "acme", children: [ { "object.entity": { name: "Post", children: [ @@ -344,3 +379,319 @@ describe("FR-017 M:N validation rules", () => { expect(codesOf(errors)).not.toContain("ERR_BAD_ATTR_VALUE"); }); }); + +// --------------------------------------------------------------------------- +// Rule (e) (#368): a `@cardinality: one` relationship must resolve to exactly +// one identity.reference. Two references onto the same target are +// indistinguishable from @objectRef alone — the resolver used to silently +// emit the first one's FK column, so ambiguity is now a load error naming +// the candidates instead. +// --------------------------------------------------------------------------- + +describe("FR-017 Rule (e) — #368 ambiguous 1:N reference resolution", () => { + test("two references to one target with an unpairable relationship name is a load error (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Match", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "alphaFk" } }, + { "field.long": { name: "betaFk" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } }, + { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } }, + { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } } ] } }, + ] } }); + expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP"); + const message = errors.map((e) => e.message).join("\n"); + expect(message).toContain("Match.winner"); + expect(message).toContain("alphaRef(alphaFk)"); + expect(message).toContain("betaRef(betaFk)"); + }); + + test("the issue #368 repro loads clean via name pairing", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Match", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "homeTeamId" } }, + { "field.long": { name: "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } } ] } }, + ] } }); + expect(errors).toHaveLength(0); + }); + + test("sourceRefField naming no local reference is a load error (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Match", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "homeTeamId" } }, + { "field.long": { name: "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "nonesuch" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } ] } }, + ] } }); + expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP"); + const message = errors.map((e) => e.message).join("\n"); + expect(message).toContain("Match.homeTeam"); + expect(message).toContain('"nonesuch"'); + // awayTeam's @sourceRefField correctly names awayTeamRef's FK field — no error for it. + expect(message).not.toContain("Match.awayTeam"); + }); + + // Fix round 1: a declared @sourceRefField naming nothing must error even + // with exactly one candidate — resolveRelationshipReference's ladder step 1 + // ("exactly one candidate -> that one") would otherwise silently return + // that lone candidate regardless of whether it matches the declared field, + // emitting a join on the wrong column with no error at all. + test("sourceRefField naming nothing with a single candidate is a load error (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Match", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "homeTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "awayTeamId" } } ] } }, + ] } }); + expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]); + const message = errors.map((e) => e.message).join("\n"); + expect(message).toContain("Match.awayTeam"); + expect(message).toContain('"awayTeamId"'); + }); + + test("sourceRefField correctly naming the single candidate loads clean (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Match", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "homeTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "homeTeamId" } } ] } }, + ] } }); + expect(errors).toHaveLength(0); + }); + + // Fix round 1: two composite references sharing a first column must still + // print distinguishably in the candidate list (previously rendered as + // `fields[0]` only, so both showed as e.g. "aRef(tenantId), bRef(tenantId)"). + // Matching still keys on fields[0] alone (documented limitation) — this is + // a message-rendering fix only. + test("composite reference candidates render their full field tuple (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Match", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "tenantId" } }, + { "field.long": { name: "homeTeamId" } }, + { "field.long": { name: "awayTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "aRef", "@fields": ["tenantId", "homeTeamId"], "@references": "Team" } }, + { "identity.reference": { name: "bRef", "@fields": ["tenantId", "awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } } ] } }, + ] } }); + expect(codesOf(errors)).toContain("ERR_INVALID_RELATIONSHIP"); + const message = errors.map((e) => e.message).join("\n"); + expect(message).toContain("aRef(tenantId, homeTeamId)"); + expect(message).toContain("bRef(tenantId, awayTeamId)"); + }); + + // Fix round 2: rule (e) must iterate the EFFECTIVE relationship set + // (obj.relationships(), own + inherited via extends), not ownChildren(). + // A entity declares the relationship + a single reference (clean on its + // own); B extends A and adds a SECOND reference onto the same target. The + // relationship is only inherited on B, so an own-scoped pass would never + // examine it there and this would load clean while codegen/runtime, which + // resolve against B's effective children, silently drop the relation. + test("an inherited relationship becomes ambiguous when a child entity adds a second reference (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "A", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "homeTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } } ] } }, + { "object.entity": { name: "B", "extends": "A", children: [ + { "field.long": { name: "awayTeamId" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } } ] } }, + ] } }); + expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]); + const message = errors.map((e) => e.message).join("\n"); + expect(message).toContain("B.winner"); + expect(message).toContain("homeTeamRef(homeTeamId)"); + expect(message).toContain("awayTeamRef(awayTeamId)"); + }); + + test("a child entity's added reference that name-pairs with the inherited relationship loads clean (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Team", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "A", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "homeTeamId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } } ] } }, + { "object.entity": { name: "B", "extends": "A", children: [ + { "field.long": { name: "awayTeamId" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } } ] } }, + ] } }); + expect(errors).toHaveLength(0); + }); + + // Fix round 3: validateRelationships (rule (d), the M:N slim-vocabulary + // pass) also switched to the resolving relationship set for ADR-0039 + // compliance. Unlike rule (e), rule (d)'s checks read only the + // relationship's own attrs, so an inherited, UNMODIFIED relationship must + // be reported exactly once no matter how many entities inherit it — this + // is the dedup guard, not a "different entity, different finding" case. + test("an inherited rule-(d) violation is reported once, not once per inheriting entity (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Program", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "A", children: [ + { "field.long": { name: "id" } }, + { "relationship.composition": { name: "program", "@objectRef": "Program", + "@cardinality": "one", "@through": "X" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "B", "extends": "A" } }, + ] } }); + expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]); + }); + + test("an inherited rule-(d) violation stays a single error across several inheriting children (#368)", async () => { + const { errors } = await loadDoc({ "metadata.root": { package: "repro", children: [ + { "object.entity": { name: "Program", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "A", children: [ + { "field.long": { name: "id" } }, + { "relationship.composition": { name: "program", "@objectRef": "Program", + "@cardinality": "one", "@through": "X" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "B", "extends": "A" } }, + { "object.entity": { name: "C", "extends": "A" } }, + { "object.entity": { name: "D", "extends": "A" } }, + ] } }); + expect(codesOf(errors)).toEqual(["ERR_INVALID_RELATIONSHIP"]); + }); +}); + +// --------------------------------------------------------------------------- +// Two latent "obj vs. declaring entity" bugs, backfilled from the C#/Java +// ports (see Issue368RelationshipReferenceValidationTests.cs and +// Issue368RelationshipReferenceValidationTest.java). Both bugs are +// ORDER-DEPENDENT: they only manifest when an inheriting entity is visited +// by validateRelationships's outer loop BEFORE its declaring base — which is +// why neither was caught by the tests above when the #368 fix landed here +// (`const declaringEntity = rel.parent ?? obj;` in validation-passes.ts). +// Each fixture pins the visit-order invariant it depends on via +// `objectVisitOrder`, so a future change to iteration order fails loudly +// instead of silently making the test pass for the wrong reason. +// --------------------------------------------------------------------------- + +describe("FR-017 #368 order-dependence regressions (declaring entity vs. visiting entity)", () => { + function objectVisitOrder(root: { children(): readonly { type: string; resolutionKey(): string }[] }): string[] { + return root.children().filter((c) => c.type === TYPE_OBJECT).map((o) => o.resolutionKey()); + } + + test("inherited self-join relationship is not misflagged as non-self-join", async () => { + // Node extends NodeBase, which declares a @symmetric self-join relationship + // onto NodeBase itself (@objectRef: "NodeBase"). Node is declared BEFORE + // NodeBase (extends is resolved order-independently by a deferred pass, so + // this is legal) so that the outer validation loop visits `obj = Node` + // FIRST — if rule (a)'s self-join comparison used the visiting `obj` + // instead of the relationship's DECLARING entity (NodeBase, via rel.parent), + // it would wrongly conclude @objectRef "NodeBase" is not the (visiting) + // declaring entity "Node" and misfire ERR_BAD_ATTR_VALUE. + const { root, errors } = await loadDoc({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Node", "extends": "NodeBase", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "NodeBase", "@isAbstract": true, children: [ + { "relationship.association": { name: "peers", "@cardinality": "many", "@objectRef": "NodeBase", + "@through": "NodeLink", "@symmetric": true } } ] } }, + { "object.entity": { name: "NodeLink", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "aId" } }, + { "field.long": { name: "bId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "a", "@fields": ["aId"], "@references": "NodeBase" } }, + { "identity.reference": { name: "b", "@fields": ["bId"], "@references": "NodeBase" } } ] } }, + ] } }); + // Pin the iteration-order invariant this test's premise depends on: if + // root.children() ever stopped iterating in declaration order (e.g. started + // sorting alphabetically), "Node" would no longer be visited before + // "NodeBase" and this test would keep passing for the wrong reason — + // silently no longer exercising the bug at all. Fail loudly instead. + expect(objectVisitOrder(root)).toEqual(["acme::Node", "acme::NodeBase", "acme::NodeLink"]); + expect(codesOf(errors)).not.toContain("ERR_BAD_ATTR_VALUE"); + expect(codesOf(errors)).not.toContain("ERR_INVALID_RELATIONSHIP"); + }); + + test("inherited bare @through resolves in the declaring entity's package, not the visiting one", async () => { + // WeekBase (package "base") declares a M:N relationship with a BARE + // @through "Tag" — ADR-0042 says a bare ref resolves in the DECLARING + // entity's package ("base::Tag"), never the package of whichever entity + // inherits and visits it. Week extends WeekBase from a DIFFERENT package + // ("acme") that also happens to declare its own unrelated "Tag" entity. + // The acme source is loaded FIRST so the outer validation loop visits + // `obj = Week` before `obj = WeekBase` — if @through resolution used + // the visiting entity's package it would wrongly bind to "acme::Tag" + // (which has zero identity.reference children) instead of "base::Tag" + // (which correctly has two). + const acmeDoc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Week", "extends": "base::WeekBase", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + { "object.entity": { name: "Tag", children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } ] } }, + ] } }; + const baseDoc = { "metadata.root": { package: "base", children: [ + { "object.entity": { name: "WeekBase", "@isAbstract": true, children: [ + { "relationship.association": { name: "tags", "@cardinality": "many", "@objectRef": "Tag", "@through": "Tag" } } ] } }, + { "object.entity": { name: "Tag", children: [ + { "field.long": { name: "id" } }, + { "field.long": { name: "weekId" } }, + { "field.long": { name: "labelId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { name: "w", "@fields": ["weekId"], "@references": "base::WeekBase" } }, + { "identity.reference": { name: "l", "@fields": ["labelId"], "@references": "base::Tag" } } ] } }, + ] } }; + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(acmeDoc), { id: "acme.json" }), + new InMemoryStringSource(JSON.stringify(baseDoc), { id: "base.json" }), + ]); + // Pin the iteration-order invariant: the acme source must be fully visited + // (Week, then acme::Tag) before base's WeekBase/Tag, or this test's premise + // (obj = Week visited before obj = WeekBase) silently stops holding and the + // test would keep passing without ever exercising the bug. + expect(objectVisitOrder(root)).toEqual(["acme::Week", "acme::Tag", "base::WeekBase", "base::Tag"]); + expect(codesOf(errors)).not.toContain("ERR_INVALID_RELATIONSHIP"); + }); +}); diff --git a/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts new file mode 100644 index 000000000..d6f634e9d --- /dev/null +++ b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader } from "../src/loader/meta-data-loader.js"; +import { InMemoryStringSource } from "../src/loader/meta-data-source.js"; +import { + referenceCandidatesFor, + resolveRelationshipReference, +} from "../src/core/relationship/resolve-relationship-reference.js"; +import type { MetaObject } from "../src/core/object/meta-object.js"; + +const MATCH_MODEL = { + "metadata.root": { + package: "repro", + children: [ + { + "object.entity": { + name: "Team", + children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ], + }, + }, + { + "object.entity": { + name: "Match", + children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }, + ], + }, + }, + ], + }, +}; + +async function loadMatch(): Promise { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(MATCH_MODEL), { id: "meta.repro.json" }), + ]); + expect(errors).toEqual([]); + return root.findObject("Match")!; +} + +describe("resolveRelationshipReference", () => { + test("enumerates every candidate reference for the target", async () => { + const candidates = referenceCandidatesFor(await loadMatch(), "Team"); + expect(candidates.map((r) => r.name)).toEqual(["homeTeamRef", "awayTeamRef"]); + }); + + test("name pairing resolves each association to its own reference", async () => { + const match = await loadMatch(); + expect(resolveRelationshipReference(match, "homeTeam", "Team")?.name).toBe("homeTeamRef"); + expect(resolveRelationshipReference(match, "awayTeam", "Team")?.name).toBe("awayTeamRef"); + }); + + test("@sourceRefField wins over name pairing", async () => { + const match = await loadMatch(); + expect( + resolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")?.name, + ).toBe("awayTeamRef"); + }); + + test("a single candidate resolves regardless of name", async () => { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "winnerFk" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "anythingAtAll", "@fields": ["winnerFk"], "@references": "Team" } }, + { "relationship.association": { name: "champion", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }), + { id: "meta.repro.json" }, + ), + ]); + expect(errors).toEqual([]); + const match = root.findObject("Match")!; + expect(resolveRelationshipReference(match, "champion", "Team")?.name).toBe("anythingAtAll"); + }); + + test("unpairable names return undefined rather than guessing", async () => { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "alphaFk" } }, + { "field.int": { name: "betaFk" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } }, + { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } }, + { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }), + { id: "meta.repro.json" }, + ), + ]); + // #368 rule (e) (validation-passes.ts) now flags this fixture as a load + // error — it's the exact ambiguity the ladder returning undefined exists + // to surface. The ladder function itself is unchanged; assert its return + // value directly against the merged root rather than requiring a clean load. + expect(errors.map((e) => (e as { code?: string }).code)).toEqual(["ERR_INVALID_RELATIONSHIP"]); + const match = root.findObject("Match")!; + expect(resolveRelationshipReference(match, "winner", "Team")).toBeUndefined(); + }); + + test("suffix stripping never applies to the relationship name", async () => { + // "valid" must NOT be stripped to "val" and pair with valRef. + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "valFk" } }, + { "field.int": { name: "otherFk" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "identity.reference": { name: "valRef", "@fields": ["valFk"], "@references": "Team" } }, + { "identity.reference": { name: "otherRef", "@fields": ["otherFk"], "@references": "Team" } }, + { "relationship.association": { name: "valid", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, + }), + { id: "meta.repro.json" }, + ), + ]); + // #368 rule (e) (validation-passes.ts) now flags this fixture as a load + // error too — see the comment on "unpairable names return undefined + // rather than guessing" above. + expect(errors.map((e) => (e as { code?: string }).code)).toEqual(["ERR_INVALID_RELATIONSHIP"]); + const match = root.findObject("Match")!; + expect(resolveRelationshipReference(match, "valid", "Team")).toBeUndefined(); + }); +}); diff --git a/server/typescript/packages/migrate-ts/src/referential-actions.ts b/server/typescript/packages/migrate-ts/src/referential-actions.ts index 71d128d8d..95f63e8d1 100644 --- a/server/typescript/packages/migrate-ts/src/referential-actions.ts +++ b/server/typescript/packages/migrate-ts/src/referential-actions.ts @@ -7,6 +7,7 @@ import { VALIDATOR_SUBTYPE_REQUIRED, refMatchesObject, resolveObjectRef, + resolveRelationshipReference, type MetaObject, type MetaRelationship, type MetaReferenceIdentity, @@ -76,7 +77,12 @@ export function isRequired(field: MetaData): boolean { * omits actions when the DB value is "no-action", so the expected side does the same * to keep round-trip diffs clean. * - * If multiple relationships target the same entity (rare), the first one is used. + * When more than one relationship targets the same entity (Match.homeTeam / + * awayTeam both -> Team), each is correlated to its OWN identity.reference via + * the shared relationship<->reference ladder (#368 round 2) — never the first + * match. When the correlation is genuinely ambiguous, it contributes nothing + * rather than an arbitrary — possibly wrong — action (see the tier-2/tier-3 + * bodies below for the exact rules). * * The single `as FkAction` cast in normalize() is safe because REFERENTIAL_ACTIONS * (metadata package) and FkAction (migrate-ts/src/types.ts) are the same four-value @@ -116,6 +122,18 @@ export function resolveReferentialActions( // When the target does not resolve (dangling @references — normally a // load error), fall back to the legacy exact-string match so behavior on // partially-valid trees is unchanged. + // + // #368 (round 2): `entity` may declare MORE THAN ONE identity.reference + // onto this same target (Match.homeTeamRef / awayTeamRef both -> Team). + // Matching `r` on the target alone can't say which of those references + // `r` supplies actions FOR — every FK past the first silently inherited + // the first relationship's actions. Resolve it with the INVERSE of the + // relationship->reference ladder (resolveRelationshipReference): `r` + // belongs to `ref` iff the ladder, applied to `r`, resolves back to + // `ref` itself — not merely "resolves to *some* reference on this + // target". `r` and `ref` are always declared on the same `entity`, the + // exact shape the ladder is built for, so this is a direct inversion, + // not a second parallel rule. // (3) Failing that, correlate the REVERSE relationship declared on the // TARGET entity (the documented parent-side authoring shape). let rel = entity.relationships().find((r) => { @@ -125,7 +143,8 @@ export function resolveReferentialActions( if (targetObj === undefined) return objectRef === target; const relOwner = r.parent ?? entity; const relOwnerPkg = relOwner.package ?? relOwner.fileDefaultPackage ?? ""; - return refMatchesObject(targetObj, objectRef, relOwnerPkg); + if (!refMatchesObject(targetObj, objectRef, relOwnerPkg)) return false; + return resolveRelationshipReference(entity, r.name, objectRef, r.sourceRefField) === ref; }); // When the tier-3 satisfiability guard fires, the reverse relationship's // AUTHORED @onUpdate still applies (only the inferred contributions drop). @@ -185,9 +204,19 @@ export function resolveReferentialActions( * same target, the reverse relationship cannot say WHICH FK carries the * ownership edge, so it contributes to none of them (arming every FK could * cascade through an edge the author never designated). - * - * If multiple reverse relationships point back at the entity (rare), the first - * one is used — mirroring the tier-2 sibling-relationship rule. + * - When the TARGET entity declares more than one non-@through relationship + * back at `entity` (rare — e.g. a "posts" composition and a separate + * "latestPost" association both @objectRef-ing Post), no candidate is + * preferred: this is the tier-2 ambiguity's mirror image (multiple + * RELATIONSHIPS rather than multiple REFERENCES), and it cannot be + * resolved by the relationship->reference ladder — that ladder picks among + * references declared on the SAME object as the relationship, whereas here + * the candidate relationships live on `targetObj` while `ref` lives on + * `entity`, a different object, so the ladder has no candidates to apply + * to. Previously this used the FIRST match (the same silently-wrong-schema + * defect as tier 2); it now fails closed like the guard above, once the + * ambiguity guard above has already established `ref` is the entity's only + * candidate reference to this target. */ function findReverseRelationship( entity: MetaObject, @@ -211,7 +240,11 @@ function findReverseRelationship( // The reverse relationship's bare @objectRef resolves in ITS declaring // owner's package (normally the target entity's own package). - return targetObj.relationships().find((r) => { + // + // Fail closed on ambiguity rather than taking the first match: if more than + // one non-@through relationship on targetObj resolves back to `entity`, + // none of them is preferred (see the class doc above). + const reverseCandidates = targetObj.relationships().filter((r) => { if (r.through !== undefined) return false; // M:N — junction path, not this FK const objectRef = r.objectRef; if (objectRef === undefined) return false; @@ -219,6 +252,7 @@ function findReverseRelationship( const relOwnerPkg = relOwner.package ?? relOwner.fileDefaultPackage ?? ""; return refMatchesObject(entity, objectRef, relOwnerPkg); }); + return reverseCandidates.length === 1 ? reverseCandidates[0] : undefined; } function normalize(a: string | undefined): FkAction | undefined { diff --git a/server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts b/server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts index d3dc851c4..994ce3c59 100644 --- a/server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts +++ b/server/typescript/packages/migrate-ts/test/unit/referential-actions.test.ts @@ -88,6 +88,137 @@ describe("resolveReferentialActions", () => { }); }); +// --------------------------------------------------------------------------- +// #368 round 2: an entity declaring MORE THAN ONE relationship to the SAME +// target (Match.homeTeam / Match.awayTeam, both -> Team). Matching on the +// target alone can't say which FK a relationship supplies actions for, so +// resolveReferentialActions must correlate each relationship to its OWN +// identity.reference via the same relationship<->reference ladder used +// elsewhere in the repo (issue #368 round 1), not the first match. +// +// REGRESSION: against the pre-fix code, "each FK gets its own relationship's +// action" FAILED — awayTeamRef resolved to { onDelete: "restrict" } (homeTeam's +// action, matched first in declaration order) instead of its own "cascade". +// --------------------------------------------------------------------------- + +function matchTeamDoc(rels: Record[]) { + return { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Team", children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "field.long": { name: "homeTeamId" } }, + { "field.long": { name: "awayTeamId" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + ...rels, + { "identity.primary": { "name": "id", "@fields": "id" } }, + ] } }, + ] } }; +} + +async function loadMatchTeam(rels: Record[]) { + const { root, errors } = await loadDoc(matchTeamDoc(rels)); + expect(errors).toHaveLength(0); + const match = root.objects().find((o) => o.name === "Match")!; + const homeTeamRef = match.referenceIdentities().find((r) => r.name === "homeTeamRef")!; + const awayTeamRef = match.referenceIdentities().find((r) => r.name === "awayTeamRef")!; + return { root, match, homeTeamRef, awayTeamRef }; +} + +describe("#368 round 2: two relationships to the same target correlate to their OWN reference", () => { + test("REGRESSION: name-pairing — each FK gets its own relationship's action, not the first relationship's", async () => { + const { match, homeTeamRef, awayTeamRef } = await loadMatchTeam([ + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } }, + { "relationship.composition": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } }, + ]); + expect(resolveReferentialActions(match, homeTeamRef)).toEqual({ onDelete: "restrict", onUpdate: "cascade" }); + expect(resolveReferentialActions(match, awayTeamRef)).toEqual({ onDelete: "cascade", onUpdate: "cascade" }); + }); + + test("end-to-end: each FK's own ON DELETE lands in the emitted DDL (Postgres)", async () => { + const { root } = await loadMatchTeam([ + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } }, + { "relationship.composition": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } }, + ]); + const snapshot = buildExpectedSchema(root); + const { changes } = await diff(snapshot, EMPTY_SCHEMA); + const { up } = emit(changes, { dialect: "postgres" }); + expect(up).toContain('ADD CONSTRAINT "matches_home_team_id_fk"'); + expect(up).toMatch(/matches_home_team_id_fk[^;]*ON DELETE RESTRICT/); + expect(up).toContain('ADD CONSTRAINT "matches_away_team_id_fk"'); + expect(up).toMatch(/matches_away_team_id_fk[^;]*ON DELETE CASCADE/); + }); + + test("REGRESSION: @sourceRefField — each FK gets its own relationship's action even when names don't pair", async () => { + // Relationship names deliberately don't name-pair with either reference — + // only @sourceRefField can route these correctly. + const { match, homeTeamRef, awayTeamRef } = await loadMatchTeam([ + { "relationship.association": { name: "primary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict", "@sourceRefField": "homeTeamId" } }, + { "relationship.composition": { name: "secondary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade", "@sourceRefField": "awayTeamId" } }, + ]); + expect(resolveReferentialActions(match, homeTeamRef)).toEqual({ onDelete: "restrict", onUpdate: "cascade" }); + expect(resolveReferentialActions(match, awayTeamRef)).toEqual({ onDelete: "cascade", onUpdate: "cascade" }); + }); + + test("no regression: a single relationship to a target still resolves regardless of its name", async () => { + // Only one reference to Team exists here (homeTeamRef) — the relationship + // name "champion" pairs with neither reference's name, but the ladder's + // "exactly one candidate" tier means naming never mattered for this, + // by far the most common, shape. + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Team", children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "field.long": { name: "winnerId" } }, + { "identity.reference": { name: "winnerRef", "@fields": ["winnerId"], "@references": "Team" } }, + { "relationship.composition": { name: "champion", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + ] } }, + ] } }; + const { root, errors } = await loadDoc(doc); + expect(errors).toHaveLength(0); + const match = root.objects().find((o) => o.name === "Match")!; + const ref = match.referenceIdentities()[0]!; + expect(resolveReferentialActions(match, ref)).toEqual({ onDelete: "cascade", onUpdate: "cascade" }); + }); + + test("unresolvable correlation emits the default, not the first relationship's action", async () => { + // Neither relationship name pairs with either reference and no + // @sourceRefField disambiguates — the ladder can't choose. This is the + // SAME ambiguity rule (e) (validateOneSideReferenceResolution) already + // refuses at load time (ADR-0029 §5), so — mirroring how the ladder's own + // suite tests this (resolve-relationship-reference.test.ts, + // "unpairable names return undefined rather than guessing") — assert the + // load error directly instead of requiring a clean load, then assert + // resolveReferentialActions still fails closed (no action on either FK) + // as defense in depth, rather than either one inheriting the first + // relationship's action. + const { root, errors } = await loadDoc(matchTeamDoc([ + { "relationship.association": { name: "primary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "restrict" } }, + { "relationship.composition": { name: "secondary", "@objectRef": "Team", "@cardinality": "one", "@onDelete": "cascade" } }, + ])); + expect(errors.map((e) => (e as { code?: string }).code)).toEqual([ + "ERR_INVALID_RELATIONSHIP", + "ERR_INVALID_RELATIONSHIP", + ]); + const match = root.objects().find((o) => o.name === "Match")!; + const homeTeamRef = match.referenceIdentities().find((r) => r.name === "homeTeamRef")!; + const awayTeamRef = match.referenceIdentities().find((r) => r.name === "awayTeamRef")!; + expect(resolveReferentialActions(match, homeTeamRef)).toEqual({ onDelete: undefined, onUpdate: undefined }); + expect(resolveReferentialActions(match, awayTeamRef)).toEqual({ onDelete: undefined, onUpdate: undefined }); + }); +}); + // --------------------------------------------------------------------------- // Round-trip: buildExpectedSchema → diff (against empty) → emit // diff --git a/server/typescript/packages/runtime-ts/src/relation-resolver.ts b/server/typescript/packages/runtime-ts/src/relation-resolver.ts index 61ae7def0..8c5a7f5c4 100644 --- a/server/typescript/packages/runtime-ts/src/relation-resolver.ts +++ b/server/typescript/packages/runtime-ts/src/relation-resolver.ts @@ -1,12 +1,11 @@ -import type { ColumnNamingStrategy, MetaData } from "@metaobjectsdev/metadata"; +import type { ColumnNamingStrategy, MetaData, MetaObject } from "@metaobjectsdev/metadata"; import { - TYPE_OBJECT, TYPE_RELATIONSHIP, TYPE_IDENTITY, - IDENTITY_SUBTYPE_REFERENCE, - IDENTITY_ATTR_FIELDS, - IDENTITY_REFERENCE_ATTR_REFERENCES, + TYPE_OBJECT, TYPE_RELATIONSHIP, RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF, + RELATIONSHIP_ATTR_SOURCE_REF_FIELD, CARDINALITY_ONE, CARDINALITY_MANY, DEFAULT_COLUMN_NAMING_STRATEGY, + resolveRelationshipReference, } from "@metaobjectsdev/metadata"; import { MetadataError } from "./errors.js"; import { @@ -26,28 +25,20 @@ export interface RelationDescriptor { } /** - * Find an identity.reference declared on `holder` whose @references targets `targetName`. - * Returns the FK field name (first field on the identity) or undefined. + * The FK field a `@cardinality: one` relationship navigates through. + * #368: an entity may declare several identity.reference nodes onto the same + * target, so the target alone is not enough — resolve through the shared ladder. */ -function findReferenceFkField(holder: MetaData, targetName: string): string | undefined { - // ADR-0039: effective children — an identity.reference may be inherited via extends. - for (const child of holder.children()) { - if (child.type !== TYPE_IDENTITY) continue; - if (child.subType !== IDENTITY_SUBTYPE_REFERENCE) continue; - // ADR-0039: effective attrs — @references/@fields may be inherited. - const ref = child.attr(IDENTITY_REFERENCE_ATTR_REFERENCES); - if (typeof ref !== "string") continue; - const dotIdx = ref.indexOf("."); - const entityName = dotIdx === -1 ? ref : ref.slice(0, dotIdx); - if (entityName !== targetName) continue; - const fields = child.attr(IDENTITY_ATTR_FIELDS); - if (Array.isArray(fields) && fields.length > 0) return String(fields[0]); - if (typeof fields === "string") { - const first = fields.split(",")[0]?.trim(); - if (first) return first; - } - } - return undefined; +function findReferenceFkField( + holder: MetaData, + targetName: string, + relationshipName: string, + sourceRefField?: string, +): string | undefined { + const ref = resolveRelationshipReference( + holder as unknown as MetaObject, relationshipName, targetName, sourceRefField, + ); + return ref?.fields[0]; } /** @@ -77,7 +68,9 @@ export function resolveRelationDescriptor( { entity: sourceEntity.name }, ); } - const fkField = findReferenceFkField(sourceEntity, targetEntityName); + // ADR-0039: resolving — @sourceRefField may be inherited via extends. + const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; + const fkField = findReferenceFkField(sourceEntity, targetEntityName, child.name, declaredRefField); if (!fkField) { throw new MetadataError( `Relationship '${relationName}' on '${sourceEntity.name}' has no identity.reference targeting '${targetEntityName}'`, @@ -114,7 +107,11 @@ export function resolveRelationDescriptor( if (targetEntityName !== sourceEntity.name) continue; const inverseName = inversePluralName(other.name); if (inverseName !== relationName) continue; - const fkField = findReferenceFkField(other, sourceEntity.name); + // ADR-0039: resolving — @sourceRefField may be inherited via extends. This + // FK lives on `other` and belongs to `other`'s own relationship `child` — + // not to anything declared on sourceEntity (see #368 module comment). + const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; + const fkField = findReferenceFkField(other, sourceEntity.name, child.name, declaredRefField); if (!fkField) { throw new MetadataError( `Inverse relationship for '${relationName}' on '${sourceEntity.name}': entity '${other.name}' has no identity.reference targeting '${sourceEntity.name}'`, diff --git a/server/typescript/packages/runtime-ts/test/_meta-build.ts b/server/typescript/packages/runtime-ts/test/_meta-build.ts index 6ac16b538..015475dea 100644 --- a/server/typescript/packages/runtime-ts/test/_meta-build.ts +++ b/server/typescript/packages/runtime-ts/test/_meta-build.ts @@ -19,6 +19,9 @@ import { TYPE_LAYOUT, TYPE_SOURCE, TYPE_ORIGIN, + IDENTITY_SUBTYPE_PRIMARY, + IDENTITY_SUBTYPE_SECONDARY, + IDENTITY_SUBTYPE_REFERENCE, MetaRoot, MetaObject, MetaField, @@ -26,6 +29,9 @@ import { MetaValidator, MetaView, MetaIdentity, + MetaPrimaryIdentity, + MetaSecondaryIdentity, + MetaReferenceIdentity, MetaRelationship, MetaLayout, MetaSource, @@ -48,11 +54,28 @@ const CTORS: Record = { [TYPE_ORIGIN]: MetaOrigin, }; +// identity.reference nodes must be actual MetaReferenceIdentity instances — +// the loader's IDENTITY_CLASS_MAP (core-types.ts) dispatches on subtype the +// same way, since resolveRelationshipReference() (#368) reads its +// subtype-only getters (targetEntity, referencesRaw). A plain MetaIdentity +// built for subtype "reference" answers `undefined` to those, so identity +// subtypes get their own dispatch here too rather than falling through to +// the generic TYPE_IDENTITY entry above. +const IDENTITY_CTORS: Record = { + [IDENTITY_SUBTYPE_PRIMARY]: MetaPrimaryIdentity, + [IDENTITY_SUBTYPE_SECONDARY]: MetaSecondaryIdentity, + [IDENTITY_SUBTYPE_REFERENCE]: MetaReferenceIdentity, +}; + /** * Build a concrete metadata node from a TypeId + name. Drop-in replacement for * the removed `new MetaData(typeId, name)` constructor. */ export function meta(typeId: TypeId, name = ""): MetaData { + if (typeId.type === TYPE_IDENTITY) { + const IdentityCtor = IDENTITY_CTORS[typeId.subType] ?? MetaIdentity; + return new IdentityCtor(typeId, name); + } const Ctor = CTORS[typeId.type]; if (Ctor === undefined) { throw new Error(`meta(): no concrete class for type "${typeId.type}"`); diff --git a/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts b/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts new file mode 100644 index 000000000..c78940367 --- /dev/null +++ b/server/typescript/packages/runtime-ts/test/relation-resolver-two-refs.test.ts @@ -0,0 +1,164 @@ +// #368 — an entity may declare more than one identity.reference onto the SAME +// target entity (Match -> Team twice, via homeTeamRef/awayTeamRef). REST +// runtime's resolveRelationDescriptor (relation-resolver.ts) had the same +// defect Task 4 fixed in codegen: findReferenceFkField(holder, targetName) +// walked the holder's identity.reference children and returned the FIRST +// whose @references matched the target, so every relation traversal onto that +// target read the SAME FK column — one relation's lazy load / include would +// silently return the wrong rows via the generated REST API. +// +// Two cases: +// - one-side: a relationship declared ON the entity holding both FKs +// (Match.homeTeam / Match.awayTeam) must each read its own FK. +// - many-side: the inverse traversal (Team -> Match) resolves the FK that +// lives on the OTHER entity (Match); that lookup must also disambiguate by +// the matched relationship, not just take the first identity.reference. + +import { describe, expect, test } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { resolveRelationDescriptor } from "../src/relation-resolver.js"; + +// One-side model: mirrors Task 4's codegen-ts repro exactly (proven to load +// clean — both relationships name-pair with their own identity.reference). +const ONE_SIDE_MODEL = { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, +}; + +// Many-side model: same shape, but the identity.reference children are +// declared in the OPPOSITE order from the relationship children — awayTeamRef +// appears before homeTeamRef, while the homeTeam relationship (the first +// cardinality:one relationship on Match targeting Team) still appears first. +// Name-pairing is order-independent, so this loads exactly as cleanly as +// ONE_SIDE_MODEL. It exists to separate "first identity.reference +// structurally" (the #368 defect) from "the identity.reference that actually +// name-pairs with the matched relationship" (the fix) — with the same +// declaration order as ONE_SIDE_MODEL the two happen to coincide and the bug +// would go unnoticed on the many-side path. +const MANY_SIDE_MODEL = { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "homeTeamId" } }, + { "field.int": { name: "awayTeamId" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "awayTeamRef", "@fields": ["awayTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "homeTeam", "@objectRef": "Team", "@cardinality": "one" } }, + { "identity.reference": { name: "homeTeamRef", "@fields": ["homeTeamId"], "@references": "Team" } }, + { "relationship.association": { name: "awayTeam", "@objectRef": "Team", "@cardinality": "one" } }, + ] } }, + ], + }, +}; + +// @sourceRefField model: neither relationship name pairs with either reference +// ("winner"/"loser" vs alphaRef(alphaFk)/betaRef(betaFk)), so ladder step 3 cannot +// resolve them and only the declared @sourceRefField (step 2) can. Declaration +// order is deliberate: alphaRef comes first, so a lookup that ignored +// @sourceRefField would return alphaFk for BOTH relations. +const SOURCE_REF_FIELD_MODEL = { + "metadata.root": { + package: "repro", + children: [ + { "object.entity": { name: "Team", children: [ + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Match", children: [ + { "field.int": { name: "id" } }, + { "field.int": { name: "alphaFk" } }, + { "field.int": { name: "betaFk" } }, + { "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "alphaRef", "@fields": ["alphaFk"], "@references": "Team" } }, + { "identity.reference": { name: "betaRef", "@fields": ["betaFk"], "@references": "Team" } }, + { "relationship.association": { name: "winner", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "betaFk" } }, + { "relationship.association": { name: "loser", "@objectRef": "Team", "@cardinality": "one", "@sourceRefField": "alphaFk" } }, + ] } }, + ], + }, +}; + +async function load(model: unknown): Promise { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(model), { id: "meta.repro.json" }), + ]); + expect(errors).toEqual([]); + return root; +} + +describe("resolveRelationDescriptor with two references onto one target (#368)", () => { + test("one-side: each relation reads its own FK field", async () => { + const root = await load(ONE_SIDE_MODEL); + const match = root.findObject("Match")!; + expect(resolveRelationDescriptor(match, "homeTeam", root).sourceField).toBe("homeTeamId"); + expect(resolveRelationDescriptor(match, "awayTeam", root).sourceField).toBe("awayTeamId"); // was "homeTeamId" + }); + + test("many-side: the inverse traversal resolves the FK of the matched relationship, not the first reference", async () => { + const root = await load(MANY_SIDE_MODEL); + const team = root.findObject("Team")!; + // Team's only reachable inverse name is "matches" (inversePluralName is + // keyed on the OTHER entity's name, not per-relationship), which resolves + // to Match's first cardinality:one relationship targeting Team — homeTeam. + // Structurally, awayTeamRef is declared before homeTeamRef in Match's + // children, so the pre-#368 "first identity.reference" lookup would + // return awayTeamId here even though the matched relationship is homeTeam. + const desc = resolveRelationDescriptor(team, "matches", root); + expect(desc.cardinality).toBe("many"); + expect(desc.targetEntityName).toBe("Match"); + expect(desc.targetField).toBe("homeTeamId"); // was "awayTeamId" + }); + + // Both tests above resolve by NAME PAIRING (ladder step 3), so they stay green if + // the @sourceRefField argument is dropped at either call site — nothing proved it + // was ever read here. These two models name the relationships so they pair with + // NOTHING ("winner" / "loser" against alphaRef / betaRef), which makes a declared + // @sourceRefField (ladder step 2) the only thing that can resolve them: drop the + // 4th argument at either call site and the resolver throws "has no + // identity.reference targeting 'Team'". + + test("one-side: @sourceRefField reaches the resolver when the name pairs with nothing", async () => { + const root = await load(SOURCE_REF_FIELD_MODEL); + const match = root.findObject("Match")!; + // Deliberately crossed against declaration order: winner -> betaFk (the SECOND + // reference), loser -> alphaFk. + expect(resolveRelationDescriptor(match, "winner", root).sourceField).toBe("betaFk"); + expect(resolveRelationDescriptor(match, "loser", root).sourceField).toBe("alphaFk"); + }); + + test("many-side: @sourceRefField reaches the inverse resolver too", async () => { + const root = await load(SOURCE_REF_FIELD_MODEL); + const team = root.findObject("Team")!; + // Team's only reachable inverse name is "matches", which resolves to Match's + // FIRST cardinality:one relationship targeting Team — "winner", whose + // @sourceRefField names betaFk here. alphaRef is declared FIRST, so a resolver + // that ignored @sourceRefField would answer "alphaFk". + const desc = resolveRelationDescriptor(team, "matches", root); + expect(desc.cardinality).toBe("many"); + expect(desc.targetEntityName).toBe("Match"); + expect(desc.targetField).toBe("betaFk"); + }); +}); diff --git a/site-reference/types/relationship.html b/site-reference/types/relationship.html index 2f1ca2305..0617a1fdf 100644 --- a/site-reference/types/relationship.html +++ b/site-reference/types/relationship.html @@ -53,7 +53,7 @@

relationship.aggregation

A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).

Owning provider: metaobjects-core-types

-

Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).

+

Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).

When to use: One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.

Attributes

@@ -111,7 +111,7 @@

relationship.aggregation

- + @@ -137,7 +137,7 @@

relationship.aggregation

relationship.association

A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).

Owning provider: metaobjects-core-types

-

Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).

+

Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).

When to use: A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.

Attributes

metaobjects-core-typesDirected self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric.Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely.
@symmetric
@@ -195,7 +195,7 @@

relationship.association

- + @@ -221,7 +221,7 @@

relationship.association

relationship.base

Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A relationship.base node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.

Owning provider: metaobjects-core-types

-

Rules: @cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).

+

Rules: @cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).

Attributes

metaobjects-core-typesDirected self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric.Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely.
@symmetric
@@ -278,7 +278,7 @@

relationship.base

- + @@ -304,7 +304,7 @@

relationship.base

relationship.composition

An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).

Owning provider: metaobjects-core-types

-

Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).

+

Rules: M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).

When to use: You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.

Attributes

metaobjects-core-typesDirected self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric.Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely.
@symmetric
@@ -362,7 +362,7 @@

relationship.composition

- + diff --git a/spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md b/spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md index dc9afbb05..fe0adb5b6 100644 --- a/spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md +++ b/spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md @@ -75,3 +75,70 @@ needed an omission rule that five loaders can implement byte-identically. An int `fixtures/conformance/` fixture (positive + error envelope). - "This parameter is a Case identifier" becomes computable (the extends target IS the entity's identity field) for doc-gen, FR-022 emission, and MCP tool schemas. + +## Amendment 1 (2026-09-13) — the ambiguity rule extends to 1:N FK selection + +§5's contract — "a second path is a load error naming the candidates" — was written +for `@via` on `origin.*`. Issue #368 found the identical ambiguity one level down: an +entity may legitimately declare more than one `identity.reference` onto the same +target (`Match.homeTeamRef` and `Match.awayTeamRef` both `-> Team`), and a +`@cardinality: one` relationship names only that target via `@objectRef`, never which +reference it means. Every port's resolver took the first matching reference and never +noticed the second — the emitted join typechecks, produces correct DDL, and passes +`meta verify`; the only symptom is wrong rows. + +The rule now also governs 1:N FK selection, resolved by this ladder: + +1. exactly one candidate `identity.reference` onto the target → that one; +2. `@sourceRefField` declared on the relationship → the candidate whose **first** FK + field it names — this check short-circuits: a declared value that names no + candidate's FK field is a load error at ANY candidate count (zero, one, or many), + it never falls through to name-pairing; +3. exactly one candidate name-pairs with the relationship's own name → that one; +4. otherwise → `ERR_INVALID_RELATIONSHIP`, naming every candidate. + +**Stage 3, stated normatively (all four ports implement it identically):** a +candidate's pairing keys are `{lower(name), strip(lower(name)), lower(fkField), +strip(lower(fkField))}`, where `strip` removes at most one trailing suffix from the +ordered list `["reference", "ref", "id", "key"]` — first match in that order wins, and +a value no longer than the matched suffix is left unstripped, so `strip` never returns +empty. The relationship's own name is matched **un-stripped**, lowercased only; exactly +one candidate holding a matching key resolves, zero or several do not. + +Stripping is candidate-side only, deliberately. If the relationship's own name were +stripped the same way, an unrelated relationship named `valid` would falsely pair with +a candidate reference named `valRef`: `valid` ends in the stripped suffix `id`, so +stripping it yields `val` — the same key `valRef` produces by stripping `ref`. Loosely +matching the reference/FK-field side while matching the relationship side exactly is +what lets the common case (a `posts` relationship pairing with a `postId` FK) resolve +with no extra authoring, without also inventing false pairs between words that merely +share an ordinary English or SQL-ish ending. + +This still meets §5's "trivially portable" bar: stage 3 is local string comparison over +one entity's own children — the candidate set is always the `identity.reference` nodes +declared on the SAME holder as the relationship being resolved — not the multi-hop path +inference §5 declined to attempt. Every port runs the same four lowercase comparisons +over the same bounded set. + +**No new vocabulary.** `@sourceRefField` was already registered on all four +`relationship.*` subtypes; declaring it on a `@cardinality: one` relationship +previously failed to load outright (`ERR_INVALID_RELATIONSHIP` — "sets +@sourceRefField but is not a M:N relationship"). Giving it meaning there adds no +attribute, subtype or type to the registry — the only change to +`expected-registry.json` is a correction to the attribute's own description text, +which no longer claims the attribute is M:N-only (see the CHANGELOG for the release +consequence). `metamodelVersion` stays `1.0`. + +**The one piece of this change that needs `docs/compatibility-policy.md`'s correction +bar is stage 4's new refusal, not the widening above.** Before this rule, an entity +with two-or-more candidate references and no name-pairing match loaded successfully +and silently resolved to the first declared reference — a form that never had a +reliable meaning (nothing distinguished it from an author's actual intent; two +different builds could resolve the same ambiguous model to two different FKs) and +produced no correct outcome for anyone who hit it. It now stops loading in a PATCH. +That is exactly the correction bar's three-part test: (1) never validly +expressible — the ambiguity was always present, merely unreported; (2) no correct +outcome for anyone — the silent first-match is a wrong-column join, not a form of +correct output; (3) the repair is exactly named — the error lists every candidate and +states the fix (`@sourceRefField`, or a relationship name that pairs with exactly +one). diff --git a/spec/metamodel/relationship.json b/spec/metamodel/relationship.json index bc6e78d64..0fd2d5b1a 100644 --- a/spec/metamodel/relationship.json +++ b/spec/metamodel/relationship.json @@ -5,12 +5,12 @@ "type": "relationship", "subType": "base", "description": "Abstract relationship base — shared shape for the concrete association/aggregation/composition subtypes; not authored directly. A `relationship.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED): this subtype is a registry anchor concrete subtypes inherit from, never a node in a document.", - "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", + "rules": "@cardinality is an open string at the metamodel level ('one'/'many', and Java-canonical composite forms such as 'many-to-one'); @objectRef names the target entity. M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. @onDelete/@onUpdate carry referential actions (cascade/set-null/restrict/no-action).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -21,12 +21,12 @@ "subType": "association", "description": "A plain reference to another entity — no ownership; the target has an independent lifecycle (default @onDelete restrict).", "whenToUse": "A plain directed reference to another entity, no ownership or cascade. The lightest link — when you just need to point at another entity.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Association is a plain reference — the target's lifecycle is independent (default @onDelete restrict).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -37,12 +37,12 @@ "subType": "aggregation", "description": "A shared/independent containment — the parent groups the target but does not own its lifecycle (default @onDelete set-null).", "whenToUse": "One entity groups others it does NOT own (children outlive the parent; delete sets the FK null). Use instead of composition when there is no ownership.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Aggregation is shared/independent — the target outlives the parent (default @onDelete set-null).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." } @@ -53,12 +53,12 @@ "subType": "composition", "description": "An owned containment — the parent owns the target's lifecycle; deleting the parent deletes the children (default @onDelete cascade).", "whenToUse": "You need a parent that OWNS a child collection (one-to-many, cascade on delete). Declare it to generate the FK + typed navigation instead of a bare FK field + hand-written joins.", - "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", + "rules": "M:N is expressed by @cardinality:'many' + @objectRef + @through: @through names a junction entity that MUST declare two identity.reference children (one per FK side), and the relationship's FK fields are DERIVED from those references — never restated. @sourceRefField disambiguates a DIRECTED self-join by naming the source-side FK field on the junction, or on a @cardinality:'one' relationship names which of several identity.reference nodes onto the same target it navigates when name-pairing does not resolve it uniquely; @symmetric marks an UNDIRECTED self-join (union-on-read) valid only when @objectRef == the declaring entity; the two are mutually exclusive. Composition is owned lifecycle — the children do not outlive the parent (default @onDelete cascade).", "children": [ { "type": "attr", "subType": "string", "name": "cardinality", "min": 0, "max": 1, "description": "Cardinality of the relationship target (e.g. 'one', 'many', 'many-to-one')." }, { "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name or fully-qualified name of the target object the relationship points to (e.g. 'Week' or 'acme::vehicle::Car')." }, { "type": "attr", "subType": "string", "name": "through", "min": 0, "max": 1, "description": "Junction (through) entity name for M:N relationships — a third entity declaring two identity.reference children, one per FK side. The relationship's FK fields are derived from those references." }, - { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Directed self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric." }, + { "type": "attr", "subType": "string", "name": "sourceRefField", "min": 0, "max": 1, "description": "Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely." }, { "type": "attr", "subType": "boolean", "name": "symmetric", "min": 0, "max": 1, "description": "Undirected self-join flag (union-on-read). Valid only when @objectRef == the declaring entity. Mutually exclusive with @sourceRefField." }, { "type": "attr", "subType": "string", "name": "onDelete", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on parent delete. Default derives from subtype (composition→cascade, aggregation→set-null, association→restrict)." }, { "type": "attr", "subType": "string", "name": "onUpdate", "min": 0, "max": 1, "allowedValues": ["cascade", "set-null", "restrict", "no-action"], "description": "Referential action on key update. Default cascade." }
metaobjects-core-typesDirected self-join disambiguator: names the source-side FK field on the junction (the other reference is the target side). Required only for directed/ambiguous self-join M:N. Mutually exclusive with @symmetric.Disambiguates which reference/FK field a relationship uses when more than one candidate exists: on a directed self-join M:N it names the junction's source-side reference (mutually exclusive with @symmetric), and on a @cardinality:'one' relationship it names which of several identity.reference nodes onto the same target the relationship navigates when name-pairing does not resolve it uniquely.
@symmetric