diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 509eb5637e9..7f1a17f5f09 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -422,6 +422,7 @@ Semantics worth knowing: - **Every DERIVED write is targeted (document totals, `rollups:`, `aggregates:`) — the last member of the lost-update family.** A recompute reads a row, changes the one or two columns it computes, and persists. Persisting the WHOLE row silently reverts any concurrent write to another column of that row: the trigger `ProcessId` variant was fixed in #6226 and the workflow setter/writer variant in #6306, and the recompute variant was live-reproduced against a roll-up (REST-create a parent, PUT another column immediately after → 200, but a re-read shows the OLD value; the recompute had read the row before the PUT and wrote its stale snapshot after it). All three recompute sites now write only what they computed: `Repository.recalculate(Object)` collects the document totals into a map and calls the base `super.updateProperties` (no gate checks, no `-updated` — exactly the previous `super.update` semantics minus the merge); `Rollup.java.template` and `Aggregate.java.template` collect each recomputed column into a `derived` map and persist through the generated **`updateDerived(id, values)`**, which routes through `updateProperties` (so a `checks:` entity still runs its gate and a labelled entity still refreshes its `Name`) and then re-publishes `---updated` — the event contract the old full-row `update()` provided, which TRANSITIVE roll-ups above the row depend on. Two invariants when touching these: a column assigned in the recompute must also be put into `derived` (a capacity roll-up writes count + balance + status), and an EMPTY `derived` map means nothing is persisted, so the map is what the emission oracle asserts. Covered by the `IntentEmissionCoverageIT` derived-write assertions (Bill document totals, `ClaimLineClaimRollupOnCreate`, `LedgerTotalAggregateOnCreate`). **The reverse direction had the same hole (#6822):** the master's resum was wired only to the item's FULL write paths (`save`/`update`/`delete`), so a line written by a TARGETED primitive - a workflow `setField`, any glue `updateProperty`/`updateProperties`/`updateDerived`, or the event-suppressed `updateWithoutEvent` - moved the line and left the header displaying, printing and POSTING a total that did not equal the sum of its lines. Those paths now resum too, guarded on the columns actually written (an aggregated column, or the FK - which MOVES the line, so both the document it joined and the one it left are resummed), so a status hop still costs nothing extra. It cannot recurse: the master's `recalculate` persists through the BASE targeted write. - **Re-parenting is a two-sided event, and `-rekeyed` is the whole mechanism (#6819).** A row whose grouping column moves - an `aggregates:` key, or a `rollups:` child's `via` FK - leaves one group and joins another, and the ordinary events name only the group it belongs to NOW: `-updated` carries the written row, so the group it LEFT is named by nothing and kept the row's contribution forever (a cost centre reassigned by a workflow step; a `sum` roll-up whose parent FK an ordinary edit re-points). The repair is one dedicated topic, `---rekeyed`, which **only** the generated aggregate / roll-up handlers subscribe to - so a write can signal them without re-publishing `-updated` and spuriously re-firing every reaction. Three parts, and all three are needed: (1) the entity's `.model` carries **`groupingKeys`** - the union of every aggregate key over it AND every roll-up `via` FK whose child it is (`EdmIntentGenerator`; it used to be `aggregateKeys`, aggregates-only, which is why re-parenting a roll-up child was invisible); (2) the DAO compares those columns before/after on **both** write paths - the full-row `update()` publishes the PREVIOUS row (the group it moved into is recomputed off `-updated` like any other change), and `updateProperties` - the targeted primitive every workflow setter, `resolves:` and task-form writer goes through, which publishes no `-updated` at all - publishes the previous row AND the written one, since on that path neither side has an event otherwise; (3) both handler families bind it, the aggregate as its `OnRekey` variant and the roll-up as `RollupOnRekey`. Each handler recomputes the group the PAYLOAD names, from the store, so one class repairs either side and re-delivery converges. The publish is gated on a key having actually moved, so a normal edit costs nothing extra and the cascade still terminates at rest. - **A roll-up's CHILD may be owned by another model (#6930), which is the n:m allocation direction.** `rollups: [{ entity: , model: , parent: , via: , field: ..., op: sum, of: ... }]` - declared by the module that owns the PARENT. The cross-model *parent* direction (a local child, `via`'s own `model:`) already existed, but the inverse was inexpressible, and it is the one an n:m pairing forces: the link entity lives with the document that owns ONE side (`SalesInvoiceCustomerPayment` belongs to `sales-invoices`, whose `invoicePaid` roll-up is local and works), while the OTHER side's total (`CustomerPayment.allocated`, and the `unapplied` figure derived from it) belongs to the module that owns the payment - so it had no declarative form at all and was answered by a register report instead of a stored, filterable number. **`parent:` is authored rather than derived** because a foreign child's relations are not in this document: nothing here can walk `via` to a target, which is also why `via` / `of` / `by` are resolved against the OWNER's `.model` at generation time (`firstUnresolvableChildProperty`, the schedules' cross-model-source rule) and a miss drops the roll-up loudly. The parent must be LOCAL - a total landing in a third model is that model's roll-up to declare, and writing it from here would invert the dependency edge. Emission-wise the child's coordinates simply come from the owner: `childProject` (the topic - this project publishes nothing about that entity, so a local topic would subscribe to silence) and `childGenFolder` (the imports), both defaulting to this project so **a local roll-up renders byte-identically**; the class name is prefixed with the owner alias and the pipeline's coalescing key gains `childModel`, because a local and a foreign child of the same name rolling up through the same relation are two handlers, and one class name for both would have the pipeline write one file over the other. Three deliberate limits: **`capacity`/`balance`/`status` are refused** (the capacity guard lives on the CHILD's DAO, which the owner model generates - a recomputed balance with no guard behind it would look like a limit and enforce nothing); **the vacated side of a re-parent is repaired only if the owner marks that relation as a grouping key**, since `-rekeyed` is published by the owner's DAO and `groupingKeys` is the union over the OWNER's own consumers (the handler is emitted regardless - it is the same store-driven recompute and converges whenever the notice does arrive; delete + re-create is always exact); and **`sensitive:`/`visibleTo:` do not propagate** from a foreign `of` field, so a restricted total must declare its own restriction. Both `EdmIntentGenerator` sites that walk `model.getRollups()` skip a cross-model child (`groupingKeys`, `buildRollupGuards`), as do the two parser propagation loops - otherwise a local entity that merely SHARES the foreign child's name would be treated as it. Covered by `GlueRollupCrossModelTest` (the emitted coordinates + class name, the local case unchanged, and every refusal). +- **A roll-up's `status:` is relinquished, not only set (#7016).** The `statusWhenFull` / `statusWhenPartial` branch modelled "money arrives" and forgot "money leaves": the recompute had no `else`, so deleting the only allocation of a PAID invoice left it PAID with Paid 0 / Balance = Payable - and invisible to the settlement, whose payable statuses are ISSUED/SENT/PARTIAL. Now the FIRST move into a roll-up-owned status snapshots the status it displaces into a hidden, read-only INTEGER column on the parent, `Displaced` (`IntentNaming.displacedStatusProperty`, emitted by `EdmIntentGenerator.displacedStatusProperty` for every local parent of a capacity roll-up with a status, one per status relation; `GlueIntentGenerator.buildRollups` hands it to the emitter as `statusDisplacedField`), and a sum back at zero restores it when - and only when - the parent still holds one of the two roll-up-owned statuses, then clears the snapshot (`RollupAggregates.appendStatus`; every variant, create/update/delete/rekey, since an allocation amended to 0 or re-parented away is the same situation as a deleted one). Remembering beats a declared `statusWhenEmpty:` - that is wrong for every invoice paid straight from ISSUED and never CONFIRMED - so there is no such key. A roll-up-owned status with no recorded predecessor (a deployment upgraded mid-payment) is logged and left alone, never guessed. Both writes ride the same `derived` map into ONE `updateDerived`, so the parent's listeners see one `-updated`. The column is hidden through the **`isHiddenProperty`** flag, which is now the ONE thing the Harmonia templates consult to leave bookkeeping out of forms, lists and details blocks (`ModelParameterProcessor` sets it from the model and BY NAME for `ProcessIds`, so a `.model` written before the flag existed still hides the stamps; the modeler's serializer carries unknown attributes through the generic pass, so a hand round-trip keeps it); `isReadOnlyProperty` puts it in `preservedOnUpdate`, so a full-row form save cannot null it. With a `lifecycle:` on the parent the moves back must be declared edges like the moves in. - **`checks: kind: guard` = a precondition over a keyed `aggregates:` sum, with three outcomes.** The negative-stock / credit-limit / remaining-allowance shape: `aggregate:` names an `aggregates:` entry whose `of` is THIS entity (v1 self-referential), and the post-state is checked against `minimum:` (default 0). The sum is recomputed SYNCHRONOUSLY from the guarded entity's own store for the incoming row's key-tuple, excluding this row on update, then the incoming value is added - deliberately NOT read from the async-maintained aggregate target, so the decision cannot race the handler. Consequence worth remembering: the guard and the materialised aggregate are two independent computations of the same sum, and the guard is the authoritative one - do not "optimise" it into a target read. `enabledBy: ` wraps the whole guard in a `Configurations.get(key) == "true"` gate (a tenant-level business toggle). Emitted by `EdmIntentGenerator.buildChecks` (keys + `sumField` + `pk` + `minimum` + `enabledBy` + `outcome`) → `ModelParameterProcessor` splits `guardChecks` out → the DAO's `#aggregateGuardCheck` macro at both the save and update sites. **`outcome:` decides what a violation DOES**, and each non-default outcome carries its own companion key (parser-validated - a companion belonging to another outcome is an ERROR, since the write would look guarded and do nothing): - **`block`** (the default) - throws `ValidationException`, so the REST write fails with 4xx and nothing is persisted. - **`task`** + **`marker: `** - does NOT fail the write. It stamps the marker (`false` on violation, `true` when it holds) as the BRANCH INPUT a process `decision` reads to route the record to a hold/review step. The division of labour is deliberate and must stay documented as such: this keyword stamps a flag, the process decides what the flag means - the DSL neither creates nor routes to a task. diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 56ef7ca685f..88cdd693ca8 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -596,6 +596,13 @@ rollups: status: Status, statusWhenFull: 7, statusWhenPartial: 6 } ``` +A status the roll-up sets, it also lets go of: the first move into `statusWhenFull` / +`statusWhenPartial` remembers the status it displaced in a hidden parent column (`Displaced`), +and a sum back at zero - the only allocation deleted, amended to 0 or re-parented away - restores +it, so a paid invoice returns to CONFIRMED (or to ISSUED, if it was paid straight from there) rather +than staying PAID with nothing paid. A status the roll-up did not set (a manual void of a partially +paid document) is never touched. + Roll-ups compose transitively across a multi-level composition (leaf edit -> mid total -> top total); recomputation stops when values stop changing. 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 0802784eae4..6fa464b1b96 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 @@ -667,6 +667,11 @@ private static List> buildRollups(IntentModel model, Map compositionParents = computeCompositionParents(entities); Set settingEntities = settingEntities(entities); Set triggerTargets = TriggerSupport.triggerTargetEntities(model); + Map> displacedStatuses = displacedStatusProperties(model, byName); Map usesByAlias = new LinkedHashMap<>(); for (UsesIntent uses : model.getUses()) { if (uses.getModel() != null) { @@ -496,6 +497,11 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal properties.add(processIdProperty(name)); properties.add(processIdsProperty(name)); } + // The parent of a capacity roll-up with a status remembers the status that roll-up displaced, + // so a sum back at zero can restore it (#7016). See displacedStatusProperty. + for (String statusProperty : displacedStatuses.getOrDefault(name, List.of())) { + properties.add(displacedStatusProperty(name, statusProperty)); + } // A file-child (function: Attachment or Snapshot) gets the standard file-metadata columns // injected (like audit:) - the author never hand-writes plumbing; the upload (attachment) or // the generator (snapshot) sets them server-side. FileName is the row's display title; @@ -1301,6 +1307,73 @@ private static Map processIdsProperty(String entityName) { return p; } + /** + * The status properties of each LOCAL parent that a capacity roll-up drives ({@code status:} with + * {@code capacity:}, {@code op: sum}), by parent entity name - the parents that need a + * {@link #displacedStatusProperty(String, String) displaced-status column}. A cross-model parent is + * refused a status by the parser; a cross-model CHILD names its local parent with {@code parent:}. + * One column per status relation, however many roll-ups drive it. + */ + private static Map> displacedStatusProperties(IntentModel model, Map byName) { + Map> byParent = new LinkedHashMap<>(); + for (RollupIntent rollup : model.getRollups()) { + if (!"sum".equals(rollup.getOp()) || !notBlank(rollup.getCapacity()) || !notBlank(rollup.getStatus())) { + continue; + } + String parentName; + if (rollup.isCrossModelChild()) { + parentName = rollup.getParent(); + } else { + EntityIntent child = byName.get(rollup.getEntity()); + RelationIntent via = child == null ? null : toOneRelationByName(child, rollup.getVia()); + parentName = via == null || notBlank(via.getModel()) ? null : via.getTo(); + } + if (parentName == null || !byName.containsKey(parentName)) { + continue; // the parser already reported the bad reference + } + List statuses = byParent.computeIfAbsent(parentName, ignored -> new ArrayList<>()); + String statusProperty = IntentNaming.pascalCase(rollup.getStatus()); + if (!statuses.contains(statusProperty)) { + statuses.add(statusProperty); + } + } + return byParent; + } + + /** + * The column a capacity roll-up keeps the status it DISPLACED in. The roll-up moves the parent's + * status to {@code statusWhenFull} / {@code statusWhenPartial} as the summed children arrive; the + * first such move records the status the parent held until then, and a sum that returns to zero + * puts that status back - so an invoice whose only allocation is deleted is CONFIRMED (or ISSUED, + * if it was paid straight from there) again rather than PAID with nothing paid (#7016). Remembering + * beats a declared "empty" status, which is wrong for every parent that entered the roll-up's + * region from a different status than the declared one. + * + *

+ * System-managed like {@code ProcessIds}: read-only (so a full-row form save preserves it), never a + * major column, and hidden from every generated form, list and details block - it is bookkeeping + * the roll-up handler alone reads and writes. + */ + private static Map displacedStatusProperty(String entityName, String statusProperty) { + String name = IntentNaming.displacedStatusProperty(statusProperty); + Map p = new LinkedHashMap<>(); + p.put("name", name); + p.put("description", "The " + statusProperty + " a roll-up displaced, restored when its sum returns to zero"); + p.put("tooltip", ""); + p.put("dataName", IntentNaming.upperSnake(entityName) + "_" + IntentNaming.upperSnake(name)); + p.put("dataType", "INTEGER"); + p.put("dataNullable", "true"); + p.put("dataLength", ""); + p.put("auditType", "NONE"); + p.put("isReadOnlyProperty", "true"); + p.put("isHiddenProperty", "true"); + p.put("widgetType", "NUMBER"); + p.put("widgetSize", ""); + p.put("widgetLength", ""); + p.put("widgetIsMajor", "false"); + return p; + } + /** * The {@code ProcessId} back-reference property added to an entity that a process starts on create. * A plain VARCHAR holding the started process-instance id; the runtime trigger handler writes it. @@ -2278,7 +2351,8 @@ private static List> relatedColumns(List String auditType = str(property.get("auditType")); boolean excluded = "true".equals(String.valueOf(property.get("dataAutoIncrement"))) || name.equals(fkProperty) || "ProcessId".equals(name) - || "ProcessIds".equals(name) || "false".equals(String.valueOf(property.get("widgetIsMajor"))) + || "ProcessIds".equals(name) || "true".equals(String.valueOf(property.get("isHiddenProperty"))) + || "false".equals(String.valueOf(property.get("widgetIsMajor"))) || (auditType != null && !auditType.isEmpty() && !"NONE".equals(auditType)); if (!excluded) { columns.add(relatedColumn(property)); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RollupIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RollupIntent.java index ebe0e4f07f2..7661c14074d 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RollupIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RollupIntent.java @@ -79,8 +79,11 @@ public class RollupIntent { /** * Optional (sum roll-ups only, requires {@link #capacity}): a parent to-one relation set to * {@link #statusWhenFull} when {@code sum >= capacity}, or {@link #statusWhenPartial} when - * {@code 0 < sum < capacity} (left unchanged at zero). E.g. an invoice's {@code Status} → PAID / - * PARTIAL as payments accumulate. + * {@code 0 < sum < capacity}. E.g. an invoice's {@code Status} → PAID / PARTIAL as payments + * accumulate. The status the first such move displaces is remembered in a hidden parent column + * ({@code Displaced}) and put back when the sum returns to zero, so an invoice whose only + * allocation is deleted is CONFIRMED (or ISSUED) again rather than PAID with nothing paid; a status + * the roll-up did not set itself (a manual void) is never touched. */ private String status; /** Seed id set on {@link #status} when the sum reaches the capacity (fully consumed). */ diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index b86fcad2a3b..4eeb8a03ac8 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -3034,7 +3034,13 @@ left. Nothing to declare. **Sum + balance + status (payment settlement).** With `op: sum` the roll-up keeps `field` equal to the sum of the children's `of` field. Add `capacity` (a numeric parent field the sum is measured against) to also maintain a `balance` field (= `capacity − sum`) and set a `status` relation to `statusWhenFull` -(when `sum >= capacity`) or `statusWhenPartial` (when `0 < sum < capacity`; unchanged at zero): +(when `sum >= capacity`) or `statusWhenPartial` (when `0 < sum < capacity`). At zero the roll-up gives +the status back: the first move into one of its two statuses remembers the status it displaced (a +hidden `Displaced` column the generator adds to the parent), and a sum that returns to zero - +the only allocation deleted, amended to 0, re-parented away - restores it, so the invoice is CONFIRMED +(or ISSUED, if it was paid from there) again instead of PAID with nothing paid. Only a status the +roll-up itself set is ever relinquished; a manual void or cancel stays. Declare no `statusWhenEmpty` +- there is none, the remembered status is always the right one: ```yaml rollups: # Invoice.paid = sum of its payment allocations; balance = total − paid; Status -> PAID / PARTIAL. @@ -3111,7 +3117,9 @@ status seeds, which this model does not own. **Rules:** `via` must be a to-one (`manyToOne` / `oneToOne`) relation of the child entity; `field` must be an existing field on the parent (**integer** for `count`, **numeric** for `sum`). For the sum extras: `capacity`/`balance` are numeric parent fields, `status` a to-one relation of the parent, and -`statusWhenFull`/`statusWhenPartial` its target seed ids. +`statusWhenFull`/`statusWhenPartial` its target seed ids. With a `lifecycle:` on the parent, the +moves the roll-up makes - into its two statuses AND back to whatever they displaced - must be declared +edges, or the generated repository refuses the recompute. **When it recomputes.** Every roll-up - `count`, `sum` and `latest` alike - recomputes on the child's create, update **and** delete. The update pass is what keeps a count right when an ordinary edit moves diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupStatusTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupStatusTest.java new file mode 100644 index 00000000000..518041be8b8 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupStatusTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * A capacity roll-up with a status hands the emitter the column that remembers the status it + * displaced (#7016), under the same name the EDM generator gives the parent's column - a roll-up + * without a status hands it nothing, so the emitted compute block stays byte-identical. + */ +class GlueRollupStatusTest { + + private static final String YAML = """ + name: billing + entities: + - name: Bill + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: total, type: decimal, precision: 18, scale: 2 } + - { name: paid, type: decimal, precision: 18, scale: 2 } + - { name: balance, type: decimal, precision: 18, scale: 2 } + - { name: lines, type: integer } + relations: + - { name: Status, kind: manyToOne, to: BillStatus } + - name: BillStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 50 } + - name: BillPayment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal, precision: 18, scale: 2, required: true } + relations: + - { name: Bill, kind: manyToOne, to: Bill, composition: true, required: true } + rollups: + - { name: billPaid, entity: BillPayment, via: Bill, field: paid, op: sum, of: amount, + capacity: total, balance: balance, status: Status, statusWhenFull: 2, statusWhenPartial: 1 } + - { name: billLines, entity: BillPayment, via: Bill, field: lines } + """; + + @Test + void aStatusRollupNamesTheDisplacedStatusColumnAndACountRollupDoesNot() { + IntentModel model = IntentParser.parse(YAML); + List> rollups = GlueIntentGenerator.buildRollupsForTest(model); + + List> paid = rollups.stream() + .filter(r -> "Paid".equals(r.get("countField"))) + .toList(); + List> lines = rollups.stream() + .filter(r -> "Lines".equals(r.get("countField"))) + .toList(); + assertEquals(4, paid.size()); + assertEquals(4, lines.size()); + assertTrue(paid.stream() + .allMatch(r -> "Status".equals(r.get("statusField")) && "DisplacedStatus".equals(r.get("statusDisplacedField"))), + "every variant of the status roll-up - create, update, delete AND rekey - must relinquish through the same column: " + + paid); + assertTrue(lines.stream() + .allMatch(r -> "".equals(r.get("statusField")) && "".equals(r.get("statusDisplacedField"))), + "a roll-up without a status carries no displaced-status column: " + lines); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index 0556a9ff326..217e33ae178 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -2070,4 +2070,61 @@ void aTriggerTargetCarriesThePerProcessStampBesideProcessId() { assertFalse(driverProperties.contains("ProcessIds"), "an entity no process triggers on must not carry the stamps"); assertFalse(driverProperties.contains("ProcessId")); } + + /** + * A capacity roll-up that drives a status (#7016) needs somewhere to keep the status it displaced, + * so a sum back at zero can restore it: a hidden, read-only integer column on the PARENT, named + * after the status relation. The child and the nomenclature carry nothing. + */ + @SuppressWarnings("unchecked") + @Test + void aStatusRollupParentCarriesTheDisplacedStatusColumn() { + String yaml = """ + name: billing + entities: + - name: Bill + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: total, type: decimal, precision: 18, scale: 2 } + - { name: paid, type: decimal, precision: 18, scale: 2 } + - { name: balance, type: decimal, precision: 18, scale: 2 } + relations: + - { name: Status, kind: manyToOne, to: BillStatus } + - name: BillStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 50 } + - name: BillPayment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal, precision: 18, scale: 2, required: true } + relations: + - { name: Bill, kind: manyToOne, to: Bill, composition: true, required: true } + rollups: + - { name: billPaid, entity: BillPayment, via: Bill, field: paid, op: sum, of: amount, + capacity: total, balance: balance, status: Status, statusWhenFull: 2, statusWhenPartial: 1 } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "billing"); + List> entities = entities(model); + + Map displaced = propertyByName(entityByName(entities, "Bill"), "DisplacedStatus"); + assertNotNull(displaced, "the parent of a status roll-up must remember the status the roll-up displaced"); + assertEquals("INTEGER", displaced.get("dataType")); + assertEquals("BILL_DISPLACED_STATUS", displaced.get("dataName")); + assertEquals("true", displaced.get("dataNullable")); + // Read-only so a full-row form save preserves it, never a major column, and hidden outright: it is + // bookkeeping only the roll-up handler reads. + assertEquals("true", displaced.get("isReadOnlyProperty")); + assertEquals("true", displaced.get("isHiddenProperty")); + assertEquals("false", displaced.get("widgetIsMajor")); + + for (String other : List.of("BillPayment", "BillStatus")) { + List names = ((List>) entityByName(entities, other).get("properties")).stream() + .map(p -> String.valueOf( + p.get("name"))) + .toList(); + assertFalse(names.contains("DisplacedStatus"), other + " is not the parent of the roll-up"); + } + } } diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java index d0e215ef570..2eba979a90c 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java @@ -282,6 +282,11 @@ private static void processProperty(Map property, Map rollup, String countField) { .append(");\n"); } if (!statusField.isEmpty()) { - out.append(" if (sum.signum() > 0) {\n"); - out.append(" parent.") - .append(statusField) - .append(" = sum.compareTo(capacity) >= 0 ? ") - .append(str(rollup, "statusWhenFull")) - .append(" : ") - .append(str(rollup, "statusWhenPartial")) - .append(";\n"); - out.append(" derived.put(\"") - .append(statusField) - .append("\", parent.") - .append(statusField) - .append(");\n"); - out.append(" }\n"); + appendStatus(out, rollup, statusField); } } out.append(" changed = true;\n"); @@ -179,6 +166,90 @@ private static String renderSum(Map rollup, String countField) { return out.toString(); } + /** + * Moves the parent's status with the sum, in BOTH directions. A positive sum sets + * {@code statusWhenFull} / {@code statusWhenPartial}; the status that move displaces is remembered + * in the parent's displaced-status column the first time the roll-up takes the status over, and a + * sum back at zero restores it - so deleting the only allocation of a PAID invoice puts the invoice + * back where the payment found it (CONFIRMED, or ISSUED if it was paid straight from there), not + * left PAID with nothing paid (#7016). Only the two roll-up-owned statuses are ever relinquished: a + * document somebody voided or cancelled while partially paid stays voided when its allocation goes. + * + *

+ * The status is compared with {@code Objects.equals} because the seed ids are emitted as int + * literals and the property is the boxed FK type. + * + * @param out the Java source being built + * @param rollup the roll-up descriptor + * @param statusField the parent status property + */ + private static void appendStatus(StringBuilder out, Map rollup, String statusField) { + String displacedField = str(rollup, "statusDisplacedField"); + String whenFull = str(rollup, "statusWhenFull"); + String whenPartial = str(rollup, "statusWhenPartial"); + String owned = "java.util.Objects.equals(parent." + statusField + ", " + whenFull + ") || java.util.Objects.equals(parent." + + statusField + ", " + whenPartial + ")"; + out.append(" if (sum.signum() > 0) {\n"); + out.append(" if (!(") + .append(owned) + .append(")) {\n"); + out.append(" parent.") + .append(displacedField) + .append(" = parent.") + .append(statusField) + .append(";\n"); + out.append(" derived.put(\"") + .append(displacedField) + .append("\", parent.") + .append(displacedField) + .append(");\n"); + out.append(" }\n"); + out.append(" parent.") + .append(statusField) + .append(" = sum.compareTo(capacity) >= 0 ? ") + .append(whenFull) + .append(" : ") + .append(whenPartial) + .append(";\n"); + out.append(" derived.put(\"") + .append(statusField) + .append("\", parent.") + .append(statusField) + .append(");\n"); + out.append(" } else if (") + .append(owned) + .append(") {\n"); + out.append(" if (parent.") + .append(displacedField) + .append(" == null) {\n"); + out.append( + " LOG.warn(\"Roll-up: [{}] holds the roll-up-owned status [{}] with nothing summed, but records no" + + " displaced status to restore - left as it is\", entity.") + .append(str(rollup, "fkProperty")) + .append(", parent.") + .append(statusField) + .append(");\n"); + out.append(" } else {\n"); + out.append(" parent.") + .append(statusField) + .append(" = parent.") + .append(displacedField) + .append(";\n"); + out.append(" parent.") + .append(displacedField) + .append(" = null;\n"); + out.append(" derived.put(\"") + .append(statusField) + .append("\", parent.") + .append(statusField) + .append(");\n"); + out.append(" derived.put(\"") + .append(displacedField) + .append("\", null);\n"); + out.append(" }\n"); + out.append(" }\n"); + } + /** * Counts the child rows into the parent. * diff --git a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java index 4d9558e6174..56b67be0bbc 100644 --- a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java +++ b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java @@ -54,6 +54,24 @@ void aFlagTheModelOmitsBecomesFalse() { assertEquals(Boolean.FALSE, property.get("widgetIsMajor")); } + /** + * The one flag the generated forms, lists and details blocks consult to leave bookkeeping out: the + * model may set it (a roll-up's displaced status), and the per-process stamps carry it by name, so + * a model written before the flag existed still hides them. + */ + @Test + void hidesFlaggedBookkeepingAndTheProcessStamps() { + Map flagged = property("DisplacedStatus", "INTEGER"); + flagged.put("isHiddenProperty", "true"); + Map stamps = property("ProcessIds", "VARCHAR"); + Map plain = property("Name", "VARCHAR"); + ModelParameterProcessor.process(model(entity("Invoice", "Invoices", flagged, stamps, plain)), parameters()); + + assertEquals(Boolean.TRUE, flagged.get("isHiddenProperty")); + assertEquals(Boolean.TRUE, stamps.get("isHiddenProperty")); + assertEquals(Boolean.FALSE, plain.get("isHiddenProperty")); + } + @Test void defaultsTheWidgetLabelFromThePropertyName() { Map property = property("TaxEventDate", "DATE"); diff --git a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/RollupAggregatesTest.java b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/RollupAggregatesTest.java index dfc9a95fc2d..e739c53a947 100644 --- a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/RollupAggregatesTest.java +++ b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/RollupAggregatesTest.java @@ -44,6 +44,8 @@ private static Map sumRollup(String capacityField, String balanc rollup.put("statusField", statusField); rollup.put("statusWhenFull", ""); rollup.put("statusWhenPartial", ""); + rollup.put("statusDisplacedField", statusField.isEmpty() ? "" : "Displaced" + statusField); + rollup.put("fkProperty", "SalesOrder"); return rollup; } @@ -74,6 +76,36 @@ void populatedOptionalsEmitTheCapacityBalanceAndStatusBlock() { .contains("parent.Status = sum.compareTo(capacity) >= 0 ? 3 : 2;"); } + /** + * The status moves in BOTH directions (#7016): a sum that returns to zero must give back the status + * the roll-up set, and give it back to what it displaced - not to a declared constant, which is + * wrong for a parent that entered the roll-up's region from another status. + */ + @Test + void aZeroSumRestoresTheStatusTheRollupDisplaced() { + Map rollup = sumRollup("Capacity", "Available", "Status"); + rollup.put("statusWhenFull", "3"); + rollup.put("statusWhenPartial", "2"); + + String rendered = RollupAggregates.render(rollup); + + String owned = "java.util.Objects.equals(parent.Status, 3) || java.util.Objects.equals(parent.Status, 2)"; + assertThat(rendered).as("the first move INTO a roll-up-owned status snapshots the status it displaces") + .contains("if (!(" + owned + ")) {") + .contains("parent.DisplacedStatus = parent.Status;") + .contains("derived.put(\"DisplacedStatus\", parent.DisplacedStatus);"); + assertThat(rendered).as("a zero sum relinquishes only a status the roll-up itself set") + .contains("} else if (" + owned + ") {") + .contains("parent.Status = parent.DisplacedStatus;") + .contains("parent.DisplacedStatus = null;") + .contains("derived.put(\"Status\", parent.Status);") + .contains("derived.put(\"DisplacedStatus\", null);"); + assertThat(rendered).as("a roll-up-owned status with no recorded predecessor is reported, not guessed") + .contains("if (parent.DisplacedStatus == null) {") + .contains("LOG.warn(") + .contains("entity.SalesOrder, parent.Status);"); + } + @Test void absentOptionalsBehaveLikeEmptyOnes() { Map rollup = new LinkedHashMap<>(); diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template index 4b3a2f58db6..e4f3aa7e7c1 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template @@ -28,7 +28,7 @@ document.addEventListener('alpine:init', () => { deleteBusy: false, form: { #foreach($property in $properties) -#if(!$property.dataAutoIncrement && !$property.sensitiveProperty && $property.name != "ProcessId" && $property.name != "ProcessIds" && (!$property.auditType || $property.auditType == "NONE") && $property.name != "Name") +#if(!$property.dataAutoIncrement && !$property.sensitiveProperty && $property.name != "ProcessId" && !$property.isHiddenProperty && (!$property.auditType || $property.auditType == "NONE") && $property.name != "Name") ${property.name}: #if($property.widgetType == "CHECKBOX")false#elseif($property.widgetType == "MULTISELECT")[]#{else}''#end, #end #end diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template index 1a35c796c76..74e63411087 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template @@ -46,7 +46,7 @@ #end

#foreach($property in $properties) -#if(!$property.dataAutoIncrement && !$property.sensitiveProperty && $property.name != "ProcessId" && $property.name != "ProcessIds" && (!$property.auditType || $property.auditType == "NONE") && $property.name != "Name" && $property.name != "$!{personalProperty}" && $property.isReadOnlyProperty != "true" && $property.widgetType != "DOCUMENT_STATUS" && !($property.widgetType == "DOCUMENT_NUMBER" && $property.numberSeries) && $property.aggregate != "true") +#if(!$property.dataAutoIncrement && !$property.sensitiveProperty && $property.name != "ProcessId" && !$property.isHiddenProperty && (!$property.auditType || $property.auditType == "NONE") && $property.name != "Name" && $property.name != "$!{personalProperty}" && $property.isReadOnlyProperty != "true" && $property.widgetType != "DOCUMENT_STATUS" && !($property.widgetType == "DOCUMENT_NUMBER" && $property.numberSeries) && $property.aggregate != "true")