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