diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index a21bcbd4c1c..cbcc07752cd 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -32,6 +32,8 @@ 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. + **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. **An amended source rewrites its posting ([#7071](https://github.com/eclipse-dirigible/dirigible/issues/7071)):** the amend path (Confirm → Reject → edit the lines → Issue again) raises a `postings:` trigger a SECOND time, and the old idempotency test - an existing post whose item count reached the derived one - read that as "already posted", so the journal entry silently kept the amounts of the previous issue while the invoice it references had moved on. No second entry (right), a ledger short by the difference (wrong), and nothing anywhere said so. The generated handler now derives the WHOLE content first and compares it with what the post carries: identical is a redelivery (no-op), different is either a half-post to complete or an amendment to REWRITE the post from - header assignments re-applied, items replaced, never a second document. The rewrite stops where the created document's own lifecycle says someone has taken it over: it is rewritable only while its `function: EntityStatus` relation still holds the `init:` the posting's own create wrote (with no status lifecycle there is nothing to act on, so it is always rewritable), and past that the divergence is logged naming both documents and left to a correcting entry - `reverses:` - rather than overwritten behind the accountant's back. The comparison is order-insensitive over every cell the item rows assign, and numbers compare by value so a rescaled amount is not a change. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 2821bf387dd..c68098e7dbb 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -457,6 +457,7 @@ Semantics worth knowing: - **`fromStatus:` (and the guard `sourceStatus:` implies) = a create-from is not offered twice (#7068).** A `generates:` was unconditional. With a `sourceStatus:` completion hook it flipped the 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 ProformaInvoice already INVOICED produced SalesInvoice 10 and then SalesInvoice 11, both in the customer's hands. The hook DECLARED what "already done" looks like; nothing consulted it - the authored-but-unconsumed class, and the reason the gap was invisible (both halves of the model read correctly). The fix is one rule resolved once, `GeneratesGuardSupport`, feeding **both** halves of the action: the generated `run()` refuses with **409** before anything is created (the pre-check load is on the guarded path only), and the contributed action descriptor carries the same `guard: { property, allowed | blocked }` so the shared `customActions` store stops OFFERING the click on a record it would refuse - the store's `getActions(view, type, record)` gained an optional record and the four entity-action sites pass the one they already have (`selected`, and the document form). Two shapes, one guard: `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, which is why the issue's suggested `from:` could not be taken literally; absent it, a declared `sourceStatus` IMPLIES the deny-list of exactly that status, which is the minimal refusal and needs no authoring change for the models that already carry the defect. The guard is on the **click**, deliberately: an event-driven create-from already carries the at-most-once back-reference guard (or asked for a row per event with `mode: append`) and qualifies its moment with `event.when`, so `fromStatus` on an event-only rule is **refused at parse** rather than silently ignored. Also refused: a `page` scope (no record to read a status from), a source with no `function: EntityStatus` relation (nothing to read), and an allow-list containing the `sourceStatus` the action itself writes (it re-opens exactly the duplicate the guard removes). The statuses are symbolic like every other status site (`StatusSymbolResolver.rewriteGenerates` now resolves `fromStatus` too). Emission is gated on a new `hasStatusGuard` boolean, so a `.glue` written before the key existed renders the unguarded `run()` it always did. Covered by `GeneratesIntentTest` + `GlueGeneratesTest` + `IntentEngineIT.generates_completion_hook_flips_the_source_via_targeted_update` (the 409 branch, its ordering before the create, and the descriptor's guard). - **A mutual cross-model `generates` cycle bootstraps through a declared pass, not a hand-strip ([#6539](https://github.com/eclipse-dirigible/dirigible/issues/6539)).** A cross-model create-from is resolved against the target's real `.model` (`CrossModelSupport.resolve`, workspace-or-registry, loud on absence), which has no first project when the pair is MUTUAL - the canonical opportunity -> quotation funnel, where A mints a document into B while B holds a foreign key back to A: A cannot generate because B's `.model` does not exist, and B cannot because A's does not. The workaround was to strip A's `generates` block, generate A, generate B, restore the block, regenerate A - five steps, four of them editing the intent to say something it does not mean. Now the pass itself takes `bootstrap=true` (`POST /services/ide/intent/generate?...&bootstrap=true` -> `IntentGenerationService.generate(..., bootstrap)` -> `IntentGenerationContext.isBootstrap()`), which skips exactly the create-from whose owner model is not there yet and names it in `warnings`: bootstrap here, generate the dependency, regenerate here normally. **Absence is the whole trigger, and it is asked as a separate question** - `CrossModelSupport.ownerModelExists` tests whether the owner's `.model` FILE is readable from either source, deliberately narrower than `resolve` succeeding, so an owner that IS there but declares no such entity keeps failing loudly in a bootstrap pass too ("the dependency is not generated yet" and "the reference is wrong" want opposite answers, and the second is the one a bootstrap flag could hide forever). **Nothing else is relaxed**: a cross-model RELATION never degrades to a guess - its table, key column and FK type would have to be invented and the emitted schema would be wrong rather than incomplete - and lazy resolution was rejected for the same reason (a `generates` glue entry needs the target's perspective + PK at glue-generation time, and the convention fallback is exactly the dead-dropdown guess `CrossModelSupport` exists to refuse). **The default pass teaches the escape**: `buildGenerates` asks the absence question in both modes and, outside a bootstrap, throws its own `BootstrapRequiredException extends IntentValidationException` naming the cycle and the three-step recipe - so the endpoint can answer the ordinary 422 plus `bootstrap: true`, the one fact a caller cannot read out of the text, and the Intent Editor offers "Generate anyway" as a retry instead of leaving the developer to edit the document. Covered by `GlueGeneratesBootstrapTest` (skip + warning, the loud default with the recipe, and the present-but-wrong reference staying fatal under bootstrap) and `IntentEngineIT.mutual_cross_model_generates_bootstraps`. - **`generates.event:` on the process-step axis + an opt-in `mode: append` (#6800).** Two narrow extensions that together close "on event E, append a derived row" - a `LogEntry` per process step, a protocol line per transition - which **no** event-driven construct could express: every candidate either writes into an existing row (`postings`/`rollups`/`aggregates`), or was at-most-once by construction (`generates` + `event:`), so the shape needed a hand-written listener under `custom/` or an `outbound` -> `inbound` loopback. (1) The `event:` map now also takes the **step axis** `onStepReached`/`onStepCompleted: { process, step }` that `notifications`/`integrations`/`outbound` already bind to (#6537) - so a create-from can hang off a moment in a flow rather than a status write, which is also the one route around a state write that publishes nothing. Its extra narrowing over the other consumers: the process's `trigger:` entity must EQUAL `from:` (the step event is delivered as a message about the process's trigger record, and that record is what the create-from reads by id), and the source must be local - a process and its steps belong to the model that declares them, so a `fromUses:` source is rejected. `when:` stays optional on this axis: the step IS the moment. (2) `mode: once` (**default** - unchanged behaviour, byte-identical output) vs `mode: append`, which drops the existing-target lookup in `Generate.java.template` (`#if($hasEvent && !$appendMode)`, the single guard site, inside the shared `create()`), so every delivery creates a row. **The back-reference stays REQUIRED in both modes** - the dedup key under `once`, the row's provenance under `append` (a log row nothing points back at cannot be read); the parser message names both roles. Emission: `putGeneratesEvent` gained `isStep`/`stepProcess`/`stepName`/`topicSuffix`/`appendMode`, and the listener's `destination()` now renders `${topicSuffix}` instead of branching on `isCreate` (`""` for a create, `-transitioned` for a transition, `-step---reached|completed` for a step - same strings as before). **`StepEventSupport.boundEvents` had to learn about `generates`**, not just `GlueIntentGenerator`: `emitters()` reads that list, so without it a moment whose ONLY consumer is a create-from got no `JavaDelegate` emitter and the listener bound a topic nothing published to. **What `append` is NOT:** a state-aware guard. It is the ABSENCE of one - a redelivery appends a duplicate (the step topic is published after commit, not transactionally with the step, the same at-least-once contract `outbound` states), and it is the wrong answer to "I voided the target and cannot regenerate it" (that is #6814's stage-aware predicate on `mode: once`). Two `append` rules sharing a target AND a back-reference are **legal by design** (each records a different moment) - which is why #6813's parse-time collision diagnostic must be scoped to `once` pairs only. Covered by `GeneratesIntentTest` (step binding accepted; unknown process/step, non-eventable kind, trigger-entity mismatch, cross-model source, a mode with no trigger, an unknown mode, a missing back-reference under append, a prompt on an appending create-from all rejected) + `GlueGeneratesTest` (the step topic, `appendMode`, the emitter for a generates-only moment, and both lifecycle axes unchanged) + `IntentEmissionCoverageIT.assertGeneratesStepAxisRuntime` - one shipment whose all-serviceTask flow appends TWO log rows from two moments sharing the same back-reference, a click appending a THIRD, and an at-most-once sibling on the same moment minting exactly one summary that a later click hands back. +- **A create-from's `items:` has a SOURCE-ROW RULE (`where:` + `refuse:`, [#7091](https://github.com/eclipse-dirigible/dirigible/issues/7091)).** The mirror `items:` block cloned EVERY row of the source document into a target line, and there was no way to say which rows qualified - `GeneratesItemsIntent` carried `from`/`to`/`map`/`defaults` and nothing else, and `checks:` lives on entities and cannot gate a generate's item selection. So base-timesheets `invoice-from-timesheet` billed every `EmployeeTimesheet` of the project-month: a DRAFT / SUBMITTED / REJECTED one at the same footing as an APPROVED one (hours nobody has approved on the customer's invoice), and an EMPTY one - `totalHours` null - refused the whole Generate since #7081, so the clerk could not invoice the month at all 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 (`ScheduleSupport.conditionChain` is the shared renderer, extracted from `criteriaExpression`), pushed into the very `Criteria` that already selects the source's rows by their master foreign key - so an unqualified row is never loaded, rather than filtered in Java after the fact. A condition naming the source item's own `function: EntityStatus` relation may use the seeded status NAME (`StatusSymbolResolver.rewriteGeneratesItemsWhere`, on the ITEM's nomenclature, not the header's - resolving against the document's lifecycle would take an id out of the wrong nomenclature and quietly filter on it); only that one condition is a candidate, exactly as a register lookup's static filter is, or a `like` pattern on a name would be reported as an unknown status. **`refuse:` declares the other reading**: an unqualified row stops the whole create-from with the authored message plus the KEYS of the offending rows, instead of being left out. Which of the two a document means is a property of the document, not of the platform - a rejected timesheet quietly dropped from an invoice and a rejected timesheet quietly billed are both wrong, for different months - so skipping is the default and `refuse:` is opt-in (and refused at parse without a `where`, there being nothing for a row to be unqualified against). **A rule that qualifies NO row refuses too**, rather than committing a header with no lines at all: that is the harder of the two failures to notice, since the document exists and counts as the period's billing. Scoped to a `where`-declaring block on purpose - a rule-less items block keeps exactly the behaviour it had, and the descriptor's two new keys default to the empty string in `GlueGenerator.bindGenerate`, so a `.glue` written before them renders the unfiltered clone loop it always did (the third-edit trap #7070 documents). Unlike a schedule's query, whose source may be a cross-model row or an `audit:` column this model cannot see, the rule reads a LOCAL row being cloned - so the `field` is checked against the item source's own fields and to-one relations at parse, a name it does not declare being a condition the database would reject on the first click. Covered by `GlueGeneratesItemsWhereTest` (the rendered chain incl. the resolved status name and a moment value; the rule-less descriptor unchanged; each refusal) and `IntentEmissionCoverageIT` (the emitted query and both refusals, then end to end: a mixed stay bills exactly its past nights, an all-unqualified one answers 400 with the authored message, and the same source under the skip rule answers 400 for the lineless document). - **A create-from is ONE transaction, and a missing required value is refused by name (#7069).** The create-from writes three things that only make sense together - the target header, its lines, the source's `sourceStatus:` flip - and each repository call was its own transaction, so a line the target refused left the other two committed: a header-only invoice, its source already marked INVOICED, and an HTTP **500** carrying a PostgreSQL message about `SALES_INVOICE_ITEM_QUANTITY`. The record could never be generated from correctly again without an administrator, and the result is exactly the "empty document" class the quality gates exist to catch - reached through the platform's own generator. Two halves, both in the shared machinery rather than in this create-from: (1) `JavaEntityStore.inUnitOfWork` binds a session and a transaction to the thread, so every store call inside joins it, reads see the block's own writes, and the events ride the transaction and dispatch only after the whole unit commits; the client-facing entry is `org.eclipse.dirigible.components.data.store.java.repository.UnitOfWork.call(...)`, and `Generate.java.template` wraps `create(...)` in it. The `-transitioned` announcement moved OUTSIDE the block on purpose - the commit is what makes the transition true - guarded by a flag set on the one path that actually generated, so the early returns (no source; the at-most-once guard handing back an existing document) still announce nothing. Deliberately outside the unit: the `History` trail, document numbering and the outbox DDL, each on its own connection - a rolled-back unit leaves a history row and consumes a number, both records of an attempt. (2) The generated repository refuses a write that leaves a **NOT NULL** column empty (`. is required`, a `ValidationException`, i.e. 400) instead of letting the statement reach the database. It is the NOT NULL columns the schema declares, MINUS those carrying a DEFAULT - the database supplies that value, so an empty one is not missing, which is exactly what a relation's `init:` opening status is (refusing it would reject every create that leaves the status to the model; `IntentEmissionCoverageIT` catches it at runtime). Nothing that used to be written is now refused - an insert of null into a defaultless NOT NULL column never had another ending; only the answer changed, from a driver message naming a physical column to the property the author knows. The check sits after everything the repository computes itself (numbering, uuid, calculated fields, a document's totals) and before the insert, in `save`, `update` and `updateWithoutEvent` - not on the targeted primitives, which name their own columns. The create-from adds the row the refused line was mapped FROM (`... (from EmployeeTimesheet [7])`), because which of a hundred lines is missing a value is the whole question the caller has - which needed one new glue key, `fromItemPk`. Covered by `JavaUnitOfWorkIT` (rollback, the control case without the block, read-your-own-writes) and `IntentEngineIT` (the unit wraps the body, the announcement follows it, the required refusal is generated by name). - **`prompt:` on a `generates` action = a declared input form before the create (#6685).** The gap it closes: `transitions:` writes but takes no input and `generates:` creates but declares every value up front, so an action that collects the two answers the source cannot derive (which payment, how much) had to be a hand-written page. It reaches a post-issue child on an IMMUTABLE document too, because per-record action buttons are deliberately NOT gated on mutability (that is why Void works) - the **action-shaped sibling of `locksWithMaster: false`** (#6700), which reopens the child's own panel: the panel is the affordance for ordinary data entry, a prompted action for a guided create over mostly-derived values. `prompt:` entries name fields / to-one relations of the TARGET; parser (`validateGeneratesPrompt`): local target only, target must declare a composition to-one relation to `forEntity` (that guarantees the generated detail registration the dialog renders from), scope `entity`, no `timestamp` fields, no overlap with `map`/`defaults` (one writer), no duplicates, and **no `event:`** (an event-driven create-from runs with nobody there to answer the form - which is also why the prompted values ride the ENDPOINT path only: `run()` checks the required ones and passes the map into `create(sourceId, values)`, while the event listener's `create(sourceId)` signature is untouched). Server half: `promptFields` in the glue (PascalCase prop + required + a pre-rendered `Object raw` -> field-type conversion), `Generate.java.template` takes `values` in the Request, 400s on a missing required input BEFORE anything is written, and sets prompted values after map/defaults - the save still goes through the target's repository so numbering/checks/events fire. Client half: the descriptor carries `prompt` + `promptEntity` (authored names ONLY - control types, lookup URLs and `dependsOn` metadata are resolved AT RUNTIME from `App.detailsFor(view)`'s edit-columns registration, so the intent layer never references template routes); the shared `customActions` store opens an input dialog instead of the plain confirm (`openPrompt`/`promptRun` + a mini dependsOn cascade seeded from the clicked master id - the invoice's Customer chain narrows the payment list, `valueFrom` defaults the amount), degrading to the confirm when the registration is absent (the shared shell). Dialog markup rides in all five shells wrapped in the `customActionPrompt` Alpine component so the Velocity shell stays `$store`-free. Covered by the `GeneratesIntentTest` prompt tests + `GlueGeneratesTest.promptFieldsRenderTypedConversions` + the `IntentEmissionCoverageIT` prompted-generates assertions (emission + 400 + value-reaches-the-row). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index f8c41b6786b..61e8bb3216c 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -1096,6 +1096,12 @@ private static List> buildGenerates(IntentModel model, Map()); + // The source-row rule (issue #7091), pre-rendered as the tail of the Criteria that + // already selects the source's item rows by their master foreign key - so the rows the + // rule excludes are never loaded, and a rule of no conditions renders the empty string + // and therefore the query this always ran. + e.put("itemWhere", ScheduleSupport.conditionChain(items.getWhere())); + e.put("itemRefuse", items.hasWhere() && items.hasRefuse() ? items.getRefuse() : ""); } else if (hasItemLines) { // The synthetic lines write into the TARGET document's composition line-items child, // resolved automatically (never named in the intent): same-model from this model, @@ -1132,6 +1138,8 @@ private static List> buildGenerates(IntentModel model, Map()); + e.put("itemWhere", ""); + e.put("itemRefuse", ""); // Cell expressions are written over the SOURCE record, so the known-property set comes // from wherever the source is defined - locally, or the owner .model for a cross-model // source (an unresolved owner yields an empty set, i.e. no local name check). @@ -1148,6 +1156,8 @@ private static List> buildGenerates(IntentModel model, Map()); e.put("itemLines", new ArrayList<>()); + e.put("itemWhere", ""); + e.put("itemRefuse", ""); } e.put("hasPrompt", g.hasPrompt()); e.put("promptFields", promptFields(g, crossModel ? null : byName.get(g.getTo()))); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupport.java index 8466969707e..85e7ab36ac4 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupport.java @@ -12,6 +12,7 @@ import java.time.Duration; import java.time.Period; import java.time.format.DateTimeParseException; +import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -175,8 +176,24 @@ public static boolean isSupportedOperator(String op) { * {@code Criteria.create().lt("DueOn", java.time.LocalDate.now()).eq("Status", "ACTIVE")} */ public static String criteriaExpression(ScheduleIntent schedule) { - StringBuilder expr = new StringBuilder("Criteria.create()"); - for (ScheduleConditionIntent condition : schedule.getWhere()) { + return "Criteria.create()" + conditionChain(schedule.getWhere()); + } + + /** + * The same conditions as a chain of {@code Criteria} calls with no {@code Criteria.create()} in + * front, so a caller that has already opened a criteria can append them - a create-from's + * source-row rule ({@code items: where:}, issue #7091), which narrows the very query that selects + * the source document's item rows by their master foreign key. + * + * @param conditions the authored conditions, may be {@code null} + * @return e.g. {@code .eq("Status", 3).gt("TotalHours", 0)}, or the empty string for no conditions + */ + public static String conditionChain(List conditions) { + if (conditions == null) { + return ""; + } + StringBuilder expr = new StringBuilder(); + for (ScheduleConditionIntent condition : conditions) { String method = OPERATORS.get(condition.getOp()); if (method == null) { continue; // validated at parse time; defensively skip an unknown operator diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/GeneratesItemsIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/GeneratesItemsIntent.java index 7f1cedd3651..561d0e8d8bd 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/GeneratesItemsIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/GeneratesItemsIntent.java @@ -10,6 +10,7 @@ package org.eclipse.dirigible.components.intent.model; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -19,6 +20,14 @@ * {@link #defaults} follow the same semantics as on the parent (source copy vs * {@code now}/literal). The foreign key back to the master is set automatically - it must not be * listed in {@link #map}. + * + *

+ * {@link #where} is the SOURCE-ROW RULE (issue #7091): without it every row of the source document + * became a line, so a document could only be generated whole - an unapproved timesheet was billed + * at the same footing as an approved one, and an empty one (whose mapped value the target refuses) + * stopped the whole create-from until someone deleted the row by hand. {@link #refuse} declares + * which of the two readings the document means: dropping an unqualified row silently and billing it + * silently are both wrong, for different months. */ public class GeneratesItemsIntent { @@ -34,6 +43,37 @@ public class GeneratesItemsIntent { /** Target item property -> {@code now} or a literal value. */ private Map defaults = new LinkedHashMap<>(); + /** + * Optional source-row rule (issue #7091): only the rows of {@link #from} that satisfy every + * condition become target lines. The conditions are the same field/op/value triples a + * {@code schedules[].where} carries and are pushed into the very {@code Criteria} that already + * selects the source's rows by their master foreign key, so the unqualified rows are never loaded. + * + *

+ * A condition naming the source item's {@code function: EntityStatus} relation may use the seeded + * status NAME - an id is positional, and a status inserted mid-nomenclature would otherwise + * silently retarget the rule. + * + *

+ * With a rule declared, a source whose rows ALL fail it refuses the create-from rather than + * committing a header with no lines: a document of no lines is not the document that was asked for, + * and the empty invoice is the harder failure to notice of the two. + */ + private List where; + + /** + * Optional refusal message (issue #7091): with it, a source row that does not satisfy + * {@link #where} stops the whole create-from - a {@code ValidationException} carrying this text and + * the keys of the offending rows - instead of being left out of the document. + * + *

+ * Which of the two an unqualified row deserves is a property of the document, not of the platform: + * a rejected timesheet quietly dropped from an invoice and a rejected timesheet quietly billed are + * both wrong, so skipping is the default and this declares the other reading. Requires + * {@link #where} - there is nothing for a row to be unqualified against without it. + */ + private String refuse; + public String getFrom() { return from; } @@ -65,4 +105,30 @@ public Map getDefaults() { public void setDefaults(Map defaults) { this.defaults = defaults == null ? new LinkedHashMap<>() : defaults; } + + public List getWhere() { + return where; + } + + public void setWhere(List where) { + this.where = where; + } + + /** Whether a source-row rule is declared (see {@link #where}). */ + public boolean hasWhere() { + return where != null && !where.isEmpty(); + } + + public String getRefuse() { + return refuse; + } + + public void setRefuse(String refuse) { + this.refuse = refuse; + } + + /** Whether an unqualified source row refuses the whole create-from (see {@link #refuse}). */ + public boolean hasRefuse() { + return refuse != null && !refuse.isBlank(); + } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleConditionIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleConditionIntent.java index 9f9d74109b8..988c2951178 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleConditionIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleConditionIntent.java @@ -10,10 +10,12 @@ package org.eclipse.dirigible.components.intent.model; /** - * One condition of a {@link ScheduleIntent}'s {@code where} filter: a field, a comparison operator + * One condition of a {@code where} filter - a {@link ScheduleIntent}'s row query, or the source-row + * rule of a {@link GeneratesItemsIntent} (issue #7091): a field, a comparison operator * ({@code eq}/{@code ne}/{@code gt}/{@code ge}/{@code lt}/{@code le}/{@code like}) and a value. The - * value is a literal, or the token {@code CURRENT_DATE} / {@code CURRENT_TIMESTAMP} which the - * generated job evaluates to "now". Maps to a typed {@code Criteria} condition. + * value is a literal, or the token {@code CURRENT_DATE} / {@code CURRENT_TIMESTAMP}, which the + * generated code evaluates against the clock of the run that fires. Maps to a typed + * {@code Criteria} condition. */ public class ScheduleConditionIntent { 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 f0c75877b62..43d56918241 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 @@ -7330,6 +7330,7 @@ private static void validateGenerates(IntentModel model, Set entityNames // implies a cross-model item - resolved in the owner's .model, not here. validateMapTarget(crossModel || items.getTo() == null ? null : byName.get(items.getTo()), items.getMap(), "generates [" + name + "]", "items map", issues); + validateGeneratesItemsWhere(items, "generates [" + name + "]", itemSource, issues); } if (g.hasUnique()) { // The natural key is a SCHEDULE's idempotency guard (issue #7070). An on-demand @@ -7347,6 +7348,50 @@ private static void validateGenerates(IntentModel model, Set entityNames } } + /** + * Validate the source-row rule of a create-from's mirror items block (issue #7091): which rows of + * the source document become lines of the target, and what an unqualified one costs. + * + *

+ * The conditions are the field/op/value triples a {@code schedules[].where} carries, checked the + * same way - a supported operator, and a moment value ({@code CURRENT_DATE} and friends) whose + * shape the compared field can carry. What is checked additionally is the {@code field} itself: + * unlike a schedule's query, whose source may be a cross-model row or an {@code audit:} column this + * model cannot see, an items rule reads a LOCAL row being cloned, so a name it does not declare + * could only ever be a condition the database rejects on the first click. + * + *

+ * {@code refuse:} requires the rule: without conditions no row is ever unqualified, so the message + * is a promise nothing can keep - the class of authored-but-unconsumed key this module refuses + * everywhere else. + */ + private static void validateGeneratesItemsWhere(GeneratesItemsIntent items, String subject, EntityIntent itemSource, + List issues) { + if (items.hasRefuse() && !items.hasWhere()) { + issues.add(subject + " items declares refuse with no where - nothing can be unqualified without a source-row rule;" + + " add the where conditions the refused rows fail"); + } + if (!items.hasWhere()) { + return; + } + for (ScheduleConditionIntent condition : items.getWhere()) { + if (condition.getField() == null || condition.getField() + .isBlank()) { + issues.add(subject + " items has a where-condition with no field"); + continue; + } + if (!SCHEDULE_OPERATORS.contains(condition.getOp())) { + issues.add(subject + " items where-condition uses unsupported operator [" + condition.getOp() + + "] (supported: eq/ne/gt/ge/lt/le/like)"); + } + if (itemSource != null && !hasPropertyIgnoreCase(itemSource, condition.getField())) { + issues.add(subject + " items where-condition reads [" + condition.getField() + + "], which is not a field or to-one relation of [" + itemSource.getName() + "]"); + } + validateScheduleMoment(condition, itemSource, subject + " items", issues); + } + } + /** * Validate the from-status guard of a create-from (issue #7068): the statuses the SOURCE may stand * in for the action to run at all. 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 37eddfe45d5..85e269160d3 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 @@ -303,6 +303,36 @@ private void rewriteGenerates(Map root) { putResolvedList(generate, "fromStatus", status, subject + " fromStatus"); putResolved(generate, "sourceStatus", status, subject + " sourceStatus"); putResolved(generate, "sourceStatusOnRetire", status, subject + " sourceStatusOnRetire"); + rewriteGeneratesItemsWhere(generate, subject); + } + } + + /** + * The source-row rule of a create-from's items block (issue #7091), whose status condition alone is + * 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); + if (statusRelation == null) { + return; + } + Target itemStatus = statusOf(itemEntity); + for (Object node : asList(items.get("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 + "]"); + } } } 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 2857a89452b..9e0f703a384 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 @@ -1751,6 +1751,11 @@ generates: items: # optional MIRROR form (an OBJECT): clone each source item row from: ProjectTimesheetItem # 1:1 into a target item row (map = copy, defaults = now/literal) to: SalesInvoiceItem + where: # optional SOURCE-ROW RULE: only the rows that satisfy every + - { field: Status, op: eq, value: APPROVED } # condition become lines (default: skip + - { field: totalHours, op: gt, value: 0 } # the rest). Same shape as schedules.where. + refuse: "Member timesheet is not approved" # optional: an unqualified row REFUSES the + # whole run (400) instead of being left out map: Description: Description Amount: Amount @@ -1764,6 +1769,46 @@ generates: # target is retired (cancelled/void) - see "void and reissue" ``` +**Which source rows become lines (`items: where:` / `refuse:`).** The mirror form clones every row +of the source document by default, which is only ever right when the whole document qualifies. It +usually does not: an unapproved member timesheet must not reach the customer's invoice, and an empty +one (no hours) is a line the target refuses outright - so ONE bad row used to stop the whole month +from being invoiced, with nothing the intent could say about it. + +```yaml + items: + from: EmployeeTimesheet + to: SalesInvoiceItem + where: + - { field: Status, op: eq, value: APPROVED } # only approved member timesheets + - { field: totalHours, op: gt, value: 0 } # an empty one is not a line + map: { Name: employeeName, Quantity: totalHours, Price: rate } +``` + +`where:` takes the same `{ field, op, value }` triples a `schedules[].where` does - `op` is +`eq`/`ne`/`gt`/`ge`/`lt`/`le`/`like`, and the value may be a moment (`CURRENT_DATE`, +`CURRENT_TIMESTAMP-PT30M`), resolved against the clock of the run rather than of the generation. The +`field` is a field or a to-one relation of the items `from:` entity, and a condition naming its +`function: EntityStatus` relation may use the **seeded status name** as above (an id is positional - +inserting a status mid-nomenclature would otherwise silently retarget the rule). + +**Skipping is the default; `refuse:` is the other reading.** An unqualified row left quietly out of +an invoice and an unqualified row quietly billed are both wrong, for different months, so the +document says which it means: + +```yaml + refuse: "Member timesheet is not approved" +``` + +With it, an unqualified row stops the whole create-from with a 400 carrying that message and the +keys of the offending rows - which of a hundred lines to go and fix is the caller's whole question. +`refuse:` requires `where:`; without conditions no row is ever unqualified. + +**A rule that qualifies no row refuses either way.** An invoice with no lines is not the invoice +that was asked for, and it is the harder failure to notice - it exists and counts as the period's +billing - so the run answers 400 rather than committing the header. An items block with no `where:` +keeps exactly the behaviour it had. + **A `map:` source may hop one relation - and that is how you SNAPSHOT a value.** A value is `map`ped rather than reached through a relation when the target must keep what was true at the moment it was created: an invoice keeps the customer's VAT number as it stood at invoicing, an audit log keeps the diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesItemsWhereTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesItemsWhereTest.java new file mode 100644 index 00000000000..b906f1c1a20 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesItemsWhereTest.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.eclipse.dirigible.components.intent.parser.IntentValidationException; +import org.junit.jupiter.api.Test; + +/** + * The source-row rule of a create-from's mirror items block (issue #7091): which rows of the source + * document become lines of the target, and what an unqualified one costs. + * + *

+ * The motivating shape is "invoice the approved month": every member timesheet of a project-month + * used to be cloned into an invoice line, so an unapproved one was billed at the same footing as an + * approved one - and an empty one, whose mapped quantity the target refuses, stopped the whole + * month from being invoiced with nothing the intent could say about it. + */ +class GlueGeneratesItemsWhereTest { + + /** A project-month billed from its member timesheets, only the approved and non-empty ones. */ + private static final String YAML = """ + name: timesheets + entities: + - name: TimesheetStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: ProjectTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: period, type: string, documentTitle: true } + - name: EmployeeTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: employeeName, type: string } + - { name: totalHours, type: decimal } + - { name: rate, type: decimal } + - { name: closedOn, type: date } + relations: + - { name: ProjectTimesheet, kind: manyToOne, to: ProjectTimesheet, composition: true, required: true } + - { name: Status, kind: manyToOne, to: TimesheetStatus, function: EntityStatus, init: 1 } + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - name: SalesInvoiceItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: quantity, type: decimal } + - { name: price, type: decimal } + relations: + - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + generates: + - name: invoice-from-timesheet + from: ProjectTimesheet + to: SalesInvoice + items: + from: EmployeeTimesheet + to: SalesInvoiceItem + where: + - { field: Status, op: eq, value: APPROVED } + - { field: totalHours, op: gt, value: 0 } + map: + Name: employeeName + Quantity: totalHours + Price: rate + seeds: + - name: timesheet-statuses + entity: TimesheetStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: SUBMITTED } + - { id: 3, name: APPROVED } + - { id: 4, name: REJECTED } + """; + + /** + * The rule renders as the tail of the very {@code Criteria} that already selects the source's item + * rows by their master foreign key, so the rows it excludes are never loaded. The status is NAMED: + * an id is positional, and inserting a status mid-nomenclature would otherwise silently retarget + * the rule - so it is resolved to its seed id before the typed mapping, exactly as every other + * status site is. + */ + @Test + void theRuleRendersAsTheItemQuerysCriteriaTailWithTheStatusNameResolved() { + Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(YAML)) + .get(0); + + assertEquals(true, g.get("hasItems")); + assertEquals(".eq(\"Status\", 3).gt(\"TotalHours\", 0)", g.get("itemWhere")); + // Skipping is the default: no message, so an unqualified row is simply left out. + assertEquals("", g.get("itemRefuse")); + } + + /** + * {@code refuse:} declares the other reading - an unqualified row stops the whole create-from. It + * is a property of the document, not of the platform: dropping a rejected line silently and billing + * it silently are both wrong, for different months. + */ + @Test + void aDeclaredRefusalIsCarriedOntoTheDescriptor() { + Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(YAML.replace(""" + map: + Name: employeeName + """, """ + refuse: "Member timesheet is not approved" + map: + Name: employeeName + """))) + .get(0); + + assertEquals(".eq(\"Status\", 3).gt(\"TotalHours\", 0)", g.get("itemWhere")); + assertEquals("Member timesheet is not approved", g.get("itemRefuse")); + } + + /** + * A moment is a legitimate rule value, resolved against the clock of the run that fires - the whole + * point of reusing the {@code where} shape a schedule's query already carries. + */ + @Test + void aMomentValueRendersAgainstTheClockOfTheRun() { + Map g = GlueIntentGenerator + .buildGeneratesForTest( + IntentParser.parse(YAML.replace("- { field: totalHours, op: gt, value: 0 }", + "- { field: closedOn, op: le, value: CURRENT_DATE }"))) + .get(0); + + assertEquals(".eq(\"Status\", 3).le(\"ClosedOn\", java.time.LocalDate.now())", g.get("itemWhere")); + } + + /** + * The keys are opt-in: an items block with no rule keeps exactly the descriptor it had, so a + * create-from written before this existed regenerates the unfiltered clone loop it always had. + */ + @Test + void withoutARuleTheDescriptorIsUnchanged() { + Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(YAML.replace(""" + where: + - { field: Status, op: eq, value: APPROVED } + - { field: totalHours, op: gt, value: 0 } + """, ""))) + .get(0); + + assertEquals(true, g.get("hasItems")); + assertEquals("", g.get("itemWhere")); + assertEquals("", g.get("itemRefuse")); + } + + /** A rule the database would reject on the first click is refused at parse. */ + @Test + void aFieldTheSourceItemDoesNotDeclareIsRefused() { + IntentValidationException failure = assertThrows(IntentValidationException.class, + () -> IntentParser.parse(YAML.replace("field: totalHours", "field: approvedHours"))); + + assertTrue(failure.getMessage() + .contains("approvedHours"), + "the failure must name the field: " + failure.getMessage()); + } + + /** The operator vocabulary is the schedule query's, and closed. */ + @Test + void anUnsupportedOperatorIsRefused() { + IntentValidationException failure = + assertThrows(IntentValidationException.class, () -> IntentParser.parse(YAML.replace("op: gt", "op: between"))); + + assertTrue(failure.getMessage() + .contains("unsupported operator"), + "the failure must name the operator: " + failure.getMessage()); + } + + /** + * Without conditions no row is ever unqualified, so the message is a promise nothing can keep - the + * authored-but-unconsumed class this module refuses everywhere else. + */ + @Test + void aRefusalWithNoRuleIsRefused() { + IntentValidationException failure = assertThrows(IntentValidationException.class, () -> IntentParser.parse(YAML.replace(""" + where: + - { field: Status, op: eq, value: APPROVED } + - { field: totalHours, op: gt, value: 0 } + """, """ + refuse: "Member timesheet is not approved" + """))); + + assertTrue(failure.getMessage() + .contains("refuse with no where"), + "the failure must say the rule is missing: " + failure.getMessage()); + } + + /** A moment compared against a non-temporal field is a query that could never match. */ + @Test + void aMomentComparedWithANonTemporalFieldIsRefused() { + IntentValidationException failure = assertThrows(IntentValidationException.class, + () -> IntentParser.parse(YAML.replace("op: gt, value: 0", "op: gt, value: CURRENT_DATE"))); + + assertTrue(failure.getMessage() + .contains("non-temporal"), + "the failure must name the shape mismatch: " + failure.getMessage()); + } +} diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java index a147625e696..e1d76c14a93 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java @@ -698,6 +698,12 @@ private static void bindGenerate(Map item, Map c // the key existed defaults to the intent convention rather than rendering its own literal into // Java that would not compile. context.put("fromItemPk", strOr(item, "fromItemPk", "Id")); + // The source-row rule (issue #7091): the Criteria tail that narrows the item query, and the + // authored message an unqualified row refuses the whole create-from with. Both default to the + // empty string, so a .glue written before the keys existed renders the unfiltered clone loop it + // always had rather than its own literal into Java that would not compile. + context.put("itemWhere", strOr(item, "itemWhere", "")); + context.put("itemRefuse", strOr(item, "itemRefuse", "")); // The one-hop `relation.field` map sources: one load per distinct relation, which the template // emits before the mapping reads a field off it. A .glue written before this key existed carries // none, and the loop renders nothing - the direct-property mapping it always had. diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template index 5bf7af8fa88..28a7bc42303 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template @@ -20,6 +20,17 @@ import org.eclipse.dirigible.sdk.http.Response; * declared fields, and saves it through the target's generated repository - so the target's create-time * logic (document numbering, status init, calculated fields) fires. Any composition items are cloned and * re-pointed at the new master. +#if($itemWhere != "") + * + * Source-row rule (intent `items: where:` - issue #7091): only the source rows the rule qualifies become + * lines, and a source whose rows all fail it refuses the run rather than committing a header with no +#if($itemRefuse != "") + * lines. An unqualified row refuses it too (`refuse:`), naming the rows: leaving a rejected line out of + * the document silently is as wrong as billing it silently, and this document says which it means. +#{else} + * lines. An unqualified row is simply left out of the document (declare `refuse:` for the other reading). +#end +#end * * Generated from the intent generates block - do not edit; it is re-generated with the application. * Entity access goes ONLY through the generated repositories (validations / events / numbering). @@ -211,10 +222,55 @@ public class ${className}Generate { gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Entity saved = new gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Repository().save(target); #if($hasItems) + #if($itemWhere != "") + // Source-row rule (intent `items: where:` - issue #7091): only the source rows the rule + // qualifies become lines. Pushed into the very query that selects them by their master + // foreign key, so an unqualified row is never loaded. Without a rule every row was cloned, + // which billed a draft or rejected line at the same footing as an approved one - and, since + // the target refuses a line missing a required value, let ONE empty row stop the whole month + // from being invoiced with nothing the intent could say about it. + java.util.List qualifying = + new gen.${fromGenFolder}.data.${fromItemJavaPerspective}.${fromItemEntity}Repository() + .findAll(Criteria.create() + .eq("${srcFkProperty}", sourceId)${itemWhere}); + #if($itemRefuse != "") + // `refuse:` - an unqualified row stops the whole create-from instead of being left out. + // Dropping a rejected line silently and billing it silently are both wrong, for different + // months; which one this document means is the author's call, and this is it. The keys of + // the offending rows travel with the message: which of them to go and fix is the whole + // question the caller has. + java.util.Set qualifyingKeys = new java.util.HashSet<>(); + for (gen.${fromGenFolder}.data.${fromItemJavaPerspective}.${fromItemEntity}Entity row : qualifying) { + qualifyingKeys.add(row.${fromItemPk}); + } + java.util.List unqualified = new java.util.ArrayList<>(); + for (gen.${fromGenFolder}.data.${fromItemJavaPerspective}.${fromItemEntity}Entity row : + new gen.${fromGenFolder}.data.${fromItemJavaPerspective}.${fromItemEntity}Repository() + .findAll(Criteria.create() + .eq("${srcFkProperty}", sourceId))) { + if (!qualifyingKeys.contains(row.${fromItemPk})) { + unqualified.add(row.${fromItemPk}); + } + } + if (!unqualified.isEmpty()) { + throw new org.eclipse.dirigible.sdk.db.ValidationException( + "${itemRefuse} (${fromItemEntity} " + unqualified + ")"); + } + #end + if (qualifying.isEmpty()) { + // Every row failed the rule, so the ${toEntity} would carry no lines at all. A document + // of no lines is not the document that was asked for, and it is the harder of the two + // failures to notice - it exists, it counts as the period's billing, and it is empty. + throw new org.eclipse.dirigible.sdk.db.ValidationException( + "no ${fromItemEntity} row of the ${fromEntity} qualifies for ${name}, so the ${toEntity} would have no lines"); + } + for (gen.${fromGenFolder}.data.${fromItemJavaPerspective}.${fromItemEntity}Entity srcItem : qualifying) { + #else for (gen.${fromGenFolder}.data.${fromItemJavaPerspective}.${fromItemEntity}Entity srcItem : new gen.${fromGenFolder}.data.${fromItemJavaPerspective}.${fromItemEntity}Repository() .findAll(Criteria.create() .eq("${srcFkProperty}", sourceId))) { + #end gen.${toGenFolder}.data.${toJavaPerspective}.${toItemEntity}Entity item = new gen.${toGenFolder}.data.${toJavaPerspective}.${toItemEntity}Entity(); #foreach($a in $itemFieldAssignments) diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index b7e6f121bae..25f279e1e88 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -1398,6 +1398,44 @@ class IntentEmissionCoverageIT extends IntegrationTest { Patrol: id defaults: note: "AUTO" + # The MIRROR items form with a source-row rule (#7091): the stay's nights become the + # bill's lines, but only the ones the rule qualifies. Before this, `items:` cloned every + # row - an unapproved or empty one was billed at the same footing as a good one, and since + # the target refuses a line missing a required value, ONE bad row could stop the whole + # document from being generated at all with nothing the intent could say about it. + # + # Skipping is the default. The rule reads a moment (CURRENT_DATE), so what qualifies moves + # with the clock of the run - the point of reusing the `where` a schedule's query carries. + - name: bill-from-stay + from: Stay + to: Bill + label: Bill the nights so far + items: + from: StayNight + to: BillLine + where: + - { field: day, op: le, value: CURRENT_DATE } + map: + Amount: amount + defaults: + note: "from stay" + # The other reading of the same rule (`refuse:`): an unqualified row stops the whole + # create-from, naming the rows. Dropping a rejected line silently and billing it silently + # are both wrong, for different months - which one a document means is the author's call. + - name: checked-bill-from-stay + from: Stay + to: Bill + label: Bill the whole stay + items: + from: StayNight + to: BillLine + where: + - { field: amount, op: gt, value: 0 } + refuse: "Stay night carries no amount" + map: + Amount: amount + defaults: + note: "from stay, checked" # resolves: (#6712) fill Patrol.Inspector from the Duty row whose validity period covers # the patrol's date, stamp the outcome, and route the record by status. The status write is @@ -3164,6 +3202,25 @@ private void assertEmission() { assertTrue(generate.contains("if (candidate.Status == null || !(candidate.Status == 3)) {"), "the guard must step over a target whose status stage is cancelled/void, and only over those"); + // The MIRROR items form's source-row rule (#7091): the rule is pushed into the very Criteria + // that already selects the source's item rows by their master foreign key, so an unqualified + // row is never loaded - and the empty result is refused rather than committed as a header with + // no lines. The moment is evaluated at each run, not baked at generation. + String billFromStay = contentOf("gen/events/emission/BillFromStayGenerate.java"); + assertTrue(billFromStay.contains(".le(\"Day\", java.time.LocalDate.now())"), + "the source-row rule must narrow the item query itself, with the moment evaluated per run"); + assertTrue(billFromStay.contains("StayNightEntity srcItem : qualifying"), + "the clone loop must read the narrowed result, not the whole child set"); + assertTrue(billFromStay.contains("would have no lines"), + "a rule that qualifies no row must refuse rather than commit a header with no lines"); + assertFalse(billFromStay.contains("java.util.List unqualified"), + "without refuse: an unqualified row is simply left out, so no second query collects them"); + // The other reading: the refusal names the rows, so the caller knows which of a hundred lines + // to go and fix - the whole question they have. + String checkedBillFromStay = contentOf("gen/events/emission/CheckedBillFromStayGenerate.java"); + assertTrue(checkedBillFromStay.contains("\"Stay night carries no amount (StayNight \" + unqualified + \")\""), + "refuse: must throw the authored message carrying the keys of the offending rows"); + // generates on the step axis + mode: append (#6800): the listener binds the step-scoped topic // the generated emitter publishes the trigger entity on (NOT a lifecycle topic), and the // appending create-from renders WITHOUT the existing-target lookup - while its at-most-once @@ -4659,6 +4716,90 @@ private void assertRuntimeEnforcement() { assertBpmEventsRuntime(); assertResolveTransitionRuntime(); assertGeneratesStepAxisRuntime(); + assertGeneratesItemsRuleRuntime(); + } + + /** + * The source-row rule of a create-from's mirror items block, end to end (#7091): which of the + * source document's rows become lines, and what an unqualified one costs. + * + *

+ * Only this layer shows the three things the rule is for. A rule that qualifies SOME rows must + * produce a document of exactly those (the skip reading); a rule that qualifies NONE must refuse + * rather than commit a header with no lines at all - the harder of the two failures to notice, + * since the document exists and counts as the period's billing; and a {@code refuse:} rule must + * stop the run naming the offending rows, which is the reading a document whose rejected lines must + * not be silently dropped needs. Asserting the emitted source cannot show any of it: the + * unqualified rows have to really not be there, and the refusals have to really be 4xx. + * + *

+ * The nights are the {@code nights} expansion's own rows, spread from the stay's total - so the + * fixture is the model's, not a hand-built child set, and the day rule is read against the clock of + * the run exactly as a schedule's query is. + */ + private void assertGeneratesItemsRuleRuntime() { + java.time.LocalDate today = java.time.LocalDate.now(); + // Four nights, three of them already past: the day rule qualifies exactly three. + int mixed = createStay(today.minusDays(2), today.plusDays(1), 400); + // Three nights, all in the future and all spread from a zero total: neither rule qualifies a + // single one of them. + int unqualified = createStay(today.plusDays(1), today.plusDays(3), 0); + restAssuredExecutor.execute(() -> assertEquals(4, stayNights(mixed).size(), "the mixed stay must expand into four nights"), 60); + restAssuredExecutor.execute( + () -> assertEquals(3, stayNights(unqualified).size(), "the unqualified stay must expand into three nights"), 60); + + // The skip reading: the bill carries the three past nights and not the future one. + AtomicInteger bill = new AtomicInteger(); + restAssuredExecutor.execute(() -> bill.set(io.restassured.path.json.JsonPath.from(given().contentType("application/json") + .body("{\"id\":" + mixed + "}") + .when() + .post("/services/java/" + PROJECT + + "/gen/events/emission/BillFromStayGenerate/run") + .then() + .statusCode(200) + .extract() + .asString()) + .getInt("Id"))); + restAssuredExecutor.execute(() -> given().when() + .get(API + "/bill/BillLineController?Bill=" + bill.get()) + .then() + .statusCode(200) + .body("$", hasSize(3)), + 30); + + // The refusal reading: every night fails the amount rule, so the run stops with the authored + // message rather than leaving them out - and it names the rows to go and fix. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + unqualified + "}") + .when() + .post("/services/java/" + PROJECT + "/gen/events/emission/CheckedBillFromStayGenerate/run") + .then() + .statusCode(400) + .body(containsString("Stay night carries no amount"))); + + // No rule qualifies a row, so the document would have no lines at all - refused, not committed. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + unqualified + "}") + .when() + .post("/services/java/" + PROJECT + "/gen/events/emission/BillFromStayGenerate/run") + .then() + .statusCode(400) + .body(containsString("would have no lines"))); + } + + /** A stay whose nights the {@code nights} expansion spreads the given total across. */ + private int createStay(java.time.LocalDate from, java.time.LocalDate to, int total) { + AtomicInteger id = new AtomicInteger(); + restAssuredExecutor.execute(() -> id.set(given().contentType("application/json") + .body("{\"FromDate\":\"" + from + "\",\"ToDate\":\"" + to + "\",\"Total\":" + total + + "}") + .when() + .post(API + "/stay/StayController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + return id.get(); } /**