Skip to content

Commit 19a1231

Browse files
delchevclaude
andauthored
feat(intent): rollup op: latest - keep a parent field equal to the newest child's value (#6350)
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 <noreply@anthropic.com>
1 parent 56697fc commit 19a1231

7 files changed

Lines changed: 172 additions & 7 deletions

File tree

components/engine/engine-intent/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ Every action below has a real SDK surface to generate against, so none of this n
421421
rollups:
422422
- { name: memberLoanCount, entity: Loan, via: member, field: loanCount } # Member.loanCount = #Loans whose `member` FK = that Member
423423
```
424-
→ two `gen/events/<Name>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("<Fk>", entity.<Fk>)).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).
424+
→ two `gen/events/<Name>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("<Fk>", entity.<Fk>)).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`).
425425
10. **Dynamic user-task assignment**`assignee: { fromPath: member.branch.manager }`, resolver-driven (extends the existing user-task glue).
426426

427427
### Guardrails (so this doesn't become the MDE expressiveness trap)

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,11 +406,19 @@ private static List<Map<String, Object>> buildRollups(IntentModel model, Map<Str
406406
String op = rollup.getOp() == null || rollup.getOp()
407407
.isBlank() ? "count" : rollup.getOp();
408408
boolean sum = "sum".equals(op);
409+
boolean latest = "latest".equals(op);
409410
if (sum && (rollup.getOf() == null || rollup.getOf()
410411
.isBlank())) {
411412
LOGGER.warn("Sum roll-up [{}] has no 'of' field - skipping", rollup.getName());
412413
continue;
413414
}
415+
if (latest && (rollup.getOf() == null || rollup.getOf()
416+
.isBlank()
417+
|| rollup.getBy() == null || rollup.getBy()
418+
.isBlank())) {
419+
LOGGER.warn("Latest roll-up [{}] needs both 'of' and 'by' - skipping", rollup.getName());
420+
continue;
421+
}
414422
String fkProperty = IntentNaming.pascalCase(rollup.getVia());
415423
Map<String, Object> base = new LinkedHashMap<>();
416424
base.put("childEntity", rollup.getEntity());
@@ -421,6 +429,9 @@ private static List<Map<String, Object>> buildRollups(IntentModel model, Map<Str
421429
base.put("countField", IntentNaming.pascalCase(rollup.getField()));
422430
base.put("op", op);
423431
base.put("sumField", sum ? IntentNaming.pascalCase(rollup.getOf()) : "");
432+
// latest: copy the `of` value of the child row with the greatest `by` onto the parent field.
433+
base.put("ofField", latest ? IntentNaming.pascalCase(rollup.getOf()) : "");
434+
base.put("byField", latest ? IntentNaming.pascalCase(rollup.getBy()) : "");
424435
// Optional (sum) capacity/balance/status: keep a `balance` field = capacity - sum, and set a
425436
// `status` relation to whenFull/whenPartial at the thresholds. Empty string / -1 = not set.
426437
boolean withCapacity = sum && rollup.getCapacity() != null && !rollup.getCapacity()
@@ -448,8 +459,9 @@ private static List<Map<String, Object>> buildRollups(IntentModel model, Map<Str
448459
// this one class.
449460
String className = rollup.getEntity() + fkProperty;
450461
rollups.add(rollupEntry(base, className + "RollupOnCreate", ""));
451-
if (sum) {
452-
// A line edit changes the sum, so a sum roll-up must also recompute on update.
462+
if (sum || latest) {
463+
// A line edit changes the sum (or which row is latest / its value), so sum AND latest
464+
// roll-ups must also recompute on update.
453465
rollups.add(rollupEntry(base, className + "RollupOnUpdate", "-updated"));
454466
}
455467
rollups.add(rollupEntry(base, className + "RollupOnDelete", "-deleted"));
@@ -715,6 +727,11 @@ static List<Map<String, Object>> buildAbortsForTest(IntentModel model) {
715727
return buildAborts(model, IntentSettings.parse("{}"));
716728
}
717729

730+
/** Test hook: build the {@code rollups} glue collection without a repository. */
731+
static List<Map<String, Object>> buildRollupsForTest(IntentModel model) {
732+
return buildRollups(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}"));
733+
}
734+
718735
/** Test hook: build the {@code waits} glue collection without a repository. */
719736
static List<Map<String, Object>> buildWaitsForTest(IntentModel model) {
720737
return buildWaits(model, IntentSettings.parse("{}"));

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RollupIntent.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,19 @@ public class RollupIntent {
3232
private String entity;
3333
private String via;
3434
private String field;
35-
/** The aggregation: {@code count} (default) or {@code sum}. */
35+
/** The aggregation: {@code count} (default), {@code sum}, or {@code latest}. */
3636
private String op;
37-
/** The child field summed onto {@link #field} when {@link #op} is {@code sum}. */
37+
/**
38+
* The child field aggregated onto {@link #field}: summed when {@link #op} is {@code sum}, or copied
39+
* from the most-recent child row when {@link #op} is {@code latest}.
40+
*/
3841
private String of;
42+
/**
43+
* Required for {@code op: latest}: the child date/timestamp field that orders the rows; the
44+
* {@link #of} value of the row with the greatest {@code by} is copied onto the parent
45+
* {@link #field} (the "keep the parent's rate equal to the latest child rate" shape).
46+
*/
47+
private String by;
3948
/**
4049
* Optional (sum roll-ups only): a numeric "capacity" field on the parent the sum is measured
4150
* 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) {
8392
this.via = via;
8493
}
8594

95+
public String getBy() {
96+
return by;
97+
}
98+
99+
public void setBy(String by) {
100+
this.by = by;
101+
}
102+
86103
public String getField() {
87104
return field;
88105
}

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,13 +831,41 @@ private static void validateRollups(IntentModel model, List<String> issues) {
831831
EntityIntent parent = byName.get(via.getTo());
832832
FieldIntent counter = parent == null ? null : fieldByName(parent, rollup.getField());
833833
boolean sum = "sum".equals(rollup.getOp());
834+
boolean latest = "latest".equals(rollup.getOp());
834835
if (counter == null) {
835836
issues.add("rollup [" + name + "] field [" + rollup.getField() + "] is not a field of parent [" + via.getTo() + "]");
836837
} else if (sum && !NUMERIC_TYPES.contains(counter.getType())) {
837838
issues.add("rollup [" + name + "] field [" + rollup.getField() + "] must be a numeric type to hold a sum");
838-
} else if (!sum && !INTEGER_PK_TYPES.contains(counter.getType())) {
839+
} else if (!sum && !latest && !INTEGER_PK_TYPES.contains(counter.getType())) {
839840
issues.add("rollup [" + name + "] field [" + rollup.getField() + "] must be an integer type to hold a count");
840841
}
842+
if (latest) {
843+
// latest copies the child `of` value from the row with the greatest `by` date onto the
844+
// parent field; `of`+`by` required, `by` must be date/timestamp, and the parent field
845+
// should hold the same type as `of` (checked leniently: same logical type).
846+
FieldIntent of = fieldByName(child, rollup.getOf());
847+
FieldIntent by = fieldByName(child, rollup.getBy());
848+
if (rollup.getOf() == null || rollup.getOf()
849+
.isBlank()) {
850+
issues.add("rollup [" + name + "] with op latest must declare `of` (the child field to copy)");
851+
} else if (of == null) {
852+
issues.add("rollup [" + name + "] of [" + rollup.getOf() + "] is not a field of [" + rollup.getEntity() + "]");
853+
}
854+
if (rollup.getBy() == null || rollup.getBy()
855+
.isBlank()) {
856+
issues.add(
857+
"rollup [" + name + "] with op latest must declare `by` (the child date/timestamp field that orders the rows)");
858+
} else if (by == null) {
859+
issues.add("rollup [" + name + "] by [" + rollup.getBy() + "] is not a field of [" + rollup.getEntity() + "]");
860+
} else if (!"date".equals(by.getType()) && !"timestamp".equals(by.getType())) {
861+
issues.add("rollup [" + name + "] by [" + rollup.getBy() + "] must be a date/timestamp field");
862+
}
863+
if (of != null && counter != null && of.getType() != null && !of.getType()
864+
.equals(counter.getType())) {
865+
issues.add("rollup [" + name + "] field [" + rollup.getField() + "] type [" + counter.getType()
866+
+ "] must match the copied `of` field type [" + of.getType() + "]");
867+
}
868+
}
841869
if (sum) {
842870
// sum needs a numeric child field to add up; capacity / balance (optional) are numeric parent
843871
// fields and status (optional) a to-one relation of the parent - see the balance/status roll-up.

components/engine/engine-intent/src/main/resources/intent-assistant-guide.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,18 @@ rollups:
12181218
capacity: total, balance: balance, status: Status, statusWhenFull: 7, statusWhenPartial: 6 }
12191219
```
12201220

1221+
**Latest child value (`op: latest`).** Keeps `field` equal to the `of` value of the **most-recent**
1222+
child row - the row with the greatest `by` date/timestamp. Use it to mirror a latest child onto its
1223+
parent (e.g. a currency's headline rate = the newest rate row):
1224+
```yaml
1225+
rollups:
1226+
# Currency.rate = the rate of the CurrencyRate row with the newest date.
1227+
- { name: latestRate, entity: CurrencyRate, via: Currency, field: rate, op: latest, of: rate, by: date }
1228+
```
1229+
`of` is the child field copied, `by` is the child `date`/`timestamp` field that decides "latest", and
1230+
the parent `field` must be the same type as `of`. Recomputes on child create/update/delete; if a
1231+
currency has no rate rows the parent field resets to null.
1232+
12211233
**Transitive (chained) roll-ups.** Roll-ups compose across a multi-level composition: if the parent of
12221234
one roll-up is itself the child of another, a change flows all the way up. Declare one roll-up per
12231235
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
12961308
| report `chart` | `bar`, `line`, `pie`, `doughnut`, `polarArea`, `radar` |
12971309
| report `widget.kind` | `count`, `value`, `list` |
12981310
| custom `widgets` `kind` | `kpi`, `page` |
1299-
| rollup `op` | `count` (default), `sum` |
1311+
| rollup `op` | `count` (default), `sum`, `latest` (needs `of` + `by`) |
13001312
| expansion `unit` | `day`, `week`, `month` |
13011313
| transition `when` op | `==`, `!=` |
13021314

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.intent.generator;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
import static org.junit.jupiter.api.Assertions.assertTrue;
14+
15+
import java.util.List;
16+
import java.util.Map;
17+
18+
import org.eclipse.dirigible.components.intent.model.IntentModel;
19+
import org.eclipse.dirigible.components.intent.parser.IntentParser;
20+
import org.junit.jupiter.api.Test;
21+
22+
/**
23+
* Verifies the {@code rollup op: latest} glue: create/update/delete handlers over the child, and
24+
* the latest aggregate block that copies the {@code of} value of the child row with the greatest
25+
* {@code by} date onto the parent field (the "keep Currency.rate equal to the latest CurrencyRate"
26+
* shape).
27+
*/
28+
class GlueRollupLatestTest {
29+
30+
private static final String YAML = """
31+
name: fx
32+
entities:
33+
- name: Currency
34+
kind: setting
35+
fields:
36+
- { name: id, type: integer, primaryKey: true, generated: true }
37+
- { name: code, type: string, unique: true, required: true, length: 3 }
38+
- { name: rate, type: decimal, precision: 18, scale: 6 }
39+
relations:
40+
- { name: rates, kind: oneToMany, to: CurrencyRate }
41+
- name: CurrencyRate
42+
fields:
43+
- { name: id, type: integer, primaryKey: true, generated: true }
44+
- { name: date, type: date, required: true }
45+
- { name: rate, type: decimal, precision: 18, scale: 6, required: true }
46+
relations:
47+
- { name: Currency, kind: manyToOne, to: Currency, composition: true, required: true }
48+
rollups:
49+
- { name: latestRate, entity: CurrencyRate, via: Currency, field: rate, op: latest, of: rate, by: date }
50+
""";
51+
52+
@Test
53+
void rendersTheLatestRollupHandlers() {
54+
IntentModel model = IntentParser.parse(YAML);
55+
List<Map<String, Object>> rollups = GlueIntentGenerator.buildRollupsForTest(model);
56+
// create + update + delete (a new/edited/removed rate can change which row is latest).
57+
assertEquals(3, rollups.size(), "latest must recompute on create, update and delete");
58+
Map<String, Object> create = rollups.get(0);
59+
assertEquals("latest", create.get("op"));
60+
assertEquals("Rate", create.get("countField")); // parent field
61+
assertEquals("Rate", create.get("ofField")); // child value copied
62+
assertEquals("Date", create.get("byField")); // child ordering field
63+
assertEquals("Currency", create.get("fkProperty"));
64+
assertEquals("CurrencyRate", create.get("childEntity"));
65+
assertEquals("Currency", create.get("parentEntity"));
66+
assertTrue(rollups.stream()
67+
.anyMatch(r -> String.valueOf(r.get("topicSuffix"))
68+
.equals("-updated")),
69+
"latest must have an update handler");
70+
}
71+
}

components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,6 +1343,26 @@ function annotateDocumentModels(entities) {
13431343
// event and the transitive cascade terminates.
13441344
function renderRollupAggregate(ru) {
13451345
const cf = ru.countField;
1346+
if (ru.op === "latest") {
1347+
// Copy the `of` value of the child row with the greatest `by` date onto the parent field.
1348+
// Type-agnostic: track the latest ROW (childEntity), then copy its of-field via `var` +
1349+
// Objects.equals so no knowledge of the of-field's Java type is needed; `by` is date/timestamp
1350+
// (Comparable). A parent with no child rows resets to null.
1351+
let s = " {\n";
1352+
s += " " + ru.childEntity + "Entity latestRow = null;\n";
1353+
s += " for (var row : rows) {\n";
1354+
s += " if (row." + ru.byField + " != null && (latestRow == null || latestRow." + ru.byField + " == null || row." + ru.byField + ".compareTo(latestRow." + ru.byField + ") > 0)) {\n";
1355+
s += " latestRow = row;\n";
1356+
s += " }\n";
1357+
s += " }\n";
1358+
s += " var latestValue = latestRow == null ? null : latestRow." + ru.ofField + ";\n";
1359+
s += " if (!java.util.Objects.equals(parent." + cf + ", latestValue)) {\n";
1360+
s += " parent." + cf + " = latestValue;\n";
1361+
s += " changed = true;\n";
1362+
s += " }\n";
1363+
s += " }\n";
1364+
return s;
1365+
}
13461366
if (ru.op === "sum") {
13471367
const sf = ru.sumField;
13481368
let s = " {\n";

0 commit comments

Comments
 (0)