diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 6bb1eb83e34..ceeca1b3d0c 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -556,7 +556,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 ``` - → `gen/events//RollupOn{Create,Delete,Rekey}.java` `@Listener`s on the child's create/delete/**rekey** 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. The rekey handler is what repairs a **re-parented** child - see the rekey bullet in the conventions above. **Gap:** no `where` filter (counts all children). **`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`). + → four `gen/events//RollupOn{Create,Update,Delete,Rekey}.java` `@Listener`s on the child's create/update/delete/**rekey** 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 the create/update/delete trio — the update one is what keeps a count right on the RECEIVING side when an ordinary edit re-parents a child (#6820); it is the same idempotent recompute, so it is never op-specific — and the rekey handler repairs the VACATED side of a re-parent, from the full-row and the targeted write paths alike (see the rekey bullet in the conventions above). **Gap:** no `where` filter (counts all children). **`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 3846611abf6..80f265f828f 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 @@ -582,11 +582,13 @@ private static List> buildRollups(IntentModel model, Map> rollups = GlueIntentGenerator.buildRollupsForTest(model); + + 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 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 197bcdf7949..626becb7740 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 @@ -831,9 +831,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 fc47ae00838..c18ceef99e8 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 @@ -1849,6 +1849,16 @@ void rollup_generates_create_and_delete_listeners_that_recompute_the_parent_coun String onDelete = codeOf("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