Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
41 changes: 38 additions & 3 deletions docs/spec/relations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # <Declaring><Target>, 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
Expand All @@ -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**.
Expand Down