Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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).
→ 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`).
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,11 +406,19 @@ private static List<Map<String, Object>> buildRollups(IntentModel model, Map<Str
String op = rollup.getOp() == null || rollup.getOp()
.isBlank() ? "count" : rollup.getOp();
boolean sum = "sum".equals(op);
boolean latest = "latest".equals(op);
if (sum && (rollup.getOf() == null || rollup.getOf()
.isBlank())) {
LOGGER.warn("Sum roll-up [{}] has no 'of' field - skipping", rollup.getName());
continue;
}
if (latest && (rollup.getOf() == null || rollup.getOf()
.isBlank()
|| rollup.getBy() == null || rollup.getBy()
.isBlank())) {
LOGGER.warn("Latest roll-up [{}] needs both 'of' and 'by' - skipping", rollup.getName());
continue;
}
String fkProperty = IntentNaming.pascalCase(rollup.getVia());
Map<String, Object> base = new LinkedHashMap<>();
base.put("childEntity", rollup.getEntity());
Expand All @@ -421,6 +429,9 @@ private static List<Map<String, Object>> buildRollups(IntentModel model, Map<Str
base.put("countField", IntentNaming.pascalCase(rollup.getField()));
base.put("op", op);
base.put("sumField", sum ? IntentNaming.pascalCase(rollup.getOf()) : "");
// latest: copy the `of` value of the child row with the greatest `by` onto the parent field.
base.put("ofField", latest ? IntentNaming.pascalCase(rollup.getOf()) : "");
base.put("byField", latest ? IntentNaming.pascalCase(rollup.getBy()) : "");
// Optional (sum) capacity/balance/status: keep a `balance` field = capacity - sum, and set a
// `status` relation to whenFull/whenPartial at the thresholds. Empty string / -1 = not set.
boolean withCapacity = sum && rollup.getCapacity() != null && !rollup.getCapacity()
Expand Down Expand Up @@ -448,8 +459,9 @@ private static List<Map<String, Object>> buildRollups(IntentModel model, Map<Str
// this one class.
String className = rollup.getEntity() + fkProperty;
rollups.add(rollupEntry(base, className + "RollupOnCreate", ""));
if (sum) {
// A line edit changes the sum, so a sum roll-up must also recompute on update.
if (sum || latest) {
// A line edit changes the sum (or which row is latest / its value), so sum AND latest
// roll-ups must also recompute on update.
rollups.add(rollupEntry(base, className + "RollupOnUpdate", "-updated"));
}
rollups.add(rollupEntry(base, className + "RollupOnDelete", "-deleted"));
Expand Down Expand Up @@ -715,6 +727,11 @@ static List<Map<String, Object>> buildAbortsForTest(IntentModel model) {
return buildAborts(model, IntentSettings.parse("{}"));
}

/** Test hook: build the {@code rollups} glue collection without a repository. */
static List<Map<String, Object>> 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<Map<String, Object>> buildWaitsForTest(IntentModel model) {
return buildWaits(model, IntentSettings.parse("{}"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -831,13 +831,41 @@ private static void validateRollups(IntentModel model, List<String> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 | `==`, `!=` |

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>> 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<String, Object> 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading