From b438c350934218cce5cc542b8274476af1fdf477 Mon Sep 17 00:00:00 2001 From: delchev Date: Tue, 21 Jul 2026 20:32:06 +0300 Subject: [PATCH] feat(intent): rollup op: latest - keep a parent field equal to the newest child's value Adds a third rollup aggregation alongside count/sum: op: latest copies the `of` value of the child row with the greatest `by` date/timestamp onto the parent `field`. The recurring "keep the parent's rate equal to its latest child rate" shape (e.g. Currency.rate <- newest CurrencyRate.rate) that the DSL could not express before (only count/sum). - RollupIntent gains `by` (the child date/timestamp ordering field). - Parser: op: latest requires `of` + `by`, `by` must be date/timestamp, and the parent field type must match `of`. - Generator emits create/update/delete handlers (a new/edited/removed child row can change which row is latest or its value). - renderRollupAggregate's latest branch tracks the max-`by` row type-agnostically (`var` + Objects.equals), copies its `of` onto the parent field with the usual change-guard; an empty child set resets the parent field to null. Unit: GlueRollupLatestTest (create/update/delete handlers + of/by/field mapping); full engine-intent suite green. Runtime proof is the KeyFolders currencies adoption (Currency.rate maintained from CurrencyRate), verified on regeneration. Co-Authored-By: Claude Fable 5 --- components/engine/engine-intent/CLAUDE.md | 2 +- .../intent/generator/GlueIntentGenerator.java | 21 +++++- .../components/intent/model/RollupIntent.java | 21 +++++- .../intent/parser/IntentParser.java | 30 +++++++- .../main/resources/intent-assistant-guide.md | 14 +++- .../generator/GlueRollupLatestTest.java | 71 +++++++++++++++++++ .../template/generateUtils.js | 20 ++++++ 7 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupLatestTest.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index d5dda3f3f2d..0f162c1afd5 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -421,7 +421,7 @@ Every action below has a real SDK surface to generate against, so none of this n rollups: - { name: memberLoanCount, entity: Loan, via: member, field: loanCount } # Member.loanCount = #Loans whose `member` FK = that Member ``` - → two `gen/events/RollupOn{Create,Delete}.java` `@Listener`s on the child's create/delete topics that recompute the affected parent's count via a typed `Criteria` (`findAll(Criteria.create().eq("", entity.)).size()`) and write it back. Recompute-on-event (self-healing); **eventually consistent, not transactionally exact** under heavy concurrency. **Gap:** no `where` filter (counts all children), and re-parenting on child update isn't tracked (only create/delete). + → two `gen/events/RollupOn{Create,Delete}.java` `@Listener`s on the child's create/delete topics that recompute the affected parent's count via a typed `Criteria` (`findAll(Criteria.create().eq("", entity.)).size()`) and write it back. Recompute-on-event (self-healing); **eventually consistent, not transactionally exact** under heavy concurrency. **Gap:** no `where` filter (counts all children), and re-parenting on child update isn't tracked (only create/delete). **`op: sum`** keeps a decimal sum of the child `of` field (+ optional `capacity`/`balance`/`status` for payment-settlement); **`op: latest`** copies the `of` value of the child row with the greatest `by` date/timestamp onto the parent field (create/update/delete handlers; parent field must match `of`'s type; empty child set → null) — the "keep the parent's rate equal to the newest child rate" shape (currencies `Currency.rate` ← latest `CurrencyRate`). `renderRollupAggregate` in `generateUtils.js` tracks the max-`by` row type-agnostically (`var` + `Objects.equals`). 10. **Dynamic user-task assignment** — `assignee: { fromPath: member.branch.manager }`, resolver-driven (extends the existing user-task glue). ### Guardrails (so this doesn't become the MDE expressiveness trap) 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 116ec8b2b47..a7abcb2c2f4 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 @@ -406,11 +406,19 @@ private static List> buildRollups(IntentModel model, Map base = new LinkedHashMap<>(); base.put("childEntity", rollup.getEntity()); @@ -421,6 +429,9 @@ private static List> buildRollups(IntentModel model, Map> buildRollups(IntentModel model, Map> buildAbortsForTest(IntentModel model) { return buildAborts(model, IntentSettings.parse("{}")); } + /** Test hook: build the {@code rollups} glue collection without a repository. */ + static List> buildRollupsForTest(IntentModel model) { + return buildRollups(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}")); + } + /** Test hook: build the {@code waits} glue collection without a repository. */ static List> buildWaitsForTest(IntentModel model) { return buildWaits(model, IntentSettings.parse("{}")); 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 8e64c2b94c6..222642cd17a 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 @@ -32,10 +32,19 @@ public class RollupIntent { private String entity; private String via; private String field; - /** The aggregation: {@code count} (default) or {@code sum}. */ + /** The aggregation: {@code count} (default), {@code sum}, or {@code latest}. */ private String op; - /** The child field summed onto {@link #field} when {@link #op} is {@code sum}. */ + /** + * The child field aggregated onto {@link #field}: summed when {@link #op} is {@code sum}, or copied + * from the most-recent child row when {@link #op} is {@code latest}. + */ private String of; + /** + * Required for {@code op: latest}: the child date/timestamp field that orders the rows; the + * {@link #of} value of the row with the greatest {@code by} is copied onto the parent + * {@link #field} (the "keep the parent's rate equal to the latest child rate" shape). + */ + private String by; /** * Optional (sum roll-ups only): a numeric "capacity" field on the parent the sum is measured * against - e.g. an invoice's {@code total} against which the paid sum is compared. Enables @@ -83,6 +92,14 @@ public void setVia(String via) { this.via = via; } + public String getBy() { + return by; + } + + public void setBy(String by) { + this.by = by; + } + public String getField() { return field; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index f17c18cb80a..569ff7d93a9 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -831,13 +831,41 @@ private static void validateRollups(IntentModel model, List issues) { EntityIntent parent = byName.get(via.getTo()); FieldIntent counter = parent == null ? null : fieldByName(parent, rollup.getField()); boolean sum = "sum".equals(rollup.getOp()); + boolean latest = "latest".equals(rollup.getOp()); if (counter == null) { issues.add("rollup [" + name + "] field [" + rollup.getField() + "] is not a field of parent [" + via.getTo() + "]"); } else if (sum && !NUMERIC_TYPES.contains(counter.getType())) { issues.add("rollup [" + name + "] field [" + rollup.getField() + "] must be a numeric type to hold a sum"); - } else if (!sum && !INTEGER_PK_TYPES.contains(counter.getType())) { + } else if (!sum && !latest && !INTEGER_PK_TYPES.contains(counter.getType())) { issues.add("rollup [" + name + "] field [" + rollup.getField() + "] must be an integer type to hold a count"); } + if (latest) { + // latest copies the child `of` value from the row with the greatest `by` date onto the + // parent field; `of`+`by` required, `by` must be date/timestamp, and the parent field + // should hold the same type as `of` (checked leniently: same logical type). + FieldIntent of = fieldByName(child, rollup.getOf()); + FieldIntent by = fieldByName(child, rollup.getBy()); + if (rollup.getOf() == null || rollup.getOf() + .isBlank()) { + issues.add("rollup [" + name + "] with op latest must declare `of` (the child field to copy)"); + } else if (of == null) { + issues.add("rollup [" + name + "] of [" + rollup.getOf() + "] is not a field of [" + rollup.getEntity() + "]"); + } + if (rollup.getBy() == null || rollup.getBy() + .isBlank()) { + issues.add( + "rollup [" + name + "] with op latest must declare `by` (the child date/timestamp field that orders the rows)"); + } else if (by == null) { + issues.add("rollup [" + name + "] by [" + rollup.getBy() + "] is not a field of [" + rollup.getEntity() + "]"); + } else if (!"date".equals(by.getType()) && !"timestamp".equals(by.getType())) { + issues.add("rollup [" + name + "] by [" + rollup.getBy() + "] must be a date/timestamp field"); + } + if (of != null && counter != null && of.getType() != null && !of.getType() + .equals(counter.getType())) { + issues.add("rollup [" + name + "] field [" + rollup.getField() + "] type [" + counter.getType() + + "] must match the copied `of` field type [" + of.getType() + "]"); + } + } if (sum) { // sum needs a numeric child field to add up; capacity / balance (optional) are numeric parent // fields and status (optional) a to-one relation of the parent - see the balance/status roll-up. 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 d598e363fa5..311adec96f4 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 @@ -1218,6 +1218,18 @@ rollups: capacity: total, balance: balance, status: Status, statusWhenFull: 7, statusWhenPartial: 6 } ``` +**Latest child value (`op: latest`).** Keeps `field` equal to the `of` value of the **most-recent** +child row - the row with the greatest `by` date/timestamp. Use it to mirror a latest child onto its +parent (e.g. a currency's headline rate = the newest rate row): +```yaml +rollups: + # Currency.rate = the rate of the CurrencyRate row with the newest date. + - { name: latestRate, entity: CurrencyRate, via: Currency, field: rate, op: latest, of: rate, by: date } +``` +`of` is the child field copied, `by` is the child `date`/`timestamp` field that decides "latest", and +the parent `field` must be the same type as `of`. Recomputes on child create/update/delete; if a +currency has no rate rows the parent field resets to null. + **Transitive (chained) roll-ups.** Roll-ups compose across a multi-level composition: if the parent of one roll-up is itself the child of another, a change flows all the way up. Declare one roll-up per level and the chain maintains itself - e.g. a 3-level timesheet: @@ -1296,7 +1308,7 @@ payment's unallocated balance; entity writes go only through the generated repos | report `chart` | `bar`, `line`, `pie`, `doughnut`, `polarArea`, `radar` | | report `widget.kind` | `count`, `value`, `list` | | custom `widgets` `kind` | `kpi`, `page` | -| rollup `op` | `count` (default), `sum` | +| rollup `op` | `count` (default), `sum`, `latest` (needs `of` + `by`) | | expansion `unit` | `day`, `week`, `month` | | transition `when` op | `==`, `!=` | diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupLatestTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupLatestTest.java new file mode 100644 index 00000000000..50e93e8e737 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupLatestTest.java @@ -0,0 +1,71 @@ +/* + * 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; + +/** + * Verifies the {@code rollup op: latest} glue: create/update/delete handlers over the child, and + * the latest aggregate block that copies the {@code of} value of the child row with the greatest + * {@code by} date onto the parent field (the "keep Currency.rate equal to the latest CurrencyRate" + * shape). + */ +class GlueRollupLatestTest { + + private static final String YAML = """ + name: fx + entities: + - name: Currency + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: code, type: string, unique: true, required: true, length: 3 } + - { name: rate, type: decimal, precision: 18, scale: 6 } + relations: + - { name: rates, kind: oneToMany, to: CurrencyRate } + - name: CurrencyRate + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date, required: true } + - { name: rate, type: decimal, precision: 18, scale: 6, required: true } + relations: + - { name: Currency, kind: manyToOne, to: Currency, composition: true, required: true } + rollups: + - { name: latestRate, entity: CurrencyRate, via: Currency, field: rate, op: latest, of: rate, by: date } + """; + + @Test + void rendersTheLatestRollupHandlers() { + IntentModel model = IntentParser.parse(YAML); + List> rollups = GlueIntentGenerator.buildRollupsForTest(model); + // create + update + delete (a new/edited/removed rate can change which row is latest). + assertEquals(3, rollups.size(), "latest must recompute on create, update and delete"); + Map create = rollups.get(0); + assertEquals("latest", create.get("op")); + assertEquals("Rate", create.get("countField")); // parent field + assertEquals("Rate", create.get("ofField")); // child value copied + assertEquals("Date", create.get("byField")); // child ordering field + assertEquals("Currency", create.get("fkProperty")); + assertEquals("CurrencyRate", create.get("childEntity")); + assertEquals("Currency", create.get("parentEntity")); + assertTrue(rollups.stream() + .anyMatch(r -> String.valueOf(r.get("topicSuffix")) + .equals("-updated")), + "latest must have an update handler"); + } +} diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index 5e2da729de3..8702826034a 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -1343,6 +1343,26 @@ function annotateDocumentModels(entities) { // event and the transitive cascade terminates. function renderRollupAggregate(ru) { const cf = ru.countField; + if (ru.op === "latest") { + // Copy the `of` value of the child row with the greatest `by` date onto the parent field. + // Type-agnostic: track the latest ROW (childEntity), then copy its of-field via `var` + + // Objects.equals so no knowledge of the of-field's Java type is needed; `by` is date/timestamp + // (Comparable). A parent with no child rows resets to null. + let s = " {\n"; + s += " " + ru.childEntity + "Entity latestRow = null;\n"; + s += " for (var row : rows) {\n"; + s += " if (row." + ru.byField + " != null && (latestRow == null || latestRow." + ru.byField + " == null || row." + ru.byField + ".compareTo(latestRow." + ru.byField + ") > 0)) {\n"; + s += " latestRow = row;\n"; + s += " }\n"; + s += " }\n"; + s += " var latestValue = latestRow == null ? null : latestRow." + ru.ofField + ";\n"; + s += " if (!java.util.Objects.equals(parent." + cf + ", latestValue)) {\n"; + s += " parent." + cf + " = latestValue;\n"; + s += " changed = true;\n"; + s += " }\n"; + s += " }\n"; + return s; + } if (ru.op === "sum") { const sf = ru.sumField; let s = " {\n";