diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index d9e7a3deefc..b154f80642f 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -48,7 +48,9 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **The reject-twin, and it reads the PARENT (`checks: forbidWhen`, [#7275](https://github.com/eclipse-dirigible/dirigible/issues/7275)):** `checks:` could *require*, *compare* and *count* but not "refuse this write while `` holds", and no check condition read a value one hop away - so the common rule was inexpressible: *forbid adding a child while its parent is in a given status* (a payment allocation cannot be added to an already PAID invoice). `immutableWhen` blocks EDITING and reads the record's OWN status (an allocation has none), `locksWithMaster` is all-or-nothing (the allocation stays addable while the invoice is ISSUED/SENT), and the over-allocation rollup guard is a money-safety side effect, not a domain-messaged "the invoice is paid" rule. `- { kind: forbidWhen, when: "SalesInvoice.Status == PAID", message: ... }` - the condition alone, no `field`/value. **Its one reach beyond `requiredWhen`** is that a `when` term may name a one-hop `Relation.field`, so a child tests its parent: walked by the same resolver, the hops loaded by FK first, and the status literal there resolving against the RELATION TARGET's nomenclature (a cross-model status name refused for its numeric id, as everywhere). The `status:` gate routes enforcement exactly as `requiredWhen`'s does - no gate = every user write (a 400 in all three controllers), a gate = the repository's `ValidationException` on the #7014/#7063 synchronous path. **The UI half hides the affordance, not only rejects it (like `fromStatus:` #7068):** when every term reads the composition master the master-detail panel already holds, a descriptor reaches the child's detail registration and the shared `detailPanel` hides Add / row edit / delete while the condition holds against the master record it was handed - no extra fetch, the server refusal still holds on every path. Refused at parse, each because it would otherwise be silent: a `field` on a forbidWhen, a missing `message`, a `when` term that does not resolve, a cross-model status by name, and a gate with no `function: EntityStatus` relation. Details in the engine-intent guide's forbidWhen 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. +**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 by default, like `exactlyOne`: enforced in every generated controller's `validate()` (the entity, personal and partner surfaces) as a 400 carrying the authored message - a rule about two values of one row holds from the first save, not from a transition (the optional gate that routes it to the transition instead arrived with [#7338](https://github.com/eclipse-dirigible/dirigible/issues/7338), below). `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. + +**...and a field against a LITERAL (`checks: compare` with `value:`, [#7338](https://github.com/eclipse-dirigible/dirigible/issues/7338)):** all five check kinds related two things the model already NAMED - two fields of a row, two item sums, an item count - so the commonest validation in a business model had no declaration at all: `VacationDay.Days > 0` (a negative row silently inflates the parent entitlement, because the roll-up sums the column verbatim), a quantity `>= 0`, a percentage `<= 100`. The three workarounds in the fleet were each worse than the gap: a hand-edit of the generated controller's `validate()` (dropped by the next regeneration, silently), a `calculatedActionOnCreate` that throws (a calculation, not a refusal, firing only on the field that declares it and reaching the caller as whatever the action's exception carries), or not enforcing it at all. `- { kind: compare, field: days, op: gt, value: 0, message: ... }` reuses `compare` and its whole implementation: `value:` and `than:` are mutually exclusive and exactly one is required, since a comparison has one right-hand side. The literal is TYPED by the field it is compared with, by `CheckSupport.compareLiteral` - the one rule the parser refuses on and the generator renders with, so nothing is refused that would have generated and nothing generates that was not refused. A numeric field takes a number (compared by value through `BigDecimal`, exact across the widths); a temporal one takes a moment (`CURRENT_DATE` / `CURRENT_TIMESTAMP` / `NOW` with at most one signed ISO-8601 offset - the vocabulary `items: where:` already carries, resolved against the clock of the WRITE) or a quoted ISO-8601 date/instant, rendered in the shape the generated column actually carries (`LocalDate` for a `date`, `Instant` for a `timestamp`; a comparison across the two does not compile). An absent operand is not a violation, exactly as with `than:`. **The second half is the gate.** `compare` used to refuse a `status:`; it now takes the optional one `requiredWhen` has, and that is the routing: without a gate the rule holds on every user write (the three generated controllers, 400 with the authored message), with one it is the repository's and holds when the record is persisted CARRYING that status. "days > 0 before SUBMITTED" is the rule base-vacations actually needed and mis-authored as an `itemsMin`, which counted a child the approval delegate had not created yet and refused every submission in the field for three weeks. **A form may show a field of its COUNTERPARTY ([#7093](https://github.com/eclipse-dirigible/dirigible/issues/7093)):** a task form's `fields` take one-hop `relation.field` paths, but the validator resolved the hop against LOCAL entities only - so the one relation a billing document's form most needs to read a field of, its counterparty, was the one it refused (`form [SendSalesInvoice] field [Customer.email] references unknown field [email] on [Customer]`), while the same path already resolved cross-model as a `notify` recipient and a `languageFrom`. A cross-model to-one is now resolved where every other cross-model reference is: at GENERATION, against the owner model's `.model`, which supplies the perspective the generated resolver's imports name and the key type behind its `Number` accessor - the delegate loads the OWNER's `gen..data.` Entity/Repository, the registry-wide-compile mechanism a notify relation load already uses, and the control renders read-only like a local hop. A field the owner model does not declare is a **422** rather than a skipped resolver: skipping it would leave the BPMN with a service task pointing at a handler nothing generated and the control bound to a variable nothing ever sets. The same path in a `decision` condition comes with it, being one resolver. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 0c7579f6472..c3567540aca 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -424,7 +424,7 @@ 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).** 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:` 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:` or `value:` — a value of the row related by an operator to a second one: another of its own fields (a due date not before the document date, a validity `to` not before its `from`, #7095) or a LITERAL (a quantity greater than zero, a percentage at most 100, #7338)) 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 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). **The right-hand side may be a LITERAL instead of a second field (#7338)**, `value:` and `than:` mutually exclusive and exactly one required — which is what makes "a quantity is positive", "a percentage is at most 100" and "a date is not in the past" declarations instead of a hand-edit of the generated `validate()` (dropped, silently, by the next regeneration) or a `calculatedActionOnCreate` that throws (a calculation, not a refusal, and only on the field that declares it). The literal is TYPED by the field it is compared with — a number for a numeric field; for a temporal one a moment (`CURRENT_DATE` / `CURRENT_TIMESTAMP` / `NOW` with at most one signed ISO-8601 offset, the vocabulary a schedule's `where:` already carries, resolved against the clock of the WRITE) or a quoted ISO-8601 date/instant — and `CheckSupport.compareLiteral` is the ONE rule the parser refuses on and the generator renders with, so nothing is refused that would have generated and nothing generates that was not refused. It renders in the shape the generated column actually carries (`LocalDate` for a `date`, `Instant` for a `timestamp`), because a comparison across those two does not compile. Unlike `exactlyOne`, a `compare` takes the OPTIONAL `status:` gate, the same routing `requiredWhen` has: without one it holds on every user write (the three controllers), with one it is the repository's and holds when the record is persisted carrying that status — "days > 0 before SUBMITTED", the rule base-vacations mis-authored as `itemsMin`, which counted a child the approval delegate had not created yet and refused every submission in the field for three weeks. 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). diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index acdf78c4aeb..9ed8cd0d0bf 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -133,11 +133,31 @@ first declared. Flag the lines child explicitly on a document that owns several - 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. + - { kind: compare, field: discountPercent, op: le, value: 100, message: "A discount cannot exceed 100%" } +- name: VacationRequest + checks: + # ...and the same comparison gated: a zero-day draft is fine, submitting one is not + - { kind: compare, field: days, op: gt, value: 0, status: SUBMITTED, + message: "A request must cover at least one working day" } +``` + +`compare` relates a value of the row to a second one: `op:` is `ge` / `gt` / `le` / `lt` / `eq` / +`ne`, the left operand is the entity's own field, and the right one is either another of its own +fields (`than:`) or a literal (`value:`) - exactly one of the two, since a comparison has one +right-hand side. An absent operand is not a violation - requiredness is its own declaration. + +The two operands must compare: both dates, both timestamps or both numbers. A `value:` is typed the +same way by the field it is compared with - a number for a numeric field, and for a temporal one +either a **moment** (`CURRENT_DATE` / `CURRENT_TIMESTAMP` / `NOW`, with at most one signed ISO-8601 +offset - the same vocabulary a schedule's `where:` carries, resolved against the clock of the write) +or a quoted ISO-8601 date / instant. Quote a temporal literal: an unquoted `2026-01-01` is a date +object to the YAML loader long before the intent sees it. + +A `compare` is row-level by default - enforced on every user write, in all three generated surfaces' +controllers, as a 400 with the authored message. The optional `status:` gate is the routing, exactly +as on `requiredWhen`: with one, the comparison is enforced by the repository when the record is +persisted CARRYING that status, so the rule holds at the transition and the draft still being filled +in is not refused. A gated comparison needs the entity's `function: EntityStatus` relation. `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 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 index f00e157b690..6eccf8c7433 100644 --- 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 @@ -61,6 +61,10 @@ public final class CheckSupport { */ public static final Set NUMERIC_GUARD_TYPES = Set.of("integer", "int", "long"); + /** The field types a {@code compare} check orders, by the family they compare inside. */ + private static final Map COMPARE_FAMILIES = Map.of("date", "date", "timestamp", "timestamp", "integer", "number", "int", + "number", "long", "number", "decimal", "number", "double", "number"); + private CheckSupport() {} /** @@ -297,6 +301,178 @@ public static String relationKeyType(RelationIntent relation, Map + * A {@code compare} check's two operands must sit in ONE family, which is what the generated code + * needs: two temporals compare through their own {@code compareTo} (a {@code LocalDate} does not + * compare to an {@code Instant}), two numbers by value through {@code BigDecimal} so a + * {@code decimal} against a {@code long} stays exact. + * + * @param type the authored field type, may be {@code null} + * @return the family, or {@code null} + */ + public static String compareFamily(String type) { + return type == null ? null + : COMPARE_FAMILIES.get(type.trim() + .toLowerCase(Locale.ROOT)); + } + + /** + * Reads a {@code checks: compare} right-hand LITERAL (issue #7338) against the type of the field it + * is compared with - the one place the rule lives, so the parser refuses exactly what the generator + * cannot render. + * + *

+ * The literal is typed by that field: a numeric field takes a number and compares by VALUE through + * {@code BigDecimal} (the same exactness the two-field form has); a temporal field takes either a + * MOMENT - {@code CURRENT_DATE} / {@code CURRENT_TIMESTAMP} / {@code NOW} with at most one signed + * ISO-8601 offset, the vocabulary a schedule's {@code where:} already carries - or an ISO-8601 + * instant/date literal. The moment is resolved against the clock of the write, not of the + * generation, and it is rendered in the SHAPE the generated entity column actually carries + * ({@code LocalDate} for a {@code date}, {@code Instant} for a {@code timestamp}), because a + * comparison across those two shapes does not compile. + * + *

+ * A temporal literal must be QUOTED in the YAML: an unquoted {@code 2026-01-01} is resolved by the + * YAML loader into a date object long before this sees it, and would arrive here as a locale-shaped + * string nobody authored. + * + * @param fieldType the declared type of the field on the left of the comparison + * @param value the authored literal + * @return the reading - either a Java expression or the reason it is refused, never both + */ + public static CompareLiteral compareLiteral(String fieldType, Object value) { + String family = compareFamily(fieldType); + if (family == null) { + return CompareLiteral.refused("field is a [" + fieldType + "] - only dates, timestamps and numbers compare"); + } + if (value == null) { + return CompareLiteral.refused("requires a `value`"); + } + if ("number".equals(family)) { + java.math.BigDecimal number = decimal(value); + return number == null + ? CompareLiteral.refused("value [" + value + "] is not a number, and a [" + fieldType + "] compares" + " with numbers") + : CompareLiteral.of("new java.math.BigDecimal(\"" + number.toPlainString() + "\")"); + } + boolean date = "date".equals(family); + String now = date ? "java.time.LocalDate.now()" : "java.time.Instant.now()"; + ScheduleSupport.Moment moment = ScheduleSupport.moment(value); + if (moment != null) { + boolean momentIsDate = moment.shape() == ScheduleSupport.Moment.Shape.DATE; + if (momentIsDate != date) { + return CompareLiteral.refused( + "value [" + value + "] names a " + (momentIsDate ? "date" : "timestamp") + " moment, and" + " a [" + fieldType + + "] compares with " + (date ? "dates - use CURRENT_DATE" : "timestamps - use CURRENT_TIMESTAMP")); + } + String offset = moment.duration(); + if (offset == null) { + return CompareLiteral.of(now); + } + String amount = date ? period(offset) : duration(offset); + if (amount == null) { + return CompareLiteral.refused("value [" + value + "] carries an offset a [" + fieldType + "] cannot" + + (date ? " - a date has no time component" : "") + ": [" + offset + "]"); + } + return CompareLiteral.of(now + (moment.forward() ? ".plus(" : ".minus(") + amount + ")"); + } + String text = String.valueOf(value) + .trim(); + try { + if (date) { + java.time.LocalDate.parse(text); + return CompareLiteral.of("java.time.LocalDate.parse(\"" + text + "\")"); + } + java.time.Instant.parse(text); + return CompareLiteral.of("java.time.Instant.parse(\"" + text + "\")"); + } catch (java.time.format.DateTimeParseException ex) { + return CompareLiteral.refused("value [" + value + "] is neither a moment (CURRENT_DATE / CURRENT_TIMESTAMP / NOW, with at" + + " most one signed ISO-8601 offset) nor a quoted ISO-8601 " + + (date ? "date (\"2026-01-01\")" : "instant" + " (\"2026-01-01T00:00:00Z\")") + ", and a [" + fieldType + + "] compares with those"); + } + } + + /** The authored value as an exact decimal, or {@code null} when it does not read as a number. */ + private static java.math.BigDecimal decimal(Object value) { + try { + return new java.math.BigDecimal(String.valueOf(value) + .trim()); + } catch (NumberFormatException ex) { + return null; + } + } + + /** The date-only offset as the Java amount expression, or {@code null} when it is not one. */ + private static String period(String offset) { + try { + java.time.Period.parse(offset); + return "java.time.Period.parse(\"" + offset + "\")"; + } catch (java.time.format.DateTimeParseException ex) { + return null; + } + } + + /** The instant offset as the Java amount expression, or {@code null} when it is not one. */ + private static String duration(String offset) { + try { + java.time.Duration.parse(offset); + return "java.time.Duration.parse(\"" + offset + "\")"; + } catch (java.time.format.DateTimeParseException ex) { + return null; + } + } + + /** + * The reading of a {@code compare} literal: the Java expression the generated comparison evaluates, + * or the reason the literal is refused. Exactly one of the two is present - a refused literal has + * no rendering, and a rendered one has nothing to report. + */ + public static final class CompareLiteral { + + private final String javaExpression; + private final String problem; + + private CompareLiteral(String javaExpression, String problem) { + this.javaExpression = javaExpression; + this.problem = problem; + } + + private static CompareLiteral of(String javaExpression) { + return new CompareLiteral(javaExpression, null); + } + + private static CompareLiteral refused(String problem) { + return new CompareLiteral(null, problem); + } + + /** + * @return whether the literal reads + */ + public boolean valid() { + return problem == null; + } + + /** + * @return the Java expression the comparison's right-hand side renders as, or {@code null} + */ + public String javaExpression() { + return javaExpression; + } + + /** + * @return why the literal is refused, as the tail of an author-facing issue, or {@code null} + */ + public String problem() { + return problem; + } + } + /** * The authored literal without its quotes. * diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupport.java index 85e7ab36ac4..46436083ba7 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupport.java @@ -90,6 +90,14 @@ public String duration() { return duration; } + /** + * @return whether the offset moves FORWARD from the token ({@code CURRENT_DATE+P7D}), as opposed to + * back from it + */ + public boolean forward() { + return forward; + } + /** * @return whether the offset is an ISO-8601 amount this shape can carry (always {@code true} for a * bare token) 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 d3bfcf5ccf8..f3da356429c 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,9 +236,11 @@ 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. + // compare checks: the record's own field must stand in a relation to a second value - another + // of its fields, or a literal (#7338). 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 - + // and a sample quantity of 1 fails `gt 10` just as surely. The runner derives the left operand + // from whichever right-hand side the check names. List> compare = new ArrayList<>(); for (CheckIntent check : entity.getChecks() == null ? List.of() : entity.getChecks()) { if ("exactlyOne".equals(check.getKind()) && check.getFields() != null && !check.getFields() @@ -248,13 +250,18 @@ 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())); + if (check.getThan() != null) { + entry.put("than", IntentNaming.pascalCase(check.getThan())); + } else { + entry.put("value", check.getValue()); + } compare.add(entry); } } 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 0bf39a12000..8919c550ebd 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 @@ -2226,19 +2226,43 @@ private static List> buildChecks(EntityIntent entity, List *

  • {@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 compare}: {@link #field} compared with {@link #op} either to {@link #than} - another + * value of the SAME row that it must stand in a relation to (a due date not before the document + * date, a validity end not before its start) - or to a {@link #value} LITERAL (a quantity greater + * than zero, a percentage at most 100, a date not in the past). Row-level by default, so it is + * enforced on every user write; with a {@link #status} gate it is the repository's, and holds when + * the record is persisted carrying that status - "days > 0 before SUBMITTED" rather than on the + * first draft;
  • *
  • {@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 @@ -58,6 +62,17 @@ public class CheckIntent { private String op; /** {@code compare}: the record's own field on the right of the comparison. */ private String than; + /** + * {@code compare}: a LITERAL on the right of the comparison, the alternative to {@link #than} + * (issue #7338) - exactly one of the two, since a comparison has one right-hand side. Typed by the + * field it is compared with: a number for a numeric field, and for a temporal one either a moment + * ({@code CURRENT_DATE}, {@code CURRENT_TIMESTAMP}, {@code NOW}, with at most one signed ISO-8601 + * offset - the vocabulary a schedule's {@code where:} already carries, resolved against the clock + * of the write) or a quoted ISO-8601 date/instant. This is what makes "a quantity is positive", "a + * percentage is at most 100" and "a date is not in the past" declarations rather than a hand-edited + * {@code validate()} or a calculation that throws. + */ + private Object value; /** {@code itemsSumEqual}: the two numeric item fields whose sums must be equal. */ private List over; /** {@code itemsMin}: the minimum number of items. */ @@ -203,6 +218,14 @@ public String getThan() { return than; } + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } + public void setThan(String than) { this.than = than; } 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 f3d8978db87..b80c34fda30 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 @@ -130,16 +130,6 @@ public final class IntentParser { /** 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"); @@ -5037,23 +5027,39 @@ private static void validateCheck(EntityIntent entity, CheckIntent check, java.u } /** - * A {@code compare} check relates two values of the SAME row - the shape a plain + * A {@code compare} check relates a value of the row to a second value - 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. + * due date behind its own date (dirigible #7095). The right-hand side is either another of the + * entity's own fields ({@code than}: never a relation - a comparison of two foreign keys means + * nothing) or a LITERAL ({@code value}, issue #7338 - the commonest business validation of all: "a + * quantity is positive", "a percentage is at most 100"), never both and never neither, since a + * comparison has exactly one right-hand side. The operator is explicit, and the right-hand side + * must land in the left field's own comparison family so the generated comparison compiles and + * means what it says. + * + *

    + * Row-level by default, like {@code exactlyOne}: a rule about the values of one row holds from the + * first save. The optional {@code status} gate is the routing, as on {@code requiredWhen} - with + * one, the rule holds when the record is persisted carrying that status (the transition), so "days + * > 0 before SUBMITTED" is declarable without forbidding the draft that is still being filled + * in. */ 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"); + boolean hasThan = than != null && !than.isBlank(); + boolean hasValue = check.getValue() != null; + if (field == null || field.isBlank() || hasThan == hasValue) { + issues.add(subject + " requires `field` and exactly one right-hand side: `than` (another own field of [" + entity.getName() + + "]) or `value` (a literal)"); return; } if (check.getStatus() != null) { - issues.add(subject + " is row-level and cannot carry a `status` gate - it must hold on every write"); + if (check.getStatus() <= 0) { + issues.add(subject + " status gate [" + check.getStatus() + "] is not an EntityStatus seed id"); + } else if (!hasEntityStatusRelation(entity)) { + issues.add(subject + " requires the entity to declare a `function: EntityStatus` relation for the gate"); + } } String op = check.getOp() == null ? null : check.getOp() @@ -5062,18 +5068,27 @@ private static void validateCompareCheck(EntityIntent entity, CheckIntent check, 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() + "]"); + return; + } + if (hasValue) { + // The literal is typed by the field it is compared with, by the one rule the generator + // renders with - so nothing is refused here that would have generated, and nothing generates + // that was not refused here. + CheckSupport.CompareLiteral literal = CheckSupport.compareLiteral(left.getType(), check.getValue()); + if (!literal.valid()) { + issues.add(subject + " " + literal.problem()); + } + return; + } + if (field.equalsIgnoreCase(than)) { + issues.add(subject + " compares [" + field + "] with itself - the outcome cannot depend on the record"); } + FieldIntent right = fieldByName(entity, than); 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); @@ -5092,10 +5107,7 @@ private static void validateCompareCheck(EntityIntent entity, CheckIntent check, /** 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)); + return CheckSupport.compareFamily(field.getType()); } /** 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 6ca8b722174..fabaa9fcca0 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 @@ -522,13 +522,29 @@ 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: compare, field: due, op: ge, than: date, message: "..." }`: a value of the record must + stand in a relation to a second one - 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`; the left operand is the entity's own field (never a relation) and the + right one is either another of its own fields (`than:`) or a LITERAL (`value:`) - exactly one of + the two. Enforced on every user write (400 with the authored message); an absent operand is not + a violation - a comparison is about values that exist, and requiredness is its own declaration. + - `{ kind: compare, field: days, op: gt, value: 0, message: "..." }`: the same check against a + constant - **this is how "a quantity is positive", "a percentage is at most 100" and "a date is + not in the past" are declared.** Do not hand-edit the generated controller's `validate()` for + them (the next regeneration drops it, silently) and do not smuggle them into a + `calculatedActionOnCreate` that throws (that is a calculation, not a refusal, and it only fires + on the field that declares it). The literal is typed by the field it is compared with: a number + for a numeric field; for a `date`/`timestamp` either a moment (`CURRENT_DATE`, + `CURRENT_TIMESTAMP`, `NOW`, with at most one signed ISO-8601 offset such as `CURRENT_DATE+P7D`, + resolved against the clock of the write) or a QUOTED ISO-8601 date / instant - an unquoted + `2026-01-01` is read by the YAML loader as a date object and refused here. + - A `compare` takes an OPTIONAL `status:` gate, the same routing `requiredWhen` has: without one + it holds on every user write, with one the repository enforces it when the record is persisted + carrying that status. `{ kind: compare, field: days, op: gt, value: 0, status: SUBMITTED }` is + "a submitted request covers at least one day" without forbidding the draft still being filled + in - the rule to reach for instead of mis-authoring it as an `itemsMin` over a child the + approval step has not created yet. A gated compare needs the `function: EntityStatus` relation. - `{ 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/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index 8e92990c41d..5275a0e93f0 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 @@ -1395,6 +1395,65 @@ void compareChecksEmitOperatorAndFamily() { assertEquals("true", numbers.get("numeric")); } + /** + * A {@code compare} check against a LITERAL (dirigible #7338) reaches the templates as a Java + * EXPRESSION for the right-hand side, rendered in the shape the generated column carries - a + * {@code BigDecimal} for a number, a {@code LocalDate} for a date, an {@code Instant} for a + * timestamp - so the comparison compiles and is exact. The optional status gate reaches them as the + * gate status and the property that carries it, which is what routes the check to the repository + * instead of the controllers. + */ + @Test + @SuppressWarnings("unchecked") + void compareChecksAgainstLiteralsEmitJavaExpressions() { + String yaml = """ + name: leave + seeds: + - name: request-statuses + entity: RequestStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: SUBMITTED } + entities: + - name: RequestStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: VacationRequest + checks: + - { kind: compare, field: days, op: gt, value: 0, status: SUBMITTED, + message: "A request must cover at least one working day" } + - { kind: compare, field: from, op: ge, value: "CURRENT_DATE", message: "Leave cannot start in the past" } + - { kind: compare, field: filedAt, op: le, value: "CURRENT_TIMESTAMP+PT1H", message: "Not in the future" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: days, type: decimal } + - { name: from, type: date } + - { name: filedAt, type: timestamp } + relations: + - { name: Status, kind: manyToOne, to: RequestStatus, function: EntityStatus } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "leave"); + List> checks = (List>) entityByName(entities(model), "VacationRequest").get("checks"); + assertEquals(3, checks.size()); + Map positive = checks.get(0); + assertEquals("Days", positive.get("field")); + assertEquals(">", positive.get("op")); + assertEquals("true", positive.get("numeric")); + assertEquals("new java.math.BigDecimal(\"0\")", positive.get("literal")); + assertNull(positive.get("than"), "a literal comparison has no second property"); + assertEquals("2", positive.get("status"), "the gate routes the check to the repository"); + assertEquals("Status", positive.get("statusProperty")); + Map notPast = checks.get(1); + assertEquals("false", notPast.get("numeric")); + assertEquals("java.time.LocalDate.now()", notPast.get("literal")); + assertNull(notPast.get("status"), "an ungated comparison stays the controllers' - every user write"); + assertEquals("java.time.Instant.now().plus(java.time.Duration.parse(\"PT1H\"))", checks.get(2) + .get("literal"), + "a timestamp column binds java.time.Instant, so the moment renders in THAT shape"); + } + /** * 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 0e64826e019..b6f497c6b8e 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 @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -856,8 +857,7 @@ void checksParseAndValidate() { /** * 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. + * Both operands must be own fields of ONE comparison family and the operator is explicit. */ @Test void compareChecksParseAndValidate() { @@ -888,8 +888,75 @@ void compareChecksParseAndValidate() { 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`"); + assertCompareIssue(yaml.replace("field: due, op: ge, than: date, ", ""), "exactly one right-hand side"); + // ...and a right-hand side is exactly ONE thing: neither both operands nor none of them. + assertCompareIssue(yaml.replace("than: date,", "than: date, value: \"CURRENT_DATE\","), "exactly one right-hand side"); + } + + /** + * A {@code compare} check against a LITERAL (dirigible #7338) - the commonest business validation + * of all ("a quantity is positive", "a percentage is at most 100"), which had no declaration at all + * and was hand-edited into the generated {@code validate()} or smuggled into a calculation that + * throws. The literal is typed by the field it is compared with, and the optional status gate is + * the routing: with one, the rule holds at the transition rather than on the first draft. + */ + @Test + void compareChecksAgainstLiteralsParseAndValidate() { + String yaml = """ + name: leave + seeds: + - name: request-statuses + entity: RequestStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: SUBMITTED } + entities: + - name: RequestStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: VacationRequest + checks: + - { kind: compare, field: days, op: gt, value: 0, status: SUBMITTED, + message: "A request must cover at least one working day" } + - { kind: compare, field: share, op: le, value: 100, message: "A share cannot exceed 100%" } + - { kind: compare, field: from, op: ge, value: "CURRENT_DATE", message: "Leave cannot start in the past" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: days, type: decimal } + - { name: share, type: integer } + - { name: from, type: date } + - { name: note, type: string } + relations: + - { name: Status, kind: manyToOne, to: RequestStatus, function: EntityStatus } + """; + List checks = IntentParser.parse(yaml) + .getEntities() + .get(1) + .getChecks(); + assertEquals("days", checks.get(0) + .getField()); + assertEquals(0L, checks.get(0) + .getValue()); + assertEquals(2, checks.get(0) + .getStatus(), + "the gate resolves the status NAME to its seed id, as every other gate does"); + assertNull(checks.get(1) + .getStatus(), + "a gate is optional - without one the comparison holds on every user write"); + assertEquals("CURRENT_DATE", checks.get(2) + .getValue()); + + assertCompareIssue(yaml.replace("value: 100", "value: \"most\""), "is not a number"); + assertCompareIssue(yaml.replace("field: share", "field: note"), "only dates, timestamps and numbers compare"); + assertCompareIssue(yaml.replace("value: \"CURRENT_DATE\"", "value: \"CURRENT_TIMESTAMP\""), + "compares with dates - use CURRENT_DATE"); + assertCompareIssue(yaml.replace("value: \"CURRENT_DATE\"", "value: \"CURRENT_DATE-PT30M\""), "a date has no time component"); + assertCompareIssue(yaml.replace("value: \"CURRENT_DATE\"", "value: \"next monday\""), "nor a quoted ISO-8601 date"); + assertCompareIssue(yaml.replace("status: SUBMITTED", "status: 2") + .replace("function: EntityStatus", "function: Label"), + "requires the entity to declare a `function: EntityStatus` relation"); } private static void assertCompareIssue(String yaml, String expected) { 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 b60fe2e09e0..f322cd73e77 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 @@ -222,8 +222,13 @@ private static void splitChecks(Map entity, Map String kind = str(check, "kind"); resolveMessageLiteral(check); resolveCheckPathLoads(check, parameters); - if ("exactlyOne".equals(kind) || "compare".equals(kind)) { + if ("exactlyOne".equals(kind)) { rowChecks.add(check); + } else if ("compare".equals(kind)) { + // A comparison is row-level unless it names the status it is enforced at - the same + // routing requiredWhen has: without a gate it holds on every user write, with one it is + // the repository's, so "days > 0 before SUBMITTED" does not forbid the draft (#7338). + (str(check, "status") == null || str(check, "status").isEmpty() ? rowChecks : documentChecks).add(check); } else if ("guard".equals(kind)) { guardChecks.add(check); } else if ("requiredWhen".equals(kind) || "forbidWhen".equals(kind)) { 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 abcf9e12576..b0cdab02742 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 @@ -360,6 +360,26 @@ void splitsTheDeclarativeChecksByTheScopeThatEnforcesThem() { .size()); } + @Test + void aCompareSplitsByItsGateToo() { + Map ungated = new LinkedHashMap<>(); + ungated.put("kind", "compare"); + Map gated = new LinkedHashMap<>(); + gated.put("kind", "compare"); + gated.put("status", "2"); + Map entity = entity("VacationRequest", "Requests", property("Days", "DECIMAL")); + entity.put("checks", List.of(ungated, gated)); + + ModelParameterProcessor.process(model(entity), parameters()); + + // A comparison holds on every user write unless it names the status it is enforced at (#7338): + // "days > 0 before SUBMITTED" is the repository's, so the draft being filled in is not refused. + assertEquals(1, ModelValues.asList(entity.get("rowChecks")) + .size()); + assertEquals(1, ModelValues.asList(entity.get("documentChecks")) + .size()); + } + @Test void carriesEveryAuthoredMessageAsAnEscapedJavaLiteralToo() { Map row = new LinkedHashMap<>(); 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 37d833b8d5d..450cacdded7 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 @@ -1062,6 +1062,30 @@ public class ${name}Repository extends JavaRepository<${name}Entity> { if (${check.guard}) { throw new ValidationException("${check.message}"); } +#elseif($check.kind == "compare") + // A value of the row compared with a second one (intent `checks: compare`) - another field + // of the same row, or a literal (#7338) - gated on the status at which it must finally hold: + // "days > 0 before SUBMITTED" refuses the transition, not the draft still being filled in. + // An absent left operand is not a violation here either; requiredness is its own declaration. +#if($check.literal) + if (entity.${check.field} != null +#if($check.numeric == "true") + && !(new java.math.BigDecimal(entity.${check.field}.toString()).compareTo(${check.literal}) ${check.op} 0)) { +#else + && !(entity.${check.field}.compareTo(${check.literal}) ${check.op} 0)) { +#end + throw new ValidationException("${check.messageJavaLiteral}"); + } +#else + 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 ValidationException("${check.messageJavaLiteral}"); + } +#end #elseif($check.kind == "itemsSumEqual") java.math.BigDecimal sum${check.overA} = java.math.BigDecimal.ZERO; java.math.BigDecimal sum${check.overB} = java.math.BigDecimal.ZERO; 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 8da80174770..585a859c31a 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 @@ -902,10 +902,23 @@ public class ${name}Controller { } } #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. +## A value of the row compared with a second one (intent `checks: compare`) - another field of the +## SAME row, or a LITERAL (#7338: the positive quantity, the percentage at most 100, the date that is +## not in the past). A comparison is about values that EXIST, so an absent left 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 the two sides to one family - and why a moment literal is rendered +## in the column's own shape (LocalDate for a date, Instant for a timestamp). +#if($check.literal) + if (entity.${check.field} != null +#if($check.numeric == "true") + && !(new java.math.BigDecimal(entity.${check.field}.toString()).compareTo(${check.literal}) ${check.op} 0)) { +#else + && !(entity.${check.field}.compareTo(${check.literal}) ${check.op} 0)) { +#end + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); + } +#else 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)) { @@ -914,6 +927,7 @@ public class ${name}Controller { #end throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } +#end #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 912d78fe503..3ced86dcd77 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 @@ -643,10 +643,23 @@ public class ${name}MyController { } } #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. +## A value of the row compared with a second one (intent `checks: compare`) - another field of the +## SAME row, or a LITERAL (#7338: the positive quantity, the percentage at most 100, the date that is +## not in the past). A comparison is about values that EXIST, so an absent left 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 the two sides to one family - and why a moment literal is rendered +## in the column's own shape (LocalDate for a date, Instant for a timestamp). +#if($check.literal) + if (entity.${check.field} != null +#if($check.numeric == "true") + && !(new java.math.BigDecimal(entity.${check.field}.toString()).compareTo(${check.literal}) ${check.op} 0)) { +#else + && !(entity.${check.field}.compareTo(${check.literal}) ${check.op} 0)) { +#end + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); + } +#else 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)) { @@ -655,6 +668,7 @@ public class ${name}MyController { #end throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } +#end #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 1f52b6a7369..8ce2fb8a637 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 @@ -599,10 +599,23 @@ public class ${name}PartnerController { } } #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. +## A value of the row compared with a second one (intent `checks: compare`) - another field of the +## SAME row, or a LITERAL (#7338: the positive quantity, the percentage at most 100, the date that is +## not in the past). A comparison is about values that EXIST, so an absent left 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 the two sides to one family - and why a moment literal is rendered +## in the column's own shape (LocalDate for a date, Instant for a timestamp). +#if($check.literal) + if (entity.${check.field} != null +#if($check.numeric == "true") + && !(new java.math.BigDecimal(entity.${check.field}.toString()).compareTo(${check.literal}) ${check.op} 0)) { +#else + && !(entity.${check.field}.compareTo(${check.literal}) ${check.op} 0)) { +#end + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); + } +#else 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)) { @@ -611,6 +624,7 @@ public class ${name}PartnerController { #end throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } +#end #else // Row-level check (intent `checks: exactlyOne`). { diff --git a/npm/test/src/sample-values.js b/npm/test/src/sample-values.js index 6a062b16d02..2f83cf47cb7 100644 --- a/npm/test/src/sample-values.js +++ b/npm/test/src/sample-values.js @@ -51,18 +51,39 @@ 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). + // a compare check relates the record's own field to a second value - another of its fields, or a + // literal - 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, just as a sample quantity of 7 fails a + // `le 5`. Derive the left operand from whichever right-hand side the check names, 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; + if (!(check.field in record)) continue; const type = (entity.fields ?? []).find((f) => f.name === check.field)?.type; - record[check.field] = shifted(record[check.than], type, STEPS[check.op] ?? 0); + const right = 'than' in check ? record[check.than] : literalValue(check.value, type); + if (right == null) continue; + record[check.field] = shifted(right, type, STEPS[check.op] ?? 0); } return record; } +// The right-hand side of a compare check declared as a literal. A moment (CURRENT_DATE / +// CURRENT_TIMESTAMP / NOW) resolves against the runner's own clock, in the field's shape; a moment +// carrying an offset is left alone - the sample record keeps its constant and the check is simply +// not steered, which is safe for the ge/le/eq that a stale sample still satisfies. +function literalValue(value, type) { + if (typeof value !== 'string') return value; + const now = new Date(); + switch (value.trim()) { + case 'CURRENT_DATE': + return type === 'date' ? now.toISOString().slice(0, 10) : now.toISOString().replace(/\.\d{3}Z$/, 'Z'); + case 'CURRENT_TIMESTAMP': + case 'NOW': + return now.toISOString().replace(/\.\d{3}Z$/, 'Z'); + default: + return /^(CURRENT_DATE|CURRENT_TIMESTAMP|NOW)[+-]/.test(value.trim()) ? null : value; + } +} + // 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 }; 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 39be1db0773..8b9be649aeb 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 @@ -231,6 +231,10 @@ class IntentEmissionCoverageIT extends IntegrationTest { # the two comparison families the generated code emits differently. - { kind: compare, field: due, op: ge, than: date, message: 'A "due" date is never before the entry date' } - { kind: compare, field: paid, op: le, than: debit, message: "Paid cannot exceed the debit total" } + # ...and the same comparison against a LITERAL (#7338) - the commonest validation of + # all, which had no declaration at all before and was hand-edited into the generated + # controller (where the next regeneration silently dropped it). + - { kind: compare, field: paid, op: ge, value: 0, message: "A paid amount cannot be negative" } fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: date, type: date, required: true } @@ -266,6 +270,11 @@ class IntentEmissionCoverageIT extends IntegrationTest { # 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" } + # compare against a literal, GATED (#7338): the amount must be positive by the time + # the document is posted - not while it is still a draft being filled in. The gate is + # the routing, exactly as on requiredWhen: with one, the rule is the repository's. + - { kind: compare, field: amount, op: gt, value: 0, status: POSTED, + message: "A posted document must carry a positive amount" } fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: date, type: date, required: true } @@ -1878,6 +1887,12 @@ private void assertEmission() { 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); + // ...and a comparison against a LITERAL (#7338) renders the right-hand side as a Java + // expression in the column's own shape - one operand to null-guard, not two. + assertTrue( + entryController.contains("if (entity.Paid != null\n") && entryController.contains( + "!(new java.math.BigDecimal(entity.Paid.toString()).compareTo(new java.math.BigDecimal(\"0\")) >= 0)"), + "checks: compare against a numeric literal 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"); @@ -1996,8 +2011,17 @@ private void assertEmission() { // actually fires is asserted over REST in assertRuntimeEnforcement. assertFalse(docController.contains("java.util.Objects.equals(entity.Status, 2)"), "a to-one guard must not be a boxed equality against an int literal, got: " + docController); - assertFalse(contentOf("gen/emission/data/doc/DocRepository.java").contains("A posted document must name its counterparty"), + String docGateRepository = contentOf("gen/emission/data/doc/DocRepository.java"); + assertFalse(docGateRepository.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"); + // ...while a GATED comparison against a literal (#7338) is the repository's, guarded on the + // status the document is being persisted with - so a draft may still carry nothing. + assertTrue( + docGateRepository.contains("if (entity.Status != null && entity.Status == 2)") + && docGateRepository.contains( + "!(new java.math.BigDecimal(entity.Amount.toString()).compareTo(new java.math.BigDecimal(\"0\")) > 0)") + && docGateRepository.contains("A posted document must carry a positive amount"), + "a gated checks: compare must be enforced by the repository at its gate status, got: " + docGateRepository); String entryRepository = contentOf("gen/emission/data/entry/EntryRepository.java"); assertTrue(entryRepository.contains("An entry needs at least one \\\"line\\\""), @@ -3113,6 +3137,10 @@ private void assertEmission() { // 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"); + // ...including the literal ones (#7338): a sample value that fails the declared comparison + // fails the generated app test just as surely, so the runner needs the literal to steer by. + assertTrue(testManifest.contains("\"field\": \"Paid\"") && testManifest.contains("\"value\": 0"), + "the manifest must carry a compare check's literal right-hand side too"); // 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 @@ -4141,6 +4169,23 @@ private void assertRuntimeEnforcement() { .then() .statusCode(200)); + // checks: compare against a LITERAL, at runtime (#7338) - a negative paid amount is refused + // with the authored message, zero passes (`ge` is inclusive), and the creates above carry no + // Paid at all: an absent operand is not a violation here either. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Date\":\"2026-01-15\",\"Paid\":-1,\"Account\":2}") + .when() + .post(API + "/entry/EntryController") + .then() + .statusCode(400) + .body("message", containsString("A paid amount cannot be negative"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Date\":\"2026-01-15\",\"Paid\":0,\"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") @@ -4643,6 +4688,35 @@ private void assertRuntimeEnforcement() { .then() .statusCode(200)); + // ...and the GATED comparison against a literal (#7338): a zero-amount document is a perfectly + // good DRAFT, and is refused only when the write carries the POSTED status - the gate is the + // whole point, and a check that fired on the draft would be the itemsMin mis-authoring that + // refused every submission in the field. + AtomicInteger gatedCompare = new AtomicInteger(); + restAssuredExecutor.execute(() -> gatedCompare.set(given().contentType("application/json") + .body("{\"Date\":\"2026-01-19\",\"Amount\":0,\"Party\":1}") + .when() + .post(API + "/doc/DocController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + gatedCompare.get() + + ",\"Date\":\"2026-01-19\",\"Amount\":0,\"Status\":2,\"Party\":1}") + .when() + .put(API + "/doc/DocController/" + gatedCompare.get()) + .then() + .statusCode(400) + .body("message", containsString("A posted document must carry a positive amount"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + gatedCompare.get() + + ",\"Date\":\"2026-01-19\",\"Amount\":25,\"Status\":2,\"Party\":1}") + .when() + .put(API + "/doc/DocController/" + gatedCompare.get()) + .then() + .statusCode(200)); + // postings: posting a Doc creates the balanced Entry (async handler - poll)... AtomicInteger doc = new AtomicInteger(); restAssuredExecutor.execute(() -> doc.set(given().contentType("application/json")