Skip to content

feat(intent): manyToMany materialises the intermediate entity (#6718) - #6741

Merged
delchev merged 1 commit into
masterfrom
feat/intent-many-to-many
Aug 15, 2026
Merged

feat(intent): manyToMany materialises the intermediate entity (#6718)#6741
delchev merged 1 commit into
masterfrom
feat/intent-many-to-many

Conversation

@delchev

@delchev delchev commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Closes #6718.

The trap

kind: manyToMany was accepted by the parser and materialised by nothing — no join table, no UI, no error. An author (or the AI agent) who wrote it got a clean Generate and an application with the relationship simply missing: the authored-but-silently-unconsumed class of failure (same family as #6543).

The issue offered two ways out; this is the second — materialisation, using the intermediate entity that was already the supported shape.

What it does

ManyToManyExpander runs first thing in IntentParser.validate and rewrites

- name: Order
  relations:
    - { name: products, kind: manyToMany, to: Product }

into the link entity, before any validator or generator sees the model:

- name: OrderProduct                    # <Declaring><Target>, or the relation's `through: <Name>`
  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 }

with the authored relation becoming the navigation-only oneToMany to that link. Everything downstream — every validator, every generator, the editor's diagram, the /parse response — sees an ordinary composition + association pair, so the link gets its table, its FK columns, its detail grid with a dropdown under the declaring entity's page, and it can be seeded, reported on and referenced like any other entity.

Why an expansion and not a generator: one representation of an n:m in the model means no generator, present or future, can forget the case — which is exactly how the keyword came to parse cleanly and generate nothing.

Where the boundary now sits

The generated link carries only its key and the two FKs. Bridge data (a quantity, a partial amount, a valid-from date) means the link is a domain entity — authored explicitly with its own fields and no manyToMany, exactly as today, now as the documented boundary rather than the only option.

Nothing else is silently dropped

  • The target-picker attributes where / show / major / size / leafOnly travel onto the link's target relation.
  • The attributes that only describe a hand-authored to-one — composition, function, init, dependsOn, calculated actions, personal, partner — are rejected, naming what was written.
  • Also rejected: the same pair declared from both sides (it is one link table, reported once for the pair), a link name colliding with a declared entity (drop the manyToMany, or name it with through:), and through: on any other kind.
  • A self-referencing n:m is legitimate; its two ends are named apart.
  • A cross-model target (model:) is supported — the link owns the cross-model association.

Tests

  • ManyToManyIntentTest — the materialised shape, through:, cross-model, the picker attributes travelling, self-reference, and every refusal.
  • EdmManyToManyTest — the .model: a DEPENDENT link table (ORDERS_ORDER_PRODUCT, MANAGE_DETAILS) with a COMPOSITION and an ASSOCIATION FK, no column on the declaring entity, no perspective of its own.
  • IntentEmissionCoverageIT — the layer that matters, per the module's emission + runtime contract: the link table in the generated schema, the link controller, and a link row created and read back through the master's detail query on the published app.

Verified locally: full -P unit-tests sweep, IntentEmissionCoverageIT (83s, green), formatter:validate, and the release-profile javadoc build on engine-intent.

Docs

In-repo: module README.md + CLAUDE.md, the assistant guide (so the agent proposes the kind), root CLAUDE.md. Doc sites: dirigible.io PR #181 (merged) and the vendor-neutral spec IntentFile/intentfile.github.io#19.

`kind: manyToMany` was accepted by the parser and materialised by nothing - no
link table, no UI, no error. An author (or the AI agent) got a clean Generate and
an application with the relationship simply missing: the
authored-but-silently-unconsumed class of failure.

An n:m has always been an intermediate (link) entity here, so the kind that names
one now writes it. ManyToManyExpander runs first thing in IntentParser.validate
and rewrites

    - { name: products, kind: manyToMany, to: Product }

into the link entity OrderProduct - a generated integer key, a composition to the
declaring side, a manyToOne to the target (cross-model when the relation carried
model:) - and turns the authored relation into the navigation-only oneToMany to
it. Everything downstream (every validator, every generator, the editor diagram,
the /parse response) sees an ordinary composition + association pair, so the link
gets its table, its FK columns, its detail grid under the declaring entity's page,
and can be seeded, reported on and referenced like any other entity.

It is an expansion rather than a generator on purpose: one representation of an
n:m in the model means no generator, present or future, can forget the case -
which is exactly how the keyword came to parse cleanly and generate nothing.

The link carries only its key and the two foreign keys. Bridge data (a quantity, a
partial amount, a valid-from) means the link is a domain entity, authored
explicitly with its own fields and no manyToMany - unchanged, and now the
documented boundary rather than the only option.

Nothing else is silently dropped either: the target-picker attributes (where /
show / major / size / leafOnly) travel onto the link's target relation, while the
attributes that only describe a hand-authored to-one (composition, function, init,
dependsOn, calculated actions, personal, partner) are rejected naming what was
written. So are the same pair declared from both sides (it is one link table), a
link name colliding with a declared entity (drop the manyToMany or name it with
`through:`), and `through:` on any other kind. A self-referencing n:m is
legitimate and names its two ends apart.

Tests: ManyToManyIntentTest (the shape, through:, cross-model, self-reference and
every refusal), EdmManyToManyTest (the .model: a DEPENDENT link table with both
FKs, no column on the declaring entity, no perspective of its own), and
IntentEmissionCoverageIT - the layer that matters - asserting the link table in
the generated schema and a link row round-tripping through the master's detail
query on the published app.

Docs: module README + CLAUDE.md, the assistant guide (so the agent proposes the
kind), root CLAUDE.md.
@delchev
delchev merged commit ccd0a58 into master Aug 15, 2026
10 checks passed
@delchev
delchev deleted the feat/intent-many-to-many branch August 15, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

intent: manyToMany is parsed but never materialised - reject with guidance instead of silently ignoring

1 participant