diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index 935b706e488..f5452c6a5f9 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -20,7 +20,7 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **Mailing a REPORT (`attach: { report, bind }`, [#6931](https://github.com/eclipse-dirigible/dirigible/issues/6931)):** the report-side sibling of `attach: print`. Where a dunning reminder carries the one invoice it is about, a **customer statement** carries a period of rows - a report, not a document - so a notify block may now name a declared report and bind its `parameters:` from the recipient row: `attach: { report: CustomerStatement, bind: { customer: name, fromDate: periodStart, toDate: periodEnd } }`. `bind:` values are a field or a one-hop `relation.field` of the record the message is about (the ROW inside a `forEach`), resolved through the same resolver a `{placeholder}` uses so the relation loads are shared locals. **Every parameter that declares an `initial` must be bound** - a parameter is bound on every call (#6911), so an unbound one rides its `initial`: one FIXED slice, identical for every recipient, which is the "whole ledger to one customer" failure mode (the mail goes out, the attachment IS a report, and nothing about it says whose). A parameter with no `initial` has a neutral any-value default (a date window bound, a `like` search), so omitting it means the whole range; a balance report's own `fromDate`/`toDate` are bindable and optional for the same reason. The render goes through a **`.print` template of the report's own** - seeded once at `doc/Templates//Print/en/standard.print` for reports something actually mails, and developer-owned afterwards like the document scaffold. It is built from the columns the `.report` pass just resolved, so every `{{alias}}` is a key the query really SELECTs, and the **bound parameters are its header**: a table of rows never states which slice it is. Since `attach` now takes a map as well as a scalar, `NotificationIntent.attach` is typed `Object` and `getAttach()` reports only the KIND (`print` / `recordPrint` / `report`). **The schedule that mails it may live in the model that owns the REPORT** ([#7030](https://github.com/eclipse-dirigible/dirigible/issues/7030)): a schedule's cross-model source ([#6532](https://github.com/eclipse-dirigible/dirigible/pull/6532)) was `generate`-only, which left the statement mail with no legal home in a split suite - the invoices module reaches `Customer` only through `uses:`, and the customers module can neither name that report nor `uses:` back without a module cycle. The owner's resolved facts are now projected into the `EntityIntent` shape the notify machinery already consumes, so the recipient, the `{placeholder}`s and the `bind:` sources are fields of the cross-model row, checked at generation against the owner's `.model`. Refused at parse, each because only the owner can supply it: a `relation.field` hop off the source row (a foreign entity's relations are known only to its owner - the `generate map` rule), `{recordUrl}` (it routes into THIS application), and `attach: print`/`recordPrint` (the print feeder is generated where the document is). Details in the engine-intent guide's *mail a REPORT* and cross-model-schedule-notify sections. -**Lifecycle-aware aggregates (`stage:` + `scope:` + symbolic statuses, [#6645](https://github.com/eclipse-dirigible/dirigible/issues/6645)):** a status seed row classifies what it MEANS to the lifecycle — `stage: draft|live|cancelled|void`, metadata never emitted as a column — and a `reports:` entry declares `scope: all` or a stage name, emitted as `."" IN ()` ANDed onto its filter. With the nomenclature classified, an **aggregating** report **defaults to `live`** unless its dimensions/`filter` already reference the status, so a draft or voided document stops silently inflating every total (an existing model is byte-identical until it adopts `stage:`). Every site that names a status — `transitions[].from`/`setStatus`, a relation's `init:`, `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a posting's `event.when`, a report's `filter` — additionally accepts the **seeded name**, resolved to the id on the raw YAML tree before the typed mapping: an id is positional, so inserting a status mid-nomenclature used to silently retarget every guard authored against the old numbering (how a red-storno posting guarded on `Status == 8` stopped matching a Void that writes 9, leaving the ledger with a receivable for a document that no longer existed). A cross-model nomenclature can be neither stage-scoped nor named (its seeds live in the owner model) and says so; and when a lifecycle-carrying aggregate has neither a scope nor a status predicate nor a classified nomenclature, Generate emits a **warning** — now shown in the Intent Editor's own strip, not only the Builder shell's. Details in the engine-intent guide's lifecycle bullet. +**Lifecycle-aware aggregates (`stage:` + `scope:` + symbolic statuses, [#6645](https://github.com/eclipse-dirigible/dirigible/issues/6645)):** a status seed row classifies what it MEANS to the lifecycle — `stage: draft|live|cancelled|void`, metadata never emitted as a column — and a `reports:` entry declares `scope: all` or a stage name, emitted as `."" IN ()` ANDed onto its filter. With the nomenclature classified, an **aggregating** report **defaults to `live`** unless its dimensions/`filter` already reference the status, so a draft or voided document stops silently inflating every total (an existing model is byte-identical until it adopts `stage:`). Every site that names a status — `transitions[].from`/`setStatus`, a relation's `init:`, `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a posting's `event.when`, the `event.when` of a `notifications`/`integrations`/`outbound` entry ([#7289](https://github.com/eclipse-dirigible/dirigible/issues/7289)), a report's `filter` — additionally accepts the **seeded name**, resolved to the id on the raw YAML tree before the typed mapping: an id is positional, so inserting a status mid-nomenclature used to silently retarget every guard authored against the old numbering (how a red-storno posting guarded on `Status == 8` stopped matching a Void that writes 9, leaving the ledger with a receivable for a document that no longer existed). A cross-model nomenclature can be neither stage-scoped nor named (its seeds live in the owner model) and says so; and when a lifecycle-carrying aggregate has neither a scope nor a status predicate nor a classified nomenclature, Generate emits a **warning** — now shown in the Intent Editor's own strip, not only the Builder shell's. Details in the engine-intent guide's lifecycle bullet. **The glue event axis: process-step events + non-HTTP inbound ([#6537](https://github.com/eclipse-dirigible/dirigible/issues/6537)):** a `notifications`/`integrations` `event:` binds either an entity lifecycle event or a **process step** — `onStepReached`/`onStepCompleted: { process, step }`. The step half adds no second delivery mechanism: `BpmnIntentGenerator` inserts a generated `JavaDelegate` at the step boundary (before the step for *reached*; after the writer/setter for *completed*, carrying the step's `next`) which loads the process's **trigger entity** by the id in the clear-D context and publishes its JSON on the entity's own topic plus a step suffix (`-step---reached|completed`), deferred to after commit — so the consumers are generated unchanged and every recipient path, `{placeholder}`, guard and forwarded body reads exactly as for a lifecycle event (`StepEventSupport` owns binding → entity → topic → emitter, deduplicated per moment). Rejected at parse: an unknown process/step, a step kind with no observable moment (only `userTask`/`serviceTask` have one), a process without a trigger. Alongside it, `inbound[]` declares **exactly one arrival** — `path:` (the `@Controller` webhook), `source: { queue | topic }` (a self-describing `MessageHandler`), or `source: { folder, cron }` (a `JobHandler` polling the drop folder: one record or an array per file, a file touched in the last few seconds is left for the next tick, every read file moves to `processed/`/`failed/`). All three save through the same generated repository — the arrival is a transport, not a second data path; a folder is polled, never watched, hence the mandatory cron there and its rejection elsewhere. **Mapping on arrival ([#6769](https://github.com/eclipse-dirigible/dirigible/issues/6769)):** deserializing the payload straight into the entity only works when it already *is* the entity, so an arrival may declare `accept: { type, version }` — a gate whose misses are **acknowledged and ignored** with a warning (202 on a webhook), never failed into redelivery — and `map:`, projecting envelope keys onto the record's own, where a value may be `{ lookup: , by: , from: }` resolving a **business key to a relation** (the requirement that alone forced a hand-written consumer). A non-unique `by:` is refused at Generate (it would silently pick one of several rows) and a lookup matching nothing **rejects** the arrival rather than storing a null FK; `ArrivalSupport` pre-renders the gate and each typed conversion, both keys work on all three arrivals, and omitting them generates byte-identically. **The departure half ([#6767](https://github.com/eclipse-dirigible/dirigible/issues/6767)):** `outbound[]` binds the same axis and emits on a queue or a topic — `to:` names exactly one of `queue`/`topic`, `payload:` is the same declared envelope `integrations:` takes, and the generated `Publisher.java` is a `MessageHandler` **subscribed to the record's own event topic** that re-publishes through `sdk.messaging.Producer`; because it is a subscriber the write is already committed, so a failed publish is logged and never fatal (no outbox, no exactly-once, no ordering — stated, not implied). The destination name is passed to the platform verbatim, so a departure inherits the `global:` external-contract marker ([#6766](https://github.com/eclipse-dirigible/dirigible/issues/6766)): unmarked names stay application-owned and tenant-prefixed, a `global:` one addresses another deployment. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 17819d01884..fe61fa388aa 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -419,7 +419,7 @@ Semantics worth knowing: - **`trigger: { onCreate|onUpdate|onDelete: , when: "" }` starts the process on the named `` lifecycle event** - fully wired (Java). Any of the three events is supported: `onCreate` binds the entity's base topic, `onUpdate`/`onDelete` the `-updated`/`-deleted` topics the Java DAO publishes (`TriggerSupport` + `EventBinding`); an optional `when` guard (a single `field ==|!= literal`, via `NotificationSupport.guard`) gates `Process.start`. Three parts: (1) the parser validates at most one event kind and that the target is a declared entity; (2) the EDM generator adds a `ProcessId` back-reference property (VARCHAR) plus the per-process `ProcessIds` stamps column (VARCHAR, `Process=instanceId` pairs) to that entity and a `triggers` collection to the `.model` (`TriggerSupport` + `EdmIntentGenerator.buildTriggers`); (3) the **`template-application-events-java`** template (intent-driven, like the other language templates) reads that `triggers` collection and emits one **`gen/events//Trigger.java`** per trigger - a client-Java self-describing `MessageHandler` (a `@Component` whose `destination()` is the entity's per-operation topic via `topicSuffix` and whose `kind()` is `TOPIC`) that loads the entity, applies the `when` guard, calls `Process.start(, businessKey, )`, and writes the instance id back to `ProcessIds` against its own process name plus `ProcessId` (so THIS process starts at most once - one `ProcessId` cannot say WHICH process ran, and reading it as "some process ran" silently skipped every follow-up flow on an already-stamped record, #6862). **The write-back is crash-safe by construction (#6815):** the per-process stamp in `ProcessIds` IS the at-most-once guard while the start and the write-back commit independently, so everything that can precede the start does — the minted business key is persisted first, and every process variable (the `__entityUrl`/`__entityId` locators, the FK locators, `__personalUser`) rides the start payload instead of a post-start `setVariable` (all are known up front, and a wait-state-less process finishes inside `start`, where a `setVariable` would then throw). The one remaining post-start step is the targeted `updateProperties` of both process columns at once (a record carrying one without the other is either invisible to the task UI or blocked from ever starting the flow again), which a `checks:` gate can no longer refuse (the generated repository runs `enforceChecks` only for a write that touches an **authored** column - a gate has no opinion about which process handles the document, and by then the instance is running), a swallowed start (`null` id) is logged rather than written, and if the write still does not land — the row was deleted meanwhile, or it threw — the instance is **cancelled** (`Process.cancel`) and the failure re-thrown, rather than left running with nothing pointing at it. The Java DAO template (`template-application-dao-java`) now publishes the create event (`Producer.sendToTopic('${projectName}-${perspectiveName}-${name}', json)`) the way the TS DAO does - that's the topic the handler binds to. `gen/events/` (the `` segment = the sanitized intent name, `IntentNaming.javaModule`) is a sibling of `gen/`, so it survives the per-model regeneration wipe. The events template iterates the model's `triggers` via a new **`triggers` collection case in the generation pipeline's `ModelGenerator`** (the engine's collection switch is hardcoded; the case has its own loop because triggers are not entity-shaped). The BPM **business key** defaults to the entity's primary key but is **configurable**: `trigger: { ..., businessKey: }` names which trigger-entity field becomes the started instance's business key (the listener still loads the entity by its PK via `findById`; only the business key differs — a separate `businessKeyProperty` in `.glue`). An optional `businessKeyStrategy: timestamp` mints a `yyyyMMddHHmmss` value into that field when it is blank and persists it via the listener's existing update — the simple "for now" generator and the **extension point** for richer pluggable number generators later (sequential, zero-padded, config-prefixed invoice numbers); the parser validates the field exists, the strategy is supported, and (for `timestamp`) the field is `string`/`text`. `TriggerSupport.triggerBusinessKey`/`triggerBusinessKeyStrategy` read them; `GlueIntentGenerator` emits `businessKeyProperty` + `generateBusinessKey`; `Trigger.java.template` renders the mint-if-blank block. `onSchedule` is still unmodelled. **Casing subtlety in the generated handler:** its `import gen..data..{Entity,Repository}` must use the **lowercased** Java package segment (`javaPerspective` = `sanitizeJavaIdentifier(perspective)`, matching the DAO/entity templates' `javaPerspectiveName` folder), while the `destination()` topic (`"--"`) keeps the **raw** perspective so it matches the topic the DAO publishes to (`${projectName}-${perspectiveName}-${name}`). The `triggers` collection case in the pipeline supplies both (`javaPerspective` for the import, `perspective` for the topic). Using the raw perspective in the import compiled on macOS (case-insensitive FS) but failed `javac` with "package gen.x.data.Member does not exist" because the entity files declare the lowercased package. - **`dependsOn` on a to-one relation or a field = the EDM Depends-On feature (cascading dropdowns + auto-populated fields).** `dependsOn: { relation: , valueFrom?: , filterBy?: }` — the widget reacts to the sibling trigger: the generated form loads the trigger's selected record, reads `valueFrom` (default: the trigger target's PK), then a **relation** re-filters its dropdown options where its own target's `filterBy` (default: that target's PK) equals the value (`POST /search` with an EQ condition; a single remaining option auto-selects), while a **field** copies the value (auto-population; `valueFrom` mandatory, `filterBy` rejected). Emitted by `EdmIntentGenerator.putDependsOn` as the four scalar `widgetDependsOn*` property attributes the AngularJS stacks already consume (so those work for free); the Harmonia runtime was added in the same pass (`form-page.js.template` per-property watcher + `applyDependsOn` methods covering manage/master-detail/allocation forms; `document-page.js.template` header watchers + a generic metadata-driven `applyDraftDependsOn` for the line-item dialog off `detail-register.js.template`'s `editColumns[].dependsOn`, with filtered options in a separate `draftOptions` store so the items table's label resolution keeps the full set; `ModelParameterProcessor` precomputes `widgetDependsOnControllerUrl` from the trigger sibling). `valueFrom`/`filterBy` use the target's **authored** property names (field lower-camel / relation as declared); same-model references are parse-validated, cross-model ones generation-validated against the resolved owner model (`CrossModelSupport.TargetInfo.propertyNames`). A `documentStatus` relation can neither declare nor trigger a dependsOn. Canonical cases (the `codbex-sample-model-depends-on` set): Country→City cascade (`filterBy` only), Product→UoM narrow-to-referenced (`valueFrom` only), Product→price auto-populate (field). **Conditional auto-populate (#6358):** a FIELD's `valueFrom` may be `{ by: , cases: { : }, default?: }` — the copied trigger-target property is picked by a classifier resolved from the `by` path (own property / one-hop `.` / a path starting at the composition parent relation = the open document header). Parser `validateConditionalValueFrom` (shape, path segments, case/default properties against the trigger target); EDM emits `widgetDependsOnValueBy` (+`ByHeader`/`ByHeaderEntity`/`ByEntity` for the hop fetch), `widgetDependsOnValueCases` (JSON string, PascalCased properties), `widgetDependsOnValueDefault`, and NO `widgetDependsOnValueFrom`; `ModelParameterProcessor` derives `widgetDependsOnValueByUrl` (the hop record's controller); Harmonia consumes it via `resolveDependsOnSource` (document page: dialog + header form; `resolveDependsOnSource` on the manage form) — Harmonia-only, the AngularJS `#if` guards skip it (no `ValueFrom`). Editor round-trip: the six attrs are in `model.js`/`serializer.js` (no dialog UI - intent is the source). **Header-mediated trigger (#6358, the issue's other half):** `relation: .
` on a document ITEM field (`relation: SalesInvoice.Customer, valueFrom: standardDiscount`) makes the line default from a record the open DOCUMENT points at instead of one of the line's own relations - the canonical case being a line discount defaulting from the header partner's terms. Parser `validateHeaderMediatedDependsOn` (fields only - a header selection has no option list to cascade, so `valueFrom` is mandatory and `filterBy` rejected; the first segment must be the composition parent, the second a to-one of the header, and `valueFrom` resolves against THAT relation's target). `putDependsOn` resolves the trigger through the header and adds `widgetDependsOnHeader` + `widgetDependsOnHeaderEntity`; `ModelParameterProcessor` resolves `widgetDependsOnControllerUrl` on the HEADER entity (the trigger is not a property of the item). Harmonia: `detail-register` emits `dependsOn.header`, and `document-page` gains `applyHeaderDependsOnToDraft` - called on a CREATE draft open and from a `form.` watcher while the dialog is open, never on an edit draft (the stored value may be a deliberate override); `applyDraftDependsOn`/the draft watchers explicitly skip header columns so a same-named row column cannot drive them. **Every sibling-assuming stack is guarded** (`&& !$property.widgetDependsOnHeader` in the four `-java`/`-v2`/legacy AngularJS controller templates and the Harmonia `form-page`) - without it they emit a watcher on `entity.` / `this.form.` that does not exist on the item. Composable with the conditional `valueFrom`. - **`postings:` (top-level) = declarative posting (source-document status → generated local document + computed items).** The accounting "documents → ledger" capability, generalized (spike-derived; see the driving suite's spike findings). `PostingIntent` + parser `validatePostings` (creates = local document owning a composition items child; backReference = its to-one to the source, the at-most-once guard; event trigger `onTransition` with a mandatory `when: " == "` status guard, or `onCreate` for a source with NO status lifecycle - a booked payment - binding the `-created` topic with the `when` guard optional (#6421); item cells = `rule()` refs into a single-selector rule entity or Calc arithmetic over the source; row `when: ==|!= `). `GlueIntentGenerator.buildPostings` pre-renders EVERYTHING as Java expressions (the expansions convention — the template stays shape-only): topic + re-load coordinates via `CrossModelSupport`, guard, header assignments (copy / literal / `{placeholder}` concat), `ruleRow.` refs, `Calc.eval("", source, )` amounts with the scale from the LOCAL item field, null-safe Calc row guards. `postings` glue collection → the pipeline's collection case (source gen folder = sanitized model alias, topic keeps the RAW perspective) → `Posting.java.template`: a `MessageHandler` on `---transitioned` (#6220's channel) that re-loads the source by id (the payload lacks later-step data — the stamped number), guards, resolves the rule row (missing row / null referenced column → SKIP, the unposted worklist), and writes target + items through the repositories — so numbering / status `init:` / `checks:` fire on the created document. **Idempotent + resumable + amendable, and the post itself is ONE transaction** — the handler's own writes (the stale rows a rewrite replaces, the header, every derived line) share a `UnitOfWork`, so a line the item repository refuses leaves the previous post standing instead of a header with a partial line set (#7132: unlike the half-post case there is no second event to self-heal from, so a partial rewrite ends up worse than the stale but balanced post it set out to fix). Across STEPS the model is unchanged and deliberately not transactional — the source's own commit, this handler's post and a reversal are separate events, and a bad post is unwound by a correcting entry, not a rollback: the handler derives the WHOLE content first and compares it with the post the back-reference finds — identical is a redelivery (no-op), different is either a HALF-post (an item write failed after the target was saved) to complete or an AMENDED source to rewrite from. The amendment half is #7071: the amend path (Confirm → Reject → edit the lines → Issue again) raises the SAME moment a second time, and the old `item count ≥ expectedItems` test read that as "already posted", so the entry kept the amounts of the previous issue while the document it references had moved on — no second entry (right) and a ledger 60.00 short (wrong), silently. Now the existing post is REWRITTEN in place (header assignments re-applied through `update`, items replaced) — but only while nobody has acted on the created document, which the posting itself defines: its `function: EntityStatus` relation still holds the `init:` the posting's own create wrote (a target with no status lifecycle is always rewritable, one whose status is declared without an `init:` is rewritable while still empty). Once it has moved, the divergence is LOGGED naming both documents and the entry is left alone — unwinding a posted entry is a correcting entry's job (`reverses:`), not a silent overwrite. The comparison is order-insensitive (row order is not a query guarantee) over the union of every cell the item rows assign (`itemComparedProps`), and numbers compare by VALUE so a rescaled amount is not a change. **It is over the values as they will be STORED, not as the derived rows stand**: `save()` fills a column before the insert, so each compared property carries the default its own derived side will end up with (#7131), and a column the WRITE fills is compared accordingly (#7177, #7234) - a column filled UNCONDITIONALLY is dropped from the comparison entirely and the discarded assignment reported at generation (its value never stays in the column, so left in it is a difference no redelivery can ever clear): a `calculatedOnCreate`/`calculatedActionOnCreate` one; an `aggregate: true` header column the lines also declare, which the document master's `recalculate()` sets to the SUM over the lines on every write (#7234 - the item sum the write stored against the source value the `map:` computed, off by a rounding, a sign convention or a partially posted line set; the master is resolved through `IntentEntities.documentMasters`, the SAME rule the `MANAGE_DOCUMENT` layout and so the DAO's `documentMaster` are emitted by, never through the broader `documentItemsChild`); and, on the created document only - it is rewritten IN PLACE through `update()`, where its lines are deleted and re-inserted - a `calculatedOnUpdate`/`calculatedActionOnUpdate` column (recomputed on every rewrite) or any `aggregate`/`readOnly` one (`update()` preserves it from the stored row), whose mapped value survives the create and is discarded by every rewrite after it, so after one legitimate amendment the compared cell mismatched forever. A `uuid` or `number:` one - filled only when the row leaves it empty, like a `date`/`timestamp` default only the DATABASE can apply - is compared only for the rows that do derive it. Header `map:` expressions are hoisted into numbered locals so the comparison and the assignment read ONE evaluation, after the back-reference lookup so a return that writes nothing never pays for them; `amendableGuard`/`itemComparedProps` are pre-rendered into the glue like everything else, and both default to the pre-#7071 behaviour when a `.glue` predates them - and `bindPosting` reads the "compare only when derived" flag under its #7163 spelling `expressionDefault` wherever the `compareOnlyWhenDerived` #7188 renamed it to is absent - through ONE rule, in the header-assignment normaliser #7256 added and in `GlueGenerator.comparedCells` for the item cells - so a `.glue` generated between the two keeps its CURRENT_DATE-default treatment instead of silently falling to a plain `same()` until the intent is re-generated (#7234). Concurrent-redelivery de-duplication is best-effort (a check-then-act on the back-reference) until a real UNIQUE key on the back-reference lands with schema constraint emission. Storno/negation mode LANDED as **`reverses:`** (paired with the `transitions:` void primitive - the "void-document event" is a transition into the void status): a reversal posting inherits creates/backReference/rule/map/items from the reversed sibling, negates every item amount expression on the SAME side (`Calc.eval("-()", ...)` - red storno), locates the original through the empty `storno:` self-link (none -> fail-soft skip), stamps the link on its creation, and both handlers' idempotency guards discriminate by that link (reversal counts linked rows, the sibling counts unlinked ones - `stornoProperty`/`stornoFilterProperty` in the glue). The explicit manual Reverse action (no source void) remains a follow-up. Compensation, not a transaction, is how a bad post is unwound. -- **Lifecycle-aware aggregates: seed-row `stage:` + report `scope:` + symbolic status names (#6645).** An aggregate over an entity carrying a `function: EntityStatus` was **wrong by default** - drafts nobody had issued, cancelled and voided (анулиране) rows all landed in the sum unless the author remembered a magic-number status predicate in `filter:`, and nothing said so (the motivating case: a voided invoice kept its 2000 in "Revenue this month" because the report declared dimensions + measures and no `filter`, so the emitted query had no `WHERE` at all). Four coordinated pieces, all in `LifecycleStages` + `ReportIntentGenerator.scopePredicate` + `StatusSymbolResolver`: (1) a status **seed row** classifies what the status MEANS with a closed-vocabulary `stage: draft|live|cancelled|void` - metadata, never a column (the CSV generator only emits declared fields + referenced FKs, and `CsvimIntentGeneratorTest` pins that); (2) a report declares `scope: all` or a stage name, emitted as `."" IN ()` ANDed onto the filter; (3) with the nomenclature classified, an **aggregating** report **defaults to `live`** - but only when its dimensions/`filter` do not already reference the status (a breakdown BY status must keep its draft rows, and an authored predicate is authoritative), so an existing model is byte-identical until it adopts `stage:`; (4) every site that names a status accepts the **seeded name** (`from: [ISSUED]`, `setStatus: VOIDED`, `init: DRAFT`, `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a posting's `event.when`, a report's `filter`, and the status condition of a `where` row query - a `schedules[]` one ([#7251](https://github.com/eclipse-dirigible/dirigible/issues/7251)) or a create-from's `items:` rule ([#7091](https://github.com/eclipse-dirigible/dirigible/issues/7091)), both through the shared `StatusSymbolResolver.rewriteConditions`, each on the QUERIED entity's own nomenclature) - resolved on the **raw YAML tree before the typed Gson mapping** (the `rejectRemovedNumberKeys` precedent), so every validator, generator and template keeps seeing plain integers. **Why names matter more than they look:** an id is positional, so inserting a status mid-nomenclature shifts every later id and silently retargets every guard authored against the old numbering - that is how a `reverses:` posting guarded `when: "Status == 8"` stopped matching a Void that now writes 9, leaving the ledger with a receivable for a document that no longer existed, with well-formed Java emitted throughout. **Boundaries, deliberate:** the nomenclature must be seeded IN THIS MODEL - the parser holds one file and no repository, so a **cross-model** status can neither be stage-scoped nor named (both fail loudly naming the numeric-id fallback; cross-model symbols need the name→id map on the generated `.model` and are follow-up work). A cross-model **row query** is the one site where the parser cannot even say so — which of its `{ field, op, value }` triples names the status is knowable only from the owner's `.model` — so the refusal is made where that model is read, at generation: a condition on the owner's `DOCUMENT_STATUS` property whose value is not an integer is a 422 naming the relation, the name, the owner model and the id-only rule, for a create-from's `items:` rule ([#7225](https://github.com/eclipse-dirigible/dirigible/issues/7225)) and for `schedules[].where` ([#7288](https://github.com/eclipse-dirigible/dirigible/issues/7288)) alike — both through the shared `GlueIntentGenerator.crossModelStatusName`. Left silent, the schedule one was #7251's own failure mode one `model:` key away: `.eq("Status", "OVERDUE")` against an integer FK, matching nothing forever. A symbolic **ordering** comparison (`Status >= ISSUED`) is rejected - names have no order, that is what `scope:` is for. A nomenclature that declares its own `stage` property collides with the marker and is rejected rather than guessed. Nothing is emitted into the `.model` for `stage` - no consumer needs it yet (the Harmonia badge's `statusVariant` keyword guess is the obvious future one). **Part 3, the cheap half that catches everything the other three cannot:** when a report aggregates over a lifecycle entity and neither declares `scope:` nor filters on the status AND the nomenclature is unclassified, generation records a `context.addIssue` warning - surfaced in the generate response's `warnings` and now in the **Intent Editor**'s own amber strip (it used to discard them on success; the Builder shell already showed them). That warning, not the default, is what turns an invisible modelling omission into a visible one. **And the invariant is checked at the consuming site too, independently of the resolver's site list:** a `where` condition on the queried entity's `function: EntityStatus` relation must carry an integer by the time validation runs (`IntentParser.validateWhereStatusValue`), so a value no status can equal is refused instead of rendering `.eq("Status", "OVERDUE")` into a query that matches nothing for as long as the job keeps ticking. `schedules[].where` was left behind for exactly that reason - #7091 taught the resolver the items rule and not the construct it was modelled on, and nothing anywhere failed. +- **Lifecycle-aware aggregates: seed-row `stage:` + report `scope:` + symbolic status names (#6645).** An aggregate over an entity carrying a `function: EntityStatus` was **wrong by default** - drafts nobody had issued, cancelled and voided (анулиране) rows all landed in the sum unless the author remembered a magic-number status predicate in `filter:`, and nothing said so (the motivating case: a voided invoice kept its 2000 in "Revenue this month" because the report declared dimensions + measures and no `filter`, so the emitted query had no `WHERE` at all). Four coordinated pieces, all in `LifecycleStages` + `ReportIntentGenerator.scopePredicate` + `StatusSymbolResolver`: (1) a status **seed row** classifies what the status MEANS with a closed-vocabulary `stage: draft|live|cancelled|void` - metadata, never a column (the CSV generator only emits declared fields + referenced FKs, and `CsvimIntentGeneratorTest` pins that); (2) a report declares `scope: all` or a stage name, emitted as `."" IN ()` ANDed onto the filter; (3) with the nomenclature classified, an **aggregating** report **defaults to `live`** - but only when its dimensions/`filter` do not already reference the status (a breakdown BY status must keep its draft rows, and an authored predicate is authoritative), so an existing model is byte-identical until it adopts `stage:`; (4) every site that names a status accepts the **seeded name** (`from: [ISSUED]`, `setStatus: VOIDED`, `init: DRAFT`, `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a posting's `event.when`, the `event.when` of a `notifications`/`integrations`/`outbound` entry ([#7289](https://github.com/eclipse-dirigible/dirigible/issues/7289)), a report's `filter`, and the status condition of a `where` row query - a `schedules[]` one ([#7251](https://github.com/eclipse-dirigible/dirigible/issues/7251)) or a create-from's `items:` rule ([#7091](https://github.com/eclipse-dirigible/dirigible/issues/7091)), both through the shared `StatusSymbolResolver.rewriteConditions`, each on the QUERIED entity's own nomenclature) - resolved on the **raw YAML tree before the typed Gson mapping** (the `rejectRemovedNumberKeys` precedent), so every validator, generator and template keeps seeing plain integers. **Why names matter more than they look:** an id is positional, so inserting a status mid-nomenclature shifts every later id and silently retargets every guard authored against the old numbering - that is how a `reverses:` posting guarded `when: "Status == 8"` stopped matching a Void that now writes 9, leaving the ledger with a receivable for a document that no longer existed, with well-formed Java emitted throughout. **Boundaries, deliberate:** the nomenclature must be seeded IN THIS MODEL - the parser holds one file and no repository, so a **cross-model** status can neither be stage-scoped nor named (both fail loudly naming the numeric-id fallback; cross-model symbols need the name→id map on the generated `.model` and are follow-up work). A cross-model **row query** is the one site where the parser cannot even say so — which of its `{ field, op, value }` triples names the status is knowable only from the owner's `.model` — so the refusal is made where that model is read, at generation: a condition on the owner's `DOCUMENT_STATUS` property whose value is not an integer is a 422 naming the relation, the name, the owner model and the id-only rule, for a create-from's `items:` rule ([#7225](https://github.com/eclipse-dirigible/dirigible/issues/7225)) and for `schedules[].where` ([#7288](https://github.com/eclipse-dirigible/dirigible/issues/7288)) alike — both through the shared `GlueIntentGenerator.crossModelStatusName`. Left silent, the schedule one was #7251's own failure mode one `model:` key away: `.eq("Status", "OVERDUE")` against an integer FK, matching nothing forever. A symbolic **ordering** comparison (`Status >= ISSUED`) is rejected - names have no order, that is what `scope:` is for. A nomenclature that declares its own `stage` property collides with the marker and is rejected rather than guessed. Nothing is emitted into the `.model` for `stage` - no consumer needs it yet (the Harmonia badge's `statusVariant` keyword guess is the obvious future one). **Part 3, the cheap half that catches everything the other three cannot:** when a report aggregates over a lifecycle entity and neither declares `scope:` nor filters on the status AND the nomenclature is unclassified, generation records a `context.addIssue` warning - surfaced in the generate response's `warnings` and now in the **Intent Editor**'s own amber strip (it used to discard them on success; the Builder shell already showed them). That warning, not the default, is what turns an invisible modelling omission into a visible one. **And the invariant is checked at the consuming site too, independently of the resolver's site list:** a `where` condition on the queried entity's `function: EntityStatus` relation must carry an integer by the time validation runs (`IntentParser.validateWhereStatusValue`), so a value no status can equal is refused instead of rendering `.eq("Status", "OVERDUE")` into a query that matches nothing for as long as the job keeps ticking. `schedules[].where` was left behind for exactly that reason - #7091 taught the resolver the items rule and not the construct it was modelled on, and nothing anywhere failed. - **`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. @@ -427,7 +427,7 @@ Semantics worth knowing: - **`checks: kind: requiredWhen` = a value required only under a condition (#7094).** The third row-level shape, and the one no other kind could express: "the customer's e-mail address must be there when Sent Method is E-mail". Nothing in the DSL said it - `required` is unconditional, a `pattern` describes a value that exists, `exactlyOne` is about the record's own columns - so a module either shipped the rule as a hand-written delegate plus a decision plus a hold task plus a form (~15 intent lines and a Java class for one sentence), or shipped nothing and mailed an invoice to nobody: status SENT, a notify step logging a no-op for a missing recipient, and the clerk told none of it. Authored as `{ kind: requiredWhen, field: , when: " ==|!= " | [...], status?: , message }`. - **The value may be one hop away, which is the whole reason the kind exists** - the rule is about the record being sent and the address belongs to its customer. `field:` is walked by `ResolvePathSupport` (the resolver every other path in the DSL uses), so a cross-model target reads too and a path walking on PAST a cross-model relation is refused there. The hops travel into the `.model` as the check's `pathLoads` (local + null-guarded FK expression + entity + perspective + the relation's `model:` alias) and `ModelParameterProcessor.resolveCheckPathLoads` turns them into the generated `Entity`/`Repository` FQNs - it is the pass that knows the generation folder, exactly as for a master's inherited lock, and the `.model` twin could not re-derive another model's folder at all. The generated reader loads each hop by id and reads the field null-guarded, so a missing link is an EMPTY value (the check fires) rather than an NPE inside a repository. - **The `status:` gate is OPTIONAL here, unlike on the document-level kinds** - and that optionality is the routing. No gate = the rule holds on every user write, so `ModelParameterProcessor` files it with the `rowChecks` and all three controller templates (`EntityController`, `EntityMyController`, `EntityPartnerController`) enforce it in `validate()` as a 400 carrying the authored message. A gate = the repository's `enforceChecks`, like `itemsMin`, which is what puts it on #7014/#7063's SYNCHRONOUS path: the transition that sends the document reaches the gate inside the task completion, so the refusal is the 400 the Inbox shows the person who pressed the button instead of a dead-lettered incident. A gated check needs a `function: EntityStatus` relation to read the gate from (parser-enforced). - - **The condition is a closed vocabulary, and it is rendered against the guarded property's DECLARED type.** `CheckSupport` owns both halves - the pattern the parser refuses on and the Java the EDM generator emits - in one class so they cannot drift. Two refusals, both about a guard that would otherwise be silently wrong: a condition the generator cannot compile is a parse ERROR (degrading it to `true`, which is what the shared `NotificationSupport.guard` does for a glue listener, would make the value unconditionally required - a `required` nobody authored), and a literal that is not a value of the property's type is refused too, because `Objects.equals(Long, int)` never holds and a `long`-column guard would switch the rule off while looking authored. Only `string`/`text`/`integer`/`int`/`long`/`boolean` and a to-one's integer FK are guardable at all; a decimal, a double or a date is compared for equality by nobody who means it. A status name in the condition resolves to its seed id like every other guard (`StatusSymbolResolver.rewriteWhen` on the check node), and the list form is an implicit AND, as in #6957. + - **The condition is a closed vocabulary, and it is rendered against the guarded property's DECLARED type.** `CheckSupport` owns both halves - the pattern the parser refuses on and the Java the EDM generator emits - in one class so they cannot drift. Two refusals, both about a guard that would otherwise be silently wrong: a condition the generator cannot compile is a parse ERROR (degrading it to `true`, which is what the untyped `NotificationSupport.guard` still does for a process `trigger:`, a `wait` and a `resolves:` guard, would make the value unconditionally required - a `required` nobody authored; the glue event axis stopped degrading with [#7289](https://github.com/eclipse-dirigible/dirigible/issues/7289) and shares `CheckSupport.condition` with this check), and a literal that is not a value of the property's type is refused too, because `Objects.equals(Long, int)` never holds and a `long`-column guard would switch the rule off while looking authored. Only `string`/`text`/`integer`/`int`/`long`/`boolean` and a to-one's integer FK are guardable at all; a decimal, a double or a date is compared for equality by nobody who means it. A status name in the condition resolves to its seed id like every other guard (`StatusSymbolResolver.rewriteWhen` on the check node), and the list form is an implicit AND, as in #6957. - Unit: `IntentParserTest.conditionallyRequiredValuesParseAndValidate`, `EdmIntentGeneratorTest.conditionallyRequiredValuesEmitTheirConditionAndTheHopsTheirValueIsReadThrough`, `ModelParameterProcessorTest`; IT: `IntentEmissionCoverageIT` (a gated one over a hop in `EntryRepository`, an ungated one in `DocController`, and the assertion that the ungated one is NOT in the repository). - **`resolves:` = the effective-dated register lookup (#6712).** The enterprise shape with no declarative form before it: a register says "X applied to Y from A to B" (a vehicle assignment, a price list, a contract in force, an org assignment), a record carries the match key(s) and a date, and a to-one must be filled from the row whose period covers that date. Nothing else in the DSL reaches it - `dependsOn` is a UI-time copy with equality matching only, a `decision` condition is a single comparison, and `setField` writes constants - so every application hand-wrote the same delegate. Authored as `{ event: { onCreate|onUpdate: , when? }, set: , from: , match: { : , ... }, between: { start?, end?, value }, outcome?: , found?/notFound?/ambiguous?: { setStatus } }`; `ResolveIntent` -> `GlueIntentGenerator.buildResolves` -> the `resolves` glue collection -> `Resolve.java.template`, a `@Component MessageHandler` on the record's event topic. **All three outcomes are first-class, and that is the point of the construct:** exactly one covering row fills the relation, NO covering row and MORE THAN ONE covering row both leave it unset (an automation that silently picks one of two candidates is worse than none - the ambiguous register goes back to a human). Each outcome may route the record by `setStatus` (seed id or seeded name, resolved by `StatusSymbolResolver` like every other status site), and the attempt is **observable**: `outcome:` stamps `found`/`notFound`/`ambiguous` into a string field of the record - queryable, filterable in a list view, and readable by a process `decision` - and the handler logs the keys and the date it checked. **Decisions worth keeping:** the value copied is derived, not authored - the register must carry exactly ONE to-one to the same target as `set:`, and zero or two is a validation error rather than a guess (the same refusal, one altitude up); a record that already carries the relation is skipped, so a manual correction is never overwritten and a re-delivered event is a no-op; **the RESULT and the ROUTING are two targeted writes, in that order** - `updateProperties` of the relation + the outcome, then `updateProperty` of the status - because the DAO runs the `lifecycle:` and `checks:` gates against the post-write row BEFORE persisting anything, so batching the three meant a rejected status move discarded the identification and the audit trace with it (the lookup did the work, got the right answer, and threw all of it away). The routing write catches the `ValidationException`: retrying cannot help - nothing about the record changes by re-reading the register - so it logs and amends the trace to `-notRouted`, which is what keeps a routed-but-rejected record distinguishable from a fully processed one. The parser enforces the trace field is long enough for those values (19 once any outcome routes), since truncation happens at the DB where nothing reports it. No `-updated` re-fires and no concurrent write to another column is reverted; period bounds are optional on either side (open-ended = still valid), the end is INCLUSIVE, and a date-only bound covers its whole day (the generated `millis`/`endExclusive` helpers put a `LocalDate` and an `Instant` on one epoch-milli axis, UTC). v1 is same-model (`from:` must be declared here) and binds to `onCreate`/`onUpdate` only - `onDelete` is refused, there is nothing left to fill. An optional **`where: { : }`** (one or more pairs, ANDed into the `Criteria`) is the only way to narrow the register by a constant, and it closes a defect that got worse with age: every `match` pair binds a register column to a column of the RECORD, so "and only the rows still valid" had no form at all - while a register KEEPS its corrections, so a cancelled row went on covering its old period forever and turned a lookup with exactly one right answer into a permanent `ambiguous` that routed to a human and logged "multiple matches". A status pair may use the seeded NAME, resolved on the REGISTER's own nomenclature (`StatusSymbolResolver.rewriteResolveWhere`) - the record's would hand back a plausible id from the wrong lifecycle - and only that one pair is offered to the resolver, so an ordinary string column like `kind: PRIMARY` is not reported as an unknown status. A pair repeating a `match` key is refused: on a column already bound to the record a literal either repeats the match or contradicts it into matching nothing, and which one depends on data the parser cannot see. Multiple pairs are allowed although the relation-level `where:` caps at one - that cap exists because it lands in two EDM attributes, whereas these are chained `Criteria.eq` calls where a second condition costs nothing. Covered by `GlueResolveWhereTest` (including the two nomenclatures deliberately numbered differently, so resolving against the wrong entity cannot pass) and an `IntentEngineIT` assertion. The parser refuses a `when` guard it cannot render rather than degrading it to an always-open guard. @@ -627,6 +627,8 @@ Build **#1 (reactions) + #2 (notify)** first: one new concept ("reaction"), reus Consequences worth knowing: a step event is only as meaningful as the process's `trigger` (no trigger → no record → the parser rejects the binding), and only a `userTask`/`serviceTask` is eventable (a decision/wait/end occupies no moment). `EventBinding` covers the entity axis only (lifecycle + `onTransition`) — `StepEventSupport.eventEntity`/`topicSuffix` are the two polymorphic entry points the glue builders call, and they fall through to `EventBinding` when the binding is not a step one. +**The axis's `when:` guard is typed, and refused rather than degraded ([#7289](https://github.com/eclipse-dirigible/dirigible/issues/7289)).** Every binding takes an optional `event.when` - one ` ==|!= ` comparison over the event record's own properties, or the list form meaning their AND (#6957) - and it was the one `when` in the DSL that neither resolved a seeded status name nor checked its own shape. Both halves failed silently, in opposite directions. `Status == ISSUED`, the natural authoring of the construct, reached the listener as `Objects.equals(entity.Status, "ISSUED")` - a status FK compared with a string, never true, so the mail never went out with parse, generation, compile and publish all green; and a guard the renderer's one pattern did not match (`Status = ISSUED`, `status == 'ISSUED' and channel == 'mail'`, a two-element list, which the notification path stringified into the scalar pattern) answered `true`, so the reaction fired on EVERY event - a guard nobody authored. Now `StatusSymbolResolver.rewriteGlue` resolves the name on the event entity's nomenclature (the step half on the process's trigger entity), the parser holds the guard to `CheckSupport`'s grammar and type rule in `validateEventGuard`, and `CheckSupport.condition` renders it - the same compiler `checks: requiredWhen` uses, so a to-one's key is compared numerically because its width is not knowable here (#7237). The event entity is always local on this axis (no `model:`, and an unknown entity is refused), so nothing here needs the cross-model fallback the other status sites carry. + The **departure** half ([#6767](https://github.com/eclipse-dirigible/dirigible/issues/6767)): `outbound[]` binds the same axis and emits the record on a queue or a topic — `to:` names **exactly one** of `queue`/`topic` (the arrival rule read backwards), `payload:` is the very same declared envelope `integrations:` takes, and the generated `Publisher.java` (`Outbound.java.template`) is a self-describing `MessageHandler` **subscribed to the record's own event topic** that re-publishes through `sdk.messaging.Producer`. Being a subscriber is what gives the construct its semantics for free: the write is already committed when the event message is delivered, so a failed departure can never fail it — it is logged, never thrown (there is no outbox, no exactly-once, no ordering, and the docs say so rather than let an author assume otherwise). `outbound` is a construct of its own rather than a `to:` on `integrations:` because the two have different failure semantics (call-and-be-answered vs emit-and-forget) and because an author looks for the mirror of `inbound`. **The destination name is passed to the platform verbatim** — nothing in the intent layer resolves it, so a departure inherits the platform's `global:` external-contract marker ([#6766](https://github.com/eclipse-dirigible/dirigible/issues/6766)) for free: an unmarked name is application-owned and tenant-prefixed (right for a channel this deployment both publishes and consumes), a `global:` one is not (right for a contract with another deployment, at the cost of the destination no longer carrying the tenant — which is why the declared envelope's `tenantId: "{tenant}"` exists). Landing this also fixed an *authored-but-unconsumed* bug next door: the axis has always carried a `when:` guard and `Integration.java.template` ignored it, so an integration forwarded records the author had excluded. **The status axis (`onTransition`) — the `-updated` half of the DSL used to be deaf to every status the system writes.** `-transitioned` and `-updated` are fully DISJOINT channels: the three publishers of the former (`SetField.java.template`, `Transition.java.template`, and `Generate.java.template`'s `sourceStatus:` completion hook) never publish the latter, deliberately, so a system write cannot re-fire the onUpdate reactions meant for a person's edit. But `onTransition` existed only in `postings:` and `generates`' `event:`, so `notifications:`, `integrations:`, `outbound:`, a process `trigger:` and a `wait` step could not bind it at all: you could not declaratively email anyone when a workflow set a status, and a `wait` could be **aborted** by a transition (`abortOn:` binds `-transitioned`) but never **resumed** by one — the instance parked forever, with nothing anywhere to say so. `onTransition` is now in all five vocabularies. The topic already existed and already carried the full entity payload, so this is a **vocabulary** change and not a new mechanism — **no template changed at all**, because every one of them already interpolates `${topicSuffix}` into its `destination()`. The `when:` guard stays OPTIONAL here ("on any status change" is a legitimate ask), unlike on `postings:` / `generates`, which mandate one because they CREATE a document per transition. **The kinds are enumerated in five places and all five must agree:** `EventBinding.KINDS` + `topicSuffix` (the choke point every glue builder funnels through — `NotificationSupport`, `StepEventSupport`, the trigger, the resolves and the waits all read it), `IntentParser.EVENT_KINDS`, `ProcessWaitSupport.EVENT_KINDS` (which `validateWaitSteps` now reads instead of its own copy, so validation and emission cannot drift), `UnknownKeyValidator.GLUE_EVENT_KEYS` + `ProcessIntent#trigger`, and `StatusSymbolResolver.triggerEntityOf` — that last one runs on the RAW YAML tree before the typed mapping, so it *cannot* read `EventBinding`, and missing it fails in the worst way: the trigger entity stops resolving, so a status NAME anywhere in that process cannot be looked up and the model is refused with a message about the nomenclature rather than the trigger. (Adding `onTransition` to `EventBinding` does not widen `resolves:`, whose own vocabulary is still `onCreate`/`onUpdate` and is checked first.) 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 8204377d66a..60bba0363d1 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 @@ -12,10 +12,15 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.RelationIntent; + /** * The condition of a {@code checks: requiredWhen} entry - the grammar the parser refuses on and the * Java the generator renders, in one place so the two cannot drift. @@ -174,6 +179,121 @@ public static String numericComparison(String access, boolean equal, String java return equal ? equals : "!" + equals; } + /** + * Compiles a whole condition - one comparison or the ANDed list - into the Java boolean a generated + * reader tests, every comparison rendered against its property's DECLARED type. + * + *

+ * This is the one renderer of a typed guard: the {@code requiredWhen} check it was written for and + * the {@code event.when} of the declarative glue lists (issue #7289) share it, so the grammar the + * parser refuses on, the type rule and the Java emitted for it cannot drift into three answers. + * + *

+ * A to-one's foreign key is a whole number of a width this class cannot know - the column is typed + * from the TARGET's key, and a cross-model target's key lives in the owner's {@code .model}, where + * {@code long} is as legal as {@code integer} - so such a comparison is rendered numerically: + * {@code Objects.equals(Long, Integer)} never holds, and a boxed equality would switch the guard + * off while looking authored (#7237). A field's own width is declared, so it keeps the exact boxed + * equality. + * + * @param entity the entity the condition is read off + * @param byName the local entities by name (a to-one's key type comes from its target) + * @param when the authored condition - a comparison, a list of them, or {@code null} + * @return the Java expression, or {@code null} when there is no condition or a comparison does not + * compile (the parser reports it; a condition silently degraded to {@code true} is the + * failure both call sites exist to refuse) + */ + public static String condition(EntityIntent entity, Map byName, Object when) { + if (entity == null) { + return null; + } + List conditions = new ArrayList<>(); + for (String term : terms(when)) { + Comparison comparison = parse(term); + if (comparison == null) { + return null; + } + FieldIntent field = field(entity, comparison.property()); + RelationIntent relation = field == null ? toOne(entity, comparison.property()) : null; + if (field == null && relation == null) { + return null; + } + String type = guardType(field != null ? field.getType() : relationKeyType(relation, byName)); + boolean numericKey = field == null && NUMERIC_GUARD_TYPES.contains(type); + String literal = javaLiteral(numericKey ? "long" : type, comparison.literal()); + if (literal == null) { + return null; + } + String access = "entity." + IntentNaming.pascalCase(comparison.property()); + conditions.add( + numericKey ? numericComparison(access, comparison.equal(), literal) : comparison(access, comparison.equal(), literal)); + } + return conditions.isEmpty() ? null : String.join(" && ", conditions); + } + + /** + * The entity's field of that name, matched case-insensitively - the guard renders the property + * through {@link IntentNaming#pascalCase}, so the case an author wrote it in never reaches the + * generated code and must not decide whether the guard is understood at all. + * + * @param entity the entity the condition is read off + * @param name the authored property name + * @return the field, or {@code null} + */ + public static FieldIntent field(EntityIntent entity, String name) { + if (entity == null || entity.getFields() == null || name == null) { + return null; + } + for (FieldIntent field : entity.getFields()) { + if (name.equalsIgnoreCase(field.getName())) { + return field; + } + } + return null; + } + + /** + * The entity's to-one relation of that name, matched case-insensitively - its foreign key is a + * property of the record exactly as a field is, and the status guard is the reason a condition may + * name one at all. + * + * @param entity the entity the condition is read off + * @param name the authored property name + * @return the relation, or {@code null} + */ + public static RelationIntent toOne(EntityIntent entity, String name) { + if (entity == null || entity.getRelations() == null || name == null) { + return null; + } + for (RelationIntent relation : entity.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (toOne && name.equalsIgnoreCase(relation.getName())) { + return relation; + } + } + return null; + } + + /** + * The declared type of a to-one relation's foreign key - the target's primary-key type, falling + * back to the whole number intent keys always are when the target is owned by another model. + * + * @param relation the to-one relation + * @param byName the local entities by name + * @return the declared type of the foreign key + */ + public static String relationKeyType(RelationIntent relation, Map byName) { + EntityIntent target = relation == null || relation.getTo() == null || byName == null ? null : byName.get(relation.getTo()); + if (target != null && target.getFields() != null) { + for (FieldIntent field : target.getFields()) { + if (field.isPrimaryKey() && field.getType() != null) { + return field.getType(); + } + } + } + return "integer"; + } + /** * The authored literal without its quotes. * diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 8bab68a7763..23a37d87cfb 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -2294,8 +2294,12 @@ private static String stringArg(Map map, String key) { * @param event the {@code event:} binding map * @return the {@code guardExpression} / {@code hasGuard} keys */ - private static Map guardFields(Map event) { - String guard = NotificationSupport.guard(stringArg(event, "when")); + private static Map guardFields(Map event, EntityIntent entity, Map byName) { + // The guard as authored - a comparison or the list form (an implicit AND, #6957) - rendered + // against the guarded property's declared type, so a status guard compares the integer FK with + // an integer (#7289). Passing the stringified map here instead is what left a list guard + // rendering as `true`: the whole list never matched the scalar pattern. + String guard = NotificationSupport.guard(event == null ? null : event.get("when"), entity, byName); Map fields = new LinkedHashMap<>(); fields.put("guardExpression", guard); fields.put("hasGuard", !"true".equals(guard)); @@ -4003,7 +4007,7 @@ private static List> buildIntegrations(IntentModel model, Ma entry.put("urlExpression", IntegrationSupport.urlExpression(integration.getUrl())); // The event axis carries a `when` guard and every other consumer of the axis honours it - // an integration that ignored it forwarded records the author had excluded. - entry.putAll(guardFields(integration.getEvent())); + entry.putAll(guardFields(integration.getEvent(), byName.get(entity), byName)); entry.putAll(PayloadSupport.payloadFields(payload)); entry.put("relationLoads", relationLoads(payload == null ? List.of() : payload.loads())); integrations.add(entry); @@ -4065,7 +4069,7 @@ private static List> buildOutbound(IntentModel model, Map}), or a one-hop {@code relation.field} of a * to-one relation (rendered against a related entity the listener loads once by FK id - the same * one-hop mechanism the decision resolvers use, see {@link ProcessResolverSupport}). Multi-hop - * paths are not supported. The {@code when} guard supports a single {@code field ==|!= literal} - * comparison on a direct field. + * paths are not supported. The {@code when} guard is one {@code ==|!= } + * comparison over the record's own properties - or the list form meaning their AND - rendered + * against the property's declared type by {@link CheckSupport#condition} where the entity is known; + * see {@link #guard(Object, EntityIntent, Map)}. * *

* Inside a fan-out the entity every bare path resolves against is the ROW; a placeholder @@ -228,8 +230,8 @@ public static Plan plan(NotificationIntent notification, EntityIntent eventEntit Map compositionParents, CrossModelLookup crossModel) { Object when = notification.getEvent() .get("when"); - return plan(notification.getTo(), notification.getSubject(), notification.getBody(), when == null ? null : when.toString(), - eventEntity, byName, compositionParents, crossModel); + return plan(notification.getTo(), notification.getSubject(), notification.getBody(), when, eventEntity, byName, compositionParents, + crossModel); } /** @@ -241,14 +243,14 @@ public static Plan plan(NotificationIntent notification, EntityIntent eventEntit * @param to the recipient: a literal address, a direct field, or a one-hop {@code relation.field} * @param subject the subject, with {@code {field}} / {@code {relation.field}} placeholders * @param body the body, with the same placeholders - * @param when an optional guard over a direct field, or {@code null} for none + * @param when an optional guard - a comparison, a list of them, or {@code null} for none * @param entity the entity the message is about (its fields back the paths) * @param byName all LOCAL entities by name (to resolve same-model relation targets) * @param compositionParents composition-parent map (to resolve a target's perspective) * @param crossModel resolver for a cross-model relation's owner facts, or {@code null} * @return the plan, or {@code null} if the recipient cannot be resolved */ - public static Plan plan(String to, String subject, String body, String when, EntityIntent entity, Map byName, + public static Plan plan(String to, String subject, String body, Object when, EntityIntent entity, Map byName, Map compositionParents, CrossModelLookup crossModel) { return plan(to, subject, body, when, entity, null, byName, compositionParents, crossModel); } @@ -264,7 +266,7 @@ public static Plan plan(String to, String subject, String body, String when, Ent * @param subject the subject, with {@code {field}} / {@code {relation.field}} / * {@code {record.field}} placeholders * @param body the body, with the same placeholders - * @param when an optional guard over a direct field, or {@code null} for none + * @param when an optional guard - a comparison, a list of them, or {@code null} for none * @param entity the entity the message is about (a fan-out's row) * @param anchor the fan-out's anchor record, or {@code null} outside a fan-out * @param byName all LOCAL entities by name (to resolve same-model relation targets) @@ -272,7 +274,7 @@ public static Plan plan(String to, String subject, String body, String when, Ent * @param crossModel resolver for a cross-model relation's owner facts, or {@code null} * @return the plan, or {@code null} if the recipient cannot be resolved */ - public static Plan plan(String to, String subject, String body, String when, EntityIntent entity, EntityIntent anchor, + public static Plan plan(String to, String subject, String body, Object when, EntityIntent entity, EntityIntent anchor, Map byName, Map compositionParents, CrossModelLookup crossModel) { Resolver resolver = new Resolver(entity, anchor, byName, compositionParents, crossModel); String recipient = resolver.value(to); @@ -281,8 +283,8 @@ public static Plan plan(String to, String subject, String body, String when, Ent } String subjectExpression = resolver.text(subject); String bodyExpression = resolver.text(body); - return new Plan(resolver.loads(), guard(when), recipient, subjectExpression, bodyExpression, resolver.usesRecordUrl(), - resolver.usesInboxUrl()); + return new Plan(resolver.loads(), guard(when, entity, byName), recipient, subjectExpression, bodyExpression, + resolver.usesRecordUrl(), resolver.usesInboxUrl()); } /** @@ -331,6 +333,35 @@ public static String guard(Object when) { return conditions.isEmpty() ? "true" : String.join(" && ", conditions); } + /** + * A {@code when} guard rendered against the guarded property's DECLARED type - the form every guard + * of the declarative glue event axis takes (issue #7289), where the parser holds the guard to the + * same closed grammar a {@code requiredWhen} condition is held to. + * + *

+ * The typed rendering is the point. An untyped {@code Objects.equals} quotes whatever it cannot + * recognise, so {@code Status == ISSUED} - the natural authoring of a status guard, with the name + * resolved to its seed id before the typed mapping - used to compare the integer status FK with a + * string and never hold: the mail never went out, the departure never left, and parse, generation, + * compile and publish were all green. A to-one's key is compared numerically because its width is + * not knowable here; see {@link CheckSupport#condition}. + * + *

+ * When the condition does not compile - which for a glue guard the parser has already refused, and + * for a call site with no entity to read the types off (a cross-model schedule row) it cannot know + * - the untyped {@link #guard(Object)} answers instead, so nothing that renders today stops + * rendering. + * + * @param when the guard - a comparison string, a list of them, or {@code null} + * @param entity the entity the guard is read off, or {@code null} when it is not resolvable + * @param byName the local entities by name (a to-one's key type comes from its target) + * @return a Java boolean expression + */ + public static String guard(Object when, EntityIntent entity, Map byName) { + String typed = CheckSupport.condition(entity, byName, when); + return typed == null ? guard(when) : typed; + } + private static String literalToJava(String rhs) { if (rhs.length() >= 2 && (rhs.startsWith("'") && rhs.endsWith("'") || rhs.startsWith("\"") && rhs.endsWith("\""))) { return quote(rhs.substring(1, rhs.length() - 1)); 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 20391817f47..da24ae00fe8 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 @@ -2296,8 +2296,12 @@ private static FieldIntent fieldOf(EntityIntent entity, String name) { } /** - * Compiles a {@code requiredWhen} condition into the Java boolean the generated reader tests - - * every comparison rendered against its property's DECLARED type, and ANDed. + * Compiles a {@code requiredWhen} condition into the Java boolean the generated reader tests. + * + *

+ * The compiler is {@link CheckSupport#condition}, shared with the {@code event.when} guard of the + * declarative glue lists (issue #7289): one grammar, one type rule and one rendering, so a guard + * cannot mean one thing on a check and another on a listener. * * @param entity the entity carrying the check * @param byName the local entities by name (a to-one's key type comes from its target) @@ -2307,62 +2311,7 @@ private static FieldIntent fieldOf(EntityIntent entity, String name) { * the value unconditionally required) */ private static String requiredWhenGuard(EntityIntent entity, Map byName, Object when) { - List conditions = new ArrayList<>(); - for (String term : CheckSupport.terms(when)) { - CheckSupport.Comparison comparison = CheckSupport.parse(term); - if (comparison == null) { - return null; - } - FieldIntent field = fieldOf(entity, comparison.property()); - RelationIntent relation = field == null ? toOneOf(entity, comparison.property()) : null; - if (field == null && relation == null) { - return null; - } - String type = CheckSupport.guardType(field != null ? field.getType() : relationKeyType(relation, byName)); - // A to-one's foreign key is a whole number of a width this generator cannot know: the - // column is typed from the TARGET's key, and a cross-model target's key lives in the - // owner's .model, where `long` is as legal as `integer`. Objects.equals(Long, Integer) - // never holds, so such a guard is compared numerically - a boxed equality would switch the - // rule off while looking authored (#7237). A field's own width is declared, so it keeps - // the exact boxed equality. - boolean numericKey = field == null && CheckSupport.NUMERIC_GUARD_TYPES.contains(type); - String literal = CheckSupport.javaLiteral(numericKey ? "long" : type, comparison.literal()); - if (literal == null) { - return null; - } - String access = "entity." + IntentNaming.pascalCase(comparison.property()); - conditions.add(numericKey ? CheckSupport.numericComparison(access, comparison.equal(), literal) - : CheckSupport.comparison(access, comparison.equal(), literal)); - } - return conditions.isEmpty() ? null : String.join(" && ", conditions); - } - - /** The entity's to-one relation of that name, or {@code null}. */ - private static RelationIntent toOneOf(EntityIntent entity, String name) { - for (RelationIntent relation : entity.getRelations()) { - boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); - if (toOne && name != null && name.equals(relation.getName())) { - return relation; - } - } - return null; - } - - /** - * The declared type of a to-one relation's foreign key - the target's primary-key type, falling - * back to the whole number intent keys always are when the target is owned by another model. The - * width of that number is not knowable from here, so a guard on a to-one is rendered numerically - * rather than as a boxed equality - see {@link CheckSupport#numericComparison}. - */ - private static String relationKeyType(RelationIntent relation, Map byName) { - EntityIntent target = relation.getTo() == null ? null : byName.get(relation.getTo()); - if (target != null) { - FieldIntent key = primaryKeyOf(target); - if (key != null && key.getType() != null) { - return key.getType(); - } - } - return "integer"; + return CheckSupport.condition(entity, byName, when); } /** 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 158ad3564ea..76219d19260 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 @@ -2704,6 +2704,7 @@ private static String validateEventBinding(Map event, String sub } } validatePhaseBinding(event, subject, entity == null ? null : entityByName(model, entity), issues); + validateEventGuard(event, subject, entity == null ? null : entityByName(model, entity), model, issues); if (declared != 1) { issues.add( subject + " must declare exactly one of onCreate/onUpdate/onDelete/onTransition/onPhase/onStepReached/onStepCompleted"); @@ -2711,6 +2712,45 @@ private static String validateEventBinding(Map event, String sub return entity; } + /** + * The {@code when} guard of an event binding (issue #7289) - the moment qualifier of a + * {@code notifications}, {@code integrations} or {@code outbound} entry: one + * {@code ==|!= } comparison over the event entity's own properties, or the list + * form meaning their AND (#6957), with a status named by its seeded name resolved to the id before + * this runs. + * + *

+ * It is refused rather than degraded, for the reason {@code requiredWhen} refuses its own + * condition: the renderer answered {@code true} for anything its pattern did not match, so a guard + * with a typo ({@code Status = ISSUED}, {@code status == 'ISSUED' and channel == 'mail'}) switched + * itself off and the notification fired on EVERY update - a guard nobody authored, and silent all + * the way through generation, compile and publish. A guard on a property the record does not carry + * or against a literal of the wrong type is the same failure with a boxed comparison that never + * holds. + * + * @param event the binding map (may be {@code null}) + * @param subject the issue prefix naming the consumer + * @param entity the bound entity when it resolved, else {@code null} - the grammar is still held + * to, the property cannot be + * @param model the model, for the entities a to-one's key type is read from + * @param issues the collecting issue list + */ + private static void validateEventGuard(Map event, String subject, EntityIntent entity, IntentModel model, + List issues) { + if (event == null || event.get("when") == null) { + return; + } + List terms = CheckSupport.terms(event.get("when")); + if (terms.isEmpty()) { + issues.add(subject + " event when must not be an empty list"); + return; + } + java.util.Map byName = IntentEntities.byName(model); + for (String term : terms) { + validateGuardTerm(entity, byName, term, subject + " event", issues); + } + } + /** * The {@code onPhase} half of an event binding (#6929): the phase the consumer observes must be one * the entity DECLARES, and {@code phase:} belongs to that kind alone. @@ -4736,7 +4776,7 @@ private static void validateRequiredWhen(EntityIntent entity, CheckIntent check, issues.add(subject + " when must not be an empty list"); } for (String term : terms) { - validateRequiredWhenTerm(entity, byName, term, subject, issues); + validateGuardTerm(entity, byName, term, subject, issues); } } if (check.getStatus() != null && entityStatusRelationOf(entity) == null) { @@ -4746,22 +4786,34 @@ private static void validateRequiredWhen(EntityIntent entity, CheckIntent check, } /** - * One comparison of a {@code requiredWhen} condition: the property must be the record's own (the + * One comparison of a typed {@code when} guard - a {@code requiredWhen} condition or the + * {@code event.when} of the declarative glue axis: the property must be the record's own (the * condition is read off the row, nothing is loaded to evaluate it) and the literal must be a value * of that property's type. Both refusals are about a guard that would otherwise be silently * always-false - a boxed comparison across types never holds - which switches the rule off while * looking authored. + * + *

+ * The property is looked up through {@link CheckSupport}, i.e. exactly as the renderer of the same + * guard looks it up ({@link CheckSupport#condition}), so a guard the generator compiles cannot be + * refused here and a guard refused here cannot compile there. + * + * @param entity the entity the guard is read off, or {@code null} when the binding did not resolve + * (the grammar is still held to, the property cannot be) */ - private static void validateRequiredWhenTerm(EntityIntent entity, java.util.Map byName, String term, - String subject, List issues) { + private static void validateGuardTerm(EntityIntent entity, java.util.Map byName, String term, String subject, + List issues) { CheckSupport.Comparison comparison = CheckSupport.parse(term); if (comparison == null) { issues.add(subject + " when [" + term + "] must be ` ==|!= ` - a number, a status name, a quoted" + " string or a bare word"); return; } - FieldIntent field = fieldByName(entity, comparison.property()); - RelationIntent relation = field == null ? toOneByName(entity, comparison.property()) : null; + if (entity == null) { + return; + } + FieldIntent field = CheckSupport.field(entity, comparison.property()); + RelationIntent relation = field == null ? CheckSupport.toOne(entity, comparison.property()) : null; if (field == null && relation == null) { issues.add(subject + " when [" + term + "] guards [" + comparison.property() + "], which is not a field or to-one relation of [" + entity.getName() + "] - the condition is read off the record itself"); @@ -4770,7 +4822,7 @@ private static void validateRequiredWhenTerm(EntityIntent entity, java.util.Map< // Normalised before anything looks at it: a field without a `type:` is a string here as it is // everywhere else in the parser, and `Integer` is the `integer` the rest of the DSL accepts. // The raw value also must not reach GUARD_TYPES.contains, which throws on a null. - String declared = field != null ? field.getType() : relationKeyType(relation, byName); + String declared = field != null ? field.getType() : CheckSupport.relationKeyType(relation, byName); String type = CheckSupport.guardType(declared); if (field != null && !CheckSupport.GUARD_TYPES.contains(type)) { issues.add(subject + " when [" + term + "] compares [" + comparison.property() + "], which is a [" + declared @@ -4783,39 +4835,6 @@ private static void validateRequiredWhenTerm(EntityIntent entity, java.util.Map< } } - /** The entity's to-one relation of that name, or {@code null}. */ - private static RelationIntent toOneByName(EntityIntent entity, String name) { - if (entity.getRelations() != null) { - for (RelationIntent relation : entity.getRelations()) { - boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); - if (toOne && name != null && name.equals(relation.getName())) { - return relation; - } - } - } - return null; - } - - /** - * The declared type of a to-one relation's foreign key - the target's primary-key type. A - * cross-model target's model is not loaded here, and intent primary keys are whole numbers, so that - * is what an unresolvable target falls back to. The WIDTH of that number is not knowable here (the - * owner's {@code .model} may type its key {@code long}), which is why the generator renders a - * to-one's guard as a numeric comparison rather than a boxed equality - see - * {@link CheckSupport#numericComparison}. - */ - private static String relationKeyType(RelationIntent relation, java.util.Map byName) { - EntityIntent target = relation.getTo() == null ? null : byName.get(relation.getTo()); - if (target != null && target.getFields() != null) { - for (FieldIntent field : target.getFields()) { - if (field.isPrimaryKey() && field.getType() != null) { - return field.getType(); - } - } - } - return "integer"; - } - /** The entity's {@code function: EntityStatus} relation, or {@code null}. */ private static RelationIntent entityStatusRelationOf(EntityIntent entity) { if (entity.getRelations() != null) { diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java index f6505003edb..9f9da91026b 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java @@ -19,6 +19,7 @@ import java.util.regex.Pattern; import org.eclipse.dirigible.components.intent.generator.ProcessWaitSupport; +import org.eclipse.dirigible.components.intent.generator.StepEventSupport; /** * Resolves a status referenced by its seeded name to the seed id, everywhere the intent @@ -56,6 +57,10 @@ final class StatusSymbolResolver { private static final Pattern INTEGER = Pattern.compile("-?\\d+"); + /** The declarative glue lists carrying an {@code event.when} guard, to the subject naming one. */ + private static final List> GLUE_LISTS = List.of(Map.entry("notifications", "notification"), + Map.entry("integrations", "integration"), Map.entry("outbound", "outbound")); + /** Entity name to its raw node. */ private final Map> entities = new LinkedHashMap<>(); @@ -85,6 +90,7 @@ static void resolve(Object tree) { resolver.rewritePostings(root); resolver.rewriteGenerates(root); resolver.rewriteSchedules(root); + resolver.rewriteGlue(root); resolver.rewriteResolves(root); resolver.rewriteReports(root); if (!resolver.issues.isEmpty()) { @@ -359,6 +365,74 @@ private void rewriteSchedules(Map root) { } } + /** + * The {@code event.when} guard of the three declarative glue lists - {@code notifications}, + * {@code integrations} and {@code outbound} (issue #7289) - on the nomenclature of the entity the + * bound event is about. + * + *

+ * These bind the same event axis a posting and an event-driven create-from bind, whose + * {@code event.when} has been symbolic since #6711, and the natural authoring of the construct is + * the status one: "mail the customer when the invoice reaches ISSUED", "forward the record once it + * is APPROVED". Left unresolved the name reached the generated listener as a string compared + * against the integer status FK ({@code Objects.equals(entity.Status, "ISSUED")}) - never true, so + * the mail never went out and the departure never left, with parse, generation, compile and publish + * all green. + * + *

+ * The event entity is always LOCAL here (the axis takes no {@code model:}, and the parser refuses + * an unknown entity), so the nomenclature is this file's: a guard about anything else passes + * through untouched, exactly as at every other guard site. + */ + private void rewriteGlue(Map root) { + for (Map.Entry list : GLUE_LISTS) { + for (Object node : asList(root.get(list.getKey()))) { + Map entry = asMap(node); + Map event = asMap(entry == null ? null : entry.get("event")); + if (event == null || event.get("when") == null) { + continue; + } + String entity = glueEventEntityOf(root, event); + rewriteWhen(event, statusRelationName(entity), statusOf(entity), + list.getValue() + " [" + text(entry, "name") + "] event when"); + } + } + } + + /** + * The entity the bound event is about: the one a lifecycle binding names, or - for a process step + * binding, which names a step rather than a record - the trigger entity of that process, the record + * the step event is delivered about. + * + *

+ * This runs on the RAW tree, before the typed mapping, so the kinds are spelled out rather than + * read off {@code EventBinding}; keep them in step with it. A kind missing here is silent in the + * usual way: the event entity does not resolve, so a status NAME in that guard cannot be looked up + * and the model is refused with a message about the nomenclature rather than about the binding. + */ + private static String glueEventEntityOf(Map root, Map event) { + for (String kind : List.of("onCreate", "onUpdate", "onDelete", "onTransition", "onPhase", "onNotifyFailed")) { + String entity = text(event, kind); + if (entity != null) { + return entity; + } + } + for (String kind : List.of(StepEventSupport.ON_STEP_REACHED, StepEventSupport.ON_STEP_COMPLETED)) { + Map binding = asMap(event.get(kind)); + String processName = binding == null ? null : text(binding, "process"); + if (processName == null) { + continue; + } + for (Object node : asList(root.get("processes"))) { + Map process = asMap(node); + if (process != null && processName.equals(text(process, "name"))) { + return triggerEntityOf(process); + } + } + } + return null; + } + /** * Resolve the one condition of a {@code { field, op, value }} where list whose {@code field} names * the queried entity's {@code function: EntityStatus} relation, on that entity's own nomenclature. 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 539da9be3b5..6ca8b722174 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 @@ -2296,8 +2296,9 @@ is unclassified, Generate reports the aggregate as lifecycle-blind and the total Everywhere the intent names a status - `transitions[].from` / `setStatus`, a `lifecycle:` edge, a relation's `init:`, a `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a -posting's `event.when`, a report's `filter`, the status condition of a `schedules[].where` row query or -of a create-from's `items: where:` rule - use the **seeded name** instead of the id: +posting's `event.when`, the `event.when` of a `notifications` / `integrations` / `outbound` entry, a +report's `filter`, the status condition of a `schedules[].where` row query or of a create-from's +`items: where:` rule - use the **seeded name** instead of the id: ```yaml transitions: @@ -3330,9 +3331,24 @@ The step record is published **after commit** and is not transactional with the of the axis is at-least-once: a redelivery re-notifies, re-forwards, or (under `mode: append`) appends a second row. -Every axis binding also takes an optional **`when:` guard** inside the `event:` map - a single -comparison against a direct field of the record (`when: "channel != internal"`), which decides per -record whether the reaction runs at all. +Every axis binding also takes an optional **`when:` guard** inside the `event:` map, which decides per +record whether the reaction runs at all. It is one ` ==|!= ` comparison over the +event record's own properties - a field or a to-one's key - or a LIST of them, meaning their AND: + +```yaml +notifications: + - name: issued-mail + event: { onUpdate: SalesInvoice, when: "Status == ISSUED" } # the seeded status name, not the id +``` + +A status is named here exactly as at every other guard site: the name is resolved to its seed id at +parse and the comparison is rendered against the status FK's declared type, so `Status == ISSUED` +holds. A guard that does not parse - `Status = ISSUED`, `status == 'ISSUED' and channel == 'mail'` - +is **refused at parse**, and so is one naming a property the record does not carry or comparing it +with a literal of the wrong type (only strings, integers, booleans and a to-one's key are guardable; +a decimal or a date is compared for equality by nobody who means it). It is not degraded to `true`: +a guard that silently switches itself off fires the reaction on EVERY event, which is a guard nobody +authored. ### which writes are observable (what a reaction can actually see) diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueEventGuardTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueEventGuardTest.java new file mode 100644 index 00000000000..d5d93ae4eda --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueEventGuardTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * The {@code event.when} guard of the three declarative glue lists, rendered against the guarded + * property's declared type (dirigible #7289). + * + *

+ * A status guard is the natural authoring of the construct - "mail the customer when the invoice + * reaches ISSUED" - and the seeded name is resolved to its seed id before the typed mapping. The + * untyped renderer quoted whatever it did not recognise, so that id was compared as a string + * against the integer status FK: the mail never went out, the departure never left, and parse, + * generation, compile and publish were all green. + */ +class GlueEventGuardTest { + + private static final String YAML = """ + name: sales + entities: + - name: OrderStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - { name: channel, type: string } + - { name: contactEmail, type: string } + relations: + - { name: Status, kind: manyToOne, to: OrderStatus, function: EntityStatus, init: 1 } + notifications: + - name: issued-mail + event: { onUpdate: Order, when: "Status == ISSUED" } + to: contactEmail + subject: "Order {number} is on its way" + body: "Thank you for your order." + - name: web-issued-mail + event: { onUpdate: Order, when: ["Status == ISSUED", "channel == 'web'"] } + to: contactEmail + subject: "Order {number} is on its way" + body: "Thank you for your order." + integrations: + - name: pushOrder + event: { onUpdate: Order, when: "Status == ISSUED" } + method: POST + url: "https://api.example.com/orders" + outbound: + - name: publishOrder + event: { onUpdate: Order, when: "Status == ISSUED" } + to: { queue: "codbex.orders" } + seeds: + - name: order-statuses + entity: OrderStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: ISSUED } + """; + + /** + * The seed id compared numerically: a to-one's key is a whole number of a width the generator + * cannot know (a cross-model target types its key in the owner's {@code .model}), and + * {@code Objects.equals(Long, Integer)} never holds - the same rule {@code requiredWhen} renders + * its own to-one guard by (#7237). + */ + @Test + void aStatusGuardComparesTheForeignKeyNumerically() { + IntentModel model = IntentParser.parse(YAML); + String expected = "(entity.Status != null && entity.Status.longValue() == 2L)"; + + assertEquals(expected, guard(GlueIntentGenerator.buildNotificationsForTest(model), "issued-mail"), "notification"); + assertEquals(expected, guard(GlueIntentGenerator.buildIntegrationsForTest(model), "pushOrder"), "integration"); + assertEquals(expected, guard(GlueIntentGenerator.buildOutboundForTest(model), "publishOrder"), "outbound"); + } + + /** + * The list form is the implicit AND of #6957. On this axis the whole list used to be stringified + * into the scalar pattern, which never matched - so a two-term guard rendered {@code true} and the + * mail went out on every update. + */ + @Test + void theListFormRendersAsAConjunction() { + assertEquals("(entity.Status != null && entity.Status.longValue() == 2L) && java.util.Objects.equals(entity.Channel, \"web\")", + guard(GlueIntentGenerator.buildNotificationsForTest(IntentParser.parse(YAML)), "web-issued-mail")); + } + + /** + * A guard on the record's own string field keeps the exact boxed equality - its width is declared. + */ + @Test + void aStringGuardKeepsTheBoxedEquality() { + IntentModel model = IntentParser.parse(YAML.replace("when: \"Status == ISSUED\"", "when: \"channel == 'web'\"")); + + assertEquals("java.util.Objects.equals(entity.Channel, \"web\")", + guard(GlueIntentGenerator.buildNotificationsForTest(model), "issued-mail")); + } + + private static String guard(List> entries, String name) { + for (Map entry : entries) { + if (name.equals(entry.get("name"))) { + return String.valueOf(entry.get("guardExpression")); + } + } + throw new AssertionError("no glue entry [" + name + "] in " + entries); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/EventGuardIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/EventGuardIntentTest.java new file mode 100644 index 00000000000..c8e359fe4f8 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/EventGuardIntentTest.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.junit.jupiter.api.Test; + +/** + * The {@code event.when} guard of the declarative glue lists - {@code notifications}, + * {@code integrations}, {@code outbound} - is held to the closed grammar every other typed guard is + * held to, and refused when it does not parse (dirigible #7289). + * + *

+ * The renderer answered {@code true} for anything its pattern did not match, so a guard with a typo + * switched itself off and the consumer fired on EVERY event - a guard nobody authored, silent all + * the way through generation, compile and publish. That is the degradation {@code requiredWhen} + * refused for its own condition (#7094), for the same reason. + */ +class EventGuardIntentTest { + + private static final String YAML = """ + name: sales + entities: + - name: OrderStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - { name: channel, type: string } + - { name: total, type: decimal } + - { name: contactEmail, type: string } + relations: + - { name: Status, kind: manyToOne, to: OrderStatus, function: EntityStatus, init: 1 } + notifications: + - name: issued-mail + event: { onUpdate: Order, when: "Status == ISSUED" } + to: contactEmail + subject: "Order {number} is on its way" + body: "Thank you for your order." + seeds: + - name: order-statuses + entity: OrderStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: ISSUED } + """; + + /** The construct the issue is about: the seeded name is what an author writes, and it resolves. */ + @Test + void aSeededStatusNameIsAcceptedAndResolved() { + IntentModel model = IntentParser.parse(YAML); + + assertEquals("Status == 2", String.valueOf(model.getNotifications() + .get(0) + .getEvent() + .get("when"))); + } + + /** A single {@code =} is the typo the old renderer answered {@code true} to. */ + @Test + void anAssignmentInsteadOfAComparisonIsRefused() { + assertIssue(YAML.replace("Status == ISSUED", "Status = ISSUED"), "must be ` ==|!= `"); + } + + /** So is a conjunction spelled as prose - the list form is how an AND is said here. */ + @Test + void aProseConjunctionIsRefused() { + assertIssue(YAML.replace("Status == ISSUED", "Status == ISSUED and channel == 'web'"), "must be ` ==|!= `"); + } + + /** The list form IS accepted - one comparison per element, ANDed (#6957). */ + @Test + void theListFormIsAccepted() { + IntentModel model = + IntentParser.parse(YAML.replace("when: \"Status == ISSUED\"", "when: [\"Status == ISSUED\", \"channel == 'web'\"]")); + + assertEquals("[Status == 2, channel == 'web']", String.valueOf(model.getNotifications() + .get(0) + .getEvent() + .get("when"))); + } + + /** A guard is read off the record, so it can only name the record's own properties. */ + @Test + void aGuardOnAPropertyTheRecordDoesNotCarryIsRefused() { + assertIssue(YAML.replace("Status == ISSUED", "sentMethod == 1"), "is not a field or to-one relation of [Order]"); + } + + /** + * And only on the types an equality is exact on: {@code Objects.equals(BigDecimal, 0)} never holds, + * so a guard on a decimal column would switch itself off while looking authored. + */ + @Test + void aGuardOnADecimalIsRefused() { + assertIssue(YAML.replace("Status == ISSUED", "total == 0"), "which is a [decimal] field"); + } + + /** The same grammar on the other two lists of the axis - one validator, one answer. */ + @Test + void theIntegrationAndOutboundGuardsAreHeldToTheSameGrammar() { + String yaml = """ + name: sales + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: channel, type: string } + integrations: + - name: pushOrder + event: { onUpdate: Order, when: "channel = web" } + method: POST + url: "https://api.example.com/orders" + outbound: + - name: publishOrder + event: { onUpdate: Order, when: "channel == 'web' or channel == 'mail'" } + to: { queue: "codbex.orders" } + """; + IntentValidationException thrown = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + + assertTrue(thrown.getIssues() + .stream() + .anyMatch(issue -> issue.startsWith("integration [pushOrder] event when [channel = web] must be")), + "the integration guard must be refused: " + thrown.getIssues()); + assertTrue(thrown.getIssues() + .stream() + .anyMatch(issue -> issue.startsWith("outbound [publishOrder] event when [channel == 'web' or")), + "the outbound guard must be refused: " + thrown.getIssues()); + } + + /** + * A step-bound consumer's guard resolves on the process's TRIGGER entity - the record the step + * event is delivered about - because the binding itself names a step and no record at all. + */ + @Test + void aStepEventGuardResolvesOnTheProcessTriggerEntity() { + String yaml = YAML + .replace("event: { onUpdate: Order, when: \"Status == ISSUED\" }", + "event: { onStepReached: { process: OrderFlow, step: review }, when: \"Status == ISSUED\" }") + .replace("notifications:", """ + processes: + - name: OrderFlow + trigger: { onCreate: Order } + steps: + - { name: review, kind: userTask, args: { assignee: manager, form: ReviewOrder } } + forms: + - { name: ReviewOrder, forEntity: Order, fields: [number], actions: [approve] } + notifications:"""); + + assertEquals("Status == 2", String.valueOf(IntentParser.parse(yaml) + .getNotifications() + .get(0) + .getEvent() + .get("when"))); + } + + /** An empty list is a guard that says nothing while looking like one. */ + @Test + void anEmptyListGuardIsRefused() { + assertIssue(YAML.replace("when: \"Status == ISSUED\"", "when: []"), "event when must not be an empty list"); + } + + private static void assertIssue(String yaml, String expected) { + IntentValidationException thrown = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(thrown.getIssues() + .stream() + .anyMatch(issue -> issue.contains(expected)), + "expected an issue containing [" + expected + "] but got " + thrown.getIssues()); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolIntentTest.java index c95745a57c7..08adfe855f6 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolIntentTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolIntentTest.java @@ -62,6 +62,12 @@ class StatusSymbolIntentTest { source: Invoice filter: "Status != VOIDED" measures: ["sum(paid)"] + notifications: + - name: issued-mail + event: { onUpdate: Invoice, when: "Status == ISSUED" } + to: ops@example.com + subject: "Invoice {number} issued" + body: "The invoice has been issued." schedules: - name: dunning cron: "0 0 8 * * ?" @@ -121,6 +127,23 @@ void everySiteResolvesTheNameToItsSeedId() { .get(0) .getValue()), "schedule where status"); + assertEquals("Status == 3", String.valueOf(model.getNotifications() + .get(0) + .getEvent() + .get("when")), + "notification event when"); + } + + /** + * The {@code event.when} of the three declarative glue lists (issue #7289) - the guard that + * qualifies the moment a mail goes out, a record is forwarded or a departure leaves. Left + * unresolved, the name reached the generated listener as a string compared with the integer status + * FK, so the guard could never hold and the message was never sent. + */ + @Test + void anUnknownStatusNameInAGlueEventGuardIsRejected() { + assertIssue(YAML.replace("event: { onUpdate: Invoice, when: \"Status == ISSUED\" }", + "event: { onUpdate: Invoice, when: \"Status == ISUED\" }"), "not a seeded status of [InvoiceStatus]"); } /** 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 e0611077fac..62f9b9e8c8c 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 @@ -1256,10 +1256,12 @@ class IntentEmissionCoverageIT extends IntegrationTest { # The departure half (#6767): the same record leaving on a queue, as a DECLARED envelope # rather than the row as stored. The guard keeps an internal note off the wire, which is - # the assertion that the `when` of the event axis reaches a publisher at all. + # the assertion that the `when` of the event axis reaches a publisher at all. It is the + # LIST form - the implicit AND (#6957) - which this axis used to stringify into its scalar + # pattern and render as `true`, so a two-term guard let EVERY record depart (#7289). outbound: - name: publishSignal - event: { onCreate: Signal, when: "note != internal" } + event: { onCreate: Signal, when: ["note != internal", "note != secret"] } to: { queue: emission-signals-out } payload: type: "signal.raised" @@ -2662,8 +2664,10 @@ private void assertEmission() { String publisher = contentOf("gen/events/emission/PublishSignalPublisher.java"); assertTrue(publisher.contains("return \"emission-test-Signal-Signal\";") && publisher.contains("ListenerKind.TOPIC"), "a departure must subscribe to the topic the entity's repository publishes its create on"); - assertTrue(publisher.contains("!java.util.Objects.equals(entity.Note, \"internal\")"), - "the event axis carries a when guard, and a departure must honour it"); + assertTrue( + publisher.contains( + "!java.util.Objects.equals(entity.Note, \"internal\") && !java.util.Objects.equals(entity.Note, \"secret\")"), + "the event axis carries a when guard, and a departure must honour EVERY term of its list form (#7289)"); assertTrue( publisher.contains("payload.put(\"type\", \"signal.raised\")") && publisher.contains("payload.put(\"messageId\", java.util.UUID.randomUUID().toString())") @@ -5587,12 +5591,17 @@ private static String envelope(String message, int version, String raisedBy) { * declared queue, and a record the guard excludes puts nothing there. Only this layer can show it - * the publisher being really subscribed, the envelope being really built, and the guard really * running - which no assertion over the emitted source reaches. + * + *

+ * The guard is the LIST form, so this is also where the #7289 degradation shows: rendered as + * {@code true}, as the axis rendered every list guard, BOTH excluded records would depart. */ private void assertOutboundDepartureRuntime() { String signalApi = API + "/signal/SignalController"; // The guarded record first: the queue is FIFO, so had it departed it would arrive BEFORE the // one that must, and the drain below would see it. createSignal(signalApi, "internal"); + createSignal(signalApi, "secret"); createSignal(signalApi, "outbound-ok"); String departed = null; @@ -5601,7 +5610,8 @@ private void assertOutboundDepartureRuntime() { if (message == null) { continue; } - assertFalse(message.contains("\"internal\""), "a record the when guard excludes must never depart: " + message); + assertFalse(message.contains("\"internal\"") || message.contains("\"secret\""), + "a record any term of the when guard excludes must never depart: " + message); if (message.contains("outbound-ok")) { departed = message; }