diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index 7dd3a7e5658..47fa3e8f373 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -42,6 +42,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **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. +**Two values of one row, related (`checks: compare`, [#7095](https://github.com/eclipse-dirigible/dirigible/issues/7095)):** `checks:` knew `exactlyOne`, `itemsSumEqual` and `itemsMin` - nothing compared two fields of the SAME record, so "a due date is never before the invoice date" was not expressible and a document was saved (200), issued and overdue the moment it existed; the module's workaround was a `calculatedActionOnCreate`/`OnUpdate` class per document type that silently CORRECTED the date instead of refusing it, which is a different thing and never tells the clerk. `- { kind: compare, field: due, op: ge, than: date, message: ... }` is row-level like `exactlyOne`: enforced in every generated controller's `validate()` (the entity, personal and partner surfaces) as a 400 carrying the authored message, and therefore taking no `status` gate - a rule about two values of one row holds from the first save, not from a transition. `op:` is `ge`/`gt`/`le`/`lt`/`eq`/`ne`, spelled out because an omitted operator has no defensible default. Both operands are the entity's own **fields** - a comparison of two foreign keys means nothing - and must sit in ONE comparison family, which is what the generated code needs: two temporals compare through their own `compareTo` (a `LocalDate` does not compare to an `Instant`), two numbers by value through `BigDecimal` so a `decimal` against a `long` stays exact. Only dates, timestamps and numbers compare; a string / `month` / `week` is refused rather than silently ordered lexicographically, as is a field-with-itself. An **absent operand is not a violation** - a comparison is about two values that exist, and requiredness is its own declaration. + **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 d7727fd256f..7d86011e73e 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -423,8 +423,8 @@ 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).** 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 }`. +- **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Row-level and document-level kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null), `compare` (`field:` / `op:` / `than:` — two values of the SAME row related by an operator: a due date not before the document date, a validity `to` not before its `from`, #7095) and `requiredWhen` (see the next bullet), all emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400 — in all three surfaces' controllers (`EntityController`, `EntityMyController`, `EntityPartnerController`), which is what "every user write" means for a row check. A `compare` carries the Java comparison operator and a `numeric` flag precomputed by `EdmIntentGenerator` (`compareOperator` / `isNumericCompare`): two temporals compare through their own `compareTo`, which is why the parser holds both fields to ONE family (a `LocalDate` does not compare to an `Instant`), while two numbers compare by value through `BigDecimal` so a `decimal` against a `long` is still exact. An absent operand is NOT a violation — a comparison is about two values that exist, and requiredness is its own declaration — and only dates, timestamps and numbers compare (a `string`/`month`/`week` is refused rather than silently ordered lexicographically). Like `exactlyOne` it takes no `status` gate: a rule about two values of one row holds from the first save. 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 no other kind could 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. diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index c2baf1801c8..a4c369b9d1d 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -115,7 +115,7 @@ Values: `Document`, `DocumentItem`, `Master`, `Detail`, `List`, `Setting` (entit ## checks - declarative validations -Row-level `exactlyOne` and `requiredWhen` on every user write; document-level `itemsMin` / +Row-level `exactlyOne`, `compare` 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 @@ -130,8 +130,15 @@ first declared. Flag the lines child explicitly on a document that owns several - name: JournalEntryItem checks: - { kind: exactlyOne, fields: [debit, credit], message: "Exactly one of debit/credit" } +- name: SalesInvoice + checks: + - { kind: compare, field: due, op: ge, than: date, message: "Due cannot be before the invoice date" } ``` +`compare` relates two values of the same row: `op:` is `ge` / `gt` / `le` / `lt` / `eq` / `ne`, both +operands are the entity's own fields, and both must be dates, both timestamps or both numbers. An +absent operand is not a violation - requiredness is its own declaration. + `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 diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java index db16651d9a9..d3bfcf5ccf8 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java @@ -236,6 +236,10 @@ private static Map entityManifest(EntityIntent entity, Map> exactlyOne = new ArrayList<>(); + // compare checks: two of the record's own fields must stand in a relation - the sample values + // are per-type constants, so two dates come out EQUAL and a strict comparison (gt/lt/ne) would + // reject the sample record with 400. The runner derives the left operand from the right. + List> compare = new ArrayList<>(); for (CheckIntent check : entity.getChecks() == null ? List.of() : entity.getChecks()) { if ("exactlyOne".equals(check.getKind()) && check.getFields() != null && !check.getFields() .isEmpty()) { @@ -244,10 +248,22 @@ private static Map entityManifest(EntityIntent entity, Map entry = new LinkedHashMap<>(); + entry.put("field", IntentNaming.pascalCase(check.getField())); + entry.put("op", check.getOp() + .trim() + .toLowerCase(java.util.Locale.ROOT)); + entry.put("than", IntentNaming.pascalCase(check.getThan())); + compare.add(entry); + } } if (!exactlyOne.isEmpty()) { out.put("exactlyOne", exactlyOne); } + if (!compare.isEmpty()) { + out.put("compare", compare); + } out.put("fields", fields(entity)); List> relations = relations(entity, model, context, edmEntities); if (!relations.isEmpty()) { 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 9955bdf5bdf..b9d4cf2e5b9 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 @@ -2267,6 +2267,20 @@ private static List> buildChecks(EntityIntent entity, List> buildChecks(EntityIntent entity, List ">="; + case "gt" -> ">"; + case "le" -> "<="; + case "lt" -> "<"; + case "eq" -> "=="; + case "ne" -> "!="; + default -> null; + }; + } + + /** + * Whether a {@code compare} check's two fields are numbers (rather than temporals), or null when + * either field or its type does not resolve - the parser has already reported that. + */ + private static Boolean isNumericCompare(EntityIntent entity, org.eclipse.dirigible.components.intent.model.CheckIntent check) { + FieldIntent left = fieldOf(entity, check.getField()); + FieldIntent right = fieldOf(entity, check.getThan()); + if (left == null || right == null) { + return null; + } + boolean leftNumeric = isNumericType(left.getType()); + if (leftNumeric != isNumericType(right.getType())) { + return null; + } + return leftNumeric; + } + + private static boolean isNumericType(String type) { + return type != null && NUMERIC_FIELD_TYPES.contains(type.trim() + .toLowerCase(java.util.Locale.ROOT)); + } + + private static FieldIntent fieldOf(EntityIntent entity, String name) { + if (name == null || entity.getFields() == null) { + return null; + } + for (FieldIntent field : entity.getFields()) { + if (name.equalsIgnoreCase(field.getName())) { + return field; + } + } + return null; + } + /** * Compiles a {@code requiredWhen} condition into the Java boolean the generated reader tests - * every comparison rendered against its property's DECLARED type, and ANDed. @@ -2333,16 +2398,6 @@ private static String requiredWhenGuard(EntityIntent entity, Map}. */ + /** + * The field types a {@code checks: compare} entry compares by numeric value rather than as a + * temporal. + */ + private static final Set NUMERIC_FIELD_TYPES = Set.of("integer", "int", "long", "decimal", "double"); + private static final Set DOCUMENT_ELEMENT_KEYS = Set.of("entities", "perspectives", "navigations"); /** 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 1ee82960c9c..6b3162af918 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,13 @@ /** * A declarative validation on an {@link EntityIntent} - the cross-field / cross-line rules a plain - * {@code required}/{@code unique} cannot express. Four kinds: + * {@code required}/{@code unique} cannot express. Five 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 compare} (row-level): {@link #field} compared to {@link #than} with {@link #op} - two + * values of the SAME row that must stand in a relation to each other (a due date not before the + * document date, a validity end not before its start) - 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 @@ -33,16 +36,25 @@ public class CheckIntent { private String kind; /** {@code exactlyOne}: the record's own fields, exactly one of which must be non-null. */ private List fields; + /** + * The value the check is ABOUT - the two row-level kinds that name one share the key. + * {@code compare}: the record's own field on the left of the comparison. {@code requiredWhen}: the + * value that must be present - the record's own field, or a one-hop {@code Relation.field} over a + * to-one (whose target may be owned by another model, as everywhere else a path is walked). + */ + private String field; + /** + * {@code compare}: the comparison - {@code ge}, {@code gt}, {@code le}, {@code lt}, {@code eq} or + * {@code ne}. Required: an omitted operator has no defensible default (a due date not BEFORE the + * document date and one strictly AFTER it are different rules). + */ + private String op; + /** {@code compare}: the record's own field on the right of the comparison. */ + private String than; /** {@code itemsSumEqual}: the two numeric item fields whose sums must be equal. */ 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 @@ -169,6 +181,22 @@ public void setKind(String kind) { this.kind = kind; } + public String getOp() { + return op; + } + + public void setOp(String op) { + this.op = op; + } + + public String getThan() { + return than; + } + + public void setThan(String than) { + this.than = than; + } + public List getFields() { return fields; } 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 3639425e499..91355a7d850 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 @@ -124,6 +124,20 @@ public final class IntentParser { * (auto-increment), and a non-integer auto-increment column is invalid SQL on most databases. */ private static final Set INTEGER_PK_TYPES = Set.of("integer", "int", "long"); + + /** The comparisons a {@code checks: compare} entry may declare. */ + private static final Set COMPARE_OPS = Set.of("ge", "gt", "le", "lt", "eq", "ne"); + + /** + * A field type a {@code checks: compare} entry may compare, as the family the generated comparison + * belongs to. Two fields compare only within one family: the generated code compares two temporals + * through {@code compareTo}, which needs the SAME class (a {@code LocalDate} does not compare to an + * {@code Instant}), and two numbers by value through {@code BigDecimal}, which is exact across the + * numeric widths. Everything else - a string, a boolean, a {@code month}/{@code week} label - is + * out of scope rather than silently ordered lexicographically. + */ + private static final Map COMPARE_FAMILIES = Map.of("date", "date", "timestamp", "timestamp", "integer", "number", "int", + "number", "long", "number", "decimal", "number", "double", "number"); /** Numeric field types a sum roll-up (its field / {@code of} / capacity / balance) may use. */ private static final Set NUMERIC_TYPES = Set.of("integer", "int", "long", "decimal", "double"); private static final Set RELATION_KINDS = Set.of("oneToMany", "manyToOne", "oneToOne", "manyToMany", "subset"); @@ -4402,11 +4416,12 @@ private static void validateLeafOnly(EntityIntent entity, RelationIntent relatio } /** - * A {@code checks} entry is one of three kinds. {@code exactlyOne} is row-level: at least two own - * fields, no status gate (it must hold on every write). {@code itemsSumEqual}/{@code itemsMin} are - * document-level: the entity must own a composition child (the items), the {@code over} fields must - * be two numeric fields OF THE ITEMS entity, and a {@code status} gate (an EntityStatus seed id) is - * mandatory - without it the check would forbid drafting the document item by item. + * A {@code checks} entry is one of several kinds. {@code exactlyOne} is row-level: at least two own + * fields, no status gate (it must hold on every write); {@code compare} is row-level too - two own + * fields and an operator. {@code itemsSumEqual}/{@code itemsMin} are document-level: the entity + * must own a composition child (the items), the {@code over} fields must be two numeric fields OF + * THE ITEMS entity, and a {@code status} gate (an EntityStatus seed id) is mandatory - without it + * the check would forbid drafting the document item by item. */ /** * A guard's {@code outcome} decides what a violation does, and each outcome needs its own companion @@ -4642,6 +4657,10 @@ private static void validateCheck(EntityIntent entity, CheckIntent check, java.u } return; } + if ("compare".equals(kind)) { + validateCompareCheck(entity, check, subject, issues); + return; + } if ("itemsSumEqual".equals(kind) || "itemsMin".equals(kind)) { EntityIntent items = compositionChildOf(entity, entities); if (items == null) { @@ -4681,7 +4700,69 @@ private static void validateCheck(EntityIntent entity, CheckIntent check, java.u } return; } - issues.add(subject + " has unknown kind - expected exactlyOne, requiredWhen, itemsSumEqual or itemsMin"); + issues.add(subject + " has unknown kind - expected exactlyOne, compare, requiredWhen, guard, itemsSumEqual or itemsMin"); + } + + /** + * A {@code compare} check relates two values of the SAME row - the shape a plain + * {@code required}/{@code unique} cannot express and the reason a document could be saved with a + * due date behind its own date (dirigible #7095). Both operands must be the entity's own fields + * (never a relation - a comparison of two foreign keys means nothing), the operator is explicit, + * and the two types must land in the same comparison family so the generated comparison compiles + * and means what it says. It is row-level like {@code exactlyOne}, so it takes no {@code status} + * gate: a rule about two values of one row holds from the first save, not from a transition. + */ + private static void validateCompareCheck(EntityIntent entity, CheckIntent check, String subject, List issues) { + String field = check.getField(); + String than = check.getThan(); + if (field == null || field.isBlank() || than == null || than.isBlank()) { + issues.add(subject + " requires `field` and `than`: the two own fields to compare"); + return; + } + if (check.getStatus() != null) { + issues.add(subject + " is row-level and cannot carry a `status` gate - it must hold on every write"); + } + String op = check.getOp() == null ? null + : check.getOp() + .trim() + .toLowerCase(java.util.Locale.ROOT); + if (op == null || !COMPARE_OPS.contains(op)) { + issues.add(subject + " requires `op`: one of ge, gt, le, lt, eq, ne (got [" + check.getOp() + "])"); + } + if (field.equalsIgnoreCase(than)) { + issues.add(subject + " compares [" + field + "] with itself - the outcome cannot depend on the record"); + } + FieldIntent left = fieldByName(entity, field); + FieldIntent right = fieldByName(entity, than); + if (left == null) { + issues.add(subject + " field [" + field + "] is not a field of [" + entity.getName() + "]"); + } + if (right == null) { + issues.add(subject + " than [" + than + "] is not a field of [" + entity.getName() + "]"); + } + if (left == null || right == null) { + return; + } + String leftFamily = compareFamily(left); + String rightFamily = compareFamily(right); + if (leftFamily == null) { + issues.add(subject + " field [" + field + "] is a [" + left.getType() + "] - only dates, timestamps and numbers compare"); + } + if (rightFamily == null) { + issues.add(subject + " than [" + than + "] is a [" + right.getType() + "] - only dates, timestamps and numbers compare"); + } + if (leftFamily != null && rightFamily != null && !leftFamily.equals(rightFamily)) { + issues.add(subject + " compares a [" + left.getType() + "] with a [" + right.getType() + + "] - both fields must be dates, both timestamps or both numbers"); + } + } + + /** The comparison family of a field, or null when its type does not compare. */ + private static String compareFamily(FieldIntent field) { + return field.getType() == null ? null + : COMPARE_FAMILIES.get(field.getType() + .trim() + .toLowerCase(java.util.Locale.ROOT)); } /** Whether the name matches (case-insensitively) a field or to-one relation of the entity. */ diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index f3e019dbfcf..dec3ad1fc7c 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -512,6 +512,13 @@ field may declare: - `checks:` (entity-level) - **declarative cross-field / cross-line validations**: - `{ kind: exactlyOne, fields: [debit, credit], message: "..." }` (row-level): exactly one of the listed own fields is non-null - enforced on every user write (400). + - `{ kind: compare, field: due, op: ge, than: date, message: "..." }` (row-level): two values of + the SAME record must stand in a relation to each other - a due date not before the document + date, a validity `to` not before its `from`, a delivery date not before the order date. + `op:` is one of `ge`, `gt`, `le`, `lt`, `eq`, `ne`; both operands are the entity's own fields + (never relations) and must be both dates, both timestamps or both numbers. Enforced on every + user write (400 with the authored message); an absent operand is not a violation - a comparison + is about two values that exist, and requiredness is its own declaration. - `{ kind: itemsSumEqual, over: [debit, credit], status: 2, message: "..." }` (document-level): the sums of the two item fields must be equal - the double-entry invariant. Enforced in the repository whenever the document is persisted CARRYING the `status` gate seed id, i.e. at the diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java index b2c526411ba..637af07cb79 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java @@ -51,6 +51,8 @@ class AppTestIntentGeneratorTest { fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: name, type: string, required: true, length: 200 } + - { name: founded, type: date } + - { name: renamed, type: date } - { name: uuid, type: uuid } - { name: slug, type: string, calculatedOnCreate: "1" } - { name: total, type: decimal, aggregate: true } @@ -60,6 +62,7 @@ class AppTestIntentGeneratorTest { - { name: Twin, kind: manyToOne, to: City, dependsOn: { relation: Country, filterBy: Country }, where: { name: Plovdiv } } checks: - { kind: exactlyOne, fields: [uuid, slug], message: "one of uuid/slug" } + - { kind: compare, field: renamed, op: gt, than: founded, message: "renamed after founded" } - name: Account group: master-data hierarchy: Parent @@ -194,6 +197,10 @@ void emitsToOneRelationsAsDropdowns() { Map cityEntity = entity(AppTestIntentGenerator.buildManifest("countries", "countries", model, edm()), "City"); assertEquals(List.of(List.of("Uuid", "Slug")), cityEntity.get("exactlyOne")); + // A compare check rides into the manifest too: the sample values are per-type constants, so + // two dates come out equal and a strict comparison would have the sample record refused with + // 400 - the runner derives the left operand from the right by the operator's own step. + assertEquals(List.of(Map.of("field", "Renamed", "op", "gt", "than", "Founded")), cityEntity.get("compare")); // the cross-model relation resolves an absolute controller URL in the OWNER module (naming // convention here - no generation context; the real pass resolves against the owner's .model) 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 96b792a72b7..3fbe20ebcb8 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 @@ -1251,6 +1251,43 @@ void conditionallyRequiredValuesEmitTheirConditionAndTheHopsTheirValueIsReadThro assertNull(ownField.get("status")); } + /** + * A {@code compare} check reaches the REST templates as the two PascalCased properties plus the + * Java comparison operator and the family flag - the template must not re-derive either, and the + * flag is what decides between {@code compareTo} (temporals) and a {@code BigDecimal} comparison + * (numbers of any width), dirigible #7095. + */ + @Test + @SuppressWarnings("unchecked") + void compareChecksEmitOperatorAndFamily() { + String yaml = """ + name: billing + entities: + - name: SalesInvoice + checks: + - { kind: compare, field: due, op: ge, than: date, message: "Due cannot be before the date" } + - { kind: compare, field: paid, op: le, than: total, message: "Paid cannot exceed the total" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - { name: due, type: date } + - { name: total, type: decimal } + - { name: paid, type: decimal } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "billing"); + List> checks = (List>) entityByName(entities(model), "SalesInvoice").get("checks"); + assertEquals(2, checks.size()); + Map dates = checks.get(0); + assertEquals("Due", dates.get("field")); + assertEquals("Date", dates.get("than")); + assertEquals(">=", dates.get("op")); + assertEquals("false", dates.get("numeric")); + assertEquals("Due cannot be before the date", dates.get("message")); + Map numbers = checks.get(1); + assertEquals("<=", numbers.get("op")); + assertEquals("true", numbers.get("numeric")); + } + /** * 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 d6f2a79489c..67d86509886 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 @@ -16,6 +16,7 @@ import java.util.List; +import org.eclipse.dirigible.components.intent.model.CheckIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.NumberIntent; import org.eclipse.dirigible.components.intent.model.PeriodIntent; @@ -852,6 +853,53 @@ void checksParseAndValidate() { "expected a gate issue, got: " + ex.getIssues()); } + /** + * A {@code compare} check relates two values of the SAME row - the rule that could not be declared + * at all, so a document was saved and issued with a due date behind its own date (dirigible #7095). + * Both operands must be own fields of ONE comparison family, the operator is explicit, and it is + * row-level, so a status gate is refused. + */ + @Test + void compareChecksParseAndValidate() { + String yaml = """ + name: billing + entities: + - name: SalesInvoice + checks: + - { kind: compare, field: due, op: ge, than: date, message: "Due cannot be before the date" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - { name: due, type: date } + - { name: note, type: string } + - { name: total, type: decimal } + """; + CheckIntent check = IntentParser.parse(yaml) + .getEntities() + .get(0) + .getChecks() + .get(0); + assertEquals("due", check.getField()); + assertEquals("ge", check.getOp()); + assertEquals("date", check.getThan()); + + assertCompareIssue(yaml.replace("op: ge", "op: after"), "requires `op`"); + assertCompareIssue(yaml.replace("than: date", "than: total"), "must be dates, both timestamps or both numbers"); + assertCompareIssue(yaml.replace("field: due", "field: note"), "only dates, timestamps and numbers compare"); + assertCompareIssue(yaml.replace("than: date", "than: issuedOn"), "is not a field of [SalesInvoice]"); + assertCompareIssue(yaml.replace("field: due", "field: date"), "compares [date] with itself"); + assertCompareIssue(yaml.replace("op: ge,", "op: ge, status: 2,"), "cannot carry a `status` gate"); + assertCompareIssue(yaml.replace("field: due, op: ge, than: date, ", ""), "requires `field` and `than`"); + } + + private static void assertCompareIssue(String yaml, String expected) { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains(expected)), + "expected an issue containing [" + expected + "], got: " + ex.getIssues()); + } + @Test void conditionallyRequiredValuesParseAndValidate() { 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 99420dbbf5e..054bf3b2d40 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 @@ -219,7 +219,7 @@ private static void splitChecks(Map entity, Map for (Map check : checks) { String kind = str(check, "kind"); resolveCheckPathLoads(check, parameters); - if ("exactlyOne".equals(kind)) { + if ("exactlyOne".equals(kind) || "compare".equals(kind)) { rowChecks.add(check); } else if ("guard".equals(kind)) { guardChecks.add(check); 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 db394d3908a..6f92913aea4 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 @@ -837,6 +837,19 @@ public class ${name}Controller { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); } } +#elseif($check.kind == "compare") +## Two values of the SAME row, compared (intent `checks: compare`). A comparison is about two values +## that exist, so an absent operand is not a violation - requiredness is its own declaration. Numbers +## compare by value through BigDecimal (a decimal against a long still compares exactly); temporals +## compare through their own compareTo, which is why the parser holds both fields to one family. + if (entity.${check.field} != null && entity.${check.than} != null +#if($check.numeric == "true") + && !(new java.math.BigDecimal(entity.${check.field}.toString()).compareTo(new java.math.BigDecimal(entity.${check.than}.toString())) ${check.op} 0)) { +#else + && !(entity.${check.field}.compareTo(entity.${check.than}) ${check.op} 0)) { +#end + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + } #else // Row-level check (intent `checks: exactlyOne`). { 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 9bfeb50dab9..f546f896659 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 @@ -559,6 +559,19 @@ public class ${name}MyController { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); } } +#elseif($check.kind == "compare") +## Two values of the SAME row, compared (intent `checks: compare`). A comparison is about two values +## that exist, so an absent operand is not a violation - requiredness is its own declaration. Numbers +## compare by value through BigDecimal (a decimal against a long still compares exactly); temporals +## compare through their own compareTo, which is why the parser holds both fields to one family. + if (entity.${check.field} != null && entity.${check.than} != null +#if($check.numeric == "true") + && !(new java.math.BigDecimal(entity.${check.field}.toString()).compareTo(new java.math.BigDecimal(entity.${check.than}.toString())) ${check.op} 0)) { +#else + && !(entity.${check.field}.compareTo(entity.${check.than}) ${check.op} 0)) { +#end + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + } #else // Row-level check (intent `checks: exactlyOne`). { 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 3906051e181..36234417985 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 @@ -541,6 +541,19 @@ public class ${name}PartnerController { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); } } +#elseif($check.kind == "compare") +## Two values of the SAME row, compared (intent `checks: compare`). A comparison is about two values +## that exist, so an absent operand is not a violation - requiredness is its own declaration. Numbers +## compare by value through BigDecimal (a decimal against a long still compares exactly); temporals +## compare through their own compareTo, which is why the parser holds both fields to one family. + if (entity.${check.field} != null && entity.${check.than} != null +#if($check.numeric == "true") + && !(new java.math.BigDecimal(entity.${check.field}.toString()).compareTo(new java.math.BigDecimal(entity.${check.than}.toString())) ${check.op} 0)) { +#else + && !(entity.${check.field}.compareTo(entity.${check.than}) ${check.op} 0)) { +#end + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + } #else // Row-level check (intent `checks: exactlyOne`). { diff --git a/npm/test/src/sample-values.js b/npm/test/src/sample-values.js index 96e0421b52f..6a062b16d02 100644 --- a/npm/test/src/sample-values.js +++ b/npm/test/src/sample-values.js @@ -51,9 +51,41 @@ export function sampleRecord(entity) { for (const set of entity.exactlyOne ?? []) { for (const name of set.slice(1)) delete record[name]; } + // a compare check relates two of the record's own fields, and the sample values above are + // per-type constants - so two dates come out EQUAL and a strict comparison (gt/lt/ne) would be + // rejected with 400. Derive the left operand from the right, by the smallest step that satisfies + // the declared operator (equality satisfies ge/le/eq). + for (const check of entity.compare ?? []) { + if (!(check.field in record) || record[check.than] == null) continue; + const type = (entity.fields ?? []).find((f) => f.name === check.field)?.type; + record[check.field] = shifted(record[check.than], type, STEPS[check.op] ?? 0); + } return record; } +// How far the left operand of a compare check has to move off the right one to satisfy it. +const STEPS = { ge: 0, le: 0, eq: 0, gt: 1, ne: 1, lt: -1 }; + +// One step of the value's own unit: a day for a date, an hour for a timestamp, one for a number. +function shifted(value, type, step) { + if (step === 0) return value; + switch (type) { + case 'date': { + const at = new Date(value + 'T00:00:00Z'); + at.setUTCDate(at.getUTCDate() + step); + return at.toISOString().slice(0, 10); + } + case 'timestamp': + case 'datetime': { + const at = new Date(value); + at.setUTCHours(at.getUTCHours() + step); + return at.toISOString().replace(/\.\d{3}Z$/, 'Z'); + } + default: + return value + step; + } +} + // The searchable "handle" field: the first long string field shown in the list. Its // value identifies the record in the table across the create/edit/delete flow. // Null when the entity has no such field (all-numeric/date entities) - flows degrade: 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 80810772657..b7e6f121bae 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 @@ -10,6 +10,7 @@ package org.eclipse.dirigible.integration.tests.api; import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.everyItem; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -223,9 +224,14 @@ class IntentEmissionCoverageIT extends IntegrationTest { # 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" } + # Two values of the SAME row, related (#7095) - one temporal pair and one numeric, + # the two comparison families the generated code emits differently. + - { kind: compare, field: due, op: ge, than: date, message: "Due cannot be before the entry date" } + - { kind: compare, field: paid, op: le, than: debit, message: "Paid cannot exceed the debit total" } fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: date, type: date, required: true } + - { name: due, type: date } - { name: debit, type: decimal, aggregate: true } - { name: credit, type: decimal, aggregate: true } - { name: paid, type: decimal } @@ -1753,6 +1759,20 @@ private void assertEmission() { String entryController = contentOf("gen/emission/api/entry/EntryController.java"); assertTrue(entryController.contains("requireMutable"), "immutableWhen must emit the requireMutable gate in the entity's REST controller"); + // checks: compare - a rule about two values of ONE row, which could not be declared at all + // before #7095, so a document was saved and issued with a due date behind its own date. The + // two families are emitted differently on purpose: temporals through their own compareTo (a + // LocalDate does not compare to an Instant, which is why the parser holds both fields to one + // family), numbers by value through BigDecimal so a decimal against a long stays exact. The + // null guard is part of the rule: a comparison is about two values that exist. + assertTrue( + entryController.contains("if (entity.Due != null && entity.Date != null") + && entryController.contains("!(entity.Due.compareTo(entity.Date) >= 0)") + && entryController.contains("Due cannot be before the entry date"), + "checks: compare over two dates must emit a compareTo comparison in the REST controller, got: " + entryController); + assertTrue(entryController.contains( + "!(new java.math.BigDecimal(entity.Paid.toString()).compareTo(new java.math.BigDecimal(entity.Debit.toString())) <= 0)"), + "checks: compare over two numbers must compare by value through BigDecimal, got: " + entryController); String snapshotController = contentOf("gen/emission/api/snapshot/SnapshotController.java"); assertTrue(snapshotController.contains("requireMutable") && snapshotController.contains("append-only"), "immutable: true must emit the unconditional append-only gate in the REST controller"); @@ -2793,6 +2813,12 @@ private void assertEmission() { assertTrue(testManifest.contains("\"layout\": \"document-chat\""), "a personal chat document must be flagged so the runner drives the composer round-trip"); assertTrue(testManifest.contains("\"route\": \"#/my/Leave\""), "the calendar root's personal block must carry its /my route"); + // ...and a compare check rides in too: the sample values are per-type constants, so two dates + // come out EQUAL and a strict comparison would have every generated app test refused with 400 + // by the very check the module just declared. The runner derives the left operand from the + // right, which it can only do if the manifest says which fields and which operator (#7095). + assertTrue(testManifest.contains("\"field\": \"Due\"") && testManifest.contains("\"than\": \"Date\""), + "the manifest must carry the entity's compare checks so the sample record satisfies them"); // transitions: the server half is a controller that guards the source status + the when // guard (409) and flips ONLY the status column via the targeted updateProperty; the client @@ -3677,6 +3703,23 @@ private void assertRuntimeEnforcement() { .then() .statusCode(400)); + // checks: compare, at runtime - the whole point of the keyword. A due date behind the entry + // date is refused with the authored message (400), the same date is fine (`ge` is inclusive), + // and the valid create below carries no Due at all: an absent operand is not a violation. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Date\":\"2026-01-15\",\"Due\":\"2026-01-01\",\"Account\":2}") + .when() + .post(API + "/entry/EntryController") + .then() + .statusCode(400) + .body("message", containsString("Due cannot be before the entry date"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Date\":\"2026-01-15\",\"Due\":\"2026-01-15\",\"Account\":2}") + .when() + .post(API + "/entry/EntryController") + .then() + .statusCode(200)); + // A valid DRAFT entry on the leaf account. AtomicInteger created = new AtomicInteger(); restAssuredExecutor.execute(() -> created.set(given().contentType("application/json")