diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 62821c2fd30..6bb1eb83e34 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -393,7 +393,7 @@ Semantics worth knowing: - **`lifecycle:` on an entity = the declarative state machine (#6714).** The whole set of legal status edges, declared once over the entity's `function: EntityStatus` nomenclature (`edges: [{ from: DRAFT, to: [ISSUED, CANCELLED] }, ...]`, either side a seeded name or an id) and **enforced on every status write**. The gap it closes: the status machinery was a set of point constructs - `init:` names the start, a `transitions:` button guards the flips that go through THAT button, a workflow `setRelationField` writes one unguarded, a `checks:` rejection files another - and nothing declared which edges were legal at all, so any other writer (a workflow branch, a glue action, a plain REST call) could jump a document from any status to any other and nothing noticed. **Enforcement lives in the generated REPOSITORY, deliberately** (`Repository.java.template`: `LIFECYCLE_EDGES` + `enforceLifecycle` / `enforceLifecycleMove` / `enforceLifecycleStart`, `ValidationException` -> 400) - it is the ONE choke point every writer passes through: `update` (the REST payload), `updateWithoutEvent` (system writes), and `updateProperties` (which `updateProperty`, and therefore the transition controller, the workflow setters and `updateDerived`, all route through - so the targeted-write overrides are now emitted for a lifecycle entity too, not only for `documentChecks`/`hasLabel`). Guarding the transition endpoints instead would have left every other writer free, which is the whole defect. `enforceLifecycleStart` (emitted only when the status relation declares `init:`) additionally refuses a CREATE filed anywhere but at the start - entering the lifecycle mid-graph skips it rather than travelling it - and is placed BEFORE the aggregate-guard macros in `save()` so an `outcome: reject` can still file the record where the model says. Emission is three scalars on the entity map (`lifecycleStatusProperty`, `lifecycleEdges` as `1>2,1>9` pairs, `lifecycleStatusNames` as `1=DRAFT,...` so a rejection reads "cannot move from ISSUED to DRAFT" instead of quoting positional ids, plus `lifecycleInitialStatus`) - scalars, so they reach the `.edm` twin like `immutableStatusValues`. **Parse-time is where the other status sites are made to agree** (`validateLifecycles`): every `from` of a `transitions:` entry must reach its `setStatus` along an edge (a button is presentation over the graph), and a status written by a `setRelationField` step or forced by a check's rejection must be one some edge reaches - which is what catches a reject path transiting through an approved status when the file is read. **Deliberate boundaries:** no `on:` key - the graph is always over the EntityStatus relation, so naming it would be redundant, and YAML 1.1 reads a bare `on` as the boolean `true` (it would arrive as the key `true` and bind to nothing), so `rejectLifecycleOn` refuses it in the raw-tree preprocessing rather than dropping it silently; a cross-model nomenclature is seeded in its owner model and so is its lifecycle (refused, naming that); the nomenclature must be seeded here (the ids are validated against the seeds); no reachability check - one nomenclature may serve two entities with different graphs, so "unreachable here" is not an error. - **`immutableWhen:` / `immutable:` on an entity = user-write immutability.** `immutableWhen: "Status == 2"` (a boolean expression over EntityStatus seed ids, terms joined with `||`) makes update/delete through the generated REST controller answer 409 CONFLICT while the record's `function: EntityStatus` FK satisfies it; `immutable: true` is the unconditional append-only variant (mutually exclusive with `immutableWhen`; a non-existent id still yields 404, not 409). Emitted as the entity-level `immutableStatusProperty` + `immutableStatusValues` (or `immutableAlways`) model attrs; `requireMutable` fetches the existing row before writing. Repository writes are deliberately unaffected — the workflow (storno generation, roll-ups, ProcessId write-back) keeps working; this guards the USER surface, per the accounting audit-trail requirement (corrections are reversals, never edits). **The UI is gated up front, not just on the 409:** each of the three generated controllers (power / partner / my) also exposes a **`GET /{id}/mutable`** pre-check (`{"mutable": true|false}` via the shared `isMutable`, scoped like its reads), and every Harmonia surface consumes it — the manage form and document pages ask it on edit load and force the read-only preview mode with a "Read-only" title badge (so a directly typed `/edit` URL opens read-only), the partner/my form + document pages disable their controls (`fieldset :disabled`) and hide Save/Delete/item actions, while the browse tables (manage list, master) gate row Edit/Delete through a **baked `isRowImmutable(row)`** computed from the row's status FK against the generation-time immutable ids — no per-row API call, same generated-from-the-same-attrs no-drift argument as the client `validationSchema`. The pre-check fails OPEN (an outage must not lock the UI); the PUT/DELETE 409 stays the authoritative guard. Covered by `IntentEmissionCoverageIT` (endpoint tokens + page tokens + mutable=false/true over REST). Parser requires an EntityStatus relation. Alongside it (no DSL): every generated controller now maps a **database constraint violation on DELETE to 409** ("referenced by other records") instead of a 500. Scope of that mapping: the schema template does emit `type: "foreignKey"` structures, but `SchemasSynchronizer.parseImpl` drops them **by design** — a foreign key never becomes a database constraint on this platform, because a constraint binds insert/delete ORDER into the schema where seeds, imports, regeneration and deletes would all have to obey an ordering nothing in the model asked for; referential integrity is a business-layer check. Only the **unique** keys are carried over (`carryUniqueConstraints`, #6793), so the 409 engages for a business-key collision and never for a reference. Anything that must not outlive the record it points at therefore needs an explicit handler — which is what an expansion's `OnDelete` cleanup is (#6821). Date-based period locking (records whose date falls in a Locked period) is deliberately NOT part of this — its shape needs the real fiscal-period module and follows as its own PR. **The lock reaches the master's composition CHILDREN (#6695).** It was per-entity, and a child declares no immutability of its own — while its generated repository writes THROUGH to the master, recomputing `net`/`vat`/`total` on every `save`/`update`/`delete`. So `POST`/`PUT`/`DELETE` on a line of an ISSUED invoice succeeded over REST and silently rewrote the document's totals after the number was stamped, the immutable snapshot taken and the ledger posted — the UI forbade it, REST permitted it, and the permitted operation was the one `immutableWhen` exists to prevent. `ModelParameterProcessor.inheritMasterLock` now propagates the master's `immutableAlways` / `immutableStatusProperty` + values onto each direct composition child as a `masterLock` map (master entity + FK property + its `…Entity`/`…Repository` classes, resolved through the composition FK's perspective exactly as the personal/partner inheritance does), and all three generated controllers (power / partner / my) emit a `requireMasterMutable` that loads the master and answers the same 409 — on create (the payload's FK), on update (the STORED master *and* the incoming one, so a line cannot be moved into a locked document either), on delete, and on an attachment upload. Engine writers stay exempt by construction: they go through the repository, not the controller — which is why the issue-time snapshot generator (`Attachments.store` + `repository.save`) is untouched. The opt-out is the flag #6700 already introduced: `locksWithMaster: false` on the child (settlement is a different lifecycle from content), so the affordance and the REST guard are governed by one declaration and cannot drift apart. Only the DIRECT child is covered — that is the shape that writes through to the master. It composes with the prompted `generates` action (#6685): that create runs through the TARGET's repository, not a controller, so a guided create against a post-issue child keeps working on a locked document exactly as its per-record button (deliberately not gated on mutability) implies — the panel and the action remain the two separate answers to "this collection must go on being recorded". `IntentEmissionCoverageIT` carries both controls: `EntryLine` (silent → inherits) is refused create/update/delete on a POSTED entry and the master's total is asserted UNMOVED, while `CampaignNote` (`locksWithMaster: false`) still posts to a locked campaign. - **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Three kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null; emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400) and document-level `itemsSumEqual` (`over:` two item fields whose sums must match) / `itemsMin` (`count:`), both REQUIRING a `status:` gate (an EntityStatus seed id) — parser-enforced, because an ungated sum check would forbid drafting a document item by item. The EDM generator precomputes everything template-side (`buildChecks`: items entity + back-FK via the composition child, `statusProperty`, PascalCased fields); `ModelParameterProcessor` splits `rowChecks`/`documentChecks`; the **DAO repository** enforces document checks in `save`/`update`/**`updateWithoutEvent`** whenever the persisted entity carries the gate status — so the workflow setter flipping DRAFT→POSTED hits `enforceChecks` and an unbalanced document FAILS the write instead of silently posting: it throws the SDK `org.eclipse.dirigible.sdk.db.ValidationException`, which the client-controller dispatcher (`ControllerInvoker`) maps to **HTTP 400** with the authored message on a REST create/update, and which rolls back the task completion on the BPMN path (the capacity guard on roll-ups throws the same). `recalculate()` deliberately bypasses it (it persists the recomputed totals through the BASE targeted write, `super.updateProperties(id, totals)`, so a document still being assembled line by line never fails its own gate). No Harmonia-side mirror in v1 — the task-completion error surfaces the authored message. -- **`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 write is a single targeted `updateProperties` of the relation + the outcome + the status, so 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. The parser refuses a `when` guard it cannot render rather than degrading it to an always-open guard. +- **`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. The parser refuses a `when` guard it cannot render rather than degrading it to an always-open guard. - **Every DERIVED write is targeted (document totals, `rollups:`, `aggregates:`) — the last member of the lost-update family.** A recompute reads a row, changes the one or two columns it computes, and persists. Persisting the WHOLE row silently reverts any concurrent write to another column of that row: the trigger `ProcessId` variant was fixed in #6226 and the workflow setter/writer variant in #6306, and the recompute variant was live-reproduced against a roll-up (REST-create a parent, PUT another column immediately after → 200, but a re-read shows the OLD value; the recompute had read the row before the PUT and wrote its stale snapshot after it). All three recompute sites now write only what they computed: `Repository.recalculate(Object)` collects the document totals into a map and calls the base `super.updateProperties` (no gate checks, no `-updated` — exactly the previous `super.update` semantics minus the merge); `Rollup.java.template` and `Aggregate.java.template` collect each recomputed column into a `derived` map and persist through the generated **`updateDerived(id, values)`**, which routes through `updateProperties` (so a `checks:` entity still runs its gate and a labelled entity still refreshes its `Name`) and then re-publishes `---updated` — the event contract the old full-row `update()` provided, which TRANSITIVE roll-ups above the row depend on. Two invariants when touching these: a column assigned in the recompute must also be put into `derived` (a capacity roll-up writes count + balance + status), and an EMPTY `derived` map means nothing is persisted, so the map is what the emission oracle asserts. Covered by the `IntentEmissionCoverageIT` derived-write assertions (Bill document totals, `ClaimLineClaimRollupOnCreate`, `LedgerTotalAggregateOnCreate`). **The reverse direction had the same hole (#6822):** the master's resum was wired only to the item's FULL write paths (`save`/`update`/`delete`), so a line written by a TARGETED primitive - a workflow `setField`, any glue `updateProperty`/`updateProperties`/`updateDerived`, or the event-suppressed `updateWithoutEvent` - moved the line and left the header displaying, printing and POSTING a total that did not equal the sum of its lines. Those paths now resum too, guarded on the columns actually written (an aggregated column, or the FK - which MOVES the line, so both the document it joined and the one it left are resummed), so a status hop still costs nothing extra. It cannot recurse: the master's `recalculate` persists through the BASE targeted write. - **Re-parenting is a two-sided event, and `-rekeyed` is the whole mechanism (#6819).** A row whose grouping column moves - an `aggregates:` key, or a `rollups:` child's `via` FK - leaves one group and joins another, and the ordinary events name only the group it belongs to NOW: `-updated` carries the written row, so the group it LEFT is named by nothing and kept the row's contribution forever (a cost centre reassigned by a workflow step; a `sum` roll-up whose parent FK an ordinary edit re-points). The repair is one dedicated topic, `---rekeyed`, which **only** the generated aggregate / roll-up handlers subscribe to - so a write can signal them without re-publishing `-updated` and spuriously re-firing every reaction. Three parts, and all three are needed: (1) the entity's `.model` carries **`groupingKeys`** - the union of every aggregate key over it AND every roll-up `via` FK whose child it is (`EdmIntentGenerator`; it used to be `aggregateKeys`, aggregates-only, which is why re-parenting a roll-up child was invisible); (2) the DAO compares those columns before/after on **both** write paths - the full-row `update()` publishes the PREVIOUS row (the group it moved into is recomputed off `-updated` like any other change), and `updateProperties` - the targeted primitive every workflow setter, `resolves:` and task-form writer goes through, which publishes no `-updated` at all - publishes the previous row AND the written one, since on that path neither side has an event otherwise; (3) both handler families bind it, the aggregate as its `OnRekey` variant and the roll-up as `RollupOnRekey`. Each handler recomputes the group the PAYLOAD names, from the store, so one class repairs either side and re-delivery converges. The publish is gated on a key having actually moved, so a normal edit costs nothing extra and the cascade still terminates at rest. - **`checks: kind: guard` = a precondition over a keyed `aggregates:` sum, with three outcomes.** The negative-stock / credit-limit / remaining-allowance shape: `aggregate:` names an `aggregates:` entry whose `of` is THIS entity (v1 self-referential), and the post-state is checked against `minimum:` (default 0). The sum is recomputed SYNCHRONOUSLY from the guarded entity's own store for the incoming row's key-tuple, excluding this row on update, then the incoming value is added - deliberately NOT read from the async-maintained aggregate target, so the decision cannot race the handler. Consequence worth remembering: the guard and the materialised aggregate are two independent computations of the same sum, and the guard is the authoritative one - do not "optimise" it into a target read. `enabledBy: ` wraps the whole guard in a `Configurations.get(key) == "true"` gate (a tenant-level business toggle). Emitted by `EdmIntentGenerator.buildChecks` (keys + `sumField` + `pk` + `minimum` + `enabledBy` + `outcome`) → `ModelParameterProcessor` splits `guardChecks` out → the DAO's `#aggregateGuardCheck` macro at both the save and update sites. **`outcome:` decides what a violation DOES**, and each non-default outcome carries its own companion key (parser-validated - a companion belonging to another outcome is an ERROR, since the write would look guarded and do nothing): 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 fceff141d24..6f3784e4207 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 @@ -1843,9 +1843,23 @@ private static void validateResolveOutcomes(ResolveIntent resolve, String subjec issues.add(subject + " outcome [" + field + "] is not a field of [" + record.getName() + "]"); } else if (!"string".equals(declared.getType())) { issues.add(subject + " outcome [" + field + "] must be a string field, was [" + declared.getType() + "]"); + } else if (declared.getLength() != null && declared.getLength() < outcomeLength(anyStatus)) { + // The trace is the one field whose whole job is to be readable afterwards, so a length that + // truncates it is worse than useless - and it truncates at the DB, where nothing reports it. + // Routing widens the set the handler writes: a status the record cannot take leaves it + // amended (`ambiguous-notRouted`) so a routed-but-rejected record is not indistinguishable + // from a fully processed one. + issues.add(subject + " outcome [" + field + "] is length [" + declared.getLength() + "], too short for the values written - " + + "at least [" + outcomeLength(anyStatus) + "]" + + (anyStatus ? " once an outcome routes by setStatus (a rejected route amends the trace)" : "")); } } + /** The longest trace value the generated handler can write, with and without status routing. */ + private static int outcomeLength(boolean routesByStatus) { + return routesByStatus ? "ambiguous-notRouted".length() : "ambiguous".length(); + } + /** Whether the entity declares a {@code function: EntityStatus} relation. */ private static boolean hasEntityStatus(EntityIntent entity) { if (entity.getRelations() == null) { 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 8cbdb0a4ff9..d0ba5aa503d 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 @@ -2565,14 +2565,21 @@ are a filterable worklist a human can finish, and so a process `decision` can br - `between.start` / `between.end` are register date fields, `between.value` the record's date. Either bound may be omitted (open-ended = still valid); the end is **inclusive**, and a date-only bound covers its whole day. -- Only the resolved relation, the outcome and the status are written - nothing else of the record. +- Only the resolved relation, the outcome and the status are written - nothing else of the record, + and the RESULT (relation + outcome) is written FIRST, separately from the routing status. A + status the record cannot take where it stands - an unmodeled `lifecycle:` move, a `checks:` + gate - is rejected by the repository, and batching the three meant that rejection discarded the + identification and the trace along with it. Split, the routing can fail without taking the work + with it: the outcome is amended to `-notRouted` (e.g. `found-notRouted`) and logged, + so the record itself shows a routed-but-rejected attempt. **Rules:** `event` binds `onCreate` or `onUpdate` of a declared entity (never `onDelete`); `set` is a to-one of that entity; `from` is an entity declared in **this** model; `match` needs at least one pair (left = register property, right = record property); `between.value` is required and every period -field must be a `date` or `timestamp`; `outcome` must be a `string` field of the record; a `setStatus` -needs the record to declare a `function: EntityStatus` relation, and may be a seed id or a seeded -name. +field must be a `date` or `timestamp`; `outcome` must be a `string` field of the record, long enough for +the values written (9, or 19 once any outcome routes by `setStatus` - the amended trace); a +`setStatus` needs the record to declare a `function: EntityStatus` relation, and may be a seed id +or a seeded name. ## Allowed values @@ -2610,7 +2617,7 @@ name. | transition `when` op | `==`, `!=` | | resolve `event` | `onCreate`, `onUpdate` (never `onDelete`); `when` is ` ==\|!= ` | | resolve `between` field type | `date`, `timestamp` | -| resolve `outcome` values | `found`, `notFound`, `ambiguous` (stamped into a `string` field) | +| resolve `outcome` values | `found`, `notFound`, `ambiguous`, plus `-notRouted` when a `setStatus` route is rejected (stamped into a `string` field) | ## Mapping requests to capabilities (quick reference) diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ResolveIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ResolveIntentTest.java index 3224e566049..f982884cd48 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ResolveIntentTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ResolveIntentTest.java @@ -228,6 +228,28 @@ void rejectsAStatusOutcomeWithoutAnEntityStatusRelation() { "got: " + ex.getIssues()); } + /** + * The trace exists to be read afterwards, and it is truncated at the DB where nothing reports it. + * Routing widens the set the handler writes, because a status the record cannot take amends the + * trace rather than losing the whole attempt. + */ + @Test + void rejectsAnOutcomeFieldTooShortForTheValuesWritten() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse( + VALID.replace("{ name: resolution, type: string, readOnly: true }", "{ name: resolution, type: string, length: 12 }"))); + assertTrue(ex.getIssues() + .stream() + .anyMatch(issue -> issue.contains("outcome [resolution] is length [12], too short") && issue.contains("at least [19]") + && issue.contains("routes by setStatus")), + "got: " + ex.getIssues()); + // Without routing, the handler writes only the three plain outcomes, so 12 is ample. + IntentParser.parse( + VALID.replace("{ name: resolution, type: string, readOnly: true }", "{ name: resolution, type: string, length: 12 }") + .replace("found: { setStatus: IDENTIFIED }", "found: {}") + .replace("notFound: { setStatus: UNRESOLVED }", "notFound: {}") + .replace("ambiguous: { setStatus: UNRESOLVED }", "ambiguous: {}")); + } + @Test void rejectsAnUnknownRegister() { IntentValidationException ex = assertThrows(IntentValidationException.class, diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Resolve.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Resolve.java.template index a52ef10aa92..2cf2e7f0d9d 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Resolve.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Resolve.java.template @@ -26,9 +26,11 @@ import gen.${javaGenFolderName}.data.${javaRegisterPerspective}.${registerEntity * logged with the keys and the date that were checked. * * A record that already carries ${setProperty} is skipped, so a manual correction is never overwritten - * and a re-delivered event is a no-op. The write is TARGETED (updateProperties) - only the resolved - * column, the outcome and the status are in the UPDATE statement, so a concurrent user write to any - * other column cannot be reverted, and no "-updated" event re-fires. + * and a re-delivered event is a no-op. The writes are TARGETED (updateProperties / updateProperty) - + * only the resolved column, the outcome and the status are in the UPDATE statements, so a concurrent + * user write to any other column cannot be reverted, and no "-updated" event re-fires. The result and + * the routing status go out SEPARATELY, so a rejected status move cannot discard the identification - + * see stamp(). * * When an outcome routes the record by status, the "-transitioned" topic IS published afterwards, so * that an automatic resolution reaches the same constructs a manual transition does - `generates:` and @@ -111,41 +113,64 @@ public class ${className}Resolve implements MessageHandler { } /** - * One targeted write carrying everything this attempt decided: the resolved relation when there is - * one, the outcome trace, and the routing status. Nothing else of the record is touched. + * Persists what this attempt decided, in two writes rather than one, and in that order deliberately. * - * A targeted write publishes NO event, so when this lookup routes the record by status it must - * announce that transition itself - see the "-transitioned" publish below. + * First the RESULT - the resolved relation and the outcome trace - because that is the work: the + * register was read, exactly one row covered the date, and this is the answer. Then, separately, the + * ROUTING status. + * + * They were one updateProperties call, and the DAO runs the lifecycle and checks gates against the + * post-write row BEFORE persisting anything, so a rejected status move discarded the relation and the + * trace with it: the lookup did the work, got the right answer, and threw all of it away - including + * the field whose entire purpose is recording what happened. Split, a rejected transition can no + * longer destroy the identification, and the audit trace is the LAST thing a failure can take. + * + * Nothing else of the record is touched (both writes are targeted), so no "-updated" re-fires and a + * concurrent write to any other column cannot be reverted. A targeted write also publishes NO event, + * so a status that IS written announces itself - see the "-transitioned" publish below. */ private static void stamp(Object id, String outcome, Integer resolved#if($writesStatus == "true"), Integer status#end) { + ${entity}Repository repository = new ${entity}Repository(); java.util.Map values = new java.util.LinkedHashMap<>(); if (resolved != null) { values.put("${setProperty}", resolved); } #if($outcomeProperty != "") values.put("${outcomeProperty}", outcome); -#end -#if($writesStatus == "true") - if (status != null) { - values.put("${statusProperty}", status); - } #end if (!values.isEmpty()) { - new ${entity}Repository().updateProperties(id, values); + repository.updateProperties(id, values); } #if($writesStatus == "true") if (status != null) { - // The status this lookup just routed the record to IS a transition, and the constructs that - // react to one - `generates:` and `postings:` bound to `event: { onTransition: ... }` - listen - // on "-transitioned". updateProperties above publishes nothing at all, so without this the - // AUTOMATIC path silently did nothing while the manual one (a `transitions:` button, which - // does publish) worked: exactly the wrong way round, and with no log line to show it. - // Reload so the payload carries the committed row rather than the pre-write snapshot, the - // same shape the transition controller and the create-from completion hook publish. - ${entity}Entity transitioned = new ${entity}Repository().findById(id); - if (transitioned != null) { - org.eclipse.dirigible.sdk.messaging.Producer.sendToTopic("${projectName}-${perspective}-${entity}-transitioned", - Json.stringify(transitioned)); + try { + repository.updateProperty(id, "${statusProperty}", status); + // The status this lookup just routed the record to IS a transition, and the constructs + // that react to one - `generates:` and `postings:` bound to `event: { onTransition: ... }` + // - listen on "-transitioned". The targeted write above publishes nothing at all, so + // without this the AUTOMATIC path silently did nothing while the manual one (a + // `transitions:` button, which does publish) worked: exactly the wrong way round, and with + // no log line to show it. Inside the try on purpose - a status the record could not take + // is not a transition, so a rejected move must announce nothing. Reload so the payload + // carries the committed row rather than the pre-write snapshot, the same shape the + // transition controller and the create-from completion hook publish. + ${entity}Entity transitioned = repository.findById(id); + if (transitioned != null) { + org.eclipse.dirigible.sdk.messaging.Producer.sendToTopic("${projectName}-${perspective}-${entity}-transitioned", + Json.stringify(transitioned)); + } + } catch (org.eclipse.dirigible.sdk.db.ValidationException rejected) { + // The record cannot take this status where it currently stands - an unmodeled lifecycle + // move, or a gate the status would trip. Retrying cannot help (nothing about the record + // changes by re-reading the register), so the failure is recorded rather than thrown: the + // relation and the outcome above are already committed and stay that way. + LOG.warn("${name}: ${entity} [{}] resolved [{}] but could not be routed to status [{}] - [{}]", id, outcome, status, + rejected.getMessage()); +#if($outcomeProperty != "") + // Amend the trace so the record itself carries the evidence - otherwise a + // routed-but-rejected record is indistinguishable from a fully processed one. + repository.updateProperty(id, "${outcomeProperty}", outcome + "-notRouted"); +#end } } #end diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 48b4abe9a56..fc47ae00838 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -640,7 +640,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // this exercises the generateUtils.js "triggers" + "resolvers" collection cases end to end. generateFromModel("template-application-events-java/template/template.js", "orders.glue"); - String handler = contentOf("gen/events/orders/OrderApprovalTrigger.java"); + String handler = codeOf("gen/events/orders/OrderApprovalTrigger.java"); assertTrue(handler.contains("class OrderApprovalTrigger"), "the glue template should generate a handler class named after the process"); assertTrue(handler.contains("implements MessageHandler"), "the trigger should be a self-describing MessageHandler"); @@ -656,7 +656,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // The decision resolver (customer.creditLimit) is a JavaDelegate that loads Customer and sets // the variable the rewritten condition tests. - String resolver = contentOf("gen/events/orders/ResolveCustomerCreditLimit.java"); + String resolver = codeOf("gen/events/orders/ResolveCustomerCreditLimit.java"); assertTrue(resolver.contains("class ResolveCustomerCreditLimit implements JavaDelegate"), "the resolver should be a Flowable JavaDelegate"); assertTrue(resolver.contains("import gen.orders.data.customer.CustomerRepository"), @@ -673,7 +673,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // The form-only relation.field (customer.name on the ApproveOrder form) produces its own resolver // even though no decision references it - the user-task form is a resolver trigger in its own // right. - String formResolver = contentOf("gen/events/orders/ResolveCustomerName.java"); + String formResolver = codeOf("gen/events/orders/ResolveCustomerName.java"); assertTrue(formResolver.contains("class ResolveCustomerName implements JavaDelegate"), "a relation.field referenced only by a user-task form should still generate a resolver"); assertTrue(formResolver.contains("execution.setVariable(\"customer_name\"") && formResolver.contains("entity.Name"), @@ -681,7 +681,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // The resolver-path assignee (cfoReview -> salesRep.manager): a JavaDelegate that walks the // order's relations to the reviewing person and publishes their login for the task to bind to. - String assignee = contentOf("gen/events/orders/ResolveOrderApprovalCfoReviewAssignee.java"); + String assignee = codeOf("gen/events/orders/ResolveOrderApprovalCfoReviewAssignee.java"); assertTrue(assignee.contains("class ResolveOrderApprovalCfoReviewAssignee implements JavaDelegate"), "the assignee resolver should be a Flowable JavaDelegate"); // Published FIRST, on every path out: the task's assignee expression reads it at task creation @@ -699,7 +699,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // The notification (onUpdate: Order) is a self-describing @Component MessageHandler that sends mail // when an Order is updated - // exercises the generateUtils.js "notifications" collection case end to end. - String notification = contentOf("gen/events/orders/OrderUpdatedNotification.java"); + String notification = codeOf("gen/events/orders/OrderUpdatedNotification.java"); assertTrue(notification.contains("class OrderUpdatedNotification implements MessageHandler"), "the notification should be a message-handling listener (PascalCased class name)"); assertTrue(notification.contains("@Component") && notification.contains("return \"intent-test-Order-Order-updated\""), @@ -736,7 +736,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // The schedule is a self-describing @Component JobHandler (cron()) that queries via a typed // Criteria and notifies per row. - String job = contentOf("gen/events/orders/StaleOrdersJob.java"); + String job = codeOf("gen/events/orders/StaleOrdersJob.java"); assertTrue( job.contains("@Component") && job.contains("class StaleOrdersJob implements JobHandler") && job.contains("return \"0 0 9 * * ?\""), @@ -762,7 +762,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // The integration is a self-describing @Component MessageHandler that forwards the entity JSON to // an external endpoint. - String integration = contentOf("gen/events/orders/PushOrderToWarehouseIntegration.java"); + String integration = codeOf("gen/events/orders/PushOrderToWarehouseIntegration.java"); assertTrue(integration.contains("class PushOrderToWarehouseIntegration implements MessageHandler"), "the integration should be a message-handling listener"); assertTrue(integration.contains("@Component") && integration.contains("return \"intent-test-Order-Order\""), @@ -777,7 +777,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // A declared payload replaces that raw record with the envelope the intent spells out - every // value form in one generated method, so a contract is expressible without a hand-written // publisher (and adding an entity column no longer changes what the outside world receives). - String announce = contentOf("gen/events/orders/AnnounceOrderIntegration.java"); + String announce = codeOf("gen/events/orders/AnnounceOrderIntegration.java"); assertTrue(announce.contains("OrderEntity entity = Json.parse(message, OrderEntity.class)"), "a payload-bearing integration should read the record the values resolve against"); assertTrue( @@ -801,12 +801,12 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { assertTrue(announce.contains("String body = Json.stringify(payload)") && announce.contains("options.put(\"text\", body)"), "the declared payload, not the record, should be the request body"); assertTrue( - announce.indexOf("payload.put(\"type\"") < announce.indexOf("payload.put(\"version\"") - && announce.indexOf("payload.put(\"version\"") < announce.indexOf("payload.put(\"messageId\""), + onlyIndexOf(announce, "payload.put(\"type\"") < onlyIndexOf(announce, "payload.put(\"version\"") + && onlyIndexOf(announce, "payload.put(\"version\"") < onlyIndexOf(announce, "payload.put(\"messageId\""), "the envelope should keep the order it was authored in"); // The inbound webhook is a @Controller that ingests a posted JSON payload as the entity. - String webhook = contentOf("gen/events/orders/IngestOrderWebhook.java"); + String webhook = codeOf("gen/events/orders/IngestOrderWebhook.java"); assertTrue(webhook.contains("@Controller") && webhook.contains("class IngestOrderWebhook"), "the inbound webhook should be a @Controller"); assertTrue(webhook.contains("@Post(\"/ingest\")"), "the webhook should expose the declared path"); @@ -858,13 +858,13 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // Together with the assertions above, this proves the full declarative-glue catalog - triggers, // resolvers, notifications, schedules, integrations, inbound webhooks and rollups - is generated // from a single app.intent. - String rollupCreate = contentOf("gen/events/orders/OrderCustomerRollupOnCreate.java"); + String rollupCreate = codeOf("gen/events/orders/OrderCustomerRollupOnCreate.java"); assertTrue( rollupCreate.contains("@Component") && rollupCreate.contains("return \"intent-test-Order-Order\"") && rollupCreate.contains("new OrderRepository().findAll(Criteria.create().eq(\"Customer\", entity.Customer))") && rollupCreate.contains("int count = rows.size();") && rollupCreate.contains("parent.OrderCount = count"), "the rollup create-listener should recompute the parent count via Criteria"); - assertTrue(contentOf("gen/events/orders/OrderCustomerRollupOnDelete.java").contains("intent-test-Order-Order-deleted"), + assertTrue(codeOf("gen/events/orders/OrderCustomerRollupOnDelete.java").contains("intent-test-Order-Order-deleted"), "the rollup delete-listener should bind the child's -deleted topic"); // The print feeder (Order is a document master via the OrderItem composition child): a @Controller @@ -872,7 +872,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // { document, items } payload the .print template binds - exercises the generateUtils.js // "printFeeders" collection case end to end. This class IS the audit of what a print receives. assertTrue(contentOf("orders.glue").contains("\"printFeeders\""), "the glue should carry a printFeeders collection"); - String feeder = contentOf("gen/events/orders/OrderPrintFeeder.java"); + String feeder = codeOf("gen/events/orders/OrderPrintFeeder.java"); assertTrue(feeder.contains("@Controller") && feeder.contains("class OrderPrintFeeder") && feeder.contains("@Get(\"/{id}\")"), "the feeder should be a @Controller exposing GET /{id}"); assertTrue(feeder.contains("new gen.orders.data.order.OrderRepository().findById(id)"), @@ -892,7 +892,7 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { // The effective-dated register lookup: a self-describing @Component MessageHandler that queries // the register by the match keys, keeps only the rows whose period covers the order date, and // treats found / notFound / ambiguous as three distinct outcomes. - String lookup = contentOf("gen/events/orders/AssignSalesRepResolve.java"); + String lookup = codeOf("gen/events/orders/AssignSalesRepResolve.java"); assertTrue( lookup.contains("@Component") && lookup.contains("class AssignSalesRepResolve implements MessageHandler") && lookup.contains("return \"intent-test-Order-Order\""), @@ -909,12 +909,93 @@ void glue_template_generates_the_trigger_and_resolver_handlers() { lookup.contains("stamp(entity.Id, \"found\", resolved)") && lookup.contains("\"notFound\"") && lookup.contains("\"ambiguous\""), "all three outcomes should be generated - an ambiguous register is never resolved by picking one"); + // The RESULT - the relation and the trace - is one targeted update. The routing status is a + // second one; this lookup declares none, so resolve_writes_the_result_before_the_routing_status + // covers that half. assertTrue( lookup.contains("values.put(\"SalesRep\", resolved)") && lookup.contains("values.put(\"RepResolution\", outcome)") - && lookup.contains("new OrderRepository().updateProperties(id, values)"), + && lookup.contains("repository.updateProperties(id, values)"), "the resolved relation and the outcome trace should be written in ONE targeted update"); } + @Test + void resolve_writes_the_result_before_the_routing_status() { + // A lookup that also ROUTES by status. The three values it decides are semantically independent, + // and the DAO runs the lifecycle and checks gates against the post-write row BEFORE persisting - + // so batching them meant a rejected status move discarded the resolved relation and the outcome + // trace with it: the work was done, the answer was right, and all of it was thrown away. + String yaml = """ + name: fines + entities: + - name: FineStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Vehicle + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: plate, type: string } + - name: Driver + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: VehicleAssignment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: validFrom, type: date } + relations: + - { name: vehicle, kind: manyToOne, to: Vehicle } + - { name: driver, kind: manyToOne, to: Driver } + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: violationAt, type: timestamp } + - { name: resolution, type: string, readOnly: true } + relations: + - { name: vehicle, kind: manyToOne, to: Vehicle } + - { name: driver, kind: manyToOne, to: Driver } + - { name: status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + seeds: + - name: fineStatuses + entity: FineStatus + rows: + - { id: 1, name: NEW } + - { id: 2, name: IDENTIFIED } + resolves: + - name: identifyDriver + event: { onCreate: Fine } + set: driver + from: VehicleAssignment + match: { vehicle: vehicle } + between: { start: validFrom, value: violationAt } + outcome: resolution + found: { setStatus: IDENTIFIED } + """; + writeIntent(yaml); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + generateFromModel("template-application-events-java/template/template.js", "fines.glue"); + String lookup = codeOf("gen/events/fines/IdentifyDriverResolve.java"); + + // The result goes out on its own, and the status is NOT in that batch. + int result = onlyIndexOf(lookup, "repository.updateProperties(id, values)"); + int routing = onlyIndexOf(lookup, "repository.updateProperty(id, \"Status\", status)"); + assertTrue(result < routing, "the resolved relation and the trace must be persisted BEFORE the routing status is attempted"); + assertFalse(lookup.contains("values.put(\"Status\", status)"), + "the status must NOT ride in the same map - a rejected move would take the relation and the trace with it"); + + // A status the record cannot take is recorded, not thrown: retrying cannot help, and the record + // itself must carry the evidence or a routed-but-rejected record reads as fully processed. + assertTrue(lookup.contains("catch (org.eclipse.dirigible.sdk.db.ValidationException rejected)"), + "the routing write should catch the lifecycle/checks rejection the DAO raises"); + assertTrue(lookup.contains("repository.updateProperty(id, \"Resolution\", outcome + \"-notRouted\")"), + "a rejected route should amend the trace so the record shows what happened"); + assertTrue(lookup.contains("could not be routed to status"), "a rejected route should also be logged"); + } + @Test void set_field_glue_sets_entity_status_on_approve_reject_branches() { // A MemberApproval process whose approve/reject decision routes to two setField service tasks: @@ -982,7 +1063,7 @@ void set_field_glue_sets_entity_status_on_approve_reject_branches() { // the TARGETED single-column updateProperty (only that column is in the UPDATE statement, so a // concurrent write to another column cannot be reverted), WITHOUT re-publishing an update event. generateFromModel("template-application-events-java/template/template.js", "members.glue"); - String activate = contentOf("gen/events/members/MemberApprovalActivate.java"); + String activate = codeOf("gen/events/members/MemberApprovalActivate.java"); assertTrue(activate.contains("class MemberApprovalActivate implements JavaDelegate"), "the setter should be generated as a Flowable JavaDelegate"); assertTrue(activate.contains("import gen.members.data.member.MemberEntity") && activate.contains("execution.getVariable(\"Id\")"), @@ -991,7 +1072,7 @@ void set_field_glue_sets_entity_status_on_approve_reject_branches() { "the setter should persist the field via the targeted single-column updateProperty"); assertFalse(activate.contains("updateWithoutEvent"), "the setter must NOT full-row merge (updateWithoutEvent) - that reverts concurrent writes to other columns"); - assertTrue(contentOf("gen/events/members/MemberApprovalReject.java").contains("\"Status\", \"REJECTED\""), + assertTrue(codeOf("gen/events/members/MemberApprovalReject.java").contains("\"Status\", \"REJECTED\""), "the reject setter should persist the rejected status via the targeted write"); // The transition IS observable: the setter publishes the dedicated -transitioned topic (the // status-reached channel for posting glue / integrations), which reactions never listen on - @@ -1133,7 +1214,7 @@ void wait_step_and_boundary_timers_emit_catch_event_timers_and_correlating_glue( bpmn.contains(""), "the stub should implement the SDK contract, returning the field's type"); - assertTrue(stub.contains("Order.number"), "the stub should say which field it computes"); + // contentOf, not codeOf: the stub names its field in the scaffolded javadoc, so the prose is + // exactly what is being asserted. + assertTrue(contentOf("custom/OrderNumberAction.java").contains("Order.number"), "the stub should say which field it computes"); assertFalse(stub.contains("System.out") || stub.contains("System.err"), "the scaffolded stub must never print to stdout/stderr"); // An action in somebody else's package is somebody else's compilation unit - scaffolding it @@ -2025,7 +2108,7 @@ public class OrderNumberAction implements CalculatedField { .post(GENERATE_URL) .then() .statusCode(200)); - assertTrue(contentOf("custom/OrderNumberAction.java").contains("MY IMPLEMENTATION"), + assertTrue(codeOf("custom/OrderNumberAction.java").contains("MY IMPLEMENTATION"), "the developer's calculated action must be preserved across regeneration"); } @@ -2095,7 +2178,7 @@ void service_task_handler_stub_is_scaffolded_under_custom_and_preserved() { .statusCode(200)); // notifyCustomer has no `call`, so a Java JavaDelegate stub is scaffolded under custom/. assertTrue(resource("custom/NotifyCustomer.java").exists(), "a no-call service task should scaffold a custom/ Java stub"); - String stub = contentOf("custom/NotifyCustomer.java"); + String stub = codeOf("custom/NotifyCustomer.java"); assertTrue(stub.contains("package custom;") && stub.contains("class NotifyCustomer implements JavaDelegate"), "the stub should be a custom-package JavaDelegate"); // A generated class is read as house style, so the stub logs through the SDK logger - it never @@ -2118,6 +2201,7 @@ public void execute(DelegateExecution execution) { /* MY IMPLEMENTATION */ } .post(GENERATE_URL) .then() .statusCode(200)); + // contentOf, not codeOf: preservation is about the developer's FILE, and their marker is a comment. assertTrue(contentOf("custom/NotifyCustomer.java").contains("MY IMPLEMENTATION"), "the developer's service-task handler must be preserved across regeneration"); } @@ -2176,7 +2260,7 @@ void generating_the_events_template_preserves_the_full_stack_gen_output() { // languageFrom: customer.locale): it loads the document, follows the Customer FK, reads the // locale, and falls back to the first entry of the tenant-resolved application language set // when the chain is null or blank - the language is never hardcoded into the delegate. - String snapshotGenerator = contentOf("gen/events/orders/OrderSnapshotGenerator.java"); + String snapshotGenerator = codeOf("gen/events/orders/OrderSnapshotGenerator.java"); assertTrue(snapshotGenerator.contains("OrderEntity document = new OrderRepository().findById(id);"), "languageFrom must load the master document, got: " + snapshotGenerator); assertTrue(snapshotGenerator.contains("new CustomerRepository().findById(document.Customer)"), @@ -2401,7 +2485,7 @@ void expansion_generates_the_span_handlers_and_the_status_badge_stack() { // count column is in the UPDATE statement, so the stale message copy of the master cannot // revert concurrent writes to other columns, and no event fires). generateFromModel("template-application-events-java/template/template.js", "loans.glue"); - String onCreate = contentOf("gen/events/loans/InstallmentsExpansionOnCreate.java"); + String onCreate = codeOf("gen/events/loans/InstallmentsExpansionOnCreate.java"); assertTrue(onCreate.contains("intent-test-Loan-Loan\""), "the OnCreate handler binds the master's create topic"); assertTrue(onCreate.contains("d.plusMonths(1)"), "unit month steps by month"); assertTrue(onCreate.contains("total.subtract(share.multiply("), "the last row absorbs the rounding remainder"); @@ -2420,7 +2504,7 @@ void expansion_generates_the_span_handlers_and_the_status_badge_stack() { "the count write-back must be a targeted single-column updateProperty"); assertFalse(onCreate.contains("updateWithoutEvent"), "the count write-back must not full-row merge (updateWithoutEvent) - that reverts concurrent writes to other columns"); - String onUpdate = contentOf("gen/events/loans/InstallmentsExpansionOnUpdate.java"); + String onUpdate = codeOf("gen/events/loans/InstallmentsExpansionOnUpdate.java"); assertTrue(onUpdate.contains("intent-test-Loan-Loan-updated\""), "the OnUpdate handler binds the -updated topic"); // The master's delete removes the rows the expansion generated. Nothing else would: a foreign @@ -2552,7 +2636,7 @@ void postings_generates_the_idempotent_resumable_handler() { // Events template: the generated handler is idempotent + resumable (the cloud-native posting // semantics - no cross-step transaction): it skips a complete post and rebuilds a half-post. generateFromModel("template-application-events-java/template/template.js", "postingtest.glue"); - String posting = contentOf("gen/events/postingtest/OrderLedgerPosting.java"); + String posting = codeOf("gen/events/postingtest/OrderLedgerPosting.java"); assertTrue(posting.contains("implements MessageHandler"), "the posting is a self-describing message handler"); assertTrue(posting.contains("-transitioned"), "it listens on the source's -transitioned channel"); assertTrue(posting.contains("int expectedItems = 0"), "it computes the expected item count for the completeness check"); @@ -2623,13 +2707,17 @@ void conditional_rule_column_emits_a_classifier_ternary_and_a_runtime_guard() { .then() .statusCode(200)); generateFromModel("template-application-events-java/template/template.js", "condposting.glue"); - String posting = contentOf("gen/events/condposting/PaymentLedgerPosting.java"); + String posting = codeOf("gen/events/condposting/PaymentLedgerPosting.java"); assertTrue( posting.contains("Calc.eval(\"Method\", source, 6).compareTo(new java.math.BigDecimal(\"1\")) == 0 ? ruleRow.BankAccount"), "the account is a classifier ternary over the rule row's columns"); assertTrue(posting.contains("ruleRow.CashAccount") && posting.contains("ruleRow.SuspenseAccount"), "every case column + the default is reachable from the ternary"); - assertTrue(posting.contains("selected no account"), "the whole selection is null-guarded at runtime (fail-soft skip)"); + // The guard is over the dynamic SELECTION - the classifier ternary itself - which together with + // the assertFalse below is what distinguishes it from a static per-column skip. Matched on the + // guard rather than the explanatory comment that trails it. + assertTrue(posting.contains("if ((Calc.eval(\"Method\", source, 6)"), + "the whole selection is null-guarded at runtime (fail-soft skip)"); assertFalse(posting.contains("if (ruleRow.BankAccount == null)"), "a conditional case column must NOT be a static usedRuleColumns skip"); } @@ -2671,7 +2759,7 @@ void generates_completion_hook_flips_the_source_via_targeted_update() { .statusCode(200)); generateFromModel("template-application-events-java/template/template.js", "proforma.glue"); - String generate = contentOf("gen/events/proforma/InvoiceFromProformaGenerate.java"); + String generate = codeOf("gen/events/proforma/InvoiceFromProformaGenerate.java"); // The completion hook flips the source status via the targeted single-column primitive... // (the create-from's body is a create(Integer sourceId) method both the button endpoint and an // event trigger call - hence sourceId rather than the posted request's id, since #6711.) @@ -2727,14 +2815,14 @@ void multilingual_entity_generates_the_translation_stack() { assertFalse(schema.contains("ORDERS_CUSTOMER_LANG"), "a non-multilingual entity must not get a language table"); // Java DAO: every read overlays the translations for the caller's Accept-Language. - String repository = contentOf("gen/orders/data/settings/CountryRepository.java"); + String repository = codeOf("gen/orders/data/settings/CountryRepository.java"); assertTrue(repository.contains("Translator.translateList(super.findAll(), User.getLanguage(), \"ORDERS_COUNTRY\")"), "the multilingual repository should overlay translations on findAll"); assertTrue(repository.contains("Translator.translateEntity(super.findById(id)"), "the multilingual repository should overlay translations on findById"); assertTrue(repository.contains("public java.util.Optional findOne(Object id)"), "the multilingual repository must also override findOne - the generated controller reads single records through it"); - String customerRepository = contentOf("gen/orders/data/customer/CustomerRepository.java"); + String customerRepository = codeOf("gen/orders/data/customer/CustomerRepository.java"); assertFalse(customerRepository.contains("Translator."), "a non-multilingual repository must stay untouched"); // Shell config: the offered data languages feed the Region & Language setting. @@ -2762,14 +2850,14 @@ void report_file_stack_generates_typed_column_filters() { // Backend: the report repository validates and applies per-column conditions over the wrapped // query, typed from the report's own column metadata. - String repository = contentOf("gen/ordersbycustomer/data/reports/OrdersByCustomerRepository.java"); + String repository = codeOf("gen/ordersbycustomer/data/reports/OrdersByCustomerRepository.java"); assertTrue(repository.contains("FILTER_COLUMNS"), "the report repository should carry the filterable-column allowlist"); assertTrue(repository.contains("SELECT * FROM (\").append(QUERY).append(\") AS \\\"REPORT_DATA\\\" WHERE"), "conditions should wrap the report query"); assertTrue(repository.contains("SELECT COUNT(*) AS \\\"REPORT_COUNT\\\" FROM ("), "the count alias must be quoted - PostgreSQL folds an unquoted alias to lower case and the case-sensitive read misses it"); assertTrue(repository.contains("\"GTE\", \">=\""), "range operators should be whitelisted"); - String controller = contentOf("gen/ordersbycustomer/api/reports/OrdersByCustomerController.java"); + String controller = codeOf("gen/ordersbycustomer/api/reports/OrdersByCustomerController.java"); assertTrue(controller.contains("exportCsv(@Body Map filter)"), "export should honor the active filters"); // Frontend: the generated report page carries typed column metadata and the filter machinery. @@ -2854,7 +2942,7 @@ void calculated_field_action_emits_an_imports_backed_callout_in_the_repository() // The Java DAO template injects the imports and emits the action call-out // (Beans.get(...).calculate). generateFromModel("template-application-dao-java/template/template.js", "invoicing.model"); - String repository = contentOf("gen/invoicing/data/invoice/InvoiceRepository.java"); + String repository = codeOf("gen/invoicing/data/invoice/InvoiceRepository.java"); assertTrue(repository.contains("import custom.invoicing.InvoiceNumberAction;"), "the entity Imports should be injected into the generated repository"); assertTrue(repository.contains("import org.eclipse.dirigible.sdk.component.Beans;"), @@ -2898,7 +2986,7 @@ void editable_task_form_fields_are_coerced_to_their_java_type_on_write_back() { .statusCode(200)); generateFromModel("template-application-events-java/template/template.js", "orders.glue"); - String writer = contentOf("gen/events/orders/ApproveReviewWrite.java"); + String writer = codeOf("gen/events/orders/ApproveReviewWrite.java"); assertTrue(writer.contains("class ApproveReviewWrite implements JavaDelegate"), "a user task with editable fields should generate a Writer JavaDelegate"); assertTrue(writer.contains("values.put(\"ShippedOn\", java.time.LocalDate.parse(ShippedOnValue.toString().trim()));"), @@ -3179,6 +3267,91 @@ private String contentOf(String fileName) { return new String(resource(fileName).getContent(), StandardCharsets.UTF_8); } + /** + * Generated source with its comments stripped - what the assertions in here are almost always + * about. + * + * The templates carry long explanatory comments that necessarily name the very calls, topics and + * primitives being asserted on ("...never a full-row updateWithoutEvent", "...publishes + * -transitioned only once the document exists"). Matched against the raw file, a comment satisfies + * a contains() the code does not, or trips an assertFalse() the code never earned - so editing a + * comment can turn a correct generator red, or a broken one green. Read the code alone; assert on + * prose with {@link #contentOf} where the prose is genuinely the point. + */ + private String codeOf(String fileName) { + return stripComments(contentOf(fileName)); + } + + /** + * Removes Java comments, leaving string literals intact. + * + * Deliberately a scanner and not a regex: a generated endpoint URL ("http://...") or a JSON + * template carries "//" inside a literal, and a line-comment regex would cut the rest of that line + * away as if it were prose - silently deleting the very code an assertion is about. + */ + private static String stripComments(String source) { + StringBuilder code = new StringBuilder(source.length()); + boolean inLine = false; + boolean inBlock = false; + char quote = 0; + for (int i = 0; i < source.length(); i++) { + char current = source.charAt(i); + char next = i + 1 < source.length() ? source.charAt(i + 1) : 0; + if (inLine) { + if (current == '\n') { + inLine = false; + code.append(current); + } + } else if (inBlock) { + if (current == '*' && next == '/') { + inBlock = false; + i++; + } else if (current == '\n') { + // Keep the line structure so reported offsets stay comparable to the file. + code.append(current); + } + } else if (quote != 0) { + code.append(current); + if (current == '\\' && next != 0) { + code.append(next); + i++; + } else if (current == quote) { + quote = 0; + } + } else if (current == '/' && next == '/') { + inLine = true; + i++; + } else if (current == '/' && next == '*') { + inBlock = true; + i++; + } else { + code.append(current); + if (current == '"' || current == '\'') { + quote = current; + } + } + } + return code.toString(); + } + + /** + * The index of the ONLY occurrence of an anchor - for assertions about the order of two statements. + * + * indexOf() answers with the first match and says nothing about a second, so an anchor that becomes + * ambiguous (a call the generator now makes twice, in two different places, for two different + * reasons) silently relocates the assertion to whichever came first. That is not a hypothetical: + * the number stamp reads its row once to skip an already-stamped document and once more after the + * write to build the payload, and a bare findById(id) anchor found the guard read. Fail at the + * anchor instead, so the next such split is a message about the anchor rather than a mystery about + * order. + */ + private static int onlyIndexOf(String code, String anchor) { + int first = code.indexOf(anchor); + assertTrue(first >= 0, "anchor not found in the generated code: [" + anchor + "]"); + assertEquals(first, code.lastIndexOf(anchor), "anchor [" + anchor + "] occurs more than once - pick a more specific one"); + return first; + } + /** Run a language template against a generated model through the real generation service. */ private void generateFromModel(String templateModule, String modelFile) { String payload = "{\"template\":\"" + templateModule + "\",\"parameters\":{}}"; diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java index 78cb6639406..6dd6723658a 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; +import org.awaitility.Awaitility; import org.eclipse.dirigible.commons.config.Configuration; import org.eclipse.dirigible.repository.api.IRepository; import org.eclipse.dirigible.repository.api.IRepositoryStructure; @@ -126,10 +127,23 @@ void builderShell_loads_and_bootstraps() { openBuilder(); // Alpine started and the Builder's own stores registered - a mis-ordered or missing script - // leaves the page rendering nothing while every endpoint behind it stays green. - Object bootstrapped = Selenide.executeJavaScript("return !!(window.Alpine && Alpine.store('intent')" - + " && Alpine.store('conversation') && Alpine.store('publish') && window.IntentDiagrams);"); - Assertions.assertTrue(Boolean.TRUE.equals(bootstrapped), "The Builder shell failed to bootstrap its stores and renderer."); + // leaves the page rendering nothing while every endpoint behind it stays green. Polled rather + // than sampled once: openBuilder() returns as soon as navigation does, and the shell's scripts + // are deferred, so a single probe races the boot and reports a healthy page as broken on a slow + // runner. A genuinely mis-ordered script never registers the stores, so it still fails - just at + // the timeout instead of instantly. + Awaitility.await() + // In the calling thread: Selenide binds its WebDriver per-thread, so a probe on + // Awaitility's own poll thread finds no driver at all. + .pollInSameThread() + .atMost(Duration.ofSeconds(30)) + .pollInterval(Duration.ofMillis(250)) + .untilAsserted(() -> { + Object bootstrapped = Selenide.executeJavaScript("return !!(window.Alpine && Alpine.store('intent')" + + " && Alpine.store('conversation') && Alpine.store('publish') && window.IntentDiagrams);"); + Assertions.assertTrue(Boolean.TRUE.equals(bootstrapped), + "The Builder shell failed to bootstrap its stores and renderer."); + }); // The first-run surface is there: the invitation and the composer. Selenide.$(By.xpath("//*[contains(text(), 'Describe the application you need')]")) diff --git a/tests/tests-integrations/src/main/resources/ModelGenerationIT/orders.glue b/tests/tests-integrations/src/main/resources/ModelGenerationIT/orders.glue index efce83fdcbc..c97a08489cf 100644 --- a/tests/tests-integrations/src/main/resources/ModelGenerationIT/orders.glue +++ b/tests/tests-integrations/src/main/resources/ModelGenerationIT/orders.glue @@ -330,5 +330,67 @@ "guardValue": "", "backRefProperty": "SalesOrder" } + ], + "resolves": [ + { + "name": "salesRep", + "className": "SalesRep", + "entity": "SalesOrder", + "perspective": "Sales Orders", + "keyProperty": "Id", + "topicSuffix": "", + "guardExpression": "", + "setProperty": "SalesRep", + "registerEntity": "TerritoryAssignment", + "registerPerspective": "Settings", + "registerValueProperty": "SalesRep", + "matches": [ + { + "registerProperty": "Territory", + "recordProperty": "Territory" + } + ], + "matchSummary": "Territory", + "startProperty": "ValidFrom", + "endProperty": "ValidTo", + "valueProperty": "OrderedOn", + "outcomeProperty": "SalesRepOutcome", + "statusProperty": "Status", + "foundStatus": "2", + "notFoundStatus": "3", + "ambiguousStatus": "4", + "writesStatus": "true" + } + ], + "writers": [ + { + "process": "OrderApproval", + "userTask": "review", + "className": "OrderApprovalReviewWrite", + "entity": "SalesOrder", + "perspective": "Sales Orders", + "keyProperty": "Id", + "keyAccessor": "intValue", + "fields": [ + { + "property": "ShippedOn", + "coercion": "date" + }, + { + "property": "Quantity", + "coercion": "integer" + } + ] + } + ], + "numbering": [ + { + "entity": "SalesOrder", + "perspective": "Sales Orders", + "masterPk": "Id", + "field": "Number", + "series": "Sales Order", + "per": "" + } ] }