From 8209f56b3417770cca753bc19749976ae0b72dae Mon Sep 17 00:00:00 2001 From: delchev Date: Mon, 7 Sep 2026 15:15:30 +0300 Subject: [PATCH] intent: a value required only under a condition - `checks: requiredWhen` (#7094) `required: true` is unconditional, and most rules about a missing value are not: the value is needed for ONE way of handling the record and meaningless for the others. An invoice sent by e-mail needs the customer's e-mail address; one sent by post does not - so `required` on the address is not the rule, `checks:` had no kind that was, and the real rule was therefore not declared anywhere. An e-mailed invoice whose customer carried no address went through Send: status SENT, the mail step logging a no-op for a recipient it did not have, nothing stamped on the record, and the clerk who pressed the button told it had succeeded. `- { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT, message: ... }`. **The value may be one hop away**, which is why the kind exists at all - the rule is about the record being sent and the address belongs to its customer. `field:` is walked by `ResolvePathSupport`, the resolver every other path in the DSL uses, so a cross-model target reads too and a path walking on past one is refused there. The hops travel into the `.model` as the check's `pathLoads` and `ModelParameterProcessor` turns them into the generated Entity/Repository FQNs - the pass that knows the generation folder, exactly as for a master's inherited lock. The reader loads each hop by id and reads the field null-guarded, so a missing link is an EMPTY value the check fires on rather than a throw inside a repository. **The `status:` gate is optional, and its presence is the routing.** No gate = a row check: every generated controller's `validate()`, a 400 with the authored message, like `exactlyOne`. A gate = the repository's `enforceChecks`, like `itemsMin`, which puts it on the synchronous path #7014/#7063 opened - the refusal reaches the person completing the task instead of dead-lettering as a process incident. **The condition is closed and typed.** `CheckSupport` owns the pattern the parser refuses on and the Java the generator emits, in one class so they cannot drift. Two refusals, both about a guard that would otherwise be silently wrong: a condition the generator cannot compile is a parse error (degrading it to `true`, as the shared glue guard does, would make the value unconditionally required - a `required` nobody authored), and a literal that is not a value of the property's declared type is refused, because `Objects.equals(Long, int)` never holds and a guard on a `long` column would switch the rule off while looking authored. Only strings, integers, booleans and a to-one's key are guardable; a decimal, a double or a date is compared for equality by nobody who means it. A status name resolves to its seed id like every other guard, and the list form is an implicit AND. Unit: `IntentParserTest`, `EdmIntentGeneratorTest`, `ModelParameterProcessorTest`. IT: `IntentEmissionCoverageIT` - a gated check over a hop in `EntryRepository`, an ungated one in `DocController`, and that the ungated one is not in the repository. Fixes #7094 Co-Authored-By: Claude Opus 5 --- .claude/docs/intent-layer.md | 2 + components/engine/engine-intent/CLAUDE.md | 7 +- components/engine/engine-intent/README.md | 26 ++- .../intent/generator/CheckSupport.java | 146 +++++++++++++++++ .../generator/edm/EdmIntentGenerator.java | 152 +++++++++++++++++- .../components/intent/model/CheckIntent.java | 34 +++- .../intent/parser/IntentParser.java | 130 ++++++++++++++- .../intent/parser/StatusSymbolResolver.java | 3 + .../generator/edm/EdmIntentGeneratorTest.java | 69 ++++++++ .../intent/parser/IntentParserTest.java | 79 +++++++++ .../model/ModelParameterProcessor.java | 43 ++++- .../model/ModelParameterProcessorTest.java | 40 ++++- .../data/Repository.java.template | 32 +++- .../api/EntityController.java.template | 16 ++ .../api/EntityMyController.java.template | 16 ++ .../api/EntityPartnerController.java.template | 16 ++ .../tests/api/IntentEmissionCoverageIT.java | 76 ++++++--- 17 files changed, 849 insertions(+), 38 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/CheckSupport.java diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index b704f26dc75..7dd3a7e5658 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -40,6 +40,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **`permissions.can` is what the generated app enforces ([#6760](https://github.com/eclipse-dirigible/dirigible/issues/6760)):** the authored access model and the enforced one were two disjoint namespaces - `permissions[].role` became `.roles` while every generated controller's gate was the convention-derived `..FullAccess`, a name no intent construct mentions, so granting an authored role granted *nothing* and nothing errored or warned (the roles did show up in the UI, so it looked wired). `PermissionSupport` resolves the `can: [Resource:action]` tokens into per-resource read / write role sets that the EDM and report generators emit as the entity's / report's own `roleRead` / `roleWrite`: a **covered entity is gated entirely by the authored roles** (its convention roles are neither the gate nor declared, or `.roles` and the template's `default-roles.roles` would each declare the same name), a **composition child inherits the master's** grants when it has none of its own, a covered entity **no grant may write keeps a write gate no declared role satisfies** - what a read-only allow-list says - and an entity no token names is byte-identical to before. The action **vocabulary is closed**: `read`/`view`/`list` → read, `write`/`create`/`update`/`edit`/`delete`/`manage` → write **and** read (a caller who may change a record must be able to load it), `*`/`all` → both; anything else (`approve`, `start`) is a business action with no generated URL and becomes a generation **advisory** naming the token rather than a silent drop, an undeclared resource becomes an **issue**, and a malformed token is refused at **parse**. Since the gate may now name several roles, the rest-java controllers' entity/report gate went from `UserFacade.isInRole` to the any-of `isInAnyRole` the per-property `visibleTo:` machinery already used. The URL half - `.access` over the controller subtrees, generated pages and report pages the templates publish - is **opt-in through the project's `.settings`** (`{"access": {"generate": true}}`), not a DSL key, and carries method `*` on purpose: the generated controllers read through `POST .../search`, so a "POST means write" split would lock a read-only role out of every list. **A hand-authored `.access` at the project root is scrub-owned** (`.access` is an intent-owned extension) - hand-written constraints belong under `custom/`. +**A value required only under a condition (`checks: requiredWhen`, [#7094](https://github.com/eclipse-dirigible/dirigible/issues/7094)):** `checks:` knew `exactlyOne`, `itemsSumEqual` and `itemsMin` - none of them says "the customer's e-mail address must be there when Sent Method is E-mail", and `required` is unconditional, so the rule had no form: an invoice sent by e-mail to a customer carrying no address went through Send with status SENT, the mail step logged a no-op for a missing recipient, and the clerk who pressed the button was told nothing. The module's alternative was a delegate plus a decision plus a hold task plus a form - about fifteen intent lines and a Java class for one sentence of rule - and it landed the clerk on a hold task instead of a refusal on the button they pressed. `- { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT, message: ... }`. **The value may be one hop away**, which is the reason the kind exists at all: `field:` is a field of the record or a `Relation.field` over a to-one - cross-model included, walked by the same resolver every other path in the DSL uses - and the generated reader loads that row by FK first, null-guarded, so a missing link is an empty value the check fires on rather than a throw inside a repository. **The `status:` gate is optional, and its presence is the routing**: without one the rule holds on every user write (each generated controller's `validate()`, a 400 with the authored message, like `exactlyOne`), with one it is the repository's, like `itemsMin` - which puts it on the synchronous path #7014/#7063 opened, so the refusal reaches the person completing the task instead of dead-lettering as a process incident. **The condition is closed and typed**: one or more ` ==|!= ` comparisons over the record's own properties (ANDed, as in #6957, with a status name resolved to its seed id like every other guard), refused at parse when it does not compile - degrading it to "true" would make the value unconditionally required, a `required` nobody authored - and refused when the literal is not a value of the property's declared type, because `Objects.equals(Long, int)` never holds and a guard on a `long` column would switch the rule off while looking authored. Only strings, integers, booleans and a to-one's key are guardable; a decimal, a double or a date is compared for equality by nobody who means it. Details in the engine-intent guide's requiredWhen bullet. + **Deleting a header deletes the lines it owns (`whenMasterDeleted:`, [#7100](https://github.com/eclipse-dirigible/dirigible/issues/7100)):** a deleted master left its composition children behind - rows pointing at an id that no longer exists, invisible in the UI (no parent page renders them) and still counted by every report and roll-up over the child, so a deleted vacation request's five days kept the entitlement EXHAUSTED. The cascade is now emitted for EVERY composition master, because it is what composition MEANS: the master's generated repository deletes the children at the head of `delete`/`deleteById`, in the same transaction and through each child's OWN repository, so the child's `-deleted` event (hence the roll-up relinquishing), its history trail and its own cascade all run - a deep chain unwinds level by level. The reverse index this needs (`CompositionChildren` in `ide-template`) is DERIVED from the child's `masterEntity`/`masterEntityId`, so a hand-authored `.edm` gets it too. The author's alternative is `whenMasterDeleted: refuse` on the child's composition relation - the same method rejects the master's delete while any child exists, naming both entities - which is refused at parse on a non-composition and on a SECOND composition (the EDM emits that one as a plain association, so the key would ask for a cascade nothing would run). `cascade` is the default and emits no `.edm` attribute, so an untouched model is byte-identical. This is the data-side half of the process-side `whenDeleted: abort | refuse` (#7074). **The general platform line this enshrines:** authoring artifacts (`.edm`, `.model`, `.form`, `.report`, `.intent`) get **workspace editors + an explicit Generate**; only runtime artifacts (`.roles`, `.bpmn`, `.csvim`, `.table`, jobs, listeners, …) get **synchronizers**. Applying the synchronizer hammer to an authoring artifact generates into the registry where no modeler, Projects view, or template can use it — that mistake was made once and reverted; the inventory of synchronizers (grep `extends BaseSynchronizer`) deliberately contains no authoring formats. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 1a973ff158b..18ec95679f5 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -423,7 +423,12 @@ Semantics worth knowing: - **`lifecycle:` on an entity = the declarative state machine (#6714).** The whole set of legal status edges, declared once over the entity's `function: EntityStatus` nomenclature (`edges: [{ from: DRAFT, to: [ISSUED, CANCELLED] }, ...]`, either side a seeded name or an id) and **enforced on every status write**. The gap it closes: the status machinery was a set of point constructs - `init:` names the start, a `transitions:` button guards the flips that go through THAT button, a workflow `setRelationField` writes one unguarded, a `checks:` rejection files another - and nothing declared which edges were legal at all, so any other writer (a workflow branch, a glue action, a plain REST call) could jump a document from any status to any other and nothing noticed. **Enforcement lives in the generated REPOSITORY, deliberately** (`Repository.java.template`: `LIFECYCLE_EDGES` + `enforceLifecycle` / `enforceLifecycleMove` / `enforceLifecycleStart`, `ValidationException` -> 400) - it is the ONE choke point every writer passes through: `update` (the REST payload), `updateWithoutEvent` (system writes), and `updateProperties` (which `updateProperty`, and therefore the transition controller, the workflow setters and `updateDerived`, all route through - so the targeted-write overrides are now emitted for a lifecycle entity too, not only for `documentChecks`/`hasLabel`). Guarding the transition endpoints instead would have left every other writer free, which is the whole defect. `enforceLifecycleStart` (emitted only when the status relation declares `init:`) additionally refuses a CREATE filed anywhere but at the start - entering the lifecycle mid-graph skips it rather than travelling it - and is placed BEFORE the aggregate-guard macros in `save()` so an `outcome: reject` can still file the record where the model says. Emission is three scalars on the entity map (`lifecycleStatusProperty`, `lifecycleEdges` as `1>2,1>9` pairs, `lifecycleStatusNames` as `1=DRAFT,...` so a rejection reads "cannot move from ISSUED to DRAFT" instead of quoting positional ids, plus `lifecycleInitialStatus`) - scalars, so they reach the `.edm` twin like `immutableStatusValues`. **Parse-time is where the other status sites are made to agree** (`validateLifecycles`): every `from` of a `transitions:` entry must reach its `setStatus` along an edge (a button is presentation over the graph), and a status written by a `setRelationField` step or forced by a check's rejection must be one some edge reaches - which is what catches a reject path transiting through an approved status when the file is read. **Deliberate boundaries:** no `on:` key - the graph is always over the EntityStatus relation, so naming it would be redundant, and YAML 1.1 reads a bare `on` as the boolean `true` (it would arrive as the key `true` and bind to nothing), so `rejectLifecycleOn` refuses it in the raw-tree preprocessing rather than dropping it silently; a cross-model nomenclature is seeded in its owner model and so is its lifecycle (refused, naming that); the nomenclature must be seeded here (the ids are validated against the seeds); no reachability check - one nomenclature may serve two entities with different graphs, so "unreachable here" is not an error. - **`immutableWhen:` / `immutable:` on an entity = user-write immutability.** `immutableWhen: "Status == 2"` (a boolean expression over EntityStatus seed ids, terms joined with `||`) makes update/delete through the generated REST controller answer 409 CONFLICT while the record's `function: EntityStatus` FK satisfies it; `immutable: true` is the unconditional append-only variant (mutually exclusive with `immutableWhen`; a non-existent id still yields 404, not 409). Emitted as the entity-level `immutableStatusProperty` + `immutableStatusValues` (or `immutableAlways`) model attrs; `requireMutable` fetches the existing row before writing. Repository writes are deliberately unaffected — the workflow (storno generation, roll-ups, ProcessId write-back) keeps working; this guards the USER surface, per the accounting audit-trail requirement (corrections are reversals, never edits). **The UI is gated up front, not just on the 409:** each of the three generated controllers (power / partner / my) also exposes a **`GET /{id}/mutable`** pre-check (`{"mutable": true|false}` via the shared `isMutable`, scoped like its reads), and every Harmonia surface consumes it — the manage form and document pages ask it on edit load and force the read-only preview mode with a "Read-only" title badge (so a directly typed `/edit` URL opens read-only), the partner/my form + document pages disable their controls (`fieldset :disabled`) and hide Save/Delete/item actions, while the browse tables (manage list, master) gate row Edit/Delete through a **baked `isRowImmutable(row)`** computed from the row's status FK against the generation-time immutable ids — no per-row API call, same generated-from-the-same-attrs no-drift argument as the client `validationSchema`. The pre-check fails OPEN (an outage must not lock the UI); the PUT/DELETE 409 stays the authoritative guard. Covered by `IntentEmissionCoverageIT` (endpoint tokens + page tokens + mutable=false/true over REST). Parser requires an EntityStatus relation. Alongside it (no DSL): every generated controller now maps a **database constraint violation on DELETE to 409** ("referenced by other records") instead of a 500. Scope of that mapping: the schema template does emit `type: "foreignKey"` structures, but `SchemasSynchronizer.parseImpl` drops them **by design** — a foreign key never becomes a database constraint on this platform, because a constraint binds insert/delete ORDER into the schema where seeds, imports, regeneration and deletes would all have to obey an ordering nothing in the model asked for; referential integrity is a business-layer check. Only the **unique** keys are carried over (`carryUniqueConstraints`, #6793), so the 409 engages for a business-key collision and never for a reference. Anything that must not outlive the record it points at therefore needs an explicit handler — which is what an expansion's `OnDelete` cleanup is (#6821). Date-based period locking (records whose date falls in a Locked period) is deliberately NOT part of this — its shape needs the real fiscal-period module and follows as its own PR. **The lock reaches the master's composition CHILDREN (#6695).** It was per-entity, and a child declares no immutability of its own — while its generated repository writes THROUGH to the master, recomputing `net`/`vat`/`total` on every `save`/`update`/`delete`. So `POST`/`PUT`/`DELETE` on a line of an ISSUED invoice succeeded over REST and silently rewrote the document's totals after the number was stamped, the immutable snapshot taken and the ledger posted — the UI forbade it, REST permitted it, and the permitted operation was the one `immutableWhen` exists to prevent. `ModelParameterProcessor.inheritMasterLock` now propagates the master's `immutableAlways` / `immutableStatusProperty` + values onto each direct composition child as a `masterLock` map (master entity + FK property + its `…Entity`/`…Repository` classes, resolved through the composition FK's perspective exactly as the personal/partner inheritance does), and all three generated controllers (power / partner / my) emit a `requireMasterMutable` that loads the master and answers the same 409 — on create (the payload's FK), on update (the STORED master *and* the incoming one, so a line cannot be moved into a locked document either), on delete, and on an attachment upload. Engine writers stay exempt by construction: they go through the repository, not the controller — which is why the issue-time snapshot generator (`Attachments.store` + `repository.save`) is untouched. The opt-out is the flag #6700 already introduced: `locksWithMaster: false` on the child (settlement is a different lifecycle from content), so the affordance and the REST guard are governed by one declaration and cannot drift apart. Only the DIRECT child is covered — that is the shape that writes through to the master. It composes with the prompted `generates` action (#6685): that create runs through the TARGET's repository, not a controller, so a guided create against a post-issue child keeps working on a locked document exactly as its per-record button (deliberately not gated on mutability) implies — the panel and the action remain the two separate answers to "this collection must go on being recorded". `IntentEmissionCoverageIT` carries both controls: `EntryLine` (silent → inherits) is refused create/update/delete on a POSTED entry and the master's total is asserted UNMOVED, while `CampaignNote` (`locksWithMaster: false`) still posts to a locked campaign. - **`period:` + `immutableInPeriod:` = date-based immutability, the fiscal-period half of the lock (#6535).** `immutableWhen` guards a record by what it IS; this guards it by WHEN it falls - once the accountant closes March, nothing dated in March may be created, edited or deleted, whatever status it carries. The shape the issue asked for is deliberately TWO declarations, not one: a fiscal period is an ordinary entity (two dates and a lifecycle), so a **`period: { start, end, closedWhen }`** marker on the register states the facts that live with the register - which fields are the bounds (both `date`; a timestamp would make "the period covering this date" depend on a time of day nobody authored, and the end is inclusive) and which statuses mean CLOSED (the `immutableWhen` grammar over its own EntityStatus, so a seeded name resolves through `StatusSymbolResolver` like every other status site) - while each guarded entity spends ONE line, **`immutableInPeriod: { period: , date: }`**. Closing a period needs no new machinery: it is a status transition, so a `transitions:` button, a `lifecycle:` edge or a workflow step does it, and nothing in this feature ever WRITES the register. **Enforcement is the controllers, not the repository** - the same line `immutableWhen` draws, and the whole point of the issue: workflow/system writes (the reversal booked into an open period, a roll-up, the ProcessId stamp) must keep working. Three differences from the status guard, all deliberate: a **CREATE** dated inside a closed window is refused (that is what closing a period MEANS - `immutableWhen` has no create to guard, a fresh record has no status yet), an update that would **MOVE** a record into a closed window is refused as well (the `requireMasterMutable` stored-and-incoming precedent), and a date covered by **no** period is OPEN - periods are opened as they are needed and an undeclared month must not freeze what is booked into it, so "no covering row" can only mean open (an unset date likewise falls in none). Emission is the established split: each entity carries only its own facts as `.edm` scalars (`periodStartProperty`/`periodEndProperty`/`periodStatusProperty`/`periodClosedValues` on the register, `periodLockEntity`/`periodLockDateProperty` on the guarded one) and `ModelParameterProcessor.resolvePeriodLock` joins them into the `periodLock` map the controller templates read - the pass that already knows every entity's generated package, exactly as `inheritMasterLock` does. **The lock reaches composition CHILDREN** through that same `masterLock` map (which gained `period`; the status half of the child's guard is emitted on `always || statusProperty` rather than on a flag, so a master locked by its period ALONE emits no status branch - `ChildLockControllerTemplateIT` renders these templates against a HAND-BUILT masterLock map, so a new required key there is a silent branch loss, and a derivable one cannot drift): a line write recomputes the document's totals, so a document dated in a closed period freezes its lines with it - the #6695 argument, and `locksWithMaster: false` is still the one opt-out. The UI needs no new mechanism either: the pre-check the status lock already exposes (`GET /{id}/mutable`) now answers for both halves, so a directly typed `/edit` URL opens read-only; the browse tables keep their BAKED per-row status check, which a data-driven period lock cannot join (a row's Edit opens a read-only form instead of being hidden - stated, not hidden). **Boundary, refused loudly:** the register must be an entity of the SAME model. The guard is generated into this model's controllers and queries the register's generated repository; a cross-model register is emitted as a read-only PROJECTION with no local DAO, so there would be nothing to query - it fails at parse naming that, rather than generating a guard that silently never fires. `IntentEmissionCoverageIT` carries the whole loop over the register's own lifecycle (book into an open period, close it, then 409 on edit/delete/create-into/move-into, `mutable=false`, and an uncovered date still writable) because the lock is DATA-driven: a token assertion alone would pass against a guard that never matches. -- **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Three kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null; emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400) and document-level `itemsSumEqual` (`over:` two item fields whose sums must match) / `itemsMin` (`count:`), both REQUIRING a `status:` gate (an EntityStatus seed id) — parser-enforced, because an ungated sum check would forbid drafting a document item by item. The EDM generator precomputes everything template-side (`buildChecks`: items entity + back-FK via **`IntentEntities.documentItemsChild`** — the ONE shared resolution of "what are this document's items" (`function: DocumentItem`, else the `*Item` name, else the sole composition child, else the first declared, always in entity-declaration order), also used by the parser's `compositionChildOf` and the glue's postings/generates item lines; scanning a hash-ordered index for *some* composition child let a multi-child document's gate count its printed snapshots instead of its lines, #7027 — plus `statusProperty`, PascalCased fields); `ModelParameterProcessor` splits `rowChecks`/`documentChecks`; the **DAO repository** enforces document checks in `save`/`update`/**`updateWithoutEvent`** whenever the persisted entity carries the gate status — so the workflow setter flipping DRAFT→POSTED hits `enforceChecks` and an unbalanced document FAILS the write instead of silently posting: it throws the SDK `org.eclipse.dirigible.sdk.db.ValidationException`, which the client-controller dispatcher (`ControllerInvoker`) maps to **HTTP 400** with the authored message on a REST create/update, and which rolls back the task completion on the BPMN path (the capacity guard on roll-ups throws the same). `recalculate()` deliberately bypasses it (it persists the recomputed totals through the BASE targeted write, `super.updateProperties(id, totals)`, so a document still being assembled line by line never fails its own gate). No Harmonia-side mirror in v1 — the task-completion error surfaces the authored message. **That last half is only true because the gated status-set is emitted WITHOUT `flowable:async`** (`BpmnIntentGenerator.synchronousNodes`, #7014): every other service task is async, and an async status-set runs in a detached job, so Flowable committed the user-task completion first and the rejection then dead-lettered as a process incident — the task left the Inbox, the document stayed in its old status, and the approver was told nothing. A setter declared on the `serviceTask` itself is that one node; a setter declared on a `userTask` is the delegate inserted after it, so the **writer** that persists the reviewer's edits (inserted before it) loses its async boundary too, or that boundary commits the completion before the gate is reached. Everything downstream (number stamping, snapshots, mail) stays async. **A gate one hop further down is the same transaction, and #7063 is where that showed:** the shape every approve/reject flow has is a user task falling through a `decision` into the `serviceTask` that sets the gated status, so the setter's own position is not enough - the writer, a resolver inserted before the decision, a step-completed emitter all still sat between the completion and the gate, and any one of their boundaries commits it. `completingTransactionNodes` therefore walks BACK from each gated step (`gatedSteps`) to the user tasks that reach it, and every node on the way loses its boundary too. The walk stops at anything that is not a `decision`: a second user task is its own wait state, and an authored service task in between is real asynchronous work whose action has already succeeded - nobody is waiting on the gate behind it, so that one is legitimately a background incident. The other half is `BpmInboxEndpoint`: a `ValidationException` in the cause chain of `completeTask` becomes **400 with the message as the response BODY** (`ClientValidationFailure` matches it by class NAME — this module cannot depend on `api-modules-java`, and Spring Boot strips a `ResponseStatusException` reason from the default error payload), which is exactly what the generated task form reads into its `Submit failed` notification. +- **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Four kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null; emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400), `requiredWhen` (see the next bullet) and document-level `itemsSumEqual` (`over:` two item fields whose sums must match) / `itemsMin` (`count:`), both REQUIRING a `status:` gate (an EntityStatus seed id) — parser-enforced, because an ungated sum check would forbid drafting a document item by item. The EDM generator precomputes everything template-side (`buildChecks`: items entity + back-FK via **`IntentEntities.documentItemsChild`** — the ONE shared resolution of "what are this document's items" (`function: DocumentItem`, else the `*Item` name, else the sole composition child, else the first declared, always in entity-declaration order), also used by the parser's `compositionChildOf` and the glue's postings/generates item lines; scanning a hash-ordered index for *some* composition child let a multi-child document's gate count its printed snapshots instead of its lines, #7027 — plus `statusProperty`, PascalCased fields); `ModelParameterProcessor` splits `rowChecks`/`documentChecks`; the **DAO repository** enforces document checks in `save`/`update`/**`updateWithoutEvent`** whenever the persisted entity carries the gate status — so the workflow setter flipping DRAFT→POSTED hits `enforceChecks` and an unbalanced document FAILS the write instead of silently posting: it throws the SDK `org.eclipse.dirigible.sdk.db.ValidationException`, which the client-controller dispatcher (`ControllerInvoker`) maps to **HTTP 400** with the authored message on a REST create/update, and which rolls back the task completion on the BPMN path (the capacity guard on roll-ups throws the same). `recalculate()` deliberately bypasses it (it persists the recomputed totals through the BASE targeted write, `super.updateProperties(id, totals)`, so a document still being assembled line by line never fails its own gate). No Harmonia-side mirror in v1 — the task-completion error surfaces the authored message. **That last half is only true because the gated status-set is emitted WITHOUT `flowable:async`** (`BpmnIntentGenerator.synchronousNodes`, #7014): every other service task is async, and an async status-set runs in a detached job, so Flowable committed the user-task completion first and the rejection then dead-lettered as a process incident — the task left the Inbox, the document stayed in its old status, and the approver was told nothing. A setter declared on the `serviceTask` itself is that one node; a setter declared on a `userTask` is the delegate inserted after it, so the **writer** that persists the reviewer's edits (inserted before it) loses its async boundary too, or that boundary commits the completion before the gate is reached. Everything downstream (number stamping, snapshots, mail) stays async. **A gate one hop further down is the same transaction, and #7063 is where that showed:** the shape every approve/reject flow has is a user task falling through a `decision` into the `serviceTask` that sets the gated status, so the setter's own position is not enough - the writer, a resolver inserted before the decision, a step-completed emitter all still sat between the completion and the gate, and any one of their boundaries commits it. `completingTransactionNodes` therefore walks BACK from each gated step (`gatedSteps`) to the user tasks that reach it, and every node on the way loses its boundary too. The walk stops at anything that is not a `decision`: a second user task is its own wait state, and an authored service task in between is real asynchronous work whose action has already succeeded - nobody is waiting on the gate behind it, so that one is legitimately a background incident. The other half is `BpmInboxEndpoint`: a `ValidationException` in the cause chain of `completeTask` becomes **400 with the message as the response BODY** (`ClientValidationFailure` matches it by class NAME — this module cannot depend on `api-modules-java`, and Spring Boot strips a `ResponseStatusException` reason from the default error payload), which is exactly what the generated task form reads into its `Submit failed` notification. +- **`checks: kind: requiredWhen` = a value required only under a condition (#7094).** The third row-level shape, and the one the other three could not express: "the customer's e-mail address must be there when Sent Method is E-mail". Nothing in the DSL said it - `required` is unconditional, a `pattern` describes a value that exists, `exactlyOne` is about the record's own columns - so a module either shipped the rule as a hand-written delegate plus a decision plus a hold task plus a form (~15 intent lines and a Java class for one sentence), or shipped nothing and mailed an invoice to nobody: status SENT, a notify step logging a no-op for a missing recipient, and the clerk told none of it. Authored as `{ kind: requiredWhen, field: , when: " ==|!= " | [...], status?: , message }`. + - **The value may be one hop away, which is the whole reason the kind exists** - the rule is about the record being sent and the address belongs to its customer. `field:` is walked by `ResolvePathSupport` (the resolver every other path in the DSL uses), so a cross-model target reads too and a path walking on PAST a cross-model relation is refused there. The hops travel into the `.model` as the check's `pathLoads` (local + null-guarded FK expression + entity + perspective + the relation's `model:` alias) and `ModelParameterProcessor.resolveCheckPathLoads` turns them into the generated `Entity`/`Repository` FQNs - it is the pass that knows the generation folder, exactly as for a master's inherited lock, and the `.model` twin could not re-derive another model's folder at all. The generated reader loads each hop by id and reads the field null-guarded, so a missing link is an EMPTY value (the check fires) rather than an NPE inside a repository. + - **The `status:` gate is OPTIONAL here, unlike on the document-level kinds** - and that optionality is the routing. No gate = the rule holds on every user write, so `ModelParameterProcessor` files it with the `rowChecks` and all three controller templates (`EntityController`, `EntityMyController`, `EntityPartnerController`) enforce it in `validate()` as a 400 carrying the authored message. A gate = the repository's `enforceChecks`, like `itemsMin`, which is what puts it on #7014/#7063's SYNCHRONOUS path: the transition that sends the document reaches the gate inside the task completion, so the refusal is the 400 the Inbox shows the person who pressed the button instead of a dead-lettered incident. A gated check needs a `function: EntityStatus` relation to read the gate from (parser-enforced). + - **The condition is a closed vocabulary, and it is rendered against the guarded property's DECLARED type.** `CheckSupport` owns both halves - the pattern the parser refuses on and the Java the EDM generator emits - in one class so they cannot drift. Two refusals, both about a guard that would otherwise be silently wrong: a condition the generator cannot compile is a parse ERROR (degrading it to `true`, which is what the shared `NotificationSupport.guard` does for a glue listener, would make the value unconditionally required - a `required` nobody authored), and a literal that is not a value of the property's type is refused too, because `Objects.equals(Long, int)` never holds and a `long`-column guard would switch the rule off while looking authored. Only `string`/`text`/`integer`/`int`/`long`/`boolean` and a to-one's integer FK are guardable at all; a decimal, a double or a date is compared for equality by nobody who means it. A status name in the condition resolves to its seed id like every other guard (`StatusSymbolResolver.rewriteWhen` on the check node), and the list form is an implicit AND, as in #6957. + - Unit: `IntentParserTest.conditionallyRequiredValuesParseAndValidate`, `EdmIntentGeneratorTest.conditionallyRequiredValuesEmitTheirConditionAndTheHopsTheirValueIsReadThrough`, `ModelParameterProcessorTest`; IT: `IntentEmissionCoverageIT` (a gated one over a hop in `EntryRepository`, an ungated one in `DocController`, and the assertion that the ungated one is NOT in the repository). - **`resolves:` = the effective-dated register lookup (#6712).** The enterprise shape with no declarative form before it: a register says "X applied to Y from A to B" (a vehicle assignment, a price list, a contract in force, an org assignment), a record carries the match key(s) and a date, and a to-one must be filled from the row whose period covers that date. Nothing else in the DSL reaches it - `dependsOn` is a UI-time copy with equality matching only, a `decision` condition is a single comparison, and `setField` writes constants - so every application hand-wrote the same delegate. Authored as `{ event: { onCreate|onUpdate: , when? }, set: , from: , match: { : , ... }, between: { start?, end?, value }, outcome?: , found?/notFound?/ambiguous?: { setStatus } }`; `ResolveIntent` -> `GlueIntentGenerator.buildResolves` -> the `resolves` glue collection -> `Resolve.java.template`, a `@Component MessageHandler` on the record's event topic. **All three outcomes are first-class, and that is the point of the construct:** exactly one covering row fills the relation, NO covering row and MORE THAN ONE covering row both leave it unset (an automation that silently picks one of two candidates is worse than none - the ambiguous register goes back to a human). Each outcome may route the record by `setStatus` (seed id or seeded name, resolved by `StatusSymbolResolver` like every other status site), and the attempt is **observable**: `outcome:` stamps `found`/`notFound`/`ambiguous` into a string field of the record - queryable, filterable in a list view, and readable by a process `decision` - and the handler logs the keys and the date it checked. **Decisions worth keeping:** the value copied is derived, not authored - the register must carry exactly ONE to-one to the same target as `set:`, and zero or two is a validation error rather than a guess (the same refusal, one altitude up); a record that already carries the relation is skipped, so a manual correction is never overwritten and a re-delivered event is a no-op; **the RESULT and the ROUTING are two targeted writes, in that order** - `updateProperties` of the relation + the outcome, then `updateProperty` of the status - because the DAO runs the `lifecycle:` and `checks:` gates against the post-write row BEFORE persisting anything, so batching the three meant a rejected status move discarded the identification and the audit trace with it (the lookup did the work, got the right answer, and threw all of it away). The routing write catches the `ValidationException`: retrying cannot help - nothing about the record changes by re-reading the register - so it logs and amends the trace to `-notRouted`, which is what keeps a routed-but-rejected record distinguishable from a fully processed one. The parser enforces the trace field is long enough for those values (19 once any outcome routes), since truncation happens at the DB where nothing reports it. No `-updated` re-fires and no concurrent write to another column is reverted; period bounds are optional on either side (open-ended = still valid), the end is INCLUSIVE, and a date-only bound covers its whole day (the generated `millis`/`endExclusive` helpers put a `LocalDate` and an `Instant` on one epoch-milli axis, UTC). v1 is same-model (`from:` must be declared here) and binds to `onCreate`/`onUpdate` only - `onDelete` is refused, there is nothing left to fill. An optional **`where: { : }`** (one or more pairs, ANDed into the `Criteria`) is the only way to narrow the register by a constant, and it closes a defect that got worse with age: every `match` pair binds a register column to a column of the RECORD, so "and only the rows still valid" had no form at all - while a register KEEPS its corrections, so a cancelled row went on covering its old period forever and turned a lookup with exactly one right answer into a permanent `ambiguous` that routed to a human and logged "multiple matches". A status pair may use the seeded NAME, resolved on the REGISTER's own nomenclature (`StatusSymbolResolver.rewriteResolveWhere`) - the record's would hand back a plausible id from the wrong lifecycle - and only that one pair is offered to the resolver, so an ordinary string column like `kind: PRIMARY` is not reported as an unknown status. A pair repeating a `match` key is refused: on a column already bound to the record a literal either repeats the match or contradicts it into matching nothing, and which one depends on data the parser cannot see. Multiple pairs are allowed although the relation-level `where:` caps at one - that cap exists because it lands in two EDM attributes, whereas these are chained `Criteria.eq` calls where a second condition costs nothing. Covered by `GlueResolveWhereTest` (including the two nomenclatures deliberately numbered differently, so resolving against the wrong entity cannot pass) and an `IntentEngineIT` assertion. The parser refuses a `when` guard it cannot render rather than degrading it to an always-open guard. - **A lookup may be driven by the DOCUMENT HEADER, and may copy the found row's scalars (#7025).** #6712 named "the price from the list in force on the order date" as a motivating case and then could not express it, for two reasons that were both in the guide: every `match` pair and `between.value` bound a register column to a column of the RECORD, and the only things written were the relation, the outcome and the status. A line's price list is its header's customer's (`SalesInvoiceItem -> SalesInvoice -> Customer -> PriceList`) and the date in force is the header's - neither is on the line - while the value the business needs is `PriceListItem.price` on the line's own `price`. Both gaps had the same workaround, `dependsOn`, and it is a **UI-time** copy: a REST create, a `generates:` create-from (proforma -> invoice) and a schedule fan-out (a recurring template) never run it, so the rows produced by exactly the automated paths stayed unpriced - the silently-incomplete class, since the interactive path looks correct. So a `match` value and `between.value` may now be a **to-one PATH off the record** and the lookup may declare **`copy: { : }`**. `ResolvePathSupport` is the walker (deliberately its own class, free of Spring/IO, so the parser walks with no cross-model lookup and the generator with one): every segment but the last is a to-one, the last is a field OR a to-one whose **FK** is the value compared, hops accumulate **once per distinct path PREFIX** (a line's header is loaded once for the key AND the date), each access is null-guarded so a missing link reports `notFound` instead of throwing inside a listener, and a **cross-model relation may only be the last hop** - a projection carries the target's own properties but not its relations, the same line `ProcessAssigneeSupport` draws. A **bare property is not walked at all**: it renders as it always did and keeps its case-insensitive parser check, so an existing model is byte-identical. The hops are loaded AFTER the guards (an event the lookup ignores costs no reads) and each operand is **hoisted into a local**, so a path is not re-walked per use. `copy:` writes on `found` only, field by field, **skipping a field the record already carries a value in** - the never-overwrite rule the relation has, applied per field so a hand-typed price survives while the rest of the copy applies - and rides the RESULT write, not one of its own: a partial commit would leave a line pointing at a price-list item with no price on it. Parse refusals: a copy source that is not a plain field of the register (a copy takes a SCALAR; the relation the row points at is what `set:` fills), a target that is not a field of the record, the filled relation, the outcome trace or the primary key, two register columns onto one field, and **mismatched declared types** - otherwise the mismatch is invisible until the write reaches the database. And `set:` may now point at the **register itself** (`set: priceListItem` / `from: PriceListItem`), which is what a *value-bearing* register needs: the row carries the price, so the row IS what the line links to, and the resolved value is its own key rather than a sole to-one that does not exist. New glue keys (`pathLoads`, `valueExpression`, `copies`/`hasCopies`/`copySummary`, and per-match `recordExpression`/`local`) are all **defaulted in `GlueGenerator.bindResolve`** to the no-path, no-copy shape, the `filters` migration pattern - an absent key renders as its own literal in Velocity, which here would be a load of a record named `${load.entity}`. Covered by `GlueResolvePathCopyTest` and a second `resolves` entry in `ModelGenerationIT`'s glue fixture (the first stays in the legacy shape on purpose, so the defaults are exercised). diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 9c8d8c1e7e3..c2baf1801c8 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -115,8 +115,8 @@ Values: `Document`, `DocumentItem`, `Master`, `Detail`, `List`, `Setting` (entit ## checks - declarative validations -Row-level `exactlyOne` on every user write; document-level `itemsMin` / `itemsSumEqual` gated on a -status transition (drafting stays unconstrained; the failing transition aborts with the authored +Row-level `exactlyOne` and `requiredWhen` on every user write; document-level `itemsMin` / +`itemsSumEqual` gated on a status transition (drafting stays unconstrained; the failing transition aborts with the authored message). A document-level check counts the document's LINES: a child flagged `function: DocumentItem`, else the `*Item`-named child, else the sole composition child, else the first declared. Flag the lines child explicitly on a document that owns several composition children @@ -132,6 +132,28 @@ first declared. Flag the lines child explicitly on a document that owns several - { kind: exactlyOne, fields: [debit, credit], message: "Exactly one of debit/credit" } ``` +`requiredWhen` is a value that is required only under a condition - the rule `required` cannot +express, because the value is needed for one way of handling the record and meaningless for the +others. The value may be the record's own field or a one-hop `Relation.field` (the target may be +owned by another model), and the condition is one or more ` ==|!= ` comparisons +over the record's own properties, ANDed: + +```yaml +- name: SalesInvoice + checks: + # holds on every user write + - { kind: requiredWhen, field: reference, when: "kind == 'export'", + message: "An export needs a reference" } + # ...or only at the status the value is finally needed at, enforced by the repository, so the + # transition that sends the document refuses with this message + - { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT, + message: "Sent Method is E-mail but the customer has no e-mail address" } +``` + +A condition compares a `string`, an `integer`, a `long`, a `boolean` or a to-one's key - the types +an equality is exact on. A malformed condition, or a literal that is not a value of the property's +type, is a validation error rather than a rule that silently never (or always) holds. + ## immutableWhen / immutable - user-write immutability ```yaml diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/CheckSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/CheckSupport.java new file mode 100644 index 00000000000..890dd3404b3 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/CheckSupport.java @@ -0,0 +1,146 @@ +/* + * 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; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The condition of a {@code checks: requiredWhen} entry - the grammar the parser refuses on and the + * Java the generator renders, in one place so the two cannot drift. + * + *

+ * The condition is a closed set of equality comparisons over the record's own properties, ANDed. It + * is deliberately not an expression language: a condition the generator cannot compile would leave + * the value required unconditionally, i.e. a {@code required} nobody authored, and that failure is + * silent in exactly the way this module refuses everywhere else. + * + *

+ * The comparison is rendered against the property's DECLARED type rather than generically, because + * a boxed comparison across types is silently always-false: {@code Objects.equals(Long, int)} never + * holds, so a guard on a {@code long} column would switch the rule off and report nothing. That is + * also why only the types with an exact equality are guardable at all - a decimal, a double or a + * date is compared for equality by nobody who means it. + */ +public final class CheckSupport { + + /** + * One comparison of a condition: a property of the record against a literal - a number, a quoted + * string, a bare word (a status name is already its seed id here, resolved before the typed + * mapping) or a boolean. + */ + public static final Pattern TERM = + Pattern.compile("\\s*(\\w+)\\s*(==|!=)\\s*('[^']*'|\"[^\"]*\"|-?\\d+|[A-Za-z_][A-Za-z0-9_\\-]*)\\s*"); + + /** The field types a condition may compare - those with an exact, type-safe equality. */ + public static final Set GUARD_TYPES = Set.of("string", "text", "integer", "int", "long", "boolean"); + + private CheckSupport() {} + + /** + * One parsed comparison. + * + * @param property the record's property being compared + * @param equal whether the comparison is {@code ==} (rather than {@code !=}) + * @param literal the authored literal, quotes included when it carried them + */ + public record Comparison(String property, boolean equal, String literal) { + } + + /** + * The comparisons of a condition - one, or the list form (an implicit AND). + * + * @param when the authored condition + * @return the authored comparison strings, in order + */ + public static List terms(Object when) { + if (when == null) { + return List.of(); + } + List terms = new ArrayList<>(); + if (when instanceof List list) { + for (Object term : list) { + terms.add(term == null ? "" : String.valueOf(term)); + } + } else { + terms.add(String.valueOf(when)); + } + return terms; + } + + /** + * Parses one comparison. + * + * @param term the authored comparison + * @return the parsed comparison, or {@code null} when it does not have the shape + */ + public static Comparison parse(String term) { + if (term == null) { + return null; + } + Matcher matcher = TERM.matcher(term); + if (!matcher.matches()) { + return null; + } + return new Comparison(matcher.group(1), "==".equals(matcher.group(2)), matcher.group(3)); + } + + /** + * The Java literal a comparison against a property of this type is rendered with. + * + * @param type the property's declared type ({@code integer}, {@code string}, ...) + * @param literal the authored literal + * @return the Java literal, or {@code null} when the authored literal cannot be one of that type + */ + public static String javaLiteral(String type, String literal) { + if (type == null || literal == null) { + return null; + } + String value = unquote(literal); + return switch (type.toLowerCase(Locale.ROOT)) { + case "string", "text" -> NotificationSupport.quote(value); + case "integer", "int" -> value.matches("-?\\d+") ? value : null; + case "long" -> value.matches("-?\\d+") ? value + "L" : null; + case "boolean" -> "true".equals(value) || "false".equals(value) ? value : null; + default -> null; + }; + } + + /** + * Renders one comparison as a Java boolean expression. + * + * @param access the Java expression reading the property + * @param equal whether the comparison is {@code ==} + * @param javaLiteral the Java literal from {@link #javaLiteral} + * @return the expression + */ + public static String comparison(String access, boolean equal, String javaLiteral) { + String equals = "java.util.Objects.equals(" + access + ", " + javaLiteral + ")"; + return equal ? equals : "!" + equals; + } + + /** + * The authored literal without its quotes. + * + * @param literal the authored literal + * @return the value it carries + */ + public static String unquote(String literal) { + if (literal.length() >= 2 + && (literal.startsWith("'") && literal.endsWith("'") || literal.startsWith("\"") && literal.endsWith("\""))) { + return literal.substring(1, literal.length() - 1); + } + return literal; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index 08e79e58ec1..9955bdf5bdf 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -30,7 +30,10 @@ import org.eclipse.dirigible.components.intent.LoggedValue; import org.eclipse.dirigible.components.intent.generator.IntentEntities; import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.CheckSupport; import org.eclipse.dirigible.components.intent.generator.IntentNaming; +import org.eclipse.dirigible.components.intent.generator.NotificationSupport; +import org.eclipse.dirigible.components.intent.generator.ResolvePathSupport; import org.eclipse.dirigible.components.intent.model.GeneratesIntent; import org.eclipse.dirigible.components.intent.model.TransitionIntent; import org.eclipse.dirigible.components.intent.generator.IntentSettings; @@ -700,7 +703,8 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal FieldIntent groupingPk = primaryKeyOf(entity); entityMap.put("groupingSourcePk", groupingPk == null ? "Id" : IntentNaming.pascalCase(groupingPk.getName())); } - List> checkMaps = buildChecks(entity, entities, model.getAggregates()); + List> checkMaps = buildChecks(entity, entities, model.getAggregates(), byName, compositionParents, + crossModelLookup(context, usesByAlias)); if (!checkMaps.isEmpty()) { // Declarative validations. A List, so it lives only in the .model twin (the scalar-only // .edm XML skips it via the Iterable guard), consumed by the DAO/REST templates. @@ -2140,8 +2144,8 @@ private static String humanizeJoin(List names) { * back-reference FK property, and the EntityStatus gate property - everything the DAO/REST * templates need without re-deriving model structure. */ - private static List> buildChecks(EntityIntent entity, List entities, - List aggregates) { + private static List> buildChecks(EntityIntent entity, List entities, List aggregates, + Map byName, Map compositionParents, NotificationSupport.CrossModelLookup crossModel) { List> checkMaps = new ArrayList<>(); if (entity.getChecks() == null) { return checkMaps; @@ -2211,6 +2215,53 @@ private static List> buildChecks(EntityIntent entity, List> pathLoads = new ArrayList<>(); + for (ResolvePathSupport.Hop hop : walker.hops()) { + Map load = new LinkedHashMap<>(); + load.put("local", hop.local()); + load.put("sourceExpression", hop.sourceExpression()); + load.put("entity", hop.entity()); + load.put("perspective", hop.perspective()); + load.put("crossModel", hop.crossModel()); + load.put("targetModel", hop.targetModel()); + pathLoads.add(load); + } + if (!pathLoads.isEmpty()) { + checkMap.put("pathLoads", pathLoads); + } + // The gate is optional here: without one the rule holds on every user write (the REST + // surfaces enforce it, like exactlyOne), with one it is enforced by the repository when + // the record is persisted carrying that status - the moment the value is finally needed. + if (check.getStatus() != null) { + RelationIntent gate = entityStatusRelation(entity); + if (gate == null) { + continue; // the parser already reported it + } + checkMap.put("status", String.valueOf(check.getStatus())); + checkMap.put("statusProperty", IntentNaming.pascalCase(gate.getName())); + } + checkMaps.add(checkMap); + continue; + } if ("exactlyOne".equals(check.getKind())) { checkMap.put("fields", check.getFields() .stream() @@ -2248,6 +2299,101 @@ private static List> buildChecks(EntityIntent entity, List byName, Object when) { + List conditions = new ArrayList<>(); + for (String term : CheckSupport.terms(when)) { + CheckSupport.Comparison comparison = CheckSupport.parse(term); + if (comparison == null) { + return null; + } + FieldIntent field = fieldOf(entity, comparison.property()); + RelationIntent relation = field == null ? toOneOf(entity, comparison.property()) : null; + if (field == null && relation == null) { + return null; + } + String type = field != null ? field.getType() : relationKeyType(relation, byName); + String literal = CheckSupport.javaLiteral(type, comparison.literal()); + if (literal == null) { + return null; + } + conditions.add( + CheckSupport.comparison("entity." + IntentNaming.pascalCase(comparison.property()), comparison.equal(), literal)); + } + return conditions.isEmpty() ? null : String.join(" && ", conditions); + } + + /** The entity's field of that name, or {@code null}. */ + private static FieldIntent fieldOf(EntityIntent entity, String name) { + for (FieldIntent field : entity.getFields()) { + if (name != null && name.equals(field.getName())) { + return field; + } + } + return null; + } + + /** The entity's to-one relation of that name, or {@code null}. */ + private static RelationIntent toOneOf(EntityIntent entity, String name) { + for (RelationIntent relation : entity.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (toOne && name != null && name.equals(relation.getName())) { + return relation; + } + } + return null; + } + + /** + * The declared type of a to-one relation's foreign key - the target's primary-key type, falling + * back to the integer intent keys always are when the target is owned by another model. + */ + private static String relationKeyType(RelationIntent relation, Map byName) { + EntityIntent target = relation.getTo() == null ? null : byName.get(relation.getTo()); + if (target != null) { + FieldIntent key = primaryKeyOf(target); + if (key != null && key.getType() != null) { + return key.getType(); + } + } + return "integer"; + } + + /** + * Resolves a cross-model to-one relation's owner facts, so a path walked here can end on an entity + * another model owns - the same lookup the glue generator hands its walkers, reading the owner's + * {@code .model} through {@link CrossModelSupport}. + * + * @param context the generation context (null in a unit test, which then resolves nothing) + * @param usesByAlias the declared {@code uses:} entries, by alias + * @return the lookup + */ + private static NotificationSupport.CrossModelLookup crossModelLookup(IntentGenerationContext context, + Map usesByAlias) { + if (context == null) { + return relation -> null; + } + return relation -> { + UsesIntent uses = usesByAlias.get(relation.getModel()); + if (uses == null) { + return null; + } + CrossModelSupport.TargetInfo target = CrossModelSupport.resolve(context, uses, relation.getTo()); + return new NotificationSupport.CrossModelTarget(target.perspectiveName(), uses.resolveProject(), uses.getModel(), + target.propertyNames()); + }; + } + /** * Compiles the declarative state machine ({@code lifecycle:}) into the three scalars the generated * repository enforces it with: the status property, the legal edges as {@code 1>2} id pairs, and diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CheckIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CheckIntent.java index b0a239d1367..1ee82960c9c 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CheckIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CheckIntent.java @@ -13,10 +13,14 @@ /** * A declarative validation on an {@link EntityIntent} - the cross-field / cross-line rules a plain - * {@code required}/{@code unique} cannot express. Three kinds: + * {@code required}/{@code unique} cannot express. Four kinds: *

    *
  • {@code exactlyOne} (row-level): exactly one of {@link #fields} is non-null on the record (a * journal line is either debit or credit) - enforced on every user write;
  • + *
  • {@code requiredWhen}: {@link #field} - the record's own field, or a one-hop + * {@code Relation.field} - must carry a value while {@link #when} holds (an e-mailed invoice needs + * the customer's address). Enforced on every user write, or, with a {@link #status} gate, when the + * document is persisted carrying that status - the moment the value is finally needed;
  • *
  • {@code itemsSumEqual} (document-level): the sums of the two {@link #over} fields across the * document's composition items are equal (the double-entry invariant) - enforced when the document * is persisted carrying the {@link #status} gate seed id, i.e. at the workflow transition;
  • @@ -33,6 +37,18 @@ public class CheckIntent { private List over; /** {@code itemsMin}: the minimum number of items. */ private Integer count; + /** + * {@code requiredWhen}: the value that must be present - the record's own field, or a one-hop + * {@code Relation.field} over a to-one (the target may be owned by another model, as everywhere + * else a path is walked). + */ + private String field; + /** + * {@code requiredWhen}: the condition under which the value is required - a + * {@code == } / {@code != } comparison over the record's own properties, or a + * list of them (an implicit AND). A status name resolves to its seed id, as in every other guard. + */ + private Object when; /** * Document-level checks only: the EntityStatus seed id gating the check - it runs when the document * is persisted carrying this status (the workflow transition into e.g. POSTED), so drafting @@ -85,6 +101,22 @@ public String getKind() { return kind; } + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } + + public Object getWhen() { + return when; + } + + public void setWhen(Object when) { + this.when = when; + } + public String getOutcome() { return outcome; } 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 554679a45cc..3639425e499 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 @@ -33,6 +33,7 @@ import org.eclipse.dirigible.components.intent.generator.ProcessAssigneeSupport; import org.eclipse.dirigible.components.intent.generator.ProcessParallelSupport; import org.eclipse.dirigible.components.intent.generator.ProcessResilienceSupport; +import org.eclipse.dirigible.components.intent.generator.CheckSupport; import org.eclipse.dirigible.components.intent.generator.ResolvePathSupport; import org.eclipse.dirigible.components.intent.generator.ProcessWaitSupport; import org.eclipse.dirigible.components.intent.generator.ScheduleSupport; @@ -4459,6 +4460,129 @@ private static void validateGuardOutcome(EntityIntent entity, CheckIntent check, } } + /** + * A {@code requiredWhen} check: a value that is required only under a condition - the rule a plain + * {@code required} cannot express, because the value is needed for one way of handling the record + * and meaningless for the others (an e-mailed invoice needs the customer's address; a printed one + * does not). + * + *

    + * The value is the record's own field or a one-hop {@code Relation.field} over a to-one, walked + * with the same resolver every other path in the DSL uses - so a cross-model target reads too, and + * a path walking on past one is refused there. The condition is closed to the equality comparisons + * every other {@code when} guard takes, over the record's OWN properties: a condition the generator + * cannot compile would leave the value required unconditionally, which is a {@code required} nobody + * authored. The {@code status} gate is optional here, unlike on the document-level kinds - a rule + * about the row can hold from the first save, and a rule about the moment the value is finally + * needed (the transition that sends the document) names the status it is needed at. + */ + private static void validateRequiredWhen(EntityIntent entity, CheckIntent check, java.util.Map byName, + String subject, List issues) { + if (check.getField() == null || check.getField() + .isBlank()) { + issues.add(subject + " requires `field`: the value that must be present - a field of [" + entity.getName() + + "] or a one-hop `Relation.field`"); + } else { + ResolvePathSupport.Path path = ResolvePathSupport.walker(entity, byName, java.util.Map.of(), null) + .resolve(check.getField()); + if (!path.resolved()) { + issues.add(subject + " field " + path.failure()); + } + } + if (check.getWhen() == null) { + issues.add( + subject + " requires `when`: the condition under which the value is required, e.g." + " `when: \"SentMethod == 1\"`"); + } else { + List terms = CheckSupport.terms(check.getWhen()); + if (terms.isEmpty()) { + issues.add(subject + " when must not be an empty list"); + } + for (String term : terms) { + validateRequiredWhenTerm(entity, byName, term, subject, issues); + } + } + if (check.getStatus() != null && entityStatusRelationOf(entity) == null) { + issues.add(subject + " carries a `status` gate but [" + entity.getName() + + "] declares no `function: EntityStatus` relation to read it from"); + } + } + + /** + * One comparison of a {@code requiredWhen} condition: the property must be the record's own (the + * condition is read off the row, nothing is loaded to evaluate it) and the literal must be a value + * of that property's type. Both refusals are about a guard that would otherwise be silently + * always-false - a boxed comparison across types never holds - which switches the rule off while + * looking authored. + */ + private static void validateRequiredWhenTerm(EntityIntent entity, java.util.Map byName, String term, + String subject, List issues) { + CheckSupport.Comparison comparison = CheckSupport.parse(term); + if (comparison == null) { + issues.add(subject + " when [" + term + "] must be ` ==|!= ` - a number, a status name, a quoted" + + " string or a bare word"); + return; + } + FieldIntent field = fieldByName(entity, comparison.property()); + RelationIntent relation = field == null ? toOneByName(entity, comparison.property()) : null; + if (field == null && relation == null) { + issues.add(subject + " when [" + term + "] guards [" + comparison.property() + "], which is not a field or to-one relation of [" + + entity.getName() + "] - the condition is read off the record itself"); + return; + } + String type = field != null ? field.getType() : relationKeyType(relation, byName); + if (field != null && !CheckSupport.GUARD_TYPES.contains(type)) { + issues.add(subject + " when [" + term + "] compares [" + comparison.property() + "], which is a [" + type + + "] field - a condition compares a string, an integer or a boolean, the types an equality is exact on"); + return; + } + if (CheckSupport.javaLiteral(type, comparison.literal()) == null) { + issues.add(subject + " when [" + term + "] compares [" + comparison.property() + "], a [" + type + "], with [" + + comparison.literal() + "], which is not a value of that type"); + } + } + + /** The entity's to-one relation of that name, or {@code null}. */ + private static RelationIntent toOneByName(EntityIntent entity, String name) { + if (entity.getRelations() != null) { + for (RelationIntent relation : entity.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (toOne && name != null && name.equals(relation.getName())) { + return relation; + } + } + } + return null; + } + + /** + * The declared type of a to-one relation's foreign key - the target's primary-key type. A + * cross-model target's model is not loaded here, and intent primary keys are integers, so that is + * what an unresolvable target falls back to. + */ + private static String relationKeyType(RelationIntent relation, java.util.Map byName) { + EntityIntent target = relation.getTo() == null ? null : byName.get(relation.getTo()); + if (target != null && target.getFields() != null) { + for (FieldIntent field : target.getFields()) { + if (field.isPrimaryKey() && field.getType() != null) { + return field.getType(); + } + } + } + return "integer"; + } + + /** The entity's {@code function: EntityStatus} relation, or {@code null}. */ + private static RelationIntent entityStatusRelationOf(EntityIntent entity) { + if (entity.getRelations() != null) { + for (RelationIntent relation : entity.getRelations()) { + if (relation.isEntityStatus()) { + return relation; + } + } + } + return null; + } + private static void validateCheck(EntityIntent entity, CheckIntent check, java.util.Map byName, java.util.List entities, List aggregates, List issues) { @@ -4498,6 +4622,10 @@ private static void validateCheck(EntityIntent entity, CheckIntent check, java.u validateGuardOutcome(entity, check, subject, issues); return; } + if ("requiredWhen".equals(kind)) { + validateRequiredWhen(entity, check, byName, subject, issues); + return; + } if ("exactlyOne".equals(kind)) { if (check.getFields() == null || check.getFields() .size() < 2) { @@ -4553,7 +4681,7 @@ private static void validateCheck(EntityIntent entity, CheckIntent check, java.u } return; } - issues.add(subject + " has unknown kind - expected exactlyOne, itemsSumEqual or itemsMin"); + issues.add(subject + " has unknown kind - expected exactlyOne, requiredWhen, itemsSumEqual or itemsMin"); } /** Whether the name matches (case-insensitively) a field or to-one relation of the entity. */ diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java index a6bfd8d18a7..37eddfe45d5 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java @@ -187,6 +187,9 @@ private void rewriteEntities(Map root) { String subject = "entity [" + entityName + "] check [" + text(check, "kind") + "]"; putResolved(check, "status", status, subject + " status"); putResolved(check, "setStatus", status, subject + " setStatus"); + // A requiredWhen condition may be about the status itself ("required once ISSUED"), so + // it resolves like every other guard - the terms about other properties pass through. + rewriteWhen(check, statusRelation, status, subject + " when"); } } } diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index be78e2fdbe9..96b792a72b7 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -1182,6 +1182,75 @@ void checksEmitTemplateReadyMaps() { .get("fields")); } + @Test + @SuppressWarnings("unchecked") + void conditionallyRequiredValuesEmitTheirConditionAndTheHopsTheirValueIsReadThrough() { + String yaml = """ + name: sales + seeds: + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: DRAFT } + - { id: 4, name: SENT } + entities: + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: email, type: string } + - name: SalesInvoice + checks: + - { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT, + message: "Sent Method is E-mail but the customer has no e-mail address" } + - { kind: requiredWhen, field: reference, when: ["sentMethod == 1", "kind == 'export'"], + message: "An e-mailed export needs a reference" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: sentMethod, type: integer } + - { name: kind, type: string } + - { name: reference, type: string } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: DRAFT } + - { name: Customer, kind: manyToOne, to: Customer, required: true } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "sales"); + List> checks = (List>) entityByName(entities(model), "SalesInvoice").get("checks"); + assertEquals(2, checks.size()); + + Map overHop = checks.get(0); + // The value is read through the relation, so the hop the reader must load rides along - the + // .model twin cannot re-derive it, and this is what lets the check reach a related record. + assertEquals("java.util.Objects.equals(entity.SentMethod, 1)", overHop.get("guard")); + assertEquals("(hop0 == null ? null : hop0.Email)", overHop.get("valueExpression")); + assertEquals("Customer.Email", overHop.get("label")); + List> loads = (List>) overHop.get("pathLoads"); + assertEquals("hop0", loads.get(0) + .get("local")); + assertEquals("entity.Customer", loads.get(0) + .get("sourceExpression")); + assertEquals("Customer", loads.get(0) + .get("entity")); + // The gate: the seeded status name resolved to its id, plus the property it is read from. + assertEquals("4", overHop.get("status")); + assertEquals("Status", overHop.get("statusProperty")); + + Map ownField = checks.get(1); + // A list condition is an implicit AND, and each comparison is rendered against its property's + // declared type - a string literal quoted, an integer bare. + assertEquals("java.util.Objects.equals(entity.SentMethod, 1) && java.util.Objects.equals(entity.Kind, \"export\")", + ownField.get("guard")); + assertEquals("entity.Reference", ownField.get("valueExpression")); + assertNull(ownField.get("pathLoads")); + // No gate declared, so the rule holds on every user write and carries no status at all. + assertNull(ownField.get("status")); + } + /** * A document check counts the document's LINES, even when the document owns several composition * children - a printed {@code function: Snapshot} copy, a payment allocation, a promotion. The diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java index 0ec4b8df416..d6f2a79489c 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java @@ -852,6 +852,85 @@ void checksParseAndValidate() { "expected a gate issue, got: " + ex.getIssues()); } + @Test + void conditionallyRequiredValuesParseAndValidate() { + String yaml = """ + name: sales + seeds: + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: DRAFT } + - { id: 4, name: SENT } + entities: + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: email, type: string } + - name: SalesInvoice + checks: + - { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT, + message: "Sent Method is E-mail but the customer has no e-mail address" } + - { kind: requiredWhen, field: reference, when: ["sentMethod == 1", "kind == 'export'"], + message: "An e-mailed export needs a reference" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: sentMethod, type: integer } + - { name: kind, type: string } + - { name: reference, type: string } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: DRAFT } + - { name: Customer, kind: manyToOne, to: Customer, required: true } + """; + IntentModel model = IntentParser.parse(yaml); + org.eclipse.dirigible.components.intent.model.EntityIntent invoice = model.getEntities() + .get(2); + assertEquals(2, invoice.getChecks() + .size()); + // The gate resolves the seeded status NAME to its id, like every other status site. + assertEquals(4, invoice.getChecks() + .get(0) + .getStatus()); + + // The value must resolve - a path walking on past the relation names nothing readable. + String unknown = yaml.replace("field: Customer.email", "field: Customer.mail"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(unknown)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("has no field or to-one relation [mail]")), + "expected an unresolved value issue, got: " + ex.getIssues()); + + // A condition the generator cannot compile would leave the value unconditionally required. + String malformed = yaml.replace("when: \"sentMethod == 1\"", "when: \"sentMethod is email\""); + IntentValidationException garbled = assertThrows(IntentValidationException.class, () -> IntentParser.parse(malformed)); + assertTrue(garbled.getIssues() + .stream() + .anyMatch(i -> i.contains("must be ` ==|!= `")), + "expected a condition-shape issue, got: " + garbled.getIssues()); + + // A comparison across types never holds, so it is refused rather than silently switched off. + String mistyped = yaml.replace("when: \"sentMethod == 1\"", "when: \"sentMethod == 'email'\""); + IntentValidationException wrongType = assertThrows(IntentValidationException.class, () -> IntentParser.parse(mistyped)); + assertTrue(wrongType.getIssues() + .stream() + .anyMatch(i -> i.contains("which is not a value of that type")), + "expected a literal-type issue, got: " + wrongType.getIssues()); + + // The condition is read off the record itself - nothing is loaded to evaluate it. + String foreign = yaml.replace("when: \"sentMethod == 1\"", "when: \"postage == 1\""); + IntentValidationException unknownProperty = assertThrows(IntentValidationException.class, () -> IntentParser.parse(foreign)); + assertTrue(unknownProperty.getIssues() + .stream() + .anyMatch(i -> i.contains("is not a field or to-one relation of [SalesInvoice]")), + "expected an unknown-property issue, got: " + unknownProperty.getIssues()); + } + @Test void hierarchyAndLeafOnlyParse() { String yaml = """ diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java index 2eba979a90c..99420dbbf5e 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java @@ -122,7 +122,7 @@ private static void processEntity(Map entity, List()); - splitChecks(entity); + splitChecks(entity, parameters); resolveDataOrder(entity); for (Map property : asMaps(entity.get("properties"))) { @@ -208,7 +208,7 @@ private static void resolveReferencedProjection(Map entity, List * * @param entity the entity */ - private static void splitChecks(Map entity) { + private static void splitChecks(Map entity, Map parameters) { List> checks = asMaps(entity.get("checks")); if (checks.isEmpty()) { return; @@ -218,10 +218,16 @@ private static void splitChecks(Map entity) { List documentChecks = new ArrayList<>(); for (Map check : checks) { String kind = str(check, "kind"); + resolveCheckPathLoads(check, parameters); if ("exactlyOne".equals(kind)) { rowChecks.add(check); } else if ("guard".equals(kind)) { guardChecks.add(check); + } else if ("requiredWhen".equals(kind)) { + // A conditionally required value is row-level unless it names the status it is needed + // at: without a gate it must hold on every user write, with one it is the repository's + // business, like every other gated check. + (str(check, "status") == null || str(check, "status").isEmpty() ? rowChecks : documentChecks).add(check); } else { documentChecks.add(check); } @@ -231,6 +237,39 @@ private static void splitChecks(Map entity) { entity.put("documentChecks", documentChecks); } + /** + * Resolves a check's declared path hops to the generated classes that load them - the reader of a + * {@code Relation.field} value must fetch the related record before it can read the field. + * + *

    + * A cross-model hop resolves against the owner model's generation folder, as every other + * cross-model reference does; this is the pass that knows the generation folder at all, which is + * why the intent generator emits the hop's coordinates and not a class name. + * + * @param check the check + * @param parameters the generation parameters + */ + private static void resolveCheckPathLoads(Map check, Map parameters) { + List> hops = asMaps(check.get("pathLoads")); + if (hops.isEmpty()) { + return; + } + List loads = new ArrayList<>(); + for (Map hop : hops) { + String genFolder = truthy(hop, "crossModel") ? NamingHelper.sanitizeJavaIdentifier(str(hop, "targetModel")) + : str(parameters, "javaGenFolderName"); + String qualified = + "gen." + genFolder + ".data." + NamingHelper.sanitizeJavaIdentifier(str(hop, "perspective")) + "." + str(hop, "entity"); + Map load = new LinkedHashMap<>(); + load.put("local", hop.get("local")); + load.put("sourceExpression", hop.get("sourceExpression")); + load.put("entityClass", qualified + "Entity"); + load.put("repositoryClass", qualified + "Repository"); + loads.add(load); + } + check.put("pathLoads", loads); + } + /** * Lifts the ordering declared on the entity's properties onto the entity itself. * diff --git a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java index 56b67be0bbc..6027b9cf266 100644 --- a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java +++ b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java @@ -232,20 +232,52 @@ void splitsTheDeclarativeChecksByTheScopeThatEnforcesThem() { Map guard = new LinkedHashMap<>(); guard.put("kind", "guard"); Map document = new LinkedHashMap<>(); - document.put("kind", "requiredWhen"); + document.put("kind", "itemsMin"); + Map conditional = new LinkedHashMap<>(); + conditional.put("kind", "requiredWhen"); + Map gated = new LinkedHashMap<>(); + gated.put("kind", "requiredWhen"); + gated.put("status", "4"); Map entity = entity("Invoice", "Invoices", property("Total", "DECIMAL")); - entity.put("checks", List.of(row, guard, document)); + entity.put("checks", List.of(row, guard, document, conditional, gated)); ModelParameterProcessor.process(model(entity), parameters()); - assertEquals(1, ModelValues.asList(entity.get("rowChecks")) + // An ungated requiredWhen holds on every user write, so it joins the row checks the REST + // surfaces enforce; one naming a status is the repository's, like every other gated check. + assertEquals(2, ModelValues.asList(entity.get("rowChecks")) .size()); assertEquals(1, ModelValues.asList(entity.get("guardChecks")) .size()); - assertEquals(1, ModelValues.asList(entity.get("documentChecks")) + assertEquals(2, ModelValues.asList(entity.get("documentChecks")) .size()); } + @Test + void resolvesTheHopsAConditionalRequirementReadsItsValueThrough() { + Map hop = new LinkedHashMap<>(); + hop.put("local", "hop0"); + hop.put("sourceExpression", "entity.Customer"); + hop.put("entity", "Customer"); + hop.put("perspective", "Customers"); + hop.put("crossModel", Boolean.TRUE); + hop.put("targetModel", "base-customers"); + Map check = new LinkedHashMap<>(); + check.put("kind", "requiredWhen"); + check.put("pathLoads", List.of(hop)); + Map entity = entity("Invoice", "Invoices", property("Total", "DECIMAL")); + entity.put("checks", List.of(check)); + + ModelParameterProcessor.process(model(entity), parameters()); + + Map resolved = ModelValues.asMaps(ModelValues.asMaps(entity.get("rowChecks")) + .get(0) + .get("pathLoads")) + .get(0); + assertEquals("gen.base_customers.data.customers.CustomerEntity", resolved.get("entityClass")); + assertEquals("gen.base_customers.data.customers.CustomerRepository", resolved.get("repositoryClass")); + } + @Test void resolvesAProjectionOwnerFromEitherReferenceForm() { Map foreignKey = property("Currency", "INTEGER"); diff --git a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template index 70f24d5f6a4..5167aa41981 100644 --- a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template +++ b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template @@ -213,6 +213,16 @@ package gen.${javaGenFolderName}.data.${javaPerspectiveName}; #end #end #end +## Whether any document check counts the document's ITEMS - the kinds that need a Criteria query. A +## gated requiredWhen is a document check too but reads only the row, so the import stays out of a +## model that declares nothing else. +#if($documentChecks) +#foreach($check in $documentChecks) +#if($check.kind == "itemsSumEqual" || $check.kind == "itemsMin") +#set($haveItemChecks = "true") +#end +#end +#end import org.eclipse.dirigible.components.data.store.java.repository.JavaRepository; import org.eclipse.dirigible.sdk.component.Component; import org.eclipse.dirigible.sdk.component.Repository; @@ -260,11 +270,13 @@ import org.eclipse.dirigible.components.data.store.java.repository.Criteria; import gen.${javaGenFolderName}.data.${rollupGuard.parentPerspective}.${rollupGuard.parentEntity}Entity; import gen.${javaGenFolderName}.data.${rollupGuard.parentPerspective}.${rollupGuard.parentEntity}Repository; #end -#if($documentChecks && $documentChecks.size() > 0 && $multilingual != "true" && !$documentMaster && !$rollupGuard) -## Document checks need Criteria to load the items; deduped against the other importers above. +#if($haveItemChecks && $multilingual != "true" && !$documentMaster && !$rollupGuard) +## The item-counting document checks need Criteria to load the items; deduped against the other +## importers above. A gated requiredWhen reads the row (and at most a related row by id), so a model +## whose only document check is one of those must not import it unused. import org.eclipse.dirigible.components.data.store.java.repository.Criteria; #end -#if($guardChecks && $guardChecks.size() > 0 && $multilingual != "true" && !$documentMaster && !$rollupGuard && !($documentChecks && $documentChecks.size() > 0)) +#if($guardChecks && $guardChecks.size() > 0 && $multilingual != "true" && !$documentMaster && !$rollupGuard && !$haveItemChecks) ## Aggregate guards recompute the keyed sum via Criteria; deduped against every importer above. import org.eclipse.dirigible.components.data.store.java.repository.Criteria; #end @@ -1003,7 +1015,19 @@ public class ${name}Repository extends JavaRepository<${name}Entity> { private void enforceChecks(${name}Entity entity) { #foreach($check in $documentChecks) if (entity.${check.statusProperty} != null && entity.${check.statusProperty} == ${check.status}) { -#if($check.kind == "itemsSumEqual") +#if($check.kind == "requiredWhen") + // A value required only under a condition (intent `checks: requiredWhen`), gated on the + // status at which it is finally needed - the transition that sends the document, not the + // drafting before it. ${check.label} must carry a value while the condition holds. +#foreach($load in $check.pathLoads) + Object ${load.local}Fk = ${load.sourceExpression}; + ${load.entityClass} ${load.local} = ${load.local}Fk == null ? null : new ${load.repositoryClass}().findById(${load.local}Fk); +#end + Object requiredValue = ${check.valueExpression}; + if ((${check.guard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { + throw new ValidationException("${check.message}"); + } +#elseif($check.kind == "itemsSumEqual") java.math.BigDecimal sum${check.overA} = java.math.BigDecimal.ZERO; java.math.BigDecimal sum${check.overB} = java.math.BigDecimal.ZERO; for (${check.itemsEntity}Entity checkItem : new ${check.itemsEntity}Repository().findAll( diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template index 778d7735222..db394d3908a 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template @@ -823,6 +823,21 @@ public class ${name}Controller { private static void validate(${name}Entity entity) { #if($rowChecks && $rowChecks.size() > 0) #foreach($check in $rowChecks) +#if($check.kind == "requiredWhen") + // Row-level check (intent `checks: requiredWhen`): ${check.label} must carry a value while the + // condition holds. Ungated, so the rule holds on every user write - the moment a value becomes + // mandatory only at a transition, the check names that status and the repository owns it. + { +#foreach($load in $check.pathLoads) + Object ${load.local}Fk = ${load.sourceExpression}; + ${load.entityClass} ${load.local} = ${load.local}Fk == null ? null : new ${load.repositoryClass}().findById(${load.local}Fk); +#end + Object requiredValue = ${check.valueExpression}; + if ((${check.guard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + } + } +#else // Row-level check (intent `checks: exactlyOne`). { int assigned = 0; @@ -837,6 +852,7 @@ public class ${name}Controller { } #end #end +#end #foreach($property in $properties) ## A required value the PLATFORM supplies is not the caller's to send: the generated repository fills a ## document number and a uuid on insert, so demanding them here would refuse every create of a numbered diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template index 3841b3d7879..9bfeb50dab9 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template @@ -545,6 +545,21 @@ public class ${name}MyController { private static void validate(${name}Entity entity) { #if($rowChecks && $rowChecks.size() > 0) #foreach($check in $rowChecks) +#if($check.kind == "requiredWhen") + // Row-level check (intent `checks: requiredWhen`): ${check.label} must carry a value while the + // condition holds. Ungated, so the rule holds on every user write - the moment a value becomes + // mandatory only at a transition, the check names that status and the repository owns it. + { +#foreach($load in $check.pathLoads) + Object ${load.local}Fk = ${load.sourceExpression}; + ${load.entityClass} ${load.local} = ${load.local}Fk == null ? null : new ${load.repositoryClass}().findById(${load.local}Fk); +#end + Object requiredValue = ${check.valueExpression}; + if ((${check.guard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + } + } +#else // Row-level check (intent `checks: exactlyOne`). { int assigned = 0; @@ -559,6 +574,7 @@ public class ${name}MyController { } #end #end +#end #foreach($property in $properties) ## A required value the PLATFORM supplies is not the caller's to send: the generated repository fills a ## document number and a uuid on insert, so demanding them here would refuse every create of a numbered diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template index 78e52e62081..3906051e181 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template @@ -527,6 +527,21 @@ public class ${name}PartnerController { private static void validate(${name}Entity entity) { #if($rowChecks && $rowChecks.size() > 0) #foreach($check in $rowChecks) +#if($check.kind == "requiredWhen") + // Row-level check (intent `checks: requiredWhen`): ${check.label} must carry a value while the + // condition holds. Ungated, so the rule holds on every user write - the moment a value becomes + // mandatory only at a transition, the check names that status and the repository owns it. + { +#foreach($load in $check.pathLoads) + Object ${load.local}Fk = ${load.sourceExpression}; + ${load.entityClass} ${load.local} = ${load.local}Fk == null ? null : new ${load.repositoryClass}().findById(${load.local}Fk); +#end + Object requiredValue = ${check.valueExpression}; + if ((${check.guard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + } + } +#else // Row-level check (intent `checks: exactlyOne`). { int assigned = 0; @@ -541,6 +556,7 @@ public class ${name}PartnerController { } #end #end +#end #foreach($property in $properties) ## A required value the PLATFORM supplies is not the caller's to send: the generated repository fills a ## document number and a uuid on insert, so demanding them here would refuse every create of a numbered 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 caf6c241530..80810772657 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 @@ -71,26 +71,28 @@ * declared {@code locksWithMaster: false} keeps its writes), {@code immutableInPeriod} (the same * 409 keyed on a period register's status instead - a record booked into an open period stops being * writable when the period around it closes, a create into or a move into a closed one is refused, - * and a date no period covers stays writable), {@code checks} (exactlyOne / itemsMin / - * itemsSumEqual - including that a document gate counts the document's LINES and not a sibling - * composition child such as its printed copy), {@code hierarchy}/{@code leafOnly}, - * {@code multilingual} (the read-time overlay on an entity read, and its SQL counterpart on a - * report grouping by that nomenclature - the two must agree on the same value in the same - * language), seed rows carrying a RELATION column, aggregate totals, first-class {@code number:} - * stamping from an authored {@code .numbers} series declaration, {@code transitions} (the guarded - * on-demand status flip: allowed-status 200, wrong-status/guard 409), {@code lifecycle} (the - * declarative state machine: the graph walked through its transitions, an unmodeled flip and a - * create filed mid-lifecycle both refused through the plain REST surface no transition guard - * covers), {@code postings} with {@code reverses} (post on a transition; red-storno reversal on - * void - negated amounts, storno link, fail-soft), the {@code notify} block with - * {@code attach: print} (send the document itself by e-mail - on a transition and on a process - * step; the fail-soft contract), {@code calculatedActionOnCreate} on a to-one RELATION (the FK - * resolved server-side by a hand-written {@code custom/} action: assigned in the repository, and at - * runtime both defaulted when omitted and left alone when the caller supplied one), the - * event-driven {@code generates} (posting the source mints the whole document with nobody clicking, - * and a click afterwards returns that same document - the at-most-once back-reference guard), and - * the personal (my) surface ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, - * forced owner, stripped fields). + * and a date no period covers stays writable), {@code checks} (exactlyOne / requiredWhen / itemsMin + * / itemsSumEqual - including that a document gate counts the document's LINES and not a sibling + * composition child such as its printed copy, and that a requiredWhen reaches its value through a + * relation and lands in the repository or in every controller depending on whether it names a gate + * status), {@code hierarchy}/{@code leafOnly}, {@code multilingual} (the read-time overlay on an + * entity read, and its SQL counterpart on a report grouping by that nomenclature - the two must + * agree on the same value in the same language), seed rows carrying a RELATION column, aggregate + * totals, first-class {@code number:} stamping from an authored {@code .numbers} series + * declaration, {@code transitions} (the guarded on-demand status flip: allowed-status 200, + * wrong-status/guard 409), {@code lifecycle} (the declarative state machine: the graph walked + * through its transitions, an unmodeled flip and a create filed mid-lifecycle both refused through + * the plain REST surface no transition guard covers), {@code postings} with {@code reverses} (post + * on a transition; red-storno reversal on void - negated amounts, storno link, fail-soft), the + * {@code notify} block with {@code attach: print} (send the document itself by e-mail - on a + * transition and on a process step; the fail-soft contract), {@code calculatedActionOnCreate} on a + * to-one RELATION (the FK resolved server-side by a hand-written {@code custom/} action: assigned + * in the repository, and at runtime both defaulted when omitted and left alone when the caller + * supplied one), the event-driven {@code generates} (posting the source mints the whole document + * with nobody clicking, and a click afterwards returns that same document - the at-most-once + * back-reference guard), and the personal (my) surface + * ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, forced owner, stripped + * fields). */ @Tag("slow") class IntentEmissionCoverageIT extends IntegrationTest { @@ -216,6 +218,11 @@ class IntentEmissionCoverageIT extends IntegrationTest { checks: - { kind: itemsMin, count: 1, status: 2, message: "Entry needs at least one line" } - { kind: itemsSumEqual, over: [debit, credit], status: 2, message: "Debits must equal credits" } + # requiredWhen (#7094), gated + over a relation hop: the value lives on the related + # account, so the generated repository loads it by FK before it can read it, and the + # rule only applies at the status the value is finally needed at. + - { kind: requiredWhen, field: Account.name, when: "note == 'audited'", status: 2, + message: "An audited entry must be booked against a named account" } fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: date, type: date, required: true } @@ -244,6 +251,12 @@ class IntentEmissionCoverageIT extends IntegrationTest { edges: - { from: DRAFT, to: [POSTED] } - { from: POSTED, to: [CANCELLED] } + checks: + # requiredWhen (#7094), UNGATED: no status is named, so the rule holds on every user + # write and every generated controller enforces it (the entity, personal and partner + # surfaces). The condition names a seeded status by name, like every other guard. + - { kind: requiredWhen, field: Party.name, when: "Status == POSTED", + message: "A posted document must name its counterparty" } fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: date, type: date, required: true } @@ -1834,11 +1847,34 @@ private void assertEmission() { assertTrue(contentOf("emission.model").contains("\"calculatedActionOnCreate\": \"QuoteTariffAction\""), "the relation's calculated action must reach the .model property every downstream template reads"); + // An UNGATED requiredWhen is a row check: it holds on every user write, so it lands in each + // generated controller's validate() rather than in the repository's gated block - the same + // split exactlyOne has always had, and the reason `status:` is optional on this kind. + String docController = contentOf("gen/emission/api/doc/DocController.java"); + assertTrue( + docController.contains("A posted document must name its counterparty") + && docController.contains("PartyRepository().findById(hop0Fk)") + && docController.contains("java.util.Objects.equals(entity.Status, 2)"), + "an ungated requiredWhen must be enforced on every REST write, with the status NAME resolved to its seed id, got: " + + docController); + assertFalse(contentOf("gen/emission/data/doc/DocRepository.java").contains("A posted document must name its counterparty"), + "an ungated check is not the repository's - a gate it does not carry cannot be tested there"); + String entryRepository = contentOf("gen/emission/data/entry/EntryRepository.java"); assertTrue(entryRepository.contains("Entry needs at least one line"), "checks: itemsMin must emit its authored message into the repository gate"); assertTrue(entryRepository.contains("Debits must equal credits"), "checks: itemsSumEqual must emit its authored message into the repository gate"); + // A value required only under a condition (#7094). The rule reaches the value THROUGH the + // relation, so the gate loads the related row by FK first - a check that could only ever read + // the record's own columns would not express the rule the module actually has ("an e-mailed + // invoice needs the customer's address"), and the condition is rendered against the guarded + // property's declared type, because a boxed comparison across types is silently always-false. + assertTrue( + entryRepository.contains("An audited entry must be booked against a named account") + && entryRepository.contains("AccountRepository().findById(hop0Fk)") + && entryRepository.contains("java.util.Objects.equals(entity.Note, \"audited\")"), + "checks: requiredWhen must load the hop, test the condition and refuse the empty value, got: " + entryRepository); // ...and both gates must query the document's LINES. The items child used to be whichever // composition child a HashMap iteration yielded first, so a document that also owns a printed // copy, a payment allocation or a promotion counted THOSE rows (#7027) - an invoice guard that