From 61fd07cc1c715bc4def16d1f5cbc6c85919866c7 Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 15 Aug 2026 13:12:06 +0300 Subject: [PATCH] feat(intent): manyToMany materialises the intermediate entity (#6718) `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. --- CLAUDE.md | 2 +- components/engine/engine-intent/CLAUDE.md | 3 +- components/engine/engine-intent/README.md | 40 ++- .../intent/model/RelationIntent.java | 16 + .../intent/parser/IntentParser.java | 17 + .../intent/parser/ManyToManyExpander.java | 307 +++++++++++++++++ .../main/resources/intent-assistant-guide.md | 34 +- .../generator/edm/EdmManyToManyTest.java | 104 ++++++ .../intent/parser/ManyToManyIntentTest.java | 316 ++++++++++++++++++ .../tests/api/IntentEmissionCoverageIT.java | 62 ++++ 10 files changed, 889 insertions(+), 12 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmManyToManyTest.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ManyToManyIntentTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 9494c959dd..326f5b15f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,7 +185,7 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **Detailed guide:** [`components/engine/engine-intent/CLAUDE.md`](components/engine/engine-intent/CLAUDE.md). Read it before changing anything under that module — it covers the editor-first architecture and altitude contract (model files only, never code), the YAML schema and its semantics (integer-only primary keys, `composition: true` to-one = DEPENDENT master-detail while `required` alone is just a NOT NULL FK, PascalCase property names with UPPER_SNAKE columns, decision `then`/`else`, intent-prefixed table names via `IntentNaming`), the `writeModelFile`-only write surface with the stale-output scrub, the wrong turns already made (wrong altitude, template-output paths, registry-relative vs repository-absolute paths, the `JsonHelper` Gson pitfall, **and the synchronizer-based first incarnation — do not reintroduce it**), and the follow-up list (chaining model-to-code via `.gen` descriptors, `/custom/` escape hatch). Process triggers (`trigger: { onCreate: }`) are wired: the EDM adds a `ProcessId` field + a `triggers` collection to the `.model`, and the `template-application-events-java` template generates a `gen/events//Trigger.java` listener (module-scoped package `gen.events.` — two modules authoring same-named reactions no longer collide by FQN; generated beans/entities carry module-qualified names for the same reason) that starts the process on create. That persisted `ProcessId` is in turn **consumed by the generated entity-view UI**: the shared Harmonia runtime's `processTasks` store (`components/resources/application-core/.../application-core/shell/js/stores/processTasks.js`) surfaces the record's actionable BPM user tasks inline (correlating `entity.ProcessId === task.processInstanceId`), wired into every generated view gated on a `hasProcess` flag; the task form completes via the permission-checked `/services/inbox/tasks/{id}` and self-closes (#6074). `IntentEngineIT` is the HTTP-only end-to-end test (~1 minute, no sync cycles). The editor's diagram pane is **mxGraph** (replacing Mermaid, which had unfixable light/dark theming bugs) with a fixed brand-colour palette that reads on both themes — see the module guide's "Intent Editor diagram = mxGraph" section before touching `editor-intent/js/editor.js`. -**Multi-model + layout additions (PRs [#6089](https://github.com/eclipse-dirigible/dirigible/pull/6089)-[#6092](https://github.com/eclipse-dirigible/dirigible/pull/6092)):** the DSL now supports building an app from **several intent models that reference each other cross-model** - a top-level `uses:` block names other models, and a relation gains an optional `model:` alias; a cross-model `manyToOne`/`oneToOne` is emitted as a read-only **PROJECTION** entity + integer FK + dropdown (the codbex cross-project pattern - no local table/DAO/controller for the target), resolved against the owner's already-generated `.model` (leaf-first generation; convention fallback otherwise). **n:m** is an explicit **intermediate entity** (composition to one side + `manyToOne` to the other, which may be cross-model, plus bridge fields like `amount`) - `manyToMany` is parsed but never materialized. New field attributes: `unique`, `precision`/`scale`, `calculatedOnCreate`/`calculatedOnUpdate` (a neutral arithmetic expression for numeric totals, else emitted verbatim into the runtime), `calculatedActionOnCreate`/`calculatedActionOnUpdate` (server-side call-out to a hand-written `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField`, invoked as `Beans.get(.class).calculate(entity)`, taking precedence over the expression — for logic too custom to model, e.g. number generation); field `readOnly: true` (not editable; rendered in the Harmonia form's read-only details block — Label:Value above the buttons — via `isReadOnlyProperty`; `ProcessId`/audit columns/`uuid` are auto-flagged read-only, `status`-style fields opt in); field `major: false` (kept off the entity **list** table — the model's `widgetIsMajor="false"` — still shown in forms + the record details pane; defaults true); entity `imports:` (Java `import` lines injected into the generated repository so a calculated action can be referenced by simple name — Base64-encoded into the `.model`'s `importsCode`, which the Java DAO template emits; the editor's entity-level Imports tab is the model-editor equivalent); entity `audit: true` (the four standard audit columns); entity `group:` (the perspective's nav-group id in the shared application shell). **Depends-On** is exposed as `dependsOn: { relation, valueFrom?, filterBy? }` on a to-one relation (cascading/narrowed dropdown) or a field (auto-populated value) — emitted as the EDM `widgetDependsOn*` attributes (the AngularJS stacks consume them as-is; the Harmonia runtime — form/document watchers + the metadata-driven item-dialog cascade — was added alongside); defaults are the respective primary keys, names are the target's authored property names, cross-model triggers/targets supported. **Multi-language data** (the TS-era `multilingual` port): entity `multilingual: true` → the schema layer generates a sibling `_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention) and the generated Java repository overlays translated values on every read for the request's `Accept-Language` (SDK `Translator`, name-based merge); the supported language set is a PLATFORM concern (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en`, tenant-overridable via the tenant configuration) — the Harmonia **Region & Language** Settings entry always offers that set (an Alpine `locale` store, localStorage `codbex.harmonia.language`, sent as `Accept-Language` by the shared fetch client — one flag drives UI, data, and the Print default), while the top-level `languages: [en, bg]` only declares which languages the module PROVIDES translations for; the application shell warns about modules missing a platform language, and untranslated content falls back to the default; translations are authored as seeds with `language: bg`, and large data sets reference an authored CSV via seed `file: data/x.csv` (subfolder mandatory — root `.csv` is scrub-owned) instead of inline rows. A master owning an `*Item` composition child renders as the **document (header-items) layout** (`MANAGE_DOCUMENT` + `documentItemsEntity`, `uiDocumentModels`), with `aggregate: true` fields shown in the totals footer. `IntentNaming.upperSnake` collapses kebab/space/`.`/`/` separators so a hyphenated model name yields a valid SQL identifier (`sales-invoices` -> `SALES_INVOICES`). Worked example: `dirigiblelabs/sample-intent-multi-model` (six interdependent projects + a navigation-groups project). +**Multi-model + layout additions (PRs [#6089](https://github.com/eclipse-dirigible/dirigible/pull/6089)-[#6092](https://github.com/eclipse-dirigible/dirigible/pull/6092)):** the DSL now supports building an app from **several intent models that reference each other cross-model** - a top-level `uses:` block names other models, and a relation gains an optional `model:` alias; a cross-model `manyToOne`/`oneToOne` is emitted as a read-only **PROJECTION** entity + integer FK + dropdown (the codbex cross-project pattern - no local table/DAO/controller for the target), resolved against the owner's already-generated `.model` (leaf-first generation; convention fallback otherwise). **n:m** is always an **intermediate (link) entity** (composition to one side + `manyToOne` to the other, which may be cross-model): `kind: manyToMany` **materializes** that entity at parse time (`ManyToManyExpander` — ``, or the relation's `through: `, with a generated key + both FKs; the authored relation becomes the navigation-only `oneToMany` to it, so no generator ever sees a `manyToMany`), while a link carrying **bridge fields** like `amount` is authored explicitly and drops the `manyToMany`. New field attributes: `unique`, `precision`/`scale`, `calculatedOnCreate`/`calculatedOnUpdate` (a neutral arithmetic expression for numeric totals, else emitted verbatim into the runtime), `calculatedActionOnCreate`/`calculatedActionOnUpdate` (server-side call-out to a hand-written `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField`, invoked as `Beans.get(.class).calculate(entity)`, taking precedence over the expression — for logic too custom to model, e.g. number generation); field `readOnly: true` (not editable; rendered in the Harmonia form's read-only details block — Label:Value above the buttons — via `isReadOnlyProperty`; `ProcessId`/audit columns/`uuid` are auto-flagged read-only, `status`-style fields opt in); field `major: false` (kept off the entity **list** table — the model's `widgetIsMajor="false"` — still shown in forms + the record details pane; defaults true); entity `imports:` (Java `import` lines injected into the generated repository so a calculated action can be referenced by simple name — Base64-encoded into the `.model`'s `importsCode`, which the Java DAO template emits; the editor's entity-level Imports tab is the model-editor equivalent); entity `audit: true` (the four standard audit columns); entity `group:` (the perspective's nav-group id in the shared application shell). **Depends-On** is exposed as `dependsOn: { relation, valueFrom?, filterBy? }` on a to-one relation (cascading/narrowed dropdown) or a field (auto-populated value) — emitted as the EDM `widgetDependsOn*` attributes (the AngularJS stacks consume them as-is; the Harmonia runtime — form/document watchers + the metadata-driven item-dialog cascade — was added alongside); defaults are the respective primary keys, names are the target's authored property names, cross-model triggers/targets supported. **Multi-language data** (the TS-era `multilingual` port): entity `multilingual: true` → the schema layer generates a sibling `
_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention) and the generated Java repository overlays translated values on every read for the request's `Accept-Language` (SDK `Translator`, name-based merge); the supported language set is a PLATFORM concern (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en`, tenant-overridable via the tenant configuration) — the Harmonia **Region & Language** Settings entry always offers that set (an Alpine `locale` store, localStorage `codbex.harmonia.language`, sent as `Accept-Language` by the shared fetch client — one flag drives UI, data, and the Print default), while the top-level `languages: [en, bg]` only declares which languages the module PROVIDES translations for; the application shell warns about modules missing a platform language, and untranslated content falls back to the default; translations are authored as seeds with `language: bg`, and large data sets reference an authored CSV via seed `file: data/x.csv` (subfolder mandatory — root `.csv` is scrub-owned) instead of inline rows. A master owning an `*Item` composition child renders as the **document (header-items) layout** (`MANAGE_DOCUMENT` + `documentItemsEntity`, `uiDocumentModels`), with `aggregate: true` fields shown in the totals footer. `IntentNaming.upperSnake` collapses kebab/space/`.`/`/` separators so a hyphenated model name yields a valid SQL identifier (`sales-invoices` -> `SALES_INVOICES`). Worked example: `dirigiblelabs/sample-intent-multi-model` (six interdependent projects + a navigation-groups project). **First-class document numbering (`number:` + the `.numbers` artefact, `engine-numbering`):** a string field may declare `number: { series: Sales Invoice, per: Company, stampOn: create|issue }` — the intent references a **series by name only**; the number's shape (literal prefix + sequence zero-padded to a total width, no token grammar) lives OUTSIDE the model: declared per module in an authored **`.numbers`** artefact (`{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`, a requirement declaration like `.roles`) and configured per tenant in the application shell's Document Numbering settings (`/services/core/numbering`). One per-tenant table `DIRIGIBLE_DOCUMENT_NUMBERS` holds shape AND counter, one row per (series, partition); the synchronizer only INSERTs missing rows, Settings writes prefix/size/next, the allocator (`sdk.numbering.DocumentNumbers.next`) increments the counter — sequences are continuous, never auto-reset, and allocating an undeclared series fails loudly. `per:` partitions a series by a to-one relation's value (per company — two legal entities never share a counter); the declaration may name the **partition source** (`"partitions": {"table", "key", "label"}` — authored physical coordinates) so Settings labels partition rows by the entity's display name and lists a VIRTUAL row per value before its first allocation (saving provisions it — seed a company's starting number before its first document). A differing cross-module re-declaration fails that artefact naming both modules; the removed `format`/`scope`/`resetOn` keys are rejected at parse. Details in the engine-intent guide's numbering bullet. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index cb22b9bc0f..6dcc065da7 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -366,7 +366,8 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `int` Semantics worth knowing: -- **`composition: true` on a to-one relation makes it a composition.** The owning entity becomes DEPENDENT (managed as details under its parent's perspective) and the FK is NOT NULL. `required: true` *alone* only makes the FK NOT NULL - the entity stays a top-level PRIMARY association (plain dropdown, its own perspective). Composition is **opt-in**, matching the Dirigible convention (where it is an explicit `relationshipType="COMPOSITION"` and most required FKs are plain associations); `composition` already implies NOT NULL, so `required` need not also be set. Only a `manyToOne`/`oneToOne` can be a composition; an entity's *first* `composition` to-one is its composition parent. Declare the inverse `oneToMany` on the master (`Member` with `loans: oneToMany to Loan` + `Loan.member` `composition: true`) so `Loan` is managed as a detail of `Member`; the `oneToMany` itself is navigation-only (the EDM generator ignores `oneToMany`/`manyToMany` since the FK lives on the child). (This replaced the earlier "first required to-one is automatically a composition" heuristic, which made entities like a `Loan` with a required `member` FK silently nest under `Member` instead of staying top-level.) **Every to-one FK property** (composition or association) carries `relationshipType` / `relationshipCardinality` (`1_n` / `n_1` / `1_1`) / `relationshipName` (`_`) / `relationshipEntityName` / `relationshipEntityPerspectiveName` - the last two drive the generated dropdown's data URL, so they are not optional. +- **`composition: true` on a to-one relation makes it a composition.** The owning entity becomes DEPENDENT (managed as details under its parent's perspective) and the FK is NOT NULL. `required: true` *alone* only makes the FK NOT NULL - the entity stays a top-level PRIMARY association (plain dropdown, its own perspective). Composition is **opt-in**, matching the Dirigible convention (where it is an explicit `relationshipType="COMPOSITION"` and most required FKs are plain associations); `composition` already implies NOT NULL, so `required` need not also be set. Only a `manyToOne`/`oneToOne` can be a composition; an entity's *first* `composition` to-one is its composition parent. Declare the inverse `oneToMany` on the master (`Member` with `loans: oneToMany to Loan` + `Loan.member` `composition: true`) so `Loan` is managed as a detail of `Member`; the `oneToMany` itself is navigation-only (the EDM generator ignores it since the FK lives on the child; a `manyToMany` never reaches the generator at all - it is expanded into its link entity at parse time). (This replaced the earlier "first required to-one is automatically a composition" heuristic, which made entities like a `Loan` with a required `member` FK silently nest under `Member` instead of staying top-level.) **Every to-one FK property** (composition or association) carries `relationshipType` / `relationshipCardinality` (`1_n` / `n_1` / `1_1`) / `relationshipName` (`_`) / `relationshipEntityName` / `relationshipEntityPerspectiveName` - the last two drive the generated dropdown's data URL, so they are not optional. +- **`kind: manyToMany` is MATERIALIZED into the intermediate (link) entity — `ManyToManyExpander`, run first thing in `IntentParser.validate`.** An n:m is always a link row, so the parser writes the link entity the author used to have to write by hand: `` (or the relation's `through: `) with a generated integer key, a `composition` to the declaring side and a `manyToOne` to the target (cross-model when the relation carried `model:`), and the authored relation is rewritten to the navigation-only `oneToMany` to that link. Everything after the expansion — every validator, every generator, the editor's diagram and 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 / referenced like any other entity. **Why it is 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 `manyToMany` came to parse cleanly and generate NOTHING for a year (#6718 — the authored-but-silently-unconsumed class). The link carries ONLY its key and the two FKs: 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`. 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 rather than carried nowhere, as are: the same pair declared from both sides (one link, not two), a link name that collides 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 ends apart. The emission + runtime promise (link table in the schema, a link row round-tripping through the master's detail query) is asserted in `IntentEmissionCoverageIT`, not only in the parser's unit test. - **Perspective resolution is CENTRAL and settings-aware — `IntentEntities.resolvePerspective(name, compositionParents, model|settingEntities)`.** A `function: Setting` entity's generated artifacts live under the global `Settings` perspective (`data/settings` + `api/settings` packages, `-Settings-` event topics), so ANY emission that names a package, an import, a controller URL or a topic must resolve through the settings-aware overload — the raw composition walk is `IntentEntities.compositionPerspective` and is reserved for the deliberately settings-less use (role naming in the EDM generator). History: the settings rule originally lived only in `EdmIntentGenerator.perspectiveFor` and was hand-copied as `isSetting() ? "Settings" : …` ternaries into SOME glue sites; the sites without it (the notify relation loads, decision resolvers, several GlueIntentGenerator builders) emitted imports of a non-existent `gen..data.` package and the whole client-Java batch failed to compile — the Sofia city-signals first-use failure (`{Status.name}` in a notify body). Do not reintroduce per-site ternaries; extend the central resolution. - **`kind: setting` on an entity marks it as nomenclature / configuration.** `EntityIntent.kind` (default null = a regular managed entity); `kind: setting` makes `EdmIntentGenerator` emit the entity with `type="SETTING"` (and `entityType="SETTING"` in the mxGraph cell) instead of PRIMARY. The template engine keys on `entity.type === "SETTING"` (the generation pipeline's `ModelGenerator`) to route it under the dashboard's global **Settings** perspective (it nulls the layout and sets `perspectiveName = "Settings"`), so a setting entity does NOT get its own generated perspective. Crucially the EDM generator also resolves any relation **targeting** a setting entity to the `Settings` perspective (`perspectiveFor(...)`), so an FK dropdown to a setting points at `api/Settings/` rather than a missing per-entity perspective. Settings are still real entities (own table, CSVIM seeds, FK columns) - only their UI placement differs. - **First-class document numbering (`number:` on a string field) — the intent references a SERIES, the shape lives outside the model.** `number: { series: Sales Invoice, per: Company, stampOn: issue }` on a non-key string field gives it a platform-allocated, gap-free document number. A number series is a **tenant-level business object**: the intent (and the generated code) reference it only by name; its shape — a literal prefix + the sequence zero-padded to a total width, no token grammar — is declared once per module in a **`.numbers` artefact** at the project root (`{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`, AUTHORED like `.roles`, never generated) and configured per tenant in the application shell's **Document Numbering** settings (`/services/core/numbering`). The `.numbers` synchronizer (`engine-numbering`, `NumberSeriesSynchronizer`, multitenant, `SynchronizersOrder.NUMBER_SERIES` = before anything allocating) INSERTs a missing series row per tenant and never updates one — the counter is live and the shape may be tenant-configured; an identical cross-module re-declaration is a skip (a shared legal range), a DIFFERING one fails that artefact loudly naming both locations; artefact DELETE never touches the series row. Sequences are CONTINUOUS and never auto-reset (BG law; an annual restart is an admin setting prefix + next in January). `per:` names a to-one relation (never an EntityStatus) whose value PARTITIONS the series — one row per (series, partition) in the per-tenant `DIRIGIBLE_DOCUMENT_NUMBERS` table, each partition its own sequence/prefix/width, materialized on first allocation from the series' base row (two legal entities in one tenant must not share a counter; identical numbers across partitions are correct). A partitioned series' declaration may additionally name its **partition source** — `"partitions": {"table": "CRM_COMPANY", "key": "COMPANY_ID", "label": "COMPANY_NAME"}` (authored physical coordinates, the `.csvim` precedent; identifiers parse-validated to plain SQL names) — which lets the Document Numbering settings label a partition row by the entity's display name ("Sales Invoice — ACME Ltd.") and list a VIRTUAL row for every partition value BEFORE its first allocation, so an operator seeds a company's starting number before its first document (saving a virtual row provisions it exactly as the first allocation would have). `stampOn: create` = the generated DAO allocates at insert via `sdk.numbering.DocumentNumbers.next(series[, partition])`; `stampOn: issue` = the field is created with a UUID placeholder (the `generatedUuid` auto-fill) and the generated `gen/events//NumberStamp.java` delegate replaces it at the issue step, idempotently. Allocating an UNDECLARED series fails loudly — never invent a shape. The REMOVED keys `format`/`scope`/`resetOn` are rejected on the raw YAML tree (`IntentParser.rejectRemovedNumberKeys`) because the typed Gson mapping would silently drop them — an intent still carrying `format:` must fail, not quietly lose its shape. `NumberingSupport` builds the `numbering` glue collection; `NumberingSdkIT` covers the SDK + synchronizer end-to-end. diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 43b9bae021..2d9f62d3e4 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -314,8 +314,40 @@ entities: - { name: Country, kind: manyToOne, to: Country, model: countries } ``` -Many-to-many is an explicit intermediate entity (composition to one side + `manyToOne` to the -other, plus bridge fields); `manyToMany` is parsed but never materialized. +## manyToMany - the intermediate entity, materialized + +An n:m is always an intermediate (link) entity - one row per link. `kind: manyToMany` writes that +entity for you: + +```yaml +entities: + - name: Order + relations: + - { name: products, kind: manyToMany, to: Product } # through: OrderLine to name it +``` + +materializes, before validation and generation: + +```yaml + - name: OrderProduct # , or the authored `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 } +``` + +so the link gets a real table, a detail grid under the declaring entity's page (dropdown for the +target), and can be seeded, reported on and referenced like any other entity. The target may be +cross-model (`model:`); the target-picker attributes (`where` / `show` / `major` / `size` / +`leafOnly`) travel onto the link's target relation. + +**Author the intermediate entity yourself** (composition to one side + `manyToOne` to the other, +exactly as above) when the link carries **bridge fields** - a quantity, a partial `amount`, a +valid-from date - or a lifecycle of its own; then drop the `manyToMany`. Declare an n:m on **one** +side only, and note that a relation attribute describing a hand-authored to-one (`composition`, +`function`, `init`, `dependsOn`, a calculated action, `personal`, `partner`) is rejected on a +`manyToMany` rather than silently dropped. ## processes - workflows @@ -603,8 +635,8 @@ UI-test manifest, and its perspective in the generated Harmonia SPA + the shared - **Reserved `function:` roles** - `Board`, `Gantt`, `Timeline`; rejected with a clear "not yet available" message. (`function: Calendar` is now first-class - the role alias for `view: calendar`.) -- **`manyToMany`** - parsed but never materialized; the supported shape is the explicit - intermediate entity. +- **Bridge fields on a generated `manyToMany` link** - the materialized link entity carries only its + key and the two FKs; a link with its own data is authored as an explicit intermediate entity. - **Declarative glue actions beyond the current set** (see CLAUDE.md "Planned: declarative glue"): `publish`/consume message, `generateDocument` (PDF), `assign`, process-step events, inbound message/file events. Today's implemented glue: triggers, decision/form resolvers, diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java index ee73af1902..000b46bf5d 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java @@ -32,6 +32,14 @@ public class RelationIntent { * the target. */ private String model; + /** + * Optional name for the intermediate (link) entity a {@code manyToMany} materialises into - + * {@code } when absent. Use it to give the link a domain name + * ({@code Enrollment} rather than {@code StudentCourse}) or to keep two n:m relations between the + * same pair of entities apart. Valid on {@code manyToMany} only. + */ + private String through; + /** * Pre-rename boolean form of the status role - REJECTED by the parser with a clear migration * message; kept as a field only so the validator can detect it. Author {@code function: @@ -221,6 +229,14 @@ public boolean isCrossModel() { return model != null && !model.isBlank(); } + public String getThrough() { + return through; + } + + public void setThrough(String through) { + this.through = through; + } + public String getFunction() { return function; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 8aacb86d9b..3c42300b36 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -202,6 +202,10 @@ private static String rootMessage(Throwable ex) { private static void validate(IntentModel model) { List issues = new ArrayList<>(); propagateSensitiveDerivations(model); + // An n:m is materialised into its intermediate (link) entity FIRST, so every validator and + // generator below sees an ordinary composition + association pair - the DSL holds exactly one + // representation of a many-to-many, and nothing is accepted and then silently dropped. + ManyToManyExpander.expand(model, issues); Set usesAliases = validateUses(model, issues); Set entityNames = validateEntities(model, usesAliases, issues); validateFunctions(model, issues); @@ -2005,10 +2009,23 @@ private static Set validateEntities(IntentModel model, Set usesA issues.add("entity [" + entity.getName() + "] relation [" + relation.getName() + "] has unknown kind [" + relation.getKind() + "]"); } + // ManyToManyExpander consumed every n:m before this ran, so a surviving manyToMany is one + // it already refused, with a message naming what the author wrote. The association-shaped + // checks below would only pile contradictory advice (a composition kind, a target FK, a + // cross-model restriction) onto a relation that never becomes a column. + if ("manyToMany".equals(relation.getKind())) { + continue; + } if (relation.getSize() != null && (relation.getSize() < 1 || relation.getSize() > 12)) { issues.add("entity [" + entity.getName() + "] relation [" + relation.getName() + "] size [" + relation.getSize() + "] must be a 12-column grid span between 1 and 12 (typically 3/4/6/12)"); } + // through: names the link entity a manyToMany materialises into (the expander cleared it + // on the relations it rewrote, so a survivor was authored on a kind that has no link). + if (!isBlank(relation.getThrough())) { + issues.add("entity [" + entity.getName() + "] relation [" + relation.getName() + + "] declares through: but only a manyToMany materialises an intermediate entity"); + } if (relation.isComposition() && !"manyToOne".equals(relation.getKind()) && !"oneToOne".equals(relation.getKind())) { issues.add("entity [" + entity.getName() + "] relation [" + relation.getName() + "] is marked composition but only a manyToOne/oneToOne relation can be a composition"); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java new file mode 100644 index 0000000000..d9904f36f8 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/ManyToManyExpander.java @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.parser; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.eclipse.dirigible.components.intent.generator.IntentNaming; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.RelationIntent; +import org.eclipse.dirigible.components.intent.model.UsesIntent; + +/** + * Materialises a {@code kind: manyToMany} relation into the intermediate entity the platform + * has always required authors to write by hand - a link entity holding a {@code composition} to the + * declaring side and a {@code manyToOne} to the target: + * + *
+ * entities:
+ *   - name: Order
+ *     relations:
+ *       - { name: products, kind: manyToMany, to: Product }
+ * 
+ * + * becomes, before any validator or generator sees the model: + * + *
+ * entities:
+ *   - name: Order
+ *     relations:
+ *       - { name: products, kind: oneToMany, to: OrderProduct }
+ *   - name: OrderProduct
+ *     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 }
+ * 
+ * + *

+ * The expansion happens on the typed model at the START of validation, so the link entity is an + * ordinary entity from that point on - it gets its table, its FK columns, its detail table under + * the declaring entity's page, and it can be seeded, reported on and referenced like any other. + * That is the whole point: an n:m link is a real row, and the one thing the DSL must never do is + * accept {@code manyToMany} and generate nothing (dirigible #6718). + * + *

+ * The link entity's name is {@code } unless the relation names it with + * {@code through:}. Bridge data (a quantity, a valid-from date) means the link is a domain entity + * in its own right - author it explicitly with its own fields and drop the {@code manyToMany}; the + * relation attributes that describe the target picker ({@code where} / {@code show} / {@code major} + * / {@code size} / {@code leafOnly}) travel onto the link's target relation, and the ones that only + * make sense on a hand-authored to-one are rejected rather than silently dropped. + */ +final class ManyToManyExpander { + + private static final String MANY_TO_MANY = "manyToMany"; + + private static final String MANY_TO_ONE = "manyToOne"; + + private static final String ONE_TO_MANY = "oneToMany"; + + private ManyToManyExpander() {} + + /** + * Rewrite every {@code manyToMany} relation in the model into its link entity, in place. + * + * @param model the parsed model + * @param issues collector for the problems that make an n:m unmaterialisable + */ + static void expand(IntentModel model, List issues) { + Set names = new LinkedHashSet<>(); + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() != null) { + names.add(entity.getName()); + } + } + Set aliases = new HashSet<>(); + for (UsesIntent uses : model.getUses()) { + if (uses.getModel() != null) { + aliases.add(uses.getModel()); + } + } + Map> links = new LinkedHashMap<>(); + Set reportedPairs = new HashSet<>(); + for (EntityIntent owner : List.copyOf(model.getEntities())) { + if (owner.getName() == null) { + continue; + } + for (RelationIntent relation : owner.getRelations()) { + if (!MANY_TO_MANY.equals(relation.getKind()) || isBlank(relation.getName())) { + continue; + } + EntityIntent link = materialize(model, owner, relation, names, aliases, reportedPairs, issues); + if (link != null) { + names.add(link.getName()); + links.computeIfAbsent(owner, key -> new ArrayList<>()) + .add(link); + } + } + } + // Each link entity is inserted right after the entity that declares the n:m, so the generated + // .edm reads in the order the author thinks in (owner, its link, the next entity). + for (Map.Entry> entry : links.entrySet()) { + model.getEntities() + .addAll(model.getEntities() + .indexOf(entry.getKey()) + + 1, entry.getValue()); + } + } + + /** + * The link entity for one {@code manyToMany}, or {@code null} when the relation cannot be + * materialised (every reason is reported as an issue, naming the coordinates the author wrote). + */ + private static EntityIntent materialize(IntentModel model, EntityIntent owner, RelationIntent relation, Set names, + Set aliases, Set reportedPairs, List issues) { + String subject = "entity [" + owner.getName() + "] relation [" + relation.getName() + "]"; + String target = relation.getTo(); + if (isBlank(target)) { + issues.add(subject + " has no target"); + return null; + } + if (relation.isCrossModel()) { + if (!aliases.contains(relation.getModel())) { + issues.add(subject + " references undeclared model [" + relation.getModel() + "] - add it to uses:"); + return null; + } + } else if (!names.contains(target)) { + issues.add(subject + " points to unknown entity [" + target + "]"); + return null; + } + List unsupported = unsupportedAttributes(relation); + if (!unsupported.isEmpty()) { + issues.add(subject + " is a manyToMany so it cannot declare " + unsupported + + " - those describe a hand-authored to-one; author the intermediate entity explicitly (a composition to [" + + owner.getName() + "] plus a manyToOne to [" + target + "]) and put them on its relations"); + return null; + } + // A self-referencing n:m (both ends the same entity) is legitimate and is NOT the both-sides + // mistake - the "other side" the check looks for is the relation itself. + if (!relation.isCrossModel() && !target.equals(owner.getName()) && declaresManyToManyTo(model, target, owner.getName())) { + String pair = owner.getName() + .compareTo(target) <= 0 ? owner.getName() + "/" + target : target + "/" + owner.getName(); + if (reportedPairs.add(pair)) { + issues.add("entities [" + owner.getName() + "] and [" + target + + "] both declare a manyToMany to each other - an n:m materialises ONE link entity; keep the declaration on the" + + " side whose page should own the link lines and drop the other"); + } + return null; + } + String linkName = linkEntityName(owner, relation, target); + if (names.contains(linkName)) { + issues.add(subject + " materialises the link entity [" + linkName + + "] but an entity with that name is already declared - drop the manyToMany and relate to the declared entity" + + " (it is the intermediate entity), or name the link entity with through: "); + return null; + } + EntityIntent link = linkEntity(linkName, owner, relation, target); + rewriteToNavigation(relation, linkName); + return link; + } + + /** + * The attributes an author may write on a to-one but which have no meaning on an n:m - each one + * would describe the link's own FK, which only the intermediate entity can carry. Reported together + * so the author gets the whole list at once. + */ + private static List unsupportedAttributes(RelationIntent relation) { + List unsupported = new ArrayList<>(); + if (relation.isComposition()) { + unsupported.add("composition"); + } + if (!isBlank(relation.getFunction())) { + unsupported.add("function"); + } + if (!isBlank(relation.getInit())) { + unsupported.add("init"); + } + if (relation.getDependsOn() != null) { + unsupported.add("dependsOn"); + } + if (relation.isCalculated()) { + unsupported.add("calculatedAction"); + } + if (relation.isPersonal()) { + unsupported.add("personal"); + } + if (relation.isPartner()) { + unsupported.add("partner"); + } + return unsupported; + } + + /** Whether {@code entityName} declares a {@code manyToMany} back to {@code target}. */ + private static boolean declaresManyToManyTo(IntentModel model, String entityName, String target) { + for (EntityIntent entity : model.getEntities()) { + if (!entityName.equals(entity.getName())) { + continue; + } + for (RelationIntent relation : entity.getRelations()) { + if (MANY_TO_MANY.equals(relation.getKind()) && !relation.isCrossModel() && target.equals(relation.getTo())) { + return true; + } + } + } + return false; + } + + /** + * {@code through:} when authored, else {@code } - and, for a self-referencing + * n:m (both ends the same entity), {@code }, since the target's name would + * only repeat the declaring one. + */ + private static String linkEntityName(EntityIntent owner, RelationIntent relation, String target) { + if (!isBlank(relation.getThrough())) { + return relation.getThrough() + .trim(); + } + String suffix = target.equals(owner.getName()) ? IntentNaming.pascalCase(relation.getName()) : target; + return owner.getName() + suffix; + } + + /** + * The link entity: a generated integer key, the composition to the declaring side (so it is managed + * as a detail of that entity's page, never a top-level perspective of its own) and the association + * to the target. Both FKs are NOT NULL - a link row that points at only one end is not a link. + */ + private static EntityIntent linkEntity(String linkName, EntityIntent owner, RelationIntent relation, String target) { + EntityIntent link = new EntityIntent(); + link.setName(linkName); + link.setDescription(relation.getDescription()); + FieldIntent id = new FieldIntent(); + id.setName("id"); + id.setType("integer"); + id.setPrimaryKey(true); + id.setGenerated(true); + link.getFields() + .add(id); + RelationIntent toOwner = new RelationIntent(); + toOwner.setName(owner.getName()); + toOwner.setKind(MANY_TO_ONE); + toOwner.setTo(owner.getName()); + toOwner.setComposition(true); + toOwner.setRequired(true); + RelationIntent toTarget = new RelationIntent(); + toTarget.setName(targetRelationName(owner, relation, target)); + toTarget.setKind(MANY_TO_ONE); + toTarget.setTo(target); + toTarget.setRequired(true); + toTarget.setModel(relation.getModel()); + // The picker attributes describe the target dropdown, which lives on the link row - carry them. + toTarget.setWhere(relation.getWhere()); + toTarget.setShow(relation.getShow()); + toTarget.setSize(relation.getSize()); + toTarget.setMajor(relation.isMajor()); + toTarget.setLeafOnly(relation.isLeafOnly()); + link.getRelations() + .add(toOwner); + link.getRelations() + .add(toTarget); + return link; + } + + /** + * The link's target-side relation name - the target entity's name, except for a self-referencing + * n:m where that would collide with the composition's name (both ends being the same entity), in + * which case the authored relation name plays the role. + */ + private static String targetRelationName(EntityIntent owner, RelationIntent relation, String target) { + return target.equals(owner.getName()) ? IntentNaming.pascalCase(relation.getName()) : target; + } + + /** + * The authored relation becomes the navigation-only {@code oneToMany} to the link entity, so the + * model that reaches the validators and generators holds exactly one representation of the n:m. The + * picker attributes moved onto the link's target relation are cleared here rather than left behind + * on a kind that ignores them. + */ + private static void rewriteToNavigation(RelationIntent relation, String linkName) { + relation.setKind(ONE_TO_MANY); + relation.setTo(linkName); + relation.setThrough(null); + relation.setModel(null); + relation.setWhere(null); + relation.setShow(null); + relation.setSize(null); + relation.setLeafOnly(false); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 16cab6994a..e04d3814fa 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -658,12 +658,34 @@ entities: owned across models). Generate leaf models (the owners) before their consumers so the dropdown resolves. Each project is its own `.intent`; all must be published to the same runtime. -### Many-to-many (n:m) - an explicit intermediate entity +### Many-to-many (n:m) - the intermediate entity -There is no `manyToMany` materialization; model n:m as an **intermediate entity** that holds a -`composition` relation to one side, a `manyToOne` to the other (which may be cross-model via -`model:`), plus any bridge fields. Example - one invoice settled by many payments and one payment -across many invoices, each link carrying its partial `amount`: +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:`). You can either let +`kind: manyToMany` write that entity, or author it yourself. + +**Plain link - use `manyToMany`.** It materializes the link entity `` (or the +name given by `through:`) with a generated key and both foreign keys, and the link shows as a detail +grid with a dropdown on the declaring entity's page: + +```yaml + - name: Order + relations: + - { name: products, kind: manyToMany, to: Product } # -> OrderProduct + - { name: tags, kind: manyToMany, to: Tag, through: OrderTag } # named link + - { name: parts, kind: manyToMany, to: Part, model: parts } # cross-model target +``` + +Declare the n:m on **one** side only (declaring it from both sides is refused - it is one link +table). The target-picker attributes `where` / `show` / `major` / `size` / `leafOnly` are allowed +and travel onto the link's target relation; `composition`, `function`, `init`, `dependsOn`, +`calculatedActionOn*`, `personal` and `partner` are refused on a `manyToMany` - they describe a +hand-authored to-one. + +**Link with bridge data - author the entity.** When the link carries its own fields (a quantity, a +partial amount, a valid-from date) or its own lifecycle, write it out and drop the `manyToMany`. +Example - one invoice settled by many payments and one payment across many invoices, each link +carrying its partial `amount`: ```yaml - name: SalesInvoiceCustomerPayment @@ -2146,7 +2168,7 @@ name. - "expand a from-to span into day/week/month child rows / loan installments / vacation day items" -> **expansions** - "compute days between two dates on the form (working days / months)" -> **calculated field with a date function** - "reference a Customer/Country/Currency/UoM owned by another app" -> **uses + cross-model relation** -- "many-to-many between X and Y (with extra fields)" -> **intermediate entity** (composition + manyToOne) +- "many-to-many between X and Y" -> **`kind: manyToMany`** (materializes the intermediate entity); **with extra fields on the link** -> author the **intermediate entity** (composition + manyToOne) ### expansions - generate child rows from a date span diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmManyToManyTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmManyToManyTest.java new file mode 100644 index 0000000000..6c26892e31 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmManyToManyTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator.edm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * The n:m promise at the layer that actually runs: a {@code manyToMany} must reach the + * {@code .model} as a real link table with both foreign keys - the table the schema layer creates + * and the detail grid the generated UI renders (#6718). + */ +class EdmManyToManyTest { + + private static final String ORDERS = """ + name: orders + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + relations: + - { name: products, kind: manyToMany, to: Product } + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + """; + + @Test + void theLinkEntityIsADetailTableWithBothForeignKeys() { + List> entities = entities(EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(ORDERS), "orders")); + + Map link = entityByName(entities, "OrderProduct"); + assertNotNull(link, "the manyToMany must materialise a link entity"); + assertEquals("ORDERS_ORDER_PRODUCT", link.get("dataName"), "the link owns a real table"); + assertEquals("DEPENDENT", link.get("type")); + assertEquals("MANAGE_DETAILS", link.get("layoutType"), "the link is edited as a detail grid of its owner, not a page of its own"); + + Map ownerFk = propertyByName(link, "Order"); + assertEquals("COMPOSITION", ownerFk.get("relationshipType")); + assertEquals("1_n", ownerFk.get("relationshipCardinality")); + assertEquals("false", ownerFk.get("dataNullable")); + + Map targetFk = propertyByName(link, "Product"); + assertEquals("ASSOCIATION", targetFk.get("relationshipType")); + assertEquals("n_1", targetFk.get("relationshipCardinality")); + assertEquals("DROPDOWN", targetFk.get("widgetType"), "the target end is picked from a dropdown on the link row"); + assertEquals("Product", targetFk.get("relationshipEntityName")); + assertEquals("false", targetFk.get("dataNullable")); + } + + @Test + void theDeclaringEntityKeepsNoColumnForTheNavigation() { + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(ORDERS), "orders"); + List> entities = entities(model); + + Map order = entityByName(entities, "Order"); + assertNull(propertyByName(order, "Products"), "the n:m lives on the link table - never as a column on the declaring entity"); + assertEquals("MANAGE_MASTER", order.get("layoutType"), "owning a link makes the declaring entity a master with a detail panel"); + + // The link is a detail, so it must not claim navigation of its own. + @SuppressWarnings("unchecked") + List> perspectives = (List>) ((Map) model.get("model")).get("perspectives"); + assertTrue(perspectives.stream() + .noneMatch(perspective -> "OrderProduct".equals(perspective.get("name"))), + "a link entity must not create a perspective"); + } + + @SuppressWarnings("unchecked") + private static List> entities(Map model) { + return (List>) ((Map) model.get("model")).get("entities"); + } + + private static Map entityByName(List> entities, String name) { + return entities.stream() + .filter(entity -> name.equals(entity.get("name"))) + .findFirst() + .orElse(null); + } + + @SuppressWarnings("unchecked") + private static Map propertyByName(Map entity, String name) { + List> properties = (List>) entity.get("properties"); + return properties.stream() + .filter(property -> name.equals(property.get("name"))) + .findFirst() + .orElse(null); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ManyToManyIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ManyToManyIntentTest.java new file mode 100644 index 0000000000..7ec25978cf --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ManyToManyIntentTest.java @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.RelationIntent; +import org.junit.jupiter.api.Test; + +/** + * Coverage for {@code kind: manyToMany} - materialised into the intermediate (link) entity, never + * accepted and dropped (#6718). + */ +class ManyToManyIntentTest { + + private static final String ORDERS = """ + name: orders + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + relations: + - { name: products, kind: manyToMany, to: Product } + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + """; + + @Test + void materializesTheLinkEntityRightAfterItsOwner() { + IntentModel model = IntentParser.parse(ORDERS); + + assertEquals(List.of("Order", "OrderProduct", "Product"), names(model), "the link entity is inserted right after its owner"); + + EntityIntent link = entity(model, "OrderProduct"); + FieldIntent id = link.getFields() + .get(0); + assertEquals("id", id.getName()); + assertTrue(id.isPrimaryKey() && id.isGenerated(), "the link carries a generated integer key"); + + RelationIntent toOwner = link.getRelations() + .get(0); + assertEquals("Order", toOwner.getTo()); + assertEquals("manyToOne", toOwner.getKind()); + assertTrue(toOwner.isComposition(), "the link is a detail of the declaring entity"); + assertTrue(toOwner.isRequired()); + + RelationIntent toTarget = link.getRelations() + .get(1); + assertEquals("Product", toTarget.getTo()); + assertEquals("manyToOne", toTarget.getKind()); + assertFalse(toTarget.isComposition(), "the target end is an association, not a second owner"); + assertTrue(toTarget.isRequired(), "a link row that points at only one end is not a link"); + } + + @Test + void theAuthoredRelationBecomesNavigationToTheLink() { + IntentModel model = IntentParser.parse(ORDERS); + + RelationIntent products = entity(model, "Order").getRelations() + .get(0); + assertEquals("products", products.getName(), "the authored name is kept"); + assertEquals("oneToMany", products.getKind(), "no manyToMany survives into the generators"); + assertEquals("OrderProduct", products.getTo()); + } + + @Test + void throughNamesTheLinkEntity() { + String yaml = ORDERS.replace("kind: manyToMany, to: Product", "kind: manyToMany, to: Product, through: OrderLine"); + IntentModel model = IntentParser.parse(yaml); + + assertEquals(List.of("Order", "OrderLine", "Product"), names(model)); + assertEquals("OrderLine", entity(model, "Order").getRelations() + .get(0) + .getTo()); + } + + @Test + void throughIsRejectedOnAnyOtherKind() { + String yaml = """ + name: orders + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Customer, kind: manyToOne, to: Customer, through: OrderCustomer } + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getMessage() + .contains("only a manyToMany materialises an intermediate entity"), + ex.getMessage()); + } + + @Test + void thePickerAttributesTravelToTheLinksTargetRelation() { + String yaml = """ + name: orders + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: products, kind: manyToMany, to: Product, major: false, size: 4, where: { Kind: 1 }, show: [code] } + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: code, type: string } + - { name: Kind, type: integer } + """; + IntentModel model = IntentParser.parse(yaml); + + RelationIntent toTarget = entity(model, "OrderProduct").getRelations() + .get(1); + assertFalse(toTarget.isMajor()); + assertEquals(4, toTarget.getSize()); + assertEquals(1L, toTarget.getWhere() + .get("Kind")); + assertEquals(List.of("code"), toTarget.getShow()); + + // Nothing is left behind on the navigation relation, which ignores them. + RelationIntent products = entity(model, "Order").getRelations() + .get(0); + assertEquals(null, products.getWhere()); + assertEquals(null, products.getShow()); + } + + @Test + void aCrossModelTargetKeepsItsModelAliasOnTheLink() { + String yaml = """ + name: orders + uses: + - { model: products } + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: products, kind: manyToMany, to: Product, model: products } + """; + IntentModel model = IntentParser.parse(yaml); + + RelationIntent toTarget = entity(model, "OrderProduct").getRelations() + .get(1); + assertEquals("products", toTarget.getModel(), "the link owns the cross-model association"); + assertTrue(toTarget.isCrossModel()); + assertEquals(null, entity(model, "Order").getRelations() + .get(0) + .getModel(), + "the navigation relation points at the local link entity"); + } + + @Test + void anUndeclaredCrossModelAliasIsReportedOnTheAuthoredRelation() { + String yaml = """ + name: orders + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: products, kind: manyToMany, to: Product, model: products } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getMessage() + .contains("entity [Order] relation [products] references undeclared model [products]"), + ex.getMessage()); + } + + @Test + void aSelfReferencingManyToManyKeepsItsTwoEndsApart() { + String yaml = """ + name: catalog + entities: + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: relatedProducts, kind: manyToMany, to: Product } + """; + IntentModel model = IntentParser.parse(yaml); + + EntityIntent link = entity(model, "ProductRelatedProducts"); + assertEquals("Product", link.getRelations() + .get(0) + .getName()); + assertEquals("RelatedProducts", link.getRelations() + .get(1) + .getName(), + "both ends target the same entity, so the second FK is named after the authored relation"); + } + + @Test + void bothSidesDeclaringTheSamePairIsRejectedOnce() { + String yaml = """ + name: school + entities: + - name: Student + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: courses, kind: manyToMany, to: Course } + - name: Course + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: students, kind: manyToMany, to: Student } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + String message = ex.getMessage(); + assertTrue(message.contains("both declare a manyToMany to each other"), message); + assertEquals(message.indexOf("both declare a manyToMany"), message.lastIndexOf("both declare a manyToMany"), + "the pair is reported once, not once per side"); + } + + @Test + void aNameClashWithADeclaredEntityIsRejected() { + String yaml = """ + name: orders + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: products, kind: manyToMany, to: Product } + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - name: OrderProduct + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: Order, kind: manyToOne, to: Order, composition: true, required: true } + - { name: Product, kind: manyToOne, to: Product, required: true } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getMessage() + .contains("through: "), + "the clash message must offer both ways out: " + ex.getMessage()); + } + + @Test + void toOneOnlyAttributesAreRejectedRatherThanDropped() { + String yaml = """ + name: orders + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: products, kind: manyToMany, to: Product, composition: true, init: "1" } + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getMessage() + .contains("[composition, init]"), + "every unsupported attribute is listed at once: " + ex.getMessage()); + } + + @Test + void anUnknownTargetIsReportedOnTheAuthoredRelation() { + String yaml = """ + name: orders + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: products, kind: manyToMany, to: Product } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getMessage() + .contains("entity [Order] relation [products] points to unknown entity [Product]"), + ex.getMessage()); + } + + private static List names(IntentModel model) { + return model.getEntities() + .stream() + .map(EntityIntent::getName) + .toList(); + } + + private static EntityIntent entity(IntentModel model, String name) { + EntityIntent found = model.getEntities() + .stream() + .filter(entity -> name.equals(entity.getName())) + .findFirst() + .orElse(null); + assertNotNull(found, "entity [" + name + "] must be present, model has " + names(model)); + return found; + } +} diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index ee4351a02e..04bb3e8eeb 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -615,6 +615,21 @@ class IntentEmissionCoverageIT extends IntegrationTest { relations: - { name: Person, kind: manyToOne, to: Person, required: true } + # manyToMany: materialised into the intermediate entity CourseTag - a real link table + # with both foreign keys, edited as a detail grid of the course. The keyword used to + # parse and generate NOTHING, which is why the link table + a round-tripping link row + # are asserted here and not only in the parser's unit test. + - name: Course + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + relations: + - { name: tags, kind: manyToMany, to: Tag } + - name: Tag + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + aggregates: - name: ledgerTotal of: Ledger @@ -1083,6 +1098,13 @@ private void assertEmission() { String schema = contentOf("gen/emission/schema/" + PROJECT + ".schema"); assertTrue(schema.contains("EMISSION_UNIT_LANG"), "multilingual must emit the _LANG translation table into the schema"); + // manyToMany: the link entity is an ordinary entity from parse time on, so it must reach the + // schema as its own table and the REST layer as its own (detail) controller. Asserting the + // parsed model alone is exactly what let the keyword generate nothing for so long. + assertTrue(schema.contains("EMISSION_COURSE_TAG"), "manyToMany must emit the intermediate entity's table into the schema"); + String courseTagController = contentOf("gen/emission/api/course/CourseTagController.java"); + assertTrue(courseTagController.contains("Course") && courseTagController.contains("Tag"), + "the link controller must carry both ends of the n:m, got: " + courseTagController); assertHistoryEmission(schema, entryRepository); String unitRepository = contentOf("gen/emission/data/settings/UnitRepository.java"); assertTrue(unitRepository.contains("Translator"), "multilingual must emit the read-time translation overlay into the repository"); @@ -2764,9 +2786,49 @@ private void assertRuntimeEnforcement() { .then() .statusCode(403)); + assertManyToManyRuntime(); assertBpmEventsRuntime(); } + /** + * n:m end to end (#6718): the link entity materialised from {@code kind: manyToMany} is a real + * table with a working REST surface - a link row is created against both ends and comes back + * through the very query the master's detail grid uses. This is the layer the keyword never reached + * while it was parsed and dropped. + */ + private void assertManyToManyRuntime() { + AtomicInteger tagId = new AtomicInteger(); + AtomicInteger courseId = new AtomicInteger(); + restAssuredExecutor.execute(() -> tagId.set(given().contentType("application/json") + .body("{\"Name\":\"Modeling\"}") + .when() + .post(API + "/tag/TagController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> courseId.set(given().contentType("application/json") + .body("{\"Name\":\"Intent 101\"}") + .when() + .post(API + "/course/CourseController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Course\":" + courseId.get() + ",\"Tag\":" + tagId.get() + "}") + .when() + .post(API + "/course/CourseTagController") + .then() + .statusCode(200)); + restAssuredExecutor.execute(() -> given().when() + .get(API + "/course/CourseTagController?Course=" + courseId.get()) + .then() + .statusCode(200) + .body("$", hasSize(1)) + .body("[0].Tag", equalTo(tagId.get()))); + } + /** * BPM events wave 1, at the outermost layer: the PT2S timeout fires while the review task stays * claimable (non-cancelling); the parked wait ignores a guarded-out (internal) reply and resumes on