From 836c8108c990d92dea78ece8e58e062af85f23d3 Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 15 Aug 2026 13:08:10 +0300 Subject: [PATCH] spec(relations): manyToMany materialises the intermediate entity An n:m has always been an intermediate (link) entity in this specification; the kind that named it was parsed and then materialised by nothing, which is the worst of the three possible states - a clean parse and a relationship that simply is not there. Specify the materialisation: `kind: manyToMany` writes the link entity (or the relation's `through:`) with a generated key, a composition to the declaring side and a manyToOne to the target (cross-model allowed), and the authored relation becomes the navigation-only oneToMany to it - so the document holds exactly one representation of an n:m. The link is a real entity: table, detail grid with a dropdown, seedable, reportable. The explicit intermediate entity keeps its place, and gains a clear boundary: it is how a link that carries data of its own (a quantity, a partial amount, a valid-from) is authored. Also specified: one-sided declaration, which relation attributes travel to the link's target end and which are refused rather than ignored, the through:/name-collision rules, and the self-referencing case. --- docs/reference.md | 3 ++- docs/spec/relations.md | 41 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 48d8eae..d61f9a2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -81,6 +81,7 @@ entities: - { name: Status, kind: manyToOne, to: OrderStatus, function: EntityStatus, init: 1 } - { name: City, kind: manyToOne, to: City, dependsOn: { relation: Country, filterBy: Country } } - { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } } +- { name: tags, kind: manyToMany, to: Tag, through: OrderTag } # link entity, named ``` ### function @@ -162,6 +163,6 @@ The following are parsed (or reserved) but not yet materialised by a generator; - Reserved `function` values for upcoming presentations (`Board`, `Gantt`, `Timeline`). - **Cross-model status names and stage scopes** — a nomenclature owned by another model is seeded there, so its stages and names cannot be resolved from the referencing file; such references are rejected with the numeric-id fallback named. -- **`manyToMany`** — parsed but never materialised; the supported shape is the [explicit intermediate entity](/spec/relations#many-to-many). +- **Bridge fields on a generated `manyToMany` link** — the [materialised link entity](/spec/relations#many-to-many) carries only its key and the two foreign keys; a link with data of its own is authored as an explicit intermediate entity. - Event-driven document generation (produce a document on an event), a declarative state machine, and shadow audit-history entities (audit *columns* via `audit: true` ship today). - Arbitrary resolver-path task assignment beyond `assignee: personal`. diff --git a/docs/spec/relations.md b/docs/spec/relations.md index 32f083a..9901f9a 100644 --- a/docs/spec/relations.md +++ b/docs/spec/relations.md @@ -66,7 +66,44 @@ Composition is **opt-in** — most required FKs are plain associations, and comp ## Many-to-many -There is no `manyToMany` materialisation - the kind is parsed but never turned into a join table. Model n:m as an **explicit intermediate entity** holding a `composition` to one side, a `manyToOne` to the other (which may be cross-model via `model:`), plus any bridge fields: +An n:m is always an **intermediate (link) entity** — one row per link, holding a `composition` to one side and a `manyToOne` to the other (which may be cross-model via `model:`). It is a real entity: it has a table, it appears as a detail grid with a dropdown under the declaring entity, and it can be seeded, reported on and referenced like any other — which is what a real n:m relationship needs anyway. + +You either let `manyToMany` write that entity, or you write it yourself when the link carries data of its own. + +### `manyToMany` — the link written for you + +```yaml +- name: Order + relations: + - { name: products, kind: manyToMany, to: Product } # link entity OrderProduct + - { name: tags, kind: manyToMany, to: Tag, through: OrderTag } # named link entity + - { name: parts, kind: manyToMany, to: Part, model: parts } # cross-model target +``` + +`Order.products` materialises, before validation and generation: + +```yaml +- name: OrderProduct # , or the name given by `through:` + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Order, kind: manyToOne, to: Order, composition: true, required: true } + - { name: Product, kind: manyToOne, to: Product, required: true } +``` + +and the authored relation becomes the navigation-only `oneToMany` to the link entity, so the model holds exactly one representation of the n:m. + +Rules: + +- Declare an n:m on **one** side only — it is one link table, not two. Declaring it from both sides is an error naming the pair. +- The attributes that describe the **target picker** — `where`, `show`, `major`, `size`, `leafOnly` — are allowed and travel onto the link's target relation. +- The attributes that describe a hand-authored to-one — `composition`, `function`, `init`, `dependsOn`, calculated actions, `personal`, `partner` — are **rejected** on a `manyToMany` (they belong on the relations of an explicit intermediate entity), rather than accepted and ignored. +- `through:` is valid on `manyToMany` only. Use it to give the link a domain name (`Enrollment` rather than `StudentCourse`) or to keep two n:m relations between the same pair apart. A generated name that collides with a declared entity is an error, not a silent merge. +- A self-referencing n:m (both ends the same entity) is legitimate; the link's two ends are named apart. + +### An explicit intermediate entity — a link with data + +When the link carries **bridge fields** — a quantity, a partial amount, a valid-from date — or a lifecycle of its own, write the entity out and drop the `manyToMany`: ```yaml - name: SalesInvoiceCustomerPayment @@ -78,8 +115,6 @@ There is no `manyToMany` materialisation - the kind is parsed but never turned i - { name: CustomerPayment, kind: manyToOne, to: CustomerPayment, model: customer-payments, required: true } ``` -The intermediate entity is a real entity you can read, seed and report on — which is usually what a real n:m relationship needs anyway. - ## Multi-model applications A non-trivial domain is rarely one project. The intent layer lets you split it into **several intent projects** — one `*.intent` each — that reference each other across models, **reuse** single master-data entities instead of redefining them, and contribute their screens to one **shared shell**.