From dd9614dd03cce52b3c0e065df7610f1222aa12ec Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 17 Sep 2026 13:53:32 +0300 Subject: [PATCH] intent: the glue stops carrying Criteria, java.time and BigDecimal - the query, the period and the compared default become data (#7406) Stacked on #7405, which took the `.edm`/`.model`'s `checks[]`. The `.glue` is the same kind of artefact for the process half - the description every template reads, Java and JavaScript alike - and `GlueIntentGenerator` was writing Java SOURCE into it: "criteriaExpression": "Criteria.create().lt(\"Due\", java.time.LocalDate.now())" "lower": "java.time.LocalDate.now().withDayOfMonth(1)" "upper": "java.time.LocalDate.now().withDayOfMonth(1).plusMonths(1).minusDays(1)" "derivedDefault": "new java.math.BigDecimal(\"1\")" Across the BusinessIntents fleet that is 69 of the ~135 java-carrying values, and the whole `org.eclipse.dirigible.sdk` builder surface. The glue now carries the same facts as data, and the language appears one layer out, in `JavaLiterals`: "criteria": [ { "op": "lt", "property": "Due", "value": { "kind": "moment", "shape": "date", "offset": "P7D", "forward": false } } ] "kind": "range", "property": "Date", "period": "month" "derivedDefaultValue": { "kind": "number", "text": "1" } - `ScheduleSupport` keeps the operator vocabulary, the moment grammar and every refusal, and returns the CLAUSES instead of the chain (`criteria` / `conditions`, replacing `criteriaExpression` / `conditionChain`); `Moment.javaExpression` is gone, its rendering having moved. A create-from's source-row rule (`items: where:`) rides the same reading as `itemCriteria`. - A roll-up's and an expansion's criteria were never more than `eq(, .)` over values the descriptor already carries, so they are dropped from the glue outright and built by the binder. - A `run:` key term carries the PERIOD the author declared rather than where that period begins and ends - which is also the only place the two bounds stay derived from each other. - `JavaLiterals` gains `criteriaChain` / `criteriaExpression`, `periodLowerExpression` / `periodUpperExpression` / `todayExpression` and `derivedDefaultExpression`; `GlueGenerator` calls them in the four binders that used to copy the rendered key. - Every one of those binders falls back to the rendered key when the descriptor carries it, so a project generated before this renders byte-identically until it is regenerated - the rule `hasGenUnique` and the #7234 spellings already follow. The `ModelGenerationIT` fixture `orders.glue` still carries `criteriaExpression` and exercises that path. What is NOT in this change: the `expr` / `guard` / `guardExpr` / `dueExpression` / `guardExpression` / `languageExpression` / `fileNameExpression` family - the ternaries, the `Calc.eval` guards and the print SDK calls. Those are expressions, not literals or builder chains, and #7406 asks for them last and deliberately; the issue stays open for them. Verified: `engine-intent` (1326) and `ide-template` (176) unit suites green; `IntentEmissionCoverageIT`, `IntentEngineIT` and `ModelGenerationIT` green - the first compiles and runs the generated handlers, the last regenerates from a pre-split `.glue`. `IntentEngineIT` now asserts the neutral clauses AND that no `Criteria.create()` appears anywhere in the generated glue. Fixes #7406 Co-Authored-By: Claude Opus 5 --- components/engine/engine-intent/CLAUDE.md | 2 +- .../intent/generator/GlueIntentGenerator.java | 92 +++++--- .../intent/generator/ScheduleSupport.java | 138 ++++++------ .../GlueGeneratesItemsWhereTest.java | 14 +- .../generator/GluePostingsAmendTest.java | 17 +- .../intent/generator/GlueRendering.java | 82 +++++++ .../intent/generator/GlueRollupCountTest.java | 3 +- .../intent/generator/GlueRollupRekeyTest.java | 5 +- .../intent/generator/GlueSchedulesTest.java | 48 ++-- .../intent/generator/ScheduleSupportTest.java | 57 +++-- .../template/service/model/GlueGenerator.java | 121 ++++++++++- .../template/service/model/JavaLiterals.java | 205 +++++++++++++++++- .../service/model/GlueGeneratorTest.java | 142 ++++++++++++ .../integration/tests/api/IntentEngineIT.java | 15 +- 14 files changed, 772 insertions(+), 169 deletions(-) create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRendering.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 66034c63ebe..bad2efddb78 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -467,7 +467,7 @@ Semantics worth knowing: - **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`. - **A cross-model PARENT takes `capacity:` / `balance:`, guard included (#7410).** The two sides of an allocation are one sentence for the business - "a payment cannot be applied past its amount" is "an invoice cannot be paid past its payable" - and the DSL guarded one and forced Java for the other: a link entity's local parent got `capacity:`, while the foreign parent's was refused at parse, so a `CustomerPayment` of 100 was allocated 100 to two different invoices, both `200 OK`. Nothing about the foreign direction actually needed the owner's code: the capacity is a READ of one numeric column of the parent, the balance is a SECOND column on the targeted `updateDerived` the sum already makes, and the overdraw GUARD is emitted into the CHILD's repository - which on this direction is LOCAL (the child owns the event the roll-up binds to). So `buildRollupGuards` no longer requires the parent to be in `byName`: a `via` carrying a `model:` resolves the parent's perspective through `CrossModelSupport` and stamps the guard with the owner's **`parentGenFolder`**, and the DAO addresses that parent by its **fully-qualified** generated type instead of importing it - an import could collide with a local entity of the same name, and a local guard must stay byte-identical (it does: `parentGenFolder` is empty and the import branch is unchanged). `capacity:` / `balance:` join `field:` in `firstUnresolvableParentProperty`, checked against the owner's `.model` at Generate time, because the parser cannot see a foreign entity's fields; a `balance:` with no `capacity:` is refused at parse on this direction (the balance IS capacity minus the sum, so a lone one names a column nothing fills - the local direction has silently ignored it since the roll-up shipped and is left alone). **`status:` stays refused**: it moves the parent through the owner's own status seeds and its generated displaced-status column, which is the owner's lifecycle to declare. The cross-model CHILD direction is unchanged - its guard really does belong to the owner, and still reports that it is not installed. Covered by `EdmRollupGuardTest` (the guard's coordinates, local and foreign) and `GlueRollupCrossModelTest` (the parse rules + the owner-model checks). - **`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). **Both refusals are decided BEFORE the header is saved** ([#7224](https://github.com/eclipse-dirigible/dirigible/issues/7224)): the generated repository's `save()` allocates the document number and records the `History` create entry OUTSIDE the unit of work (#7069, by design - the counter is a sequence in its own transaction), so a refusal fired after the save took the header back but left a spent number of a gap-free series and a trail row for a document that never existed, once per click - three presses of "Invoice this month" on a month with one rejected timesheet burned three invoice numbers. The rule block runs right after the at-most-once guard, before the target is even built; `IntentEmissionCoverageIT` asserts the emitted order and, end to end, that a refused run leaves the Bill series and the Bill history trail exactly as it found them. +- **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.conditions` is the shared reader, alongside `criteria`; since [#7406](https://github.com/eclipse-dirigible/dirigible/issues/7406) both hand back the CLAUSES and `JavaLiterals.criteriaChain`/`criteriaExpression` render the builder call in the template layer), 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). **Both refusals are decided BEFORE the header is saved** ([#7224](https://github.com/eclipse-dirigible/dirigible/issues/7224)): the generated repository's `save()` allocates the document number and records the `History` create entry OUTSIDE the unit of work (#7069, by design - the counter is a sequence in its own transaction), so a refusal fired after the save took the header back but left a spent number of a gap-free series and a trail row for a document that never existed, once per click - three presses of "Invoice this month" on a month with one rejected timesheet burned three invoice numbers. The rule block runs right after the at-most-once guard, before the target is even built; `IntentEmissionCoverageIT` asserts the emitted order and, end to end, that a refused run leaves the Bill series and the Bill history trail exactly as it found them. - **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 rides the source's status flip INSIDE the block (#7160): the flip and its event are recorded by one write in the unit's own transaction, and a unit's events reach the broker only once the whole unit committed - so it still states a COMPLETED transition (a target the lines refuse takes the flip and its notice along), while a crash between commit and a separate publish no longer loses it. It needs no flag either: the flip runs only on the path that generates, past the early returns (no source; the at-most-once guard handing back an existing document). 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 is recorded inside it and never as a bare publish, 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 877da4296ca..9a5089fbd48 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 @@ -693,8 +693,9 @@ private static List> buildRollups(IntentModel model, Map> 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())); + // The source-row rule (issue #7091), as the clauses the template layer appends to 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 appends + // nothing and therefore runs the query this always ran. + e.put("itemCriteria", ScheduleSupport.conditions(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, @@ -2562,7 +2563,7 @@ private static List> buildPostings(IntentModel model, Map> buildPostings(IntentModel model, Map reading && !reading.isEmpty(); comparesUnlessDerivedIsEmpty = comparesUnlessDerivedIsEmpty || Boolean.TRUE.equals(compared.get("compareOnlyWhenDerived")); } e.put("comparesAgainstDefaults", comparesAgainstDefaults); @@ -2738,8 +2742,9 @@ private static String amendableGuard(EntityIntent creates) { *

* Two keys are written onto the assignment/comparison map: *

    - *
  • {@code derivedDefault} - the default as a Java literal for {@code same()}, which compares - * numbers by VALUE: a numeric default therefore needs no knowledge of the column's own Java type + *
  • {@code derivedDefaultValue} - the default READ against the column's kind, which the template + * layer renders the literal {@code same()} compares against from (issue #7406). {@code same()} + * compares numbers by VALUE, so a numeric default needs no knowledge of the column's own Java type * ({@code BigDecimal} stands in for all of them, exactly as the stored side is read back at * whatever scale the database chose). Empty when the column carries no default.
  • *
  • {@code compareOnlyWhenDerived} - {@code true} for a default with no Java literal to stand in @@ -2757,7 +2762,10 @@ private static String amendableGuard(EntityIntent creates) { */ private static void putDerivedDefault(Map target, EntityIntent entity, Map byName, String authoredKey) { - target.put("derivedDefault", ""); + // The reading, not the Java (issue #7406): what the column would have held is a value of the + // column's own kind, and the `new java.math.BigDecimal(...)` the comparison applies it through + // is the template layer's rendering of it. + target.put("derivedDefaultValue", Map.of()); target.put("compareOnlyWhenDerived", false); String type = null; String defaultValue = null; @@ -2794,7 +2802,7 @@ private static void putDerivedDefault(Map target, EntityIntent e case "INTEGER": case "BIGINT": try { - target.put("derivedDefault", "new java.math.BigDecimal(\"" + new java.math.BigDecimal(defaultValue.trim()) + "\")"); + target.put("derivedDefaultValue", reading("number", new java.math.BigDecimal(defaultValue.trim()).toString())); } catch (NumberFormatException ex) { // Not a number on a numeric column: the model is wrong and the repository's own // default assignment is what will say so, at the create. The comparison keeps out @@ -2805,7 +2813,7 @@ private static void putDerivedDefault(Map target, EntityIntent e return; case "BOOLEAN": String flag = defaultValue.trim(); - target.put("derivedDefault", "true".equalsIgnoreCase(flag) || "1".equals(flag) ? "Boolean.TRUE" : "Boolean.FALSE"); + target.put("derivedDefaultValue", reading("boolean", Boolean.toString("true".equalsIgnoreCase(flag) || "1".equals(flag)))); return; default: // A string default is authored either bare (what the item dialog seeds) or SQL-quoted @@ -2818,7 +2826,7 @@ private static void putDerivedDefault(Map target, EntityIntent e } // Always a QUOTED literal, even for a default that reads as a number: the column holds // a string, and `same()` would compare a bare 0 against the stored "0" as unequal. - target.put("derivedDefault", '"' + JavaLiterals.escape(text) + '"'); + target.put("derivedDefaultValue", reading("string", text)); return; } } @@ -3774,8 +3782,9 @@ private static ExpansionHandlers buildExpansions(IntentModel model, Map> buildSchedules(IntentModel model, Map RANGED_PERIODS = java.util.Set.of("week", "month", "quarter", "year"); /** * The pre-rendered terms of a scheduled generation's natural key (issues #7070, #7106): one @@ -5052,25 +5067,32 @@ private static Map runTerm(UniqueKeyIntent entry, Map TODAY + ".with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY))"; - case "month" -> TODAY + ".withDayOfMonth(1)"; - case "quarter" -> TODAY + ".with(java.time.temporal.IsoFields.DAY_OF_QUARTER, 1)"; - case "year" -> TODAY + ".withDayOfYear(1)"; - default -> null; - }; - if (lower == null) { + if (!RANGED_PERIODS.contains(period)) { return null; } - String upper = lower + switch (period) { - case "week" -> ".plusDays(6)"; - case "month" -> ".plusMonths(1).minusDays(1)"; - case "quarter" -> ".plusMonths(3).minusDays(1)"; - default -> ".plusYears(1).minusDays(1)"; - }; - return term("kind", "range", "property", property, "lower", lower, "upper", upper); + // The PERIOD, not where it begins and ends (issue #7406): a month is what the author declared, + // and the calendar arithmetic that turns it into a range is the template layer's rendering of + // it - which is also the only place the two bounds can be kept derived from one another. + return term("kind", "range", "property", property, "period", period); + } + + /** + * One neutral value reading - a kind and the text of the value, in that order (issue #7406). The + * glue carries these where it used to carry the Java literal rendered from them; the rendering + * moved to the template layer, which is the only layer that knows what language it is generating. + * + * @param kind the reading's kind + * @param text the value, as text - the spelling survives a JSON round-trip, a number's parsed value + * does not + * @return the reading + */ + private static Map reading(String kind, String text) { + Map reading = new LinkedHashMap<>(); + reading.put("kind", kind); + reading.put("text", text); + return reading; } /** 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 a5b3d6fd379..aa63ed14694 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,9 +12,10 @@ import java.time.Duration; import java.time.Period; import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.eclipse.dirigible.components.ide.template.service.model.JavaLiterals; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -22,9 +23,16 @@ import org.eclipse.dirigible.components.intent.model.ScheduleIntent; /** - * Translates a {@link ScheduleIntent}'s {@code where} filter into the typed {@code Criteria} - * builder call the generated {@code @Scheduled} job runs against the entity repository. Pure (no - * Spring/IO) so the operator mapping and date-token handling are unit-tested directly. + * Reads a {@link ScheduleIntent}'s {@code where} filter as the typed clauses the generated + * {@code @Scheduled} job queries the entity repository by. Pure (no Spring/IO) so the operator + * mapping and date-token handling are unit-tested directly. + * + *

    + * What this class produces is DATA, not code (issue #7406): the glue carries the clauses, and the + * {@code Criteria} builder chain is rendered from them a layer out, by {@code JavaLiterals} - the + * split {@code checks} took in #7405, for the same reason. A {@code Criteria.create().lt("Due", + * java.time.LocalDate.now())} in a {@code .glue} writes a runtime package and a Java date API into + * the process description every template reads, Java and JavaScript alike. */ public final class ScheduleSupport { @@ -127,40 +135,6 @@ public boolean offsetValid() { } } - /** - * The Java expression the generated job evaluates at each firing, in the shape the queried COLUMN - * carries - which is what makes the comparison bind at all. - * - *

    - * A {@code date} field generates as a {@code java.time.LocalDate} and a {@code timestamp} one - - * including the {@code audit: true} columns a staleness sweep looks at - as a - * {@code java.time.Instant}, never a {@code LocalDateTime}; handing Hibernate the wrong one fails - * the query at every tick rather than matching nothing (issue #7384). The parser holds the moment's - * own shape to the field's, so the shape alone decides here. - * - *

    - * A calendar amount has no fixed length in seconds ({@code P1M} is a month, {@code P7D} seven - * calendar days across a daylight-saving boundary), so on a timestamp it is applied on the calendar - * of the run's own zone and handed back as the instant the column holds - the meaning a - * {@code Duration} could not express at all. - * - * @return the expression - */ - public String javaExpression() { - String movement = forward ? ".plus(" : ".minus("; - if (shape == Shape.DATE) { - return duration == null ? "java.time.LocalDate.now()" - : "java.time.LocalDate.now()" + movement + "java.time.Period.parse(\"" + duration + "\"))"; - } - if (duration == null) { - return "java.time.Instant.now()"; - } - if (timeBased()) { - return "java.time.Instant.now()" + movement + "java.time.Duration.parse(\"" + duration + "\"))"; - } - return "java.time.ZonedDateTime.now()" + movement + "java.time.Period.parse(\"" + duration + "\")).toInstant()"; - } - /** * A duration carrying a time component is a {@code Duration}; a date-only one is a {@code Period}. */ @@ -219,61 +193,89 @@ public static boolean isSupportedOperator(String op) { } /** - * Build the Java {@code Criteria} expression for a schedule's {@code where} (field names are the - * PascalCase entity property names; values are bound, with a {@link Moment} - a now-token and its - * optional offset - evaluated against the clock of the run that fires). + * A schedule's {@code where} as the NEUTRAL clause list the glue carries (issue #7406) - one entry + * per condition, in declared order, each an operator, the PascalCase entity property it reads and + * the reading of its value. The {@code Criteria} chain is rendered from these a layer out, by + * {@code JavaLiterals}, the same split {@code checks} took in #7405. * * @param schedule the schedule - * @return e.g. - * {@code Criteria.create().lt("DueOn", java.time.LocalDate.now()).eq("Status", "ACTIVE")} + * @return the clauses, empty when the schedule filters on nothing */ - public static String criteriaExpression(ScheduleIntent schedule) { - return "Criteria.create()" + conditionChain(schedule.getWhere()); + public static List> criteria(ScheduleIntent schedule) { + return conditions(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. + * The same reading for a caller that holds the conditions itself - 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. + * + *

    + * A clause whose operator is not one this vocabulary has is skipped rather than approximated; the + * parser has already reported it, and a generation reached by another route narrows the query by + * the clauses it does understand rather than by a guessed comparison. * * @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 + * @return the clauses, empty for no conditions */ - public static String conditionChain(List conditions) { + public static List> conditions(List conditions) { + List> clauses = new ArrayList<>(); if (conditions == null) { - return ""; + return clauses; } - 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 } - expr.append('.') - .append(method) - .append("(\"") - .append(IntentNaming.pascalCase(condition.getField())) - .append("\", ") - .append(valueToJava(condition.getValue())) - .append(')'); + // Written in this order deliberately: the glue is a serialized artefact a regen rewrites in + // place, so a clause's byte order has to be a property of the intent and nothing else (issue + // #7130). + Map clause = new LinkedHashMap<>(); + clause.put("op", method); + clause.put("property", IntentNaming.pascalCase(condition.getField())); + clause.put("value", valueReading(condition.getValue())); + clauses.add(clause); } - return expr.toString(); + return clauses; } - /** A condition value as a Java expression: a moment, a number/boolean, or a quoted string. */ - private static String valueToJava(Object value) { + /** + * A condition value as the reading the glue carries: a moment - a now-token and its optional + * offset, resolved against the clock of the run that fires - a number, a boolean, or a string. + * + *

    + * A number and a boolean keep their TEXT rather than their parsed value, as every other neutral + * reading in this generation does: the glue is JSON, and a value that round-trips through a JSON + * number loses the spelling the author wrote and gains a float's rendering of it. + * + * @param value the authored value + * @return the reading + */ + private static Map valueReading(Object value) { + Map reading = new LinkedHashMap<>(); if (value == null) { - return "null"; + reading.put("kind", "null"); + return reading; } if (value instanceof Number || value instanceof Boolean) { - return value.toString(); + reading.put("kind", value instanceof Boolean ? "boolean" : "number"); + reading.put("text", value.toString()); + return reading; } Moment moment = moment(value); if (moment != null && moment.offsetValid()) { - return moment.javaExpression(); + reading.put("kind", "moment"); + reading.put("shape", moment.shape() == Moment.Shape.DATE ? "date" : "timestamp"); + if (moment.duration() != null) { + reading.put("offset", moment.duration()); + reading.put("forward", moment.forward()); + } + return reading; } - String text = value.toString(); - return "\"" + JavaLiterals.escape(text) + "\""; + reading.put("kind", "string"); + reading.put("text", value.toString()); + return reading; } + } 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 index c71e82bbe1d..34d130751c5 100644 --- 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 @@ -110,7 +110,7 @@ void theRuleRendersAsTheItemQuerysCriteriaTailWithTheStatusNameResolved() { .get(0); assertEquals(true, g.get("hasItems")); - assertEquals(".eq(\"Status\", 3).gt(\"TotalHours\", 0)", g.get("itemWhere")); + assertEquals(".eq(\"Status\", 3).gt(\"TotalHours\", 0)", GlueRendering.itemWhere(g)); // Skipping is the default: no message, so an unqualified row is simply left out. assertEquals("", g.get("itemRefuse")); } @@ -132,7 +132,7 @@ void aDeclaredRefusalIsCarriedOntoTheDescriptor() { """))) .get(0); - assertEquals(".eq(\"Status\", 3).gt(\"TotalHours\", 0)", g.get("itemWhere")); + assertEquals(".eq(\"Status\", 3).gt(\"TotalHours\", 0)", GlueRendering.itemWhere(g)); assertEquals("Member timesheet is not approved", g.get("itemRefuse")); } @@ -148,7 +148,7 @@ void aMomentValueRendersAgainstTheClockOfTheRun() { "- { field: closedOn, op: le, value: CURRENT_DATE }"))) .get(0); - assertEquals(".eq(\"Status\", 3).le(\"ClosedOn\", java.time.LocalDate.now())", g.get("itemWhere")); + assertEquals(".eq(\"Status\", 3).le(\"ClosedOn\", java.time.LocalDate.now())", GlueRendering.itemWhere(g)); } /** @@ -165,7 +165,7 @@ void withoutARuleTheDescriptorIsUnchanged() { .get(0); assertEquals(true, g.get("hasItems")); - assertEquals("", g.get("itemWhere")); + assertEquals("", GlueRendering.itemWhere(g)); assertEquals("", g.get("itemRefuse")); } @@ -326,7 +326,7 @@ void aStatusSeedIdOnACrossModelItemSourceRenders() { assertEquals(true, g.get("crossModelSource")); assertEquals(true, g.get("hasItems")); - assertEquals(".eq(\"Status\", 3)", g.get("itemWhere")); + assertEquals(".eq(\"Status\", 3)", GlueRendering.itemWhere(g)); // Read off the owner model, not guessed from the item's name. assertEquals("GoodsIssue", g.get("fromItemPerspective")); assertEquals("Id", g.get("fromItemPk")); @@ -344,7 +344,7 @@ void aStringOnAnOrdinaryCrossModelItemColumnIsNotAStatus() { Map g = GlueIntentGenerator.buildGeneratesForTest(context.getModel(), context) .get(0); - assertEquals(".gt(\"Quantity\", 0)", g.get("itemWhere")); + assertEquals(".gt(\"Quantity\", 0)", GlueRendering.itemWhere(g)); } /** @@ -378,7 +378,7 @@ void aMomentOfTheCrossModelItemColumnsOwnShapeRenders() { Map g = GlueIntentGenerator.buildGeneratesForTest(context.getModel(), context) .get(0); - assertEquals(".lt(\"IssuedOn\", java.time.LocalDate.now().minus(java.time.Period.parse(\"P1M\")))", g.get("itemWhere")); + assertEquals(".lt(\"IssuedOn\", java.time.LocalDate.now().minus(java.time.Period.parse(\"P1M\")))", GlueRendering.itemWhere(g)); } /** diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsAmendTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsAmendTest.java index 5e2395edd8c..cd5ba343bbf 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsAmendTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsAmendTest.java @@ -157,8 +157,9 @@ void aComparedColumnCarryingADefaultIsComparedAgainstThatDefault() { // before the insert (#7104/#7115) - while the derived one still holds null, so comparing the // two raw made `unchanged` unreachable and EVERY redelivery rewrote the whole post. Map posting = posting(WITH_STATUS); - assertEquals("new java.math.BigDecimal(\"0\")", comparedProperty(posting, "Debit").get("derivedDefault")); - assertEquals("new java.math.BigDecimal(\"0\")", comparedProperty(posting, "Credit").get("derivedDefault")); + assertEquals(Map.of("kind", "number", "text", "0"), comparedProperty(posting, "Debit").get("derivedDefaultValue")); + assertEquals("new java.math.BigDecimal(\"0\")", GlueRendering.derivedDefault(comparedProperty(posting, "Debit"))); + assertEquals("new java.math.BigDecimal(\"0\")", GlueRendering.derivedDefault(comparedProperty(posting, "Credit"))); assertEquals(Boolean.FALSE, comparedProperty(posting, "Debit").get("compareOnlyWhenDerived")); } @@ -166,7 +167,7 @@ void aComparedColumnCarryingADefaultIsComparedAgainstThatDefault() { void aComparedColumnWithNoDefaultIsComparedAsItStands() { // Nothing to apply: a null on the derived side genuinely means the column is left empty. Map posting = posting(WITH_STATUS); - assertEquals("", comparedProperty(posting, "Account").get("derivedDefault")); + assertEquals("", GlueRendering.derivedDefault(comparedProperty(posting, "Account"))); assertEquals(Boolean.FALSE, comparedProperty(posting, "Account").get("compareOnlyWhenDerived")); } @@ -184,7 +185,7 @@ void aDefaultOnlyTheDatabaseCanApplyIsReportedAsAnExpression() { .replace("- { Account: rule(revenueAccount), credit: \"Net\" }", "- { Account: rule(revenueAccount), credit: \"Net\", valueDate: \"IssueDate\" }")); assertEquals(Boolean.TRUE, comparedProperty(posting, "ValueDate").get("compareOnlyWhenDerived")); - assertEquals("", comparedProperty(posting, "ValueDate").get("derivedDefault")); + assertEquals("", GlueRendering.derivedDefault(comparedProperty(posting, "ValueDate"))); } @Test @@ -197,8 +198,7 @@ void eachHeaderExpressionIsEvaluatedIntoOneNumberedLocal() { .get("targetProp")); assertEquals("header1", header.get(0) .get("local")); - assertEquals("", header.get(0) - .get("derivedDefault")); // reason declares none + assertEquals("", GlueRendering.derivedDefault(header.get(0))); // reason declares none } @Test @@ -207,8 +207,9 @@ void aMappedHeaderColumnCarryingADefaultIsComparedAgainstThatDefault() { // carrying the target column's default. Map posting = postingOf(yaml(WITH_STATUS).replace(" - { name: reason, type: string, length: 400 }", " - { name: reason, type: string, length: 400, defaultValue: 'automatic' }")); - assertEquals("\"automatic\"", headerAssignments(posting).get(0) - .get("derivedDefault")); + assertEquals(Map.of("kind", "string", "text", "automatic"), headerAssignments(posting).get(0) + .get("derivedDefaultValue")); + assertEquals("\"automatic\"", GlueRendering.derivedDefault(headerAssignments(posting).get(0))); } @Test diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRendering.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRendering.java new file mode 100644 index 00000000000..5c427774411 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRendering.java @@ -0,0 +1,82 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.ide.template.service.model.JavaLiterals; + +/** + * What the template layer renders out of a glue descriptor's NEUTRAL halves (issue #7406) - the + * same call {@code GlueGenerator} makes, so a test can keep asserting the Java the generation + * actually runs on while the descriptor itself carries the facts. + * + *

    + * The two halves are asserted separately on purpose: a test pinning only the rendering would pass + * on a descriptor that had quietly stopped describing anything, and a test pinning only the + * descriptor would pass on a rendering that no longer compiled. + */ +final class GlueRendering { + + private GlueRendering() {} + + /** + * The whole {@code Criteria} a descriptor's clauses render as. + * + * @param descriptor the glue descriptor + * @return the expression + */ + static String criteria(Map descriptor) { + return JavaLiterals.criteriaExpression(clauses(descriptor.get("criteria"))); + } + + /** + * The {@code Criteria} tail a create-from's source-row rule renders as. + * + * @param descriptor the glue descriptor + * @return the chain, empty for no rule + */ + static String itemWhere(Map descriptor) { + return JavaLiterals.criteriaChain(clauses(descriptor.get("itemCriteria"))); + } + + /** + * The Java literal a compared column's default renders as - the empty string where the column + * carries none, which is the contract the template's own {@code #if} reads. + * + * @param entry the assignment or compared-property entry + * @return the literal, or the empty string + */ + static String derivedDefault(Map entry) { + String rendered = JavaLiterals.derivedDefaultExpression(entry.get("derivedDefaultValue")); + return rendered == null ? "" : rendered; + } + + /** + * The clause list, typed. + * + * @param raw the descriptor's value + * @return the clauses + */ + @SuppressWarnings("unchecked") + private static List> clauses(Object raw) { + List> clauses = new ArrayList<>(); + if (raw instanceof List list) { + for (Object entry : list) { + if (entry instanceof Map map) { + clauses.add((Map) map); + } + } + } + return clauses; + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java index a5d0af36a3a..b3c5ddf07aa 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java @@ -61,8 +61,7 @@ void aCountRollupRecomputesOnCreateUpdateAndDelete() { // Every variant carries the same op and recompute criteria - the update handler is not a // special case, it is the same idempotent read-modify-write of the affected parent. assertTrue(rollups.stream() - .allMatch(r -> "count".equals(r.get("op")) - && "Criteria.create().eq(\"Member\", entity.Member)".equals(r.get("criteriaExpression"))), + .allMatch(r -> "count".equals(r.get("op")) && "Member".equals(r.get("fkProperty"))), "all handlers must recompute the same way: " + rollups); } } diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupRekeyTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupRekeyTest.java index 84b2df49db7..6a88687a988 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupRekeyTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupRekeyTest.java @@ -61,8 +61,9 @@ void everyRollupBindsTheRekeyEvent() { assertEquals("Loan", rekey.get("childEntity")); assertEquals("Member", rekey.get("parentEntity")); // The recompute is keyed on the FK carried by the payload, so the SAME handler repairs the parent - // the child left (fed the previous row) and the one it moved into (fed the written row). - assertEquals("Criteria.create().eq(\"Member\", entity.Member)", rekey.get("criteriaExpression")); + // the child left (fed the previous row) and the one it moved into (fed the written row). The key + // is all the descriptor carries - the `Criteria` around it is the template layer's (issue #7406). + assertEquals("Member", rekey.get("fkProperty")); assertTrue(rollups.stream() .map(r -> String.valueOf(r.get("topicSuffix"))) .toList() diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java index 2016f26e33a..3464b7b7b85 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; +import org.eclipse.dirigible.components.ide.template.service.model.JavaLiterals; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.parser.IntentParser; import org.eclipse.dirigible.components.intent.parser.IntentValidationException; @@ -115,8 +116,9 @@ void generateScheduleEmitsCreateFromTargetAndRowAssignments() { assertEquals("Employee", s.get("entity")); assertEquals("EmployeeTimesheet", s.get("genToEntity")); assertEquals(false, s.get("genCrossModel")); - assertTrue(((String) s.get("criteriaExpression")).contains(".eq(\"Status\", \"ACTIVE\")"), - "criteria: " + s.get("criteriaExpression")); + assertTrue(GlueRendering.criteria(s) + .contains(".eq(\"Status\", \"ACTIVE\")"), + "criteria: " + GlueRendering.criteria(s)); List> fields = (List>) s.get("genFieldAssignments"); // The loop variable in the job template is "entity"; map copies the row, defaults render @@ -289,7 +291,9 @@ void crossModelSourceScheduleEmitsSourceKeysAndCrossModelForEach() { // Convention fallback (no repository): the owner perspective + key default to the entity name / Id. assertEquals("Project", s.get("perspective")); assertEquals("Id", s.get("attachKeyProperty")); - assertTrue(((String) s.get("criteriaExpression")).contains(".eq(\"Status\", 2)"), "criteria: " + s.get("criteriaExpression")); + assertTrue(GlueRendering.criteria(s) + .contains(".eq(\"Status\", 2)"), + "criteria: " + GlueRendering.criteria(s)); List> children = (List>) s.get("genChildren"); assertEquals(1, children.size()); @@ -386,10 +390,12 @@ void thePeriodOfTheRunRendersAsARangeOverTheDateTheRunWrites() { assertEquals(true, s.get("hasGenUnique")); List> unique = (List>) s.get("genUnique"); assertEquals(Map.of("property", "Supplier", "expr", "entity.Supplier"), unique.get(0)); - // The bounds are built from the assignment's own LocalDate.now(), so the period the guard - // queries is by construction the period the row is dated into. - assertEquals(Map.of("kind", "range", "property", "Date", "lower", "java.time.LocalDate.now().withDayOfMonth(1)", "upper", - "java.time.LocalDate.now().withDayOfMonth(1).plusMonths(1).minusDays(1)"), unique.get(1)); + // The glue carries the PERIOD the author declared (issue #7406); the template layer turns it + // into the two bounds, which stay derived from one another - so the period the guard queries is + // by construction the period the row is dated into. + assertEquals(Map.of("kind", "range", "property", "Date", "period", "month"), unique.get(1)); + assertEquals("java.time.LocalDate.now().withDayOfMonth(1)", JavaLiterals.periodLowerExpression("month")); + assertEquals("java.time.LocalDate.now().withDayOfMonth(1).plusMonths(1).minusDays(1)", JavaLiterals.periodUpperExpression("month")); } @SuppressWarnings("unchecked") @@ -437,8 +443,9 @@ void theRunPeriodRangesOverTheDateFieldNotAnotherNowAssignment() { assertEquals(true, s.get("hasGenUnique")); List> unique = (List>) s.get("genUnique"); assertEquals(Map.of("property", "Supplier", "expr", "entity.Supplier"), unique.get(0)); - assertEquals(Map.of("kind", "range", "property", "Date", "lower", "java.time.LocalDate.now().withDayOfMonth(1)", "upper", - "java.time.LocalDate.now().withDayOfMonth(1).plusMonths(1).minusDays(1)"), unique.get(1)); + assertEquals(Map.of("kind", "range", "property", "Date", "period", "month"), unique.get(1)); + assertEquals("java.time.LocalDate.now().withDayOfMonth(1)", JavaLiterals.periodLowerExpression("month")); + assertEquals("java.time.LocalDate.now().withDayOfMonth(1).plusMonths(1).minusDays(1)", JavaLiterals.periodUpperExpression("month")); } @SuppressWarnings("unchecked") @@ -477,11 +484,12 @@ void aQuarterlyRunPeriodRangesOverTheIsoQuarter() { .get(0) .get("genUnique"); - assertEquals("java.time.LocalDate.now().with(java.time.temporal.IsoFields.DAY_OF_QUARTER, 1)", unique.get(1) - .get("lower")); + assertEquals("quarter", unique.get(1) + .get("period")); + assertEquals("java.time.LocalDate.now().with(java.time.temporal.IsoFields.DAY_OF_QUARTER, 1)", + JavaLiterals.periodLowerExpression("quarter")); assertEquals("java.time.LocalDate.now().with(java.time.temporal.IsoFields.DAY_OF_QUARTER, 1).plusMonths(3).minusDays(1)", - unique.get(1) - .get("upper")); + JavaLiterals.periodUpperExpression("quarter")); } @SuppressWarnings("unchecked") @@ -527,7 +535,7 @@ void aSeededStatusNameInTheQueryRendersAsItsSeedId() { Map s = GlueIntentGenerator.buildSchedulesForTest(IntentParser.parse(DUNNING)) .get(0); - assertEquals("Criteria.create().eq(\"Status\", 3).lt(\"DueOn\", java.time.LocalDate.now())", s.get("criteriaExpression")); + assertEquals("Criteria.create().eq(\"Status\", 3).lt(\"DueOn\", java.time.LocalDate.now())", GlueRendering.criteria(s)); } /** @@ -648,8 +656,8 @@ void keyTermsSerializeInDeclarationOrderOnEveryJvm() { List> unique = (List>) s.get("genUnique"); assertEquals(List.of("property", "expr"), List.copyOf(unique.get(0) .keySet())); - assertEquals(List.of("kind", "property", "lower", "upper"), List.copyOf(unique.get(1) - .keySet())); + assertEquals(List.of("kind", "property", "period"), List.copyOf(unique.get(1) + .keySet())); } /** @@ -740,7 +748,7 @@ void aStatusSeedIdOnACrossModelScheduleSourceRenders() { .get(0); assertEquals(true, s.get("sourceCrossModel")); - assertEquals("Criteria.create().eq(\"Status\", 4)", s.get("criteriaExpression")); + assertEquals("Criteria.create().eq(\"Status\", 4)", GlueRendering.criteria(s)); // Read off the owner model, not guessed from the entity name. assertEquals("SalesInvoice", s.get("perspective")); } @@ -757,7 +765,7 @@ void aStringOnANonStatusConditionOfACrossModelScheduleSourceRenders() { Map s = GlueIntentGenerator.buildSchedulesForTest(context.getModel(), context) .get(0); - assertEquals("Criteria.create().eq(\"Number\", \"SI-1\")", s.get("criteriaExpression")); + assertEquals("Criteria.create().eq(\"Number\", \"SI-1\")", GlueRendering.criteria(s)); } /** @@ -794,7 +802,7 @@ void aMomentOfTheCrossModelColumnsOwnShapeRenders() { .get(0); assertEquals("Criteria.create().lt(\"DueDate\", java.time.LocalDate.now().minus(java.time.Period.parse(\"P1M\")))", - s.get("criteriaExpression")); + GlueRendering.criteria(s)); } /** An audit column of the owner is a {@code TIMESTAMP} like any other, and typed as one. */ @@ -808,7 +816,7 @@ void aTimestampMomentOnACrossModelTimestampColumnRenders() { .get(0); assertEquals("Criteria.create().lt(\"UpdatedAt\", java.time.Instant.now().minus(java.time.Duration.parse(\"PT30M\")))", - s.get("criteriaExpression")); + GlueRendering.criteria(s)); } /** A moment against a column that is not temporal at all is the third way the query cannot bind. */ diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupportTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupportTest.java index 31fd307991c..696940ca314 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupportTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/ScheduleSupportTest.java @@ -15,7 +15,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.Map; +import org.eclipse.dirigible.components.ide.template.service.model.JavaLiterals; import org.eclipse.dirigible.components.intent.model.ScheduleConditionIntent; import org.eclipse.dirigible.components.intent.model.ScheduleIntent; import org.junit.jupiter.api.Test; @@ -37,32 +39,61 @@ private static ScheduleIntent schedule(List where) { return s; } + /** + * The Java the generation actually runs on - the clauses this class reads out of the schedule, put + * through the renderer that turns them into the builder call (issue #7406). Asserting on this keeps + * every case below testing what it always tested, while the neutral halves are pinned separately by + * {@link #theWhereIsReadAsClausesNotWrittenAsJava()}, so the two cannot drift apart. + * + * @param schedule the schedule + * @return the rendered criteria + */ + private static String rendered(ScheduleIntent schedule) { + return JavaLiterals.criteriaExpression(ScheduleSupport.criteria(schedule)); + } + + /** + * What the glue carries: the clauses, as data. A {@code Criteria.create().lt("Due", + * java.time.LocalDate.now())} in a process description writes a runtime package and a Java date API + * into an artefact every template reads, Java and JavaScript alike (issue #7406). + */ + @Test + void theWhereIsReadAsClausesNotWrittenAsJava() { + List> clauses = ScheduleSupport.criteria( + schedule(List.of(cond("dueOn", "lt", "CURRENT_DATE-P7D"), cond("status", "eq", "ACTIVE"), cond("quantity", "gt", 1)))); + + assertEquals(List.of( + Map.of("op", "lt", "property", "DueOn", "value", + Map.of("kind", "moment", "shape", "date", "offset", "P7D", "forward", false)), + Map.of("op", "eq", "property", "Status", "value", Map.of("kind", "string", "text", "ACTIVE")), + Map.of("op", "gt", "property", "Quantity", "value", Map.of("kind", "number", "text", "1"))), clauses); + assertFalse(clauses.toString() + .contains("java."), + "the process description carries the facts, not the Java rendered from them"); + } + @Test void emptyWhereIsAnUnfilteredCriteria() { - assertEquals("Criteria.create()", ScheduleSupport.criteriaExpression(schedule(List.of()))); + assertEquals("Criteria.create()", rendered(schedule(List.of()))); } @Test void buildsTypedCriteriaWithPascalFieldsDateTokensAndLiterals() { ScheduleIntent s = schedule(List.of(cond("dueOn", "lt", "CURRENT_DATE"), cond("status", "eq", "ACTIVE"))); - assertEquals("Criteria.create().lt(\"DueOn\", java.time.LocalDate.now()).eq(\"Status\", \"ACTIVE\")", - ScheduleSupport.criteriaExpression(s)); + assertEquals("Criteria.create().lt(\"DueOn\", java.time.LocalDate.now()).eq(\"Status\", \"ACTIVE\")", rendered(s)); } @Test void numbersAndTimestampTokensRenderWithoutQuotes() { ScheduleIntent s = schedule(List.of(cond("quantity", "gt", 1), cond("changedAt", "ge", "CURRENT_TIMESTAMP"))); - assertEquals("Criteria.create().gt(\"Quantity\", 1).ge(\"ChangedAt\", java.time.Instant.now())", - ScheduleSupport.criteriaExpression(s)); + assertEquals("Criteria.create().gt(\"Quantity\", 1).ge(\"ChangedAt\", java.time.Instant.now())", rendered(s)); } @Test void aRelativeMomentOffsetsTheTokenAgainstTheRunsClock() { ScheduleIntent s = schedule(List.of(cond("updatedAt", "lt", "CURRENT_TIMESTAMP-PT30M"), cond("sentOn", "lt", "CURRENT_DATE-P7D"))); - assertEquals( - "Criteria.create().lt(\"UpdatedAt\", java.time.Instant.now().minus(java.time.Duration.parse(\"PT30M\")))" - + ".lt(\"SentOn\", java.time.LocalDate.now().minus(java.time.Period.parse(\"P7D\")))", - ScheduleSupport.criteriaExpression(s)); + assertEquals("Criteria.create().lt(\"UpdatedAt\", java.time.Instant.now().minus(java.time.Duration.parse(\"PT30M\")))" + + ".lt(\"SentOn\", java.time.LocalDate.now().minus(java.time.Period.parse(\"P7D\")))", rendered(s)); } @Test @@ -73,7 +104,7 @@ void theForwardFormIsAdmittedSymmetrically() { "Criteria.create().le(\"DueOn\", java.time.LocalDate.now().plus(java.time.Period.parse(\"P7D\")))" + ".le(\"ChangedAt\", java.time.Instant.now().plus(java.time.Duration.parse(\"PT1H\")))" + ".le(\"ClosedAt\", java.time.ZonedDateTime.now().plus(java.time.Period.parse(\"P1M\")).toInstant())", - ScheduleSupport.criteriaExpression(s)); + rendered(s)); } /** @@ -89,7 +120,7 @@ void theForwardFormIsAdmittedSymmetrically() { @Test void aTimestampMomentIsRenderedInTheColumnsOwnInstantShape() { ScheduleIntent s = schedule(List.of(cond("CreatedAt", "lt", "CURRENT_TIMESTAMP-PT30M"), cond("UpdatedAt", "ge", "NOW"))); - String criteria = ScheduleSupport.criteriaExpression(s); + String criteria = rendered(s); assertEquals("Criteria.create().lt(\"CreatedAt\", java.time.Instant.now().minus(java.time.Duration.parse(\"PT30M\")))" + ".ge(\"UpdatedAt\", java.time.Instant.now())", criteria); assertFalse(criteria.contains("LocalDateTime"), "a LocalDateTime is not assignable to the Instant the column binds"); @@ -103,7 +134,7 @@ void aCalendarAmountOnATimestampStaysACalendarAmount() { ScheduleIntent s = schedule(List.of(cond("changedAt", "lt", "CURRENT_TIMESTAMP-P1M"))); assertEquals( "Criteria.create().lt(\"ChangedAt\", java.time.ZonedDateTime.now().minus(java.time.Period.parse(\"P1M\")).toInstant())", - ScheduleSupport.criteriaExpression(s)); + rendered(s)); } @Test @@ -143,7 +174,7 @@ void anUnparseableOffsetLeavesTheValueAQuotedLiteral() { // The parser reports it; the generator must still emit something that compiles rather than a // half-built expression. ScheduleIntent s = schedule(List.of(cond("dueOn", "lt", "CURRENT_DATE-PT30M"))); - assertEquals("Criteria.create().lt(\"DueOn\", \"CURRENT_DATE-PT30M\")", ScheduleSupport.criteriaExpression(s)); + assertEquals("Criteria.create().lt(\"DueOn\", \"CURRENT_DATE-PT30M\")", rendered(s)); } @Test 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 d58959fd06b..bbc8610a078 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 @@ -404,8 +404,8 @@ private static void bindSchedule(Map item, Map c // job renders exactly what it always did. boolean generates = item.containsKey("generates") ? truthy(item, "generates") : "generate".equals(str(item, "action")); boolean notifies = item.containsKey("notifies") ? truthy(item, "notifies") : !"generate".equals(str(item, "action")); - copy(context, item, "name", "className", "cron", "entity", "perspective", "criteriaExpression", "toExpression", "subjectExpression", - "bodyExpression", "attachKeyProperty", "attach", "attachEntity", "attachLanguageExpression", "attachLanguageFkProperty", + copy(context, item, "name", "className", "cron", "entity", "perspective", "toExpression", "subjectExpression", "bodyExpression", + "attachKeyProperty", "attach", "attachEntity", "attachLanguageExpression", "attachLanguageFkProperty", "attachLanguageTargetEntity", "attachFileNameExpression", "attachReport", "genToEntity", "genToPk", "genFieldAssignments", // The scheduled generation's natural key (issue #7070). Absent on a .glue written // before it existed, which `copy` turns into an absent context key - so the guard's @@ -414,6 +414,10 @@ private static void bindSchedule(Map item, Map c // The days-past-due escalation ladder (issue #7276), likewise absent on an older .glue. "hasEscalation", "escalationEntity", "escalationLocal", "escalationKeyProperty", "escalationAfterProperty", "escalationSinceProperty", "escalationIntoProperty"); + bindCriteria(context, item); + if (item.containsKey("genUnique")) { + context.put("genUnique", uniqueTerms(item.get("genUnique"))); + } copyJavaLiterals(context, item, "cron"); context.put("generates", generates); context.put("notifies", notifies); @@ -600,7 +604,8 @@ private static void bindStepEvent(Map item, Map */ private static void bindExpansion(Map item, Map context, Map parameters) { copy(context, item, "className", "masterEntity", "masterPerspective", "masterPk", "childEntity", "childPk", "fkProperty", - "startProperty", "endProperty", "mapProperty", "unit", "criteriaExpression"); + "startProperty", "endProperty", "mapProperty", "unit"); + bindForeignKeyCriteria(context, item, "master", strOr(item, "masterPk", "Id")); context.put("javaMasterPerspective", sanitize(item, "masterPerspective")); context.put("javaChildPerspective", sanitize(item, "childPerspective")); String unit = str(item, "unit"); @@ -625,7 +630,8 @@ private static void bindExpansion(Map item, Map * @param parameters the generation parameters */ private static void bindExpansionCleanup(Map item, Map context, Map parameters) { - copy(context, item, "className", "masterEntity", "masterPerspective", "masterPk", "childEntity", "criteriaExpression"); + copy(context, item, "className", "masterEntity", "masterPerspective", "masterPk", "childEntity"); + bindForeignKeyCriteria(context, item, "master", strOr(item, "masterPk", "Id")); context.put("javaMasterPerspective", sanitize(item, "masterPerspective")); context.put("javaChildPerspective", sanitize(item, "childPerspective")); context.put("topicSuffix", strOr(item, "topicSuffix", "-deleted")); @@ -729,7 +735,8 @@ private static void bindGenerate(Map item, Map c // 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("itemWhere", item.containsKey("itemCriteria") ? JavaLiterals.criteriaChain(asMaps(item.get("itemCriteria"))) + : strOr(item, "itemWhere", "")); context.put("itemRefuse", strOr(item, "itemRefuse", "")); // The refusal is written into a Java string literal, so a quote or a backslash in the authored // message would end that literal and fail the compile of the whole generated module (#7241). @@ -882,7 +889,7 @@ static List> headerAssignments(Object raw) { assignment.put("hoisted", !local.isEmpty()); assignment.put("local", local); assignment.put("value", local.isEmpty() ? expr : local); - assignment.put("derivedDefault", strOr(declared, "derivedDefault", "")); + assignment.put("derivedDefault", derivedDefault(declared)); assignment.put("compareOnlyWhenDerived", comparesOnlyWhenDerived(declared)); assignment.put("overwrittenOnSave", truthy(declared, "overwrittenOnSave")); assignments.add(assignment); @@ -907,11 +914,30 @@ static List> comparedCells(Object raw) { if (!resolved.containsKey("compareOnlyWhenDerived") && resolved.containsKey("expressionDefault")) { resolved.put("compareOnlyWhenDerived", comparesOnlyWhenDerived(cell)); } + resolved.put("derivedDefault", derivedDefault(cell)); cells.add(resolved); } return cells; } + /** + * The Java literal {@code same()} compares a defaulted column against, rendered from the READING + * the glue carries (issue #7406) - the default the insert would fill the column with, which is what + * makes a redelivery distinguishable from an amendment (#7131). + * + *

    + * The empty string is the "no default" answer, not an absent key: the template's own {@code #if} + * reads it, and an undefined Velocity variable renders as its own name. A .glue written before the + * split carries the Java itself and is read exactly as it always was. + * + * @param entry the assignment or compared cell as the descriptor carries it + * @return the literal, or the empty string + */ + private static String derivedDefault(Map entry) { + String rendered = JavaLiterals.derivedDefaultExpression(entry.get("derivedDefaultValue")); + return rendered != null ? rendered : strOr(entry, "derivedDefault", ""); + } + /** * Whether a compared cell's column is filled only when the write leaves it empty, read under the * #7188 spelling {@code compareOnlyWhenDerived} when the cell carries it and under the #7163 @@ -1127,7 +1153,10 @@ private List rollups(GenerationTemplateMetadataSource source, Str aggregateBlock.append(RollupAggregates.render(rollup)); } Map context = ModelValues.copy(parameters); - copy(context, first, "className", "childEntity", "childPerspective", "parentEntity", "fkProperty", "criteriaExpression"); + copy(context, first, "className", "childEntity", "childPerspective", "parentEntity", "fkProperty"); + // A roll-up recomputes from the store for the parent the fired child row points at, so the + // key is read off that row itself. + bindForeignKeyCriteria(context, first, "entity", strOr(first, "fkProperty", "")); context.put("javaChildPerspective", sanitize(first, "childPerspective")); context.put("javaParentPerspective", sanitize(first, "parentPerspective")); // A cross-model roll-up writes into the owner model's generated package. @@ -1358,6 +1387,84 @@ static void copyJavaLiterals(Map target, Map sou } } + /** + * Derives a descriptor's {@code Criteria} expression from the NEUTRAL clauses it carries (issue + * #7406). + * + *

    + * The glue describes the query as clauses - an operator, a property, a value reading - and the + * builder call is written here, the layer that knows it is generating Java. A descriptor written + * before the split carries the rendered {@code criteriaExpression} instead and is bound as it + * always was, so an unregenerated project renders byte-identically. + * + * @param target the template context + * @param source the descriptor + */ + static void bindCriteria(Map target, Map source) { + if (source.containsKey("criteria")) { + target.put("criteriaExpression", JavaLiterals.criteriaExpression(asMaps(source.get("criteria")))); + } else { + copy(target, source, "criteriaExpression"); + } + } + + /** + * The {@code Criteria} a roll-up or an expansion queries the affected rows by: the foreign key + * alone, read off the row that fired the event or off the master being reconciled (issue #7406). + * + *

    + * Both used to reach here pre-rendered. The key and the owner are descriptor values this binder + * already reads, so nothing is lost by writing the two-call builder here - and the + * {@code org.eclipse.dirigible.sdk} package leaves the process description. A descriptor that still + * carries the rendered expression keeps it. + * + * @param target the template context + * @param source the descriptor + * @param owner the variable the key is read off - {@code entity} for a roll-up's child row, + * {@code master} for an expansion's master + * @param keyProperty the property on that variable holding the key + */ + static void bindForeignKeyCriteria(Map target, Map source, String owner, String keyProperty) { + if (source.containsKey("criteriaExpression")) { + copy(target, source, "criteriaExpression"); + return; + } + String fkProperty = str(source, "fkProperty"); + target.put("criteriaExpression", "Criteria.create().eq(\"" + JavaLiterals.escape(fkProperty == null ? "" : fkProperty) + "\", " + + owner + "." + keyProperty + ")"); + } + + /** + * The scheduled generation's natural key terms, with the Java the guard queries by derived from the + * PERIOD each range term carries (issue #7406). + * + *

    + * A term naming a period is rendered into the two bounds of the current one - or, for a + * {@code day}, into today itself; a term written before the split carries its own {@code lower} / + * {@code upper} / {@code expr} and is passed through, so an unregenerated project renders + * byte-identically. + * + * @param raw the declared terms + * @return the terms, each carrying what the template reads + */ + static List> uniqueTerms(Object raw) { + List> terms = new ArrayList<>(); + for (Map declared : asMaps(raw)) { + Map term = ModelValues.copy(declared); + String period = str(declared, "period"); + String lower = JavaLiterals.periodLowerExpression(period); + if (lower != null) { + term.put("lower", lower); + term.put("upper", JavaLiterals.periodUpperExpression(period)); + } else if ("day".equals(period)) { + // A single day needs no range: `between(today, today)` is not what `run: day` says. + term.put("expr", JavaLiterals.todayExpression()); + } + terms.add(term); + } + return terms; + } + /** * Sanitizes a descriptor's value into a Java identifier. * diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java index add4b9617e8..987cd2c3f48 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java @@ -34,6 +34,9 @@ */ public final class JavaLiterals { + /** The current date, the anchor every period range is derived from. */ + private static final String TODAY = "java.time.LocalDate.now()"; + /** * Not instantiable. */ @@ -222,6 +225,204 @@ public static String conditionExpression(List> terms) { return expression.toString(); } + /** + * The Java {@code Criteria} builder chain a neutral criteria list renders as (issue #7406) - the + * clauses in declared order, each an operator, a property and a bound value. + * + *

    + * A {@code .glue} used to carry the chain itself, {@code Criteria.create().lt("Due", + * java.time.LocalDate.now())} - the builder's package and {@code java.time} written into the + * process description every template reads. It now carries the clauses as data and the language + * appears only here, the same split {@code checks} took in #7405. + * + * @param terms the clauses the glue carries, may be null + * @return the chain, e.g. {@code .eq("Status", 3).gt("TotalHours", 0)}, empty for no clauses + */ + public static String criteriaChain(List> terms) { + if (terms == null) { + return ""; + } + StringBuilder chain = new StringBuilder(); + for (Map term : terms) { + String operator = text(term, "op"); + String property = text(term, "property"); + String value = criteriaValueExpression(term.get("value")); + // A clause the generator did not write in full cannot be narrowed into a guess: the operator + // set and the value vocabulary are both closed and validated while the author is generating, + // so anything else here is a glue no generation of this build produced. + if (operator == null || property == null || value == null) { + continue; + } + chain.append('.') + .append(operator) + .append("(\"") + .append(escape(property)) + .append("\", ") + .append(value) + .append(')'); + } + return chain.toString(); + } + + /** + * The same clauses as a whole criteria, for a caller that opens one rather than appending to one. + * + * @param terms the clauses the glue carries, may be null + * @return e.g. {@code Criteria.create().eq("Active", true)} + */ + public static String criteriaExpression(List> terms) { + return "Criteria.create()" + criteriaChain(terms); + } + + /** + * One criteria clause's bound value as a Java expression, from the reading the glue carries. + * + *

    + * The value is BOUND by the criteria rather than compared in generated code, so a number renders as + * the number it is - a {@code BigDecimal} wrapper would change the bind's type - while a moment + * renders in the shape the queried COLUMN carries, which is what makes the comparison bind at all + * (issue #7384). + * + * @param raw the reading, as the clause's {@code value} + * @return the Java expression, or null when the reading is not one this renders + */ + private static String criteriaValueExpression(Object raw) { + if (!(raw instanceof Map reading)) { + return null; + } + String kind = text(reading, "kind"); + if (kind == null) { + return null; + } + String value = text(reading, "text"); + return switch (kind) { + case "null" -> "null"; + case "number", "boolean" -> value; + case "string" -> value == null ? null : "\"" + escape(value) + "\""; + case "moment" -> momentExpression(reading); + default -> null; + }; + } + + /** + * A now-token, optionally offset, as the Java expression the generated job evaluates at each + * firing. + * + *

    + * A calendar amount has no fixed length in seconds, so on a timestamp it is applied on the calendar + * of the run's own zone and handed back as the instant the column holds - the meaning a + * {@code Duration} could not express at all. The offset's own spelling says which it is: an + * ISO-8601 amount carrying a time component is a {@code Duration}, a date-only one a + * {@code Period}. + * + * @param reading the moment reading + * @return the expression + */ + private static String momentExpression(Map reading) { + boolean date = "date".equals(text(reading, "shape")); + String offset = text(reading, "offset"); + if (offset == null) { + return date ? "java.time.LocalDate.now()" : "java.time.Instant.now()"; + } + String movement = "false".equals(text(reading, "forward")) ? ".minus(" : ".plus("; + if (date) { + return "java.time.LocalDate.now()" + movement + "java.time.Period.parse(\"" + escape(offset) + "\"))"; + } + if (offset.indexOf('T') >= 0 || offset.indexOf('t') >= 0) { + return "java.time.Instant.now()" + movement + "java.time.Duration.parse(\"" + escape(offset) + "\"))"; + } + return "java.time.ZonedDateTime.now()" + movement + "java.time.Period.parse(\"" + escape(offset) + "\")).toInstant()"; + } + + /** + * The first day of the current period, as a {@code java.time.LocalDate} expression (issue #7406). + * + *

    + * A scheduled generation's {@code run:} key term used to reach the glue as this arithmetic already + * written out. What the author declared is a PERIOD - a month, a quarter - and that is what the + * glue now carries; where the month begins is a rendering, and it belongs here with every other + * one. + * + * @param period the declared period + * @return the expression, or null for a period that has no range (a {@code day}, or an unknown one) + */ + public static String periodLowerExpression(String period) { + if (period == null) { + return null; + } + return switch (period) { + case "week" -> TODAY + ".with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY))"; + case "month" -> TODAY + ".withDayOfMonth(1)"; + case "quarter" -> TODAY + ".with(java.time.temporal.IsoFields.DAY_OF_QUARTER, 1)"; + case "year" -> TODAY + ".withDayOfYear(1)"; + default -> null; + }; + } + + /** + * The last day of the current period, derived from its first - so the range the generated guard + * queries is by construction the period the row is dated into. + * + * @param period the declared period + * @return the expression, or null for a period that has no range + */ + public static String periodUpperExpression(String period) { + String lower = periodLowerExpression(period); + if (lower == null) { + return null; + } + return lower + switch (period) { + case "week" -> ".plusDays(6)"; + case "month" -> ".plusMonths(1).minusDays(1)"; + case "quarter" -> ".plusMonths(3).minusDays(1)"; + default -> ".plusYears(1).minusDays(1)"; + }; + } + + /** + * Today, the expression a {@code run: day} key term compares against - a single day needs no range, + * and rendering it as one would make the generated guard say {@code between(today, today)} where + * the author wrote {@code run: day}. + * + * @return the expression + */ + public static String todayExpression() { + return TODAY; + } + + /** + * The Java expression a posting's compared DEFAULT renders as, from the neutral reading the glue + * carries (issue #7406) - the value the generated {@code save()} would have written into the column + * had the intent not derived one, which is what makes a redelivery distinguishable from an + * amendment (issue #7131). + * + *

    + * The reading is the column's own, taken at generation against the SQL type the property becomes, + * so this renders it and does not re-decide it: a {@code number} compares by value through + * {@code BigDecimal}, a {@code boolean} through the boxed constants the column holds, and a + * {@code string} as the stored text - always quoted, even when it reads as a number, because a bare + * {@code 0} would compare unequal to the stored {@code "0"}. + * + * @param raw the reading the glue carries, as the assignment's {@code derivedDefaultValue} + * @return the Java expression, or null when there is no reading to render + */ + public static String derivedDefaultExpression(Object raw) { + if (!(raw instanceof Map reading)) { + return null; + } + String kind = text(reading, "kind"); + String value = text(reading, "text"); + if (kind == null || value == null) { + return null; + } + return switch (kind) { + case "number" -> "new java.math.BigDecimal(\"" + escape(value) + "\")"; + case "boolean" -> "true".equals(value) ? "Boolean.TRUE" : "Boolean.FALSE"; + case "string" -> "\"" + escape(value) + "\""; + default -> null; + }; + } + /** * A guard term's value as a Java literal of its type - the exact equality the term was typed * against, and null for a type that has none. @@ -244,13 +445,13 @@ private static String guardLiteral(String type, String value) { } /** A model value as text, or null when it is absent - a model carries everything as strings. */ - private static String text(Map holder, String key) { + private static String text(Map holder, String key) { Object value = holder == null ? null : holder.get(key); return value == null ? null : String.valueOf(value); } /** A model flag, which reaches here as a boolean or as the text one serialised to. */ - private static boolean flag(Map holder, String key) { + private static boolean flag(Map holder, String key) { Object value = holder == null ? null : holder.get(key); return value instanceof Boolean bool ? bool : "true".equals(String.valueOf(value)); } diff --git a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGeneratorTest.java b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGeneratorTest.java index a7d1dd2db08..a3ce9945503 100644 --- a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGeneratorTest.java +++ b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGeneratorTest.java @@ -129,6 +129,148 @@ void derivesTheEscapedTwinOfAnAuthoredDescriptorValue() { .containsEntry("seriesJavaLiteral", "C:\\\\Sales"); } + /** + * The glue carries the query's CLAUSES and this layer writes the {@code Criteria} (issue #7406) - + * the builder package and {@code java.time} leave the process description, and the same facts can + * reach a template generating something other than Java. + */ + @Test + void aSchedulesCriteriaIsBuiltFromTheClausesTheGlueCarries() { + Map item = new LinkedHashMap<>(); + item.put("criteria", List.of(clause("lt", "Due", Map.of("kind", "moment", "shape", "date", "offset", "P7D", "forward", false)), + clause("eq", "Status", Map.of("kind", "number", "text", "3")), + clause("eq", "Reason", Map.of("kind", "string", "text", "over\"due")), + clause("eq", "Active", Map.of("kind", "boolean", "text", "true")), clause("eq", "ClosedOn", Map.of("kind", "null")))); + Map context = new LinkedHashMap<>(); + + GlueGenerator.bindCriteria(context, item); + + assertThat(context).containsEntry("criteriaExpression", + "Criteria.create().lt(\"Due\", java.time.LocalDate.now().minus(java.time.Period.parse(\"P7D\")))" + + ".eq(\"Status\", 3).eq(\"Reason\", \"over\\\"due\").eq(\"Active\", true).eq(\"ClosedOn\", null)"); + } + + /** + * A .glue written before the split carries the rendered expression and no clauses, and renders + * byte-identically to what it always did - the rule every backward-compatible glue key here + * follows. + */ + @Test + void aDescriptorCarryingTheRenderedCriteriaKeepsIt() { + Map item = new LinkedHashMap<>(); + item.put("criteriaExpression", "Criteria.create().eq(\"Status\", 4)"); + Map context = new LinkedHashMap<>(); + + GlueGenerator.bindCriteria(context, item); + + assertThat(context).containsEntry("criteriaExpression", "Criteria.create().eq(\"Status\", 4)"); + } + + /** + * A roll-up and an expansion query the affected rows by the foreign key alone, which the descriptor + * already carries - so the builder call is written here rather than shipped in the glue. + */ + @Test + void aForeignKeyCriteriaIsBuiltFromTheKeyTheDescriptorCarries() { + Map rollup = new LinkedHashMap<>(); + rollup.put("fkProperty", "Member"); + Map context = new LinkedHashMap<>(); + + GlueGenerator.bindForeignKeyCriteria(context, rollup, "entity", "Member"); + assertThat(context).containsEntry("criteriaExpression", "Criteria.create().eq(\"Member\", entity.Member)"); + + Map expansion = new LinkedHashMap<>(); + expansion.put("fkProperty", "Contract"); + GlueGenerator.bindForeignKeyCriteria(context, expansion, "master", "Id"); + assertThat(context).containsEntry("criteriaExpression", "Criteria.create().eq(\"Contract\", master.Id)"); + + Map legacy = new LinkedHashMap<>(); + legacy.put("criteriaExpression", "Criteria.create().eq(\"Member\", entity.Member)"); + legacy.put("fkProperty", "Ignored"); + GlueGenerator.bindForeignKeyCriteria(context, legacy, "entity", "Ignored"); + assertThat(context).containsEntry("criteriaExpression", "Criteria.create().eq(\"Member\", entity.Member)"); + } + + /** + * A scheduled generation's {@code run:} key term carries the PERIOD, and the calendar arithmetic + * the guard queries by is derived from it here - which is also the only place the two bounds stay + * derived from one another (issue #7406). + */ + @Test + void aRunPeriodKeyTermIsRangedHere() { + List> terms = GlueGenerator.uniqueTerms(List.of(Map.of("property", "Supplier", "expr", "entity.Supplier"), + Map.of("kind", "range", "property", "Date", "period", "month"), Map.of("property", "Day", "period", "day"))); + + assertThat(terms.get(0)).containsEntry("expr", "entity.Supplier") + .doesNotContainKey("lower"); + assertThat(terms.get(1)).containsEntry("lower", "java.time.LocalDate.now().withDayOfMonth(1)") + .containsEntry("upper", "java.time.LocalDate.now().withDayOfMonth(1).plusMonths(1).minusDays(1)"); + // A single day needs no range: `between(today, today)` is not what `run: day` says. + assertThat(terms.get(2)).containsEntry("expr", "java.time.LocalDate.now()") + .doesNotContainKey("lower"); + } + + /** + * A term written before the split carries its own bounds and is passed through untouched. + */ + @Test + void aKeyTermCarryingItsOwnBoundsKeepsThem() { + List> terms = + GlueGenerator.uniqueTerms(List.of(Map.of("kind", "range", "property", "Date", "lower", "LOWER", "upper", "UPPER"))); + + assertThat(terms.get(0)).containsEntry("lower", "LOWER") + .containsEntry("upper", "UPPER"); + } + + /** + * A posting's compared default reaches the glue as the reading of the column's own default, and the + * literal {@code same()} compares against is written here (issue #7406). + */ + @Test + void aComparedDefaultIsRenderedFromTheReadingTheGlueCarries() { + Map number = new LinkedHashMap<>(); + number.put("targetProp", "Debit"); + number.put("derivedDefaultValue", Map.of("kind", "number", "text", "0")); + Map text = new LinkedHashMap<>(); + text.put("targetProp", "Reason"); + text.put("derivedDefaultValue", Map.of("kind", "string", "text", "auto\"matic")); + Map flag = new LinkedHashMap<>(); + flag.put("targetProp", "Active"); + flag.put("derivedDefaultValue", Map.of("kind", "boolean", "text", "true")); + Map none = new LinkedHashMap<>(); + none.put("targetProp", "Account"); + none.put("derivedDefaultValue", Map.of()); + Map legacy = new LinkedHashMap<>(); + legacy.put("targetProp", "Credit"); + legacy.put("derivedDefault", "new java.math.BigDecimal(\"7\")"); + + List> assignments = GlueGenerator.headerAssignments(List.of(number, text, flag, none, legacy)); + + assertThat(assignments.get(0)).containsEntry("derivedDefault", "new java.math.BigDecimal(\"0\")"); + assertThat(assignments.get(1)).containsEntry("derivedDefault", "\"auto\\\"matic\""); + assertThat(assignments.get(2)).containsEntry("derivedDefault", "Boolean.TRUE"); + // No default: the empty string is what the template's own #if reads as "nothing to compare + // against", so an absent reading must not become a null that renders as its own text. + assertThat(assignments.get(3)).containsEntry("derivedDefault", ""); + assertThat(assignments.get(4)).containsEntry("derivedDefault", "new java.math.BigDecimal(\"7\")"); + } + + /** + * One criteria clause. + * + * @param op the operator + * @param property the property + * @param value the value reading + * @return the clause + */ + private static Map clause(String op, String property, Map value) { + Map clause = new LinkedHashMap<>(); + clause.put("op", op); + clause.put("property", property); + clause.put("value", value); + return clause; + } + /** * A key the descriptor does not carry is REMOVED rather than emptied: the context starts as a copy * of the generation parameters, and the template reads the key's absence. 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 b1f81351f82..ec2db3cb6e9 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 @@ -5462,14 +5462,21 @@ private void assertGlue() { "glue should carry the orderUpdated notification bound to the -updated topic"); assertTrue(glue.contains("\"toExpression\": \"\\\"ops@example.com\\\"\""), "glue should carry the notification recipient as a Java string expression"); - // Schedules: one per declarative schedule, carrying the cron + the typed Criteria expression. + // Schedules: one per declarative schedule, carrying the cron + the typed query CLAUSES. assertTrue( glue.contains("\"schedules\"") && glue.contains("\"name\": \"staleOrders\"") && glue.contains("\"cron\": \"0 0 9 * * ?\""), "glue should carry the staleOrders schedule with its cron"); + // Issue #7406: the tick's filter is DATA here - the operator, the property and the reading of + // the value, the relative moment included - and the `Criteria` builder call is rendered from it + // by the template layer. The glue is the process description every template reads, Java and + // JavaScript alike; a builder call in it is a runtime package and a Java date API nothing but a + // Java template can interpret. assertTrue( - glue.contains("Criteria.create().lt(\\\"OrderDate\\\", java.time.LocalDate.now()" - + ".minus(java.time.Period.parse(\\\"P7D\\\")))"), - "glue should carry the schedule's typed Criteria expression, the relative moment included"); + glue.contains("\"criteria\"") && glue.contains("\"op\": \"lt\"") && glue.contains("\"property\": \"OrderDate\"") + && glue.contains("\"kind\": \"moment\"") && glue.contains("\"shape\": \"date\"") + && glue.contains("\"offset\": \"P7D\"") && glue.contains("\"forward\": false"), + "glue should carry the schedule's query as typed clauses, the relative moment included"); + assertFalse(glue.contains("Criteria.create()"), "no builder call belongs in the glue: " + glue); // Integrations: one per outbound integration, carrying the HTTP method + URL expression. assertTrue(glue.contains("\"integrations\"") && glue.contains("\"name\": \"pushOrderToWarehouse\"") && glue.contains("\"clientMethod\": \"post\""), "glue should carry the pushOrderToWarehouse integration as a POST");