From 6527b3a586c1caebbd7ff11b688b0d43a04fb1ca Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 19 Aug 2026 12:25:59 +0300 Subject: [PATCH 1/2] fix(intent): a count roll-up recomputes when a child changes parents A `rollups:` entry with the default `op: count` got a create and a delete handler but no `-updated` one - the generator assumed a count can only move when a child is created or destroyed. A child changes parents by an ordinary EDIT of its parent relation, so re-parenting recomputed neither count: the parent that received the child never counted it. Emit the update handler for every op. The recompute is the same query in all three cases and it reads the child rows back from the store, so it is idempotent and never op-specific - an edit that touched nothing the roll-up reads finds the value unchanged and writes nothing. The parent a child moved AWAY from is still stale until roll-ups also consume the `-rekeyed` event, which is filed separately; this alone makes the new parent correct. Covered by a `GlueRollupCountTest` unit test on the emitted descriptors and by IntentEngineIT, which now asserts the generated `LoanMemberRollupOnUpdate` binds the child's `-updated` topic and recomputes exactly like the create handler. Closes #6820 Co-Authored-By: Claude Opus 5 --- components/engine/engine-intent/CLAUDE.md | 2 +- .../intent/generator/GlueIntentGenerator.java | 12 ++-- .../main/resources/intent-assistant-guide.md | 5 ++ .../intent/generator/GlueRollupCountTest.java | 67 +++++++++++++++++++ .../template/service/model/GlueGenerator.java | 7 +- .../integration/tests/api/IntentEngineIT.java | 10 +++ 6 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 16987dfcca4..4a811c9fb9d 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -554,7 +554,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). **`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`). `RollupAggregates` in the pipeline tracks the max-`by` row type-agnostically (`var` + `Objects.equals`). + → three `gen/events//RollupOn{Create,Update,Delete}.java` `@Listener`s on the child's create/update/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. Every `op` gets all three handlers — the update one is what keeps a count right when an ordinary edit RE-PARENTS a child (#6820); it is the same idempotent recompute, so it is never op-specific. **Gap:** no `where` filter (counts all children), and the parent a child moved AWAY from stays stale until roll-ups consume the `-rekeyed` event. **`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`). `RollupAggregates` in the pipeline tracks the max-`by` row type-agnostically (`var` + `Objects.equals`). 10. **Dynamic user-task assignment** — `assignee: { path: member.branch.manager, fallback: manager }`, resolver-driven (extends the existing user-task glue). **(v1 implemented — see the resolver-path bullet in the conventions above.)** ### 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 4ad6244f5e3..721a15ee998 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 @@ -576,11 +576,13 @@ private static List> buildRollups(IntentModel model, Map> rollups = GlueIntentGenerator.buildRollupsForTest(model); + + assertEquals(3, rollups.size(), "a count roll-up must recompute on create, update and delete"); + assertEquals(List.of("", "-updated", "-deleted"), rollups.stream() + .map(r -> String.valueOf(r.get("topicSuffix"))) + .toList(), + "the three handlers bind the child's base, -updated and -deleted topics"); + assertEquals(List.of("LoanMemberRollupOnCreate", "LoanMemberRollupOnUpdate", "LoanMemberRollupOnDelete"), rollups.stream() + .map(r -> String.valueOf( + r.get("className"))) + .toList()); + // Every variant carries the same op and recompute criteria - the update handler is not a + // special case, it is the same idempotent read-modify-write of the affected parent. + assertTrue(rollups.stream() + .allMatch(r -> "count".equals(r.get("op")) + && "Criteria.create().eq(\"Member\", entity.Member)".equals(r.get("criteriaExpression"))), + "all three handlers must recompute the same way: " + rollups); + } +} diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java index d9392925489..fa9401fe0c1 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java @@ -757,9 +757,10 @@ private static void bindResolve(Map item, Map co * *

* The coalescing is what keeps the totals correct: separate handlers would each persist the whole - * parent row and clobber each other's fields. Grouping by the event as well as the relation is what - * makes each event's aggregate set right - a count roll-up contributes no update entry, so the - * update handler ends up sum-only. + * parent row and clobber each other's fields. The event is part of the grouping key because a + * handler binds exactly one topic, so each of the child's create / update / delete events yields + * its own handler carrying the aggregate blocks of every roll-up that shares that child and + * relation. * * @param source the template source * @param content the template content diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 1d37a33f222..1571312af66 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -1601,6 +1601,16 @@ void rollup_generates_create_and_delete_listeners_that_recompute_the_parent_coun String onDelete = contentOf("gen/events/library/LoanMemberRollupOnDelete.java"); assertTrue(onDelete.contains("@Component") && onDelete.contains("return \"intent-test-Loan-Loan-deleted\""), "the delete listener binds the child's -deleted topic via destination()"); + + // A child moves between parents by an ordinary EDIT of its parent relation, so a count needs the + // update handler too - without it the parent the loan was moved to never counted it (#6820). + String onUpdate = contentOf("gen/events/library/LoanMemberRollupOnUpdate.java"); + assertTrue(onUpdate.contains("@Component") && onUpdate.contains("return \"intent-test-Loan-Loan-updated\""), + "a count roll-up must also bind the child's -updated topic, so re-parenting recomputes the new parent"); + assertTrue( + onUpdate.contains("new LoanRepository().findAll(Criteria.create().eq(\"Member\", entity.Member))") + && onUpdate.contains("int count = rows.size();") && onUpdate.contains("parent.LoanCount = count"), + "the update listener recomputes exactly like the create one"); } @Test From 9aaf72eef377d3d842711c142b79ecd301fd52f4 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 20 Aug 2026 11:57:26 +0300 Subject: [PATCH 2/2] test(intent): the count roll-up trio composes with the rekey handler The merged expectation: a count roll-up now emits four handlers - the create/update/delete trio this branch adds, plus the RollupOnRekey #6845 merged meanwhile. The stale comment claiming the vacated parent was 'tracked separately' now points at that handler. Co-Authored-By: Claude Fable 5 --- .../intent/generator/GlueRollupCountTest.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java index c4bbde799de..a5d0af36a3a 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueRollupCountTest.java @@ -48,20 +48,21 @@ void aCountRollupRecomputesOnCreateUpdateAndDelete() { IntentModel model = IntentParser.parse(YAML); List> rollups = GlueIntentGenerator.buildRollupsForTest(model); - assertEquals(3, rollups.size(), "a count roll-up must recompute on create, update and delete"); - assertEquals(List.of("", "-updated", "-deleted"), rollups.stream() - .map(r -> String.valueOf(r.get("topicSuffix"))) - .toList(), - "the three handlers bind the child's base, -updated and -deleted topics"); - assertEquals(List.of("LoanMemberRollupOnCreate", "LoanMemberRollupOnUpdate", "LoanMemberRollupOnDelete"), rollups.stream() - .map(r -> String.valueOf( - r.get("className"))) - .toList()); + assertEquals(4, rollups.size(), + "a count roll-up must recompute on create, update, delete and rekey (the rekey variant repairs the vacated parent, #6819)"); + assertEquals(List.of("", "-updated", "-deleted", "-rekeyed"), rollups.stream() + .map(r -> String.valueOf(r.get("topicSuffix"))) + .toList(), + "the handlers bind the child's base, -updated, -deleted and -rekeyed topics"); + assertEquals(List.of("LoanMemberRollupOnCreate", "LoanMemberRollupOnUpdate", "LoanMemberRollupOnDelete", "LoanMemberRollupOnRekey"), + rollups.stream() + .map(r -> String.valueOf(r.get("className"))) + .toList()); // Every variant carries the same op and recompute criteria - the update handler is not a // special case, it is the same idempotent read-modify-write of the affected parent. assertTrue(rollups.stream() .allMatch(r -> "count".equals(r.get("op")) && "Criteria.create().eq(\"Member\", entity.Member)".equals(r.get("criteriaExpression"))), - "all three handlers must recompute the same way: " + rollups); + "all handlers must recompute the same way: " + rollups); } }