diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index e3f7c998616..6a766e2e389 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -32,7 +32,7 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **The enrichment channel (`phases:` + `onPhase`, [#6929](https://github.com/eclipse-dirigible/dirigible/issues/6929)):** a value a listener computes AFTER the insert — a moving-average cost, a snapshot column, an external lookup — must be written back **event-silently** or it re-fires every onUpdate consumer of a change the user never made; so it published nothing at all, and a declarative consumer of that value had no moment to bind. Bound to `onCreate` it RACED the enrichment (two listeners on one topic have no order — each `MessageHandler` is its own durable subscriber, and there is no priority anywhere), and posted a balanced-looking journal entry for a null amount with parse, generation, compile and publish all green. The fix is a CHANNEL, not an ordering contract the broker cannot keep: an entity declares the moments it announces (`phases: [costed]`), the Java DAO template emits one **`announce(id, values)`** per phase — `updateProperties` with the phase's own topic, so the enrichment and its notice ride ONE write into the outbox and commit together — and any glue consumer binds `event: { onPhase: , phase: }`. The generated method is the point: a hand-typed topic string reproduces exactly the silence being removed, a mistyped `announceCosted` is a compile error. Accepted by `postings:` (the driver), `notifications:`, `integrations:`, `outbound:` and an event-driven `generates:`, with the `when:` guard optional there (the phase already IS one moment); deliberately not by a process `trigger:`, a `wait` or `resolves:`. Refused at parse, each because it is otherwise silent: a phase that is not a lower-camel identifier, one named after a platform channel (`updated`/`deleted`/`transitioned`/`rekeyed`), a duplicate, a `phase:` key on another axis, and a binding naming a phase the entity does not declare. Details in the engine-intent guide's phases bullet. -**Which source rows become lines (`items: where:` + `refuse:`, [#7091](https://github.com/eclipse-dirigible/dirigible/issues/7091)):** a create-from's mirror `items:` block cloned EVERY row of the source document into a target line and the DSL could not say which rows qualified, so base-timesheets billed every member timesheet of the project-month - a DRAFT / REJECTED one at the same footing as an APPROVED one, and an EMPTY one (whose mapped quantity the target refuses) stopped the whole Generate until someone deleted the row by hand. "Invoice the approved month" is the one flow a billing clerk runs, and the module could either bill unapproved hours or not bill at all; the gap is fleet-wide (proforma -> invoice, quotation -> order, order -> invoice). `where:` is the rule - the same `{ field, op, value }` triples a `schedules[].where` carries, incl. a moment value resolved against the clock of the run - pushed into the very `Criteria` that already selects the source's rows by their master foreign key, so an unqualified row is never loaded; a condition naming the source ITEM's own `function: EntityStatus` relation may use the seeded status name (on the item's nomenclature, never the header's). `refuse:` declares the other reading: an unqualified row stops the whole run with the authored message plus the KEYS of the offending rows, instead of being left out - dropping a rejected line silently and billing it silently are both wrong for different months, so skipping is the default and `refuse:` without a `where:` is refused at parse. **A rule that qualifies no row refuses too**, rather than committing a header with no lines - the harder failure to notice, the document existing and counting as the period's billing. Scoped to a `where`-declaring block, so a rule-less items block is byte-identical; the rule's `field` is checked against the item source's own properties at parse, unlike a schedule's query, whose source may be a cross-model row. +**Which source rows become lines (`items: where:` + `refuse:`, [#7091](https://github.com/eclipse-dirigible/dirigible/issues/7091)):** a create-from's mirror `items:` block cloned EVERY row of the source document into a target line and the DSL could not say which rows qualified, so base-timesheets billed every member timesheet of the project-month - a DRAFT / REJECTED one at the same footing as an APPROVED one, and an EMPTY one (whose mapped quantity the target refuses) stopped the whole Generate until someone deleted the row by hand. "Invoice the approved month" is the one flow a billing clerk runs, and the module could either bill unapproved hours or not bill at all; the gap is fleet-wide (proforma -> invoice, quotation -> order, order -> invoice). `where:` is the rule - the same `{ field, op, value }` triples a `schedules[].where` carries, incl. a moment value resolved against the clock of the run - pushed into the very `Criteria` that already selects the source's rows by their master foreign key, so an unqualified row is never loaded; a condition naming the source ITEM's own `function: EntityStatus` relation may use the seeded status name (on the item's nomenclature, never the header's) - as, since [#7251](https://github.com/eclipse-dirigible/dirigible/issues/7251), does the `schedules[].where` row query this shape was modelled on, where a name generated `.eq("Status", "OVERDUE")` into the job and matched nothing forever. `refuse:` declares the other reading: an unqualified row stops the whole run with the authored message plus the KEYS of the offending rows, instead of being left out - dropping a rejected line silently and billing it silently are both wrong for different months, so skipping is the default and `refuse:` without a `where:` is refused at parse. **A rule that qualifies no row refuses too**, rather than committing a header with no lines - the harder failure to notice, the document existing and counting as the period's billing. Scoped to a `where`-declaring block, so a rule-less items block is byte-identical; the rule's `field` is checked against the item source's own properties at parse, unlike a schedule's query, whose source may be a cross-model row. **A create-from is not offered twice (`fromStatus:`, [#7068](https://github.com/eclipse-dirigible/dirigible/issues/7068)):** a `generates:` with a `sourceStatus:` completion hook flipped its source once the target existed and then went on offering the same button on the flipped record - and answering the same endpoint 200 - so a second click minted a **second document**: a proforma already INVOICED produced a second invoice, in the customer's hands. The hook declared what "already done" looks like; nothing consulted it. A create-from now carries a from-status guard resolved ONCE and fed to both halves of the action: the generated `run()` refuses with **409** before anything is created, and the contributed action descriptor carries the same guard so the shared `customActions` store stops OFFERING the click on a record it would refuse (`getActions(view, type, record)` takes the record the view already has). Two shapes: `fromStatus: [...]` is the explicit allow-list - the `from:` of a `transitions:` entry, spelled differently only because `from:` on a create-from already names the source ENTITY - and absent it a declared `sourceStatus` IMPLIES the deny-list of exactly that status, so a model that already carries the defect is fixed with no authoring change. The guard is on the CLICK: an event-driven create-from keeps its own at-most-once back-reference guard and qualifies its moment with `event.when`, so `fromStatus` on an event-only rule is refused at parse rather than silently ignored - as are a `page` scope, a source with no `function: EntityStatus` relation, and an allow-list containing the `sourceStatus` the action itself writes. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index d2f9bc884c3..cb2d7f0c111 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 computes is compared accordingly (#7177) - a `calculatedOnCreate`/`calculatedActionOnCreate` one is dropped from the comparison entirely and the discarded assignment reported at generation (its value never reaches the column, so left in it is a difference no redelivery can ever clear), while 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. 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`) - 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 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. +- **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 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. 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 a5cd5a5ed5c..7734a8821d5 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 @@ -933,6 +933,7 @@ private static void validateSchedules(IntentModel model, Set entityNames + "] (supported: eq/ne/gt/ge/lt/le/like)"); } validateScheduleMoment(condition, source, "schedule [" + name + "]", issues); + validateWhereStatusValue(condition, source, "schedule [" + name + "]", issues); } // A schedule performs exactly one per-row action: notify (mail) or generate (create-from). boolean hasNotify = schedule.getNotify() != null; @@ -2024,6 +2025,66 @@ private static void validateScheduleMoment(ScheduleConditionIntent condition, En } } + /** + * A {@code where} condition on the queried entity's own {@code function: EntityStatus} relation + * must carry a status ID. + * + *

+ * A status may be referenced by its seeded NAME, and that rewrite ({@code StatusSymbolResolver}, + * issue #7251) runs on the raw tree before this validation - so a name never arrives here: it has + * already become the seed id, or been refused as an unknown one. What can still arrive is a value + * no status can ever equal (a stage word, a blank, a moment token), which renders as + * {@code .eq("Status", "OVERDUE")} into the generated query and then matches nothing for as long as + * the schedule keeps ticking. Refusing it here also keeps the invariant checkable independently of + * the resolver's site list - the drift that left {@code schedules[].where} behind when the sibling + * {@code items: where:} gained the rewrite. + * + *

+ * Only the status condition is checked: every other condition compares an ordinary column, where a + * string literal is just a literal. A cross-model source has no local relations to check against + * (its field references are resolved at generation time against the owner's {@code .model}), so it + * keeps the numeric-id form the same way every other cross-model status site does. + */ + private static void validateWhereStatusValue(ScheduleConditionIntent condition, EntityIntent source, String subject, + List issues) { + if (source == null || source.getRelations() == null || condition.getField() == null) { + return; + } + for (RelationIntent relation : source.getRelations()) { + if (!relation.isEntityStatus() || relation.getName() == null || !relation.getName() + .equalsIgnoreCase(condition.getField())) { + continue; + } + if (!isIntegerLiteral(condition.getValue())) { + issues.add(subject + " where-condition on the status relation [" + relation.getName() + "] compares it with [" + + condition.getValue() + "], which is not a status - a status is an integer FK, so name the seeded status" + + " (resolved to its id at parse) or give the numeric seed id"); + } + return; + } + } + + /** Whether a {@code where} value is a whole number - as an id, or as the text of one. */ + private static boolean isIntegerLiteral(Object value) { + if (value instanceof Number number) { + return number.longValue() == number.doubleValue(); + } + if (value == null) { + return false; + } + String text = String.valueOf(value) + .trim(); + if (text.isEmpty()) { + return false; + } + try { + Long.parseLong(text); + return true; + } catch (NumberFormatException ex) { + return false; + } + } + /** * Each outbound departure must have a unique name, bind to exactly one event of the glue event * axis, and name exactly one channel to leave on. A departure declaring no channel is a promise @@ -7462,6 +7523,7 @@ private static void validateGeneratesItemsWhere(GeneratesItemsIntent items, Stri + "], which is not a field or to-one relation of [" + itemSource.getName() + "]"); } validateScheduleMoment(condition, itemSource, subject + " items", issues); + validateWhereStatusValue(condition, itemSource, subject + " items", issues); } } 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 85e269160d3..06d8dac2ecc 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 @@ -84,6 +84,7 @@ static void resolve(Object tree) { resolver.rewriteProcesses(root); resolver.rewritePostings(root); resolver.rewriteGenerates(root); + resolver.rewriteSchedules(root); resolver.rewriteResolves(root); resolver.rewriteReports(root); if (!resolver.issues.isEmpty()) { @@ -312,26 +313,58 @@ private void rewriteGenerates(Map root) { * symbolic - and on the ITEM row's own nomenclature, not the header's: the rule selects the rows of * the source document, so resolving a name against the document's lifecycle would take an id out of * the wrong nomenclature and quietly filter on it. - * - *

- * Only the condition whose {@code field} names that {@code function: EntityStatus} relation is a - * candidate at all, exactly as a register lookup's static filter is: every other condition compares - * an ordinary column, whose string value ({@code op: like} on a name) is just a value and would be - * reported as an unknown status. */ private void rewriteGeneratesItemsWhere(Map generate, String subject) { Map items = asMap(generate.get("items")); String itemEntity = items == null ? null : text(items, "from"); - String statusRelation = statusRelationName(itemEntity); + rewriteConditions(items == null ? null : items.get("where"), itemEntity, subject + " items where"); + } + + /** + * The row query of a cron schedule (issue #7251) - the same {@code { field, op, value }} triples an + * items rule carries, and the site a status guard is written at most often: a dunning run, a + * staleness sweep, a month-end generation all start by naming the status the row must stand in. + * Left unresolved, the name reached the generated job as a string compared against the integer + * status FK ({@code .eq("Status", "OVERDUE")}), so the query matched nothing forever and the + * schedule ticked on doing nothing - the silent failure naming a status exists to remove (#6645). + * + *

+ * Same-model source only. A cross-model source ({@code model: }) is not in this file's + * {@code entities}, so neither its nomenclature nor even WHICH of the conditions names its status + * is knowable here - its {@code where} field references are validated at generation time against + * the owner's {@code .model} - and it therefore keeps the numeric-id form, exactly as every other + * cross-model status site does. + */ + private void rewriteSchedules(Map root) { + for (Object node : asList(root.get("schedules"))) { + Map schedule = asMap(node); + if (schedule == null || text(schedule, "model") != null) { + continue; + } + rewriteConditions(schedule.get("where"), text(schedule, "entity"), "schedule [" + text(schedule, "name") + "] where"); + } + } + + /** + * 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. + * + *

+ * Only that condition is a candidate at all, exactly as a register lookup's static filter is: every + * other condition compares an ordinary column, whose string value (an {@code op: like} on a name) + * is just a value and would be reported as an unknown status. + */ + private void rewriteConditions(Object where, String entityName, String subject) { + String statusRelation = statusRelationName(entityName); if (statusRelation == null) { return; } - Target itemStatus = statusOf(itemEntity); - for (Object node : asList(items.get("where"))) { + Target status = statusOf(entityName); + for (Object node : asList(where)) { Map condition = asMap(node); String field = condition == null ? null : text(condition, "field"); if (field != null && lower(field).equals(lower(statusRelation))) { - putResolved(condition, "value", itemStatus, subject + " items where [" + field + "]"); + putResolved(condition, "value", status, subject + " [" + field + "]"); } } } 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 dd44ffd53fb..17d643a5da4 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 @@ -2295,7 +2295,8 @@ 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` - use the **seeded name** instead of the id: +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: ```yaml transitions: @@ -2959,6 +2960,28 @@ schedules: # add `attach: print` to carry the row's own rendered document (dunning with the invoice) ``` +**A condition on the source's own `function: EntityStatus` relation takes the seeded status NAME**, +resolved to its seed id at parse: + +```yaml +schedules: + - name: dunning + cron: "0 0 8 * * ?" + entity: SalesInvoice + where: + - { field: Status, op: eq, value: OVERDUE } # the SalesInvoice EntityStatus relation + - { field: dueOn, op: lt, value: CURRENT_DATE } + notify: { to: contactEmail, subject: "Invoice {number} is overdue", attach: print } +``` + +This matters most here, since a schedule filter is where a status guard is written most often +(dunning, staleness sweeps, month-end runs) and an id is **positional**: inserting a status +mid-nomenclature would silently retarget the query. A name that is not seeded is a generation error, +and so is a value that is no status at all - never a `.eq("Status", "OVERDUE")` that matches nothing +for as long as the schedule keeps ticking. The nomenclature must be seeded in THIS model: a +cross-model source (`model: `) keeps the numeric seed id, as every other cross-model +status site does. + **A `where` value may be a moment relative to now** - which is what makes the archetypal schedule, a **staleness sweep**, expressible at all ("stuck provisioning for 30 minutes", "unanswered for a week", "abandoned for an hour"). Write the moment token with one signed ISO-8601 offset; it resolves against diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java index c669f85a9bd..b236114da9f 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java @@ -10,6 +10,7 @@ package org.eclipse.dirigible.components.intent.generator; 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 java.util.List; @@ -17,6 +18,7 @@ import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.eclipse.dirigible.components.intent.parser.IntentValidationException; import org.junit.jupiter.api.Test; /** @@ -28,6 +30,44 @@ */ class GlueSchedulesTest { + /** A dunning run: the overdue invoices of a nomenclature seeded in this model. */ + private static final String DUNNING = """ + name: billing + entities: + - name: InvoiceStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - { name: dueOn, type: date } + - { name: contactEmail, type: string } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + schedules: + - name: dunning + cron: "0 0 8 * * ?" + entity: SalesInvoice + where: + - { field: Status, op: eq, value: OVERDUE } + - { field: dueOn, op: lt, value: CURRENT_DATE } + notify: + to: contactEmail + subject: "Invoice {number} is overdue" + body: "Please settle the attached invoice." + seeds: + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: ISSUED } + - { id: 3, name: OVERDUE } + - { id: 4, name: PAID } + """; + @SuppressWarnings("unchecked") @Test void generateScheduleEmitsCreateFromTargetAndRowAssignments() { @@ -417,6 +457,35 @@ void aScheduleWithNoDeclaredKeyStillGeneratesAndCarriesNoGuard() { assertTrue(((List>) s.get("genUnique")).isEmpty()); } + /** + * Issue #7251: the query of a dunning run names the status the row must stand in, and that name is + * resolved to its seed id before the typed mapping - so the criteria compares the integer status FK + * with an integer. Left as the authored name it rendered {@code .eq("Status", "OVERDUE")}, a query + * that matched nothing for as long as the schedule kept ticking. + */ + @Test + void aSeededStatusNameInTheQueryRendersAsItsSeedId() { + Map s = GlueIntentGenerator.buildSchedulesForTest(IntentParser.parse(DUNNING)) + .get(0); + + assertEquals("Criteria.create().eq(\"Status\", 3).lt(\"DueOn\", java.time.LocalDate.now())", s.get("criteriaExpression")); + } + + /** + * The backstop that keeps the invariant checkable independently of the resolver's site list: a + * value the resolver never even reads as a symbol (a blank) is still no status the FK can equal, so + * it is refused rather than generated into a query that matches nothing. + */ + @Test + void aValueThatIsNoStatusAtAllIsRefused() { + IntentValidationException failure = + assertThrows(IntentValidationException.class, () -> IntentParser.parse(DUNNING.replace("value: OVERDUE", "value: \"\""))); + + assertTrue(failure.getMessage() + .contains("which is not a status"), + "the failure must say the value is no status: " + failure.getMessage()); + } + @Test void notifyScheduleStillEmitsMailPlan() { String yaml = """ 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 be03b713ca8..ed7fb0037fc 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,13 @@ class StatusSymbolIntentTest { source: Invoice filter: "Status != VOIDED" measures: ["sum(paid)"] + schedules: + - name: dunning + cron: "0 0 8 * * ?" + entity: Invoice + where: + - { field: Status, op: eq, value: ISSUED } + notify: { to: ops@example.com, subject: "Invoice overdue" } seeds: - name: invoice-statuses entity: InvoiceStatus @@ -108,6 +115,23 @@ void everySiteResolvesTheNameToItsSeedId() { .get(0) .getFilter(), "report filter"); + assertEquals("3", String.valueOf(model.getSchedules() + .get(0) + .getWhere() + .get(0) + .getValue()), + "schedule where status"); + } + + /** + * The row query of a cron schedule (issue #7251) - the site a status guard is written at most + * often, and the one left behind when the sibling {@code items: where:} gained the rewrite: a name + * there generated {@code .eq("Status", "OVERDUE")} into the job and matched nothing forever. + */ + @Test + void anUnknownStatusNameInAScheduleQueryIsRejected() { + assertIssue(YAML.replace("field: Status, op: eq, value: ISSUED", "field: Status, op: eq, value: ISUED"), + "not a seeded status of [InvoiceStatus]"); } /** The point of the exercise: a mistyped status is a parse error, not another status. */