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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,92 @@ here.**

### Fixed

- **An M:N relationship inherited through `extends` derived its junction FK columns
against the wrong entity — and in codegen the failure was silent.** The derivation
classified the self-join, and matched the junction's source-side
`identity.reference`, against the entity the CALLER was iterating. Every caller walks
a resolving relationship accessor, so for a relationship declared on a base and
reached through a subclass that is the INHERITING entity, not the one that declared
it. Two failures followed: an inherited self-join compared `@objectRef` (the base)
against the child, read as hetero, looked for a junction reference to the child and
found none; and an inherited hetero whose junction references the base found nothing
either. TypeScript codegen and the docs-site link graph catch the resulting error and
return `null`, so the navigation was **dropped from the generated output with no
error at all**; C# codegen did the same; the TypeScript, Java, Kotlin and Python
runtime and codegen paths let it escape, so the traversal or the generation run
failed outright.

The declaring entity now comes from the relationship's own parent — the same shape as
[#368](https://github.com/metaobjectsdev/metaobjects/issues/368)'s loader fix — in all
four derivations (TypeScript, Java, C#, Python; Kotlin calls the Java helper). The
entity being navigated from is kept alongside it rather than discarded: under
inheritance both are legitimate names for the relationship's subject, because a
junction FK usually references the concrete child while `@objectRef` on a hoisted
self-join names the base. The authoring contract this establishes — **the junction FK
may reference either the declaring base or the concrete child, and only those two** —
is now written down in
[`docs/features/relationships.md`](docs/features/relationships.md).

C# additionally fixes `M2MNavigation.IsSelfJoin`, which had the same confusion one
layer up. It never ran on an inherited self-join before (the derivation threw first),
and `DbContextGenerator` uses it to decide whether to emit EF `UsingEntity` wiring —
so fixing only the derivation would have turned a silent drop into silently wrong EF
configuration.

**Not a pure widening.** One shape that derived before now refuses: a base declaring
`@objectRef: <itself>` + `@through` with neither `@symmetric` nor `@sourceRefField`,
reached through a subclass, used to be misread as hetero and returned an arbitrary FK
direction; it is now correctly recognised as an ambiguous self-join and refused. That
model was already broken — deriving the same relationship from the base itself threw —
so codegen emitted for the child and dropped it for the base. The refusal is the
correct behaviour, but on Java, Kotlin and Python, whose callers do not catch, it
moves from "generates wrongly" to "the generation run fails", and the fix is to add
`@symmetric` or `@sourceRefField`.

Three narrower resolution changes come with moving the junction matches onto identity,
all three matching what the Java port already did. A junction `@references` (or an
`@objectRef`) that **is** package-qualified must now resolve **exactly**: a
partially-qualified or stale package no longer falls back to matching the bare tail, so
a reference that used to bind by luck now matches neither the subject **nor** the
target — both sides are affected, not just the subject side. A **bare** reference whose
short name exists in more than one package resolves first-declared-wins, which can pick
the wrong-package entity — the pre-existing
[#174](https://github.com/metaobjectsdev/metaobjects/issues/174) behaviour, now reached
by M:N derivation as well. And on **Python only**, a junction whose `@references` use
the dotted `Entity.field` form now resolves: that port compared the whole attr value, so
`Team.id` never matched the entity `Team` and an M:N through such a junction failed
derivation outright. Both junction matches now take the entity head through the same
canonical parse the loader uses, so those models derive where they previously raised.

**Cross-port divergence goes DOWN, not up.** Both junction matches — "does this
reference name the relationship's subject?" and "does this one name the target?" — now
resolve the name to an ENTITY and compare identity in all four derivations, which is
what the Java port already did on both sides. Matching only one side would be worse
than matching neither: the two searches are independent and nothing excludes the
source-side reference from the target search, so a cross-package M:N could bind the
same junction column as BOTH sides and emit `(srcFk, srcFk)` silently. C#'s
`M2MNavigation` descriptor resolves its target the same way for the same reason — its
`IsSelfJoin` feeds the EF `UsingEntity` wiring, and a descriptor that disagreed with
the derivation would mis-map the relationship. TypeScript, C# and Python had been
comparing package-stripped short names, so a genuine cross-package hetero M:N onto a
target whose short name matched the subject's (`a::NodeBase` relating to `b::NodeBase`)
was misread as a self-join on those three — a regression the two-name subject
introduced, caught in review and fixed rather than documented.

What changed, exactly: both junction matches in the four derivations, the C# navigation
builder's own target/junction resolution (so the descriptor cannot disagree with the
derivation feeding it), and Python's reference head-parse, which now delegates to the
loader's canonical helper instead of keeping a third copy. `@through` resolution and
every comparison outside M:N derivation are untouched, and this is **not** a general
[ADR-0041](spec/decisions/ADR-0041-cross-package-reference-resolution.md) sweep — the
resolver added here is deliberately narrow and is not the port's general reference
resolver.

No vocabulary change: `metamodelVersion` stays `1.0` and the registry manifest is
untouched.

||||||| 7dafb055e

- **Both shipped libraries failed `meta verify`'s requirement gate**, in metadata an
adopter cannot fix: every L4 in `ai` claimed FIELDS (`ERR_REQUIREMENT_L4_NOT_OBJECT`),
and both libraries wrote their concerns as SIBLINGS of the L2 segment their own comments
Expand Down
87 changes: 87 additions & 0 deletions docs/features/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ metadata:
| `@cardinality` | `relationship.composition` | `one` / `many` | Multiplicity on the target side |
| `@fields` | `identity.reference` | One field name or array | The FK column(s) on this entity |
| `@references` | `identity.reference` | Entity name | The target entity (PK on the other side) |
| `@through` | `relationship.*` | Junction entity name | Makes the relationship M:N. With `@cardinality: many`, names the junction entity whose two `identity.reference` children the FK columns are DERIVED from — the relationship never restates them. |
| `@sourceRefField` | `relationship.*` | FK field name | On an M:N, names the source-side FK field on the junction (a DIRECTED self-join). On a `@cardinality: one` relationship, picks which of several `identity.reference` nodes onto the same target it navigates (see below). Mutually exclusive with `@symmetric`. |
| `@symmetric` | `relationship.*` | `true` | Marks an UNDIRECTED M:N self-join (union-on-read). Valid only when `@objectRef` is the relationship's own subject. Mutually exclusive with `@sourceRefField`. |
| `@onDelete` | `relationship.*` and `identity.reference` | `cascade` / `set-null` / `restrict` / `no-action` | RDB referential action. Default derives from the relationship subtype: composition -> `cascade`, aggregation -> `set-null`, association -> `restrict`. |
| `@onUpdate` | `relationship.*` and `identity.reference` | same as `@onDelete` (default `cascade` when a relationship correlates) | RDB referential action |

Expand Down Expand Up @@ -240,6 +243,79 @@ See [ADR-0029](../../spec/decisions/ADR-0029-entity-child-extends-and-via-infere
Amendment 1 for the full ladder specification, including why suffix-stripping applies
to candidates only.

## Inheriting an M:N relationship through `extends`

An M:N relationship declared on an abstract base is visible on every entity that
`extends` it — relationship accessors are RESOLVING, so `Post` sees the `tags`
relationship its `PostBase` declared. The junction's two `identity.reference`
children are what give the FK direction, and under inheritance there are two
defensible entities for the source-side reference to name:

- the **declaring base** (`PostBase`) — the entity the relationship is written on, and
what `@objectRef` names for a self-join hoisted onto a base; or
- the **concrete child** (`Post`) — usually what the FK actually references, because
an abstract base has no table for a foreign key to point at.

**Both are accepted, and only those two.** The FK derivation treats the declaring
entity and the entity you are navigating from as the relationship's *subject*: the
source-side junction reference may name either, and `@objectRef` naming either makes
the relationship a self-join. Nothing else counts — in particular an entity lying
strictly *between* the declaring base and the navigating entity in a deeper hierarchy
is **not** accepted, and a junction reference naming one fails derivation with
`ERR_INVALID_RELATIONSHIP`.

```yaml
# PostBase (abstract) declares the M:N; Post extends it. The junction may reference
# EITHER PostBase or Post — both derive postId/tagId for Post.tags.
- object.entity:
name: PostBase
isAbstract: true
children:
- relationship.association:
name: tags
objectRef: Tag
cardinality: many
through: PostTag
- object.entity:
name: Post
extends: PostBase
- object.entity:
name: PostTag
children:
- identity.reference:
name: fkPost
fields: postId
references: Post # or PostBase — either resolves
- identity.reference:
name: fkTag
fields: tagId
references: Tag
```

The same rule governs an inherited **self-join**: a base declaring
`@objectRef: <itself>` with `@symmetric` or `@sourceRefField` derives the same two FK
sides whichever subclass you reach it through. The derivation's answer never depends
on which entity's effective view got there first — that independence is the point, and
it is what
[#368](https://github.com/metaobjectsdev/metaobjects/issues/368)'s loader fix
established for validation and this rule extends to FK derivation.

**Cross-package targets are safe.** All five ports resolve `@objectRef` and each junction
`identity.reference` to an ENTITY and compare identity — on **both** sides of the
derivation, the source-side match and the target-side match alike. So a genuine
cross-package hetero M:N whose target's short name happens to match the source's
(`a::Account` relating to `b::Account`, or `a::NodeBase` to `b::NodeBase`) binds each
junction reference to its own entity instead of matching one of them twice. A
package-qualified name resolves exactly
([ADR-0041](../../spec/decisions/ADR-0041-cross-package-reference-resolution.md)); a bare
name matches a short name, where a collision across packages is the deferred follow-up
[#174](https://github.com/metaobjectsdev/metaobjects/issues/174), the same as everywhere
else a bare reference is resolved. Two consequences worth knowing when authoring: a
junction `@references` that *is* package-qualified must resolve **exactly** — a
partially-qualified or stale package no longer falls back to a bare-tail match — and a
**bare** `@references` whose short name exists in more than one package binds the first
declared, which is #174 and not specific to M:N.

## What each port generates

### TypeScript
Expand Down Expand Up @@ -372,6 +448,17 @@ Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
via their respective conformance runners. See [`docs/CONFORMANCE.md`](../CONFORMANCE.md)
for the per-port pass/skip ledger.

**Not fixture-gated, and why.** The M:N junction-FK DERIVATION — including the
inherited-relationship rule above — cannot be expressed in `fixtures/conformance/`:
that corpus is a load→canonical-serialize round-trip, and the serializer preserves the
declared `@objectRef` / `@through` / `@sourceRefField` strings without ever surfacing
which junction column the derivation picked. Two models that derive differently
serialize identically. It is gated instead by per-port unit tests over the shared
derivation helper (`relationship-m2m.test.ts`, `M2MSlimVocabularyTest.java`,
`M2MInheritedDeclaringEntityTests.cs`, `test_derive_m2m_declaring_entity.py`,
`KotlinM2mCodegenTest.kt`), which is the same call the M:N FQN-collision cases already
made.

## See also

- [entities.md](entities.md) — host node `object.entity`
Expand Down
Loading
Loading