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 @@ -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/<module>/<Name>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("<Fk>", entity.<Fk>)).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/<module>/<Name>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("<Fk>", entity.<Fk>)).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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -582,11 +582,13 @@ 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 || 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"));
}
// EVERY op recomputes on update, not just sum / latest: a line edit changes the sum (or which
// row is latest, or its value), and an edit that RE-PARENTS a child - the ordinary way a child
// moves between parents - changes the count of the parent it moved TO. The recompute is the
// same query for every op and reads the child rows back from the store, so the update handler
// is idempotent and never op-specific. (The parent the child moved AWAY from is repaired by
// the RollupOnRekey handler below, off the "-rekeyed" event the DAO publishes for the move.)
rollups.add(rollupEntry(base, className + "RollupOnUpdate", "-updated"));
rollups.add(rollupEntry(base, className + "RollupOnDelete", "-deleted"));
// Re-parenting: the child's create/update/delete events all name the parent it belongs to NOW,
// so the parent it moved AWAY from is named by no event of theirs and kept the child's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,11 @@ must be an existing field on the parent (**integer** for `count`, **numeric** fo
extras: `capacity`/`balance` are numeric parent fields, `status` a to-one relation of the parent, and
`statusWhenFull`/`statusWhenPartial` its target seed ids.

**When it recomputes.** Every roll-up - `count`, `sum` and `latest` alike - recomputes on the child's
create, update **and** delete. The update pass is what keeps a count right when an ordinary edit moves
a child to a different parent: the parent it moved *to* is corrected immediately (the one it moved
*away from* is corrected the next time one of its own children changes).

### settlements - auto-allocate payments across invoices

**Use when:** a payment should be automatically applied to a customer's open invoices (partial / full),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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;

/**
* A {@code count} roll-up - the default {@code op} - must recompute on the child's UPDATE as well
* as its create and delete. A child moves between parents by an ordinary edit of its parent
* relation, and without the update handler neither count moved: the new parent never counted the
* row it received.
*/
class GlueRollupCountTest {

private static final String YAML = """
name: library
entities:
- name: Member
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: loanCount, type: integer }
- name: Loan
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
relations:
- { name: Member, kind: manyToOne, to: Member }
rollups:
- { name: memberLoanCount, entity: Loan, via: Member, field: loanCount }
""";

@Test
void aCountRollupRecomputesOnCreateUpdateAndDelete() {
IntentModel model = IntentParser.parse(YAML);
List<Map<String, Object>> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -831,9 +831,10 @@ private static void bindResolve(Map<String, Object> item, Map<String, Object> co
*
* <p>
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading