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 25b53ae45e..e33aa1829e 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 @@ -100,6 +100,7 @@ public void generate(IntentGenerationContext context) { List> transitions = buildTransitions(model, byName, compositionParents, settings); List> postings = buildPostings(model, byName, compositionParents, settings, context); List> posts = buildPosts(model, byName, compositionParents); + List> aggregates = buildAggregates(model, byName, compositionParents); List> printFeeders = PrintFeederSupport.buildPrintFeeders(model, byName, compositionParents, context); List> snapshots = SnapshotSupport.buildSnapshots(model, byName, compositionParents); List> numbering = NumberingSupport.buildNumbering(model, compositionParents); @@ -108,7 +109,7 @@ public void generate(IntentGenerationContext context) { && aborts.isEmpty() && writers.isEmpty() && setters.isEmpty() && notifications.isEmpty() && schedules.isEmpty() && integrations.isEmpty() && inbound.isEmpty() && rollups.isEmpty() && expansions.isEmpty() && settlements.isEmpty() && generates.isEmpty() && transitions.isEmpty() && printFeeders.isEmpty() && postings.isEmpty() && snapshots.isEmpty() - && numbering.isEmpty()) { + && numbering.isEmpty() && posts.isEmpty() && aggregates.isEmpty()) { // No process glue for this intent - any stale .glue is removed by the post-pass scrub. return; } @@ -133,6 +134,7 @@ public void generate(IntentGenerationContext context) { glue.put("transitions", transitions); glue.put("postings", postings); glue.put("posts", posts); + glue.put("aggregates", aggregates); glue.put("printFeeders", printFeeders); glue.put("snapshots", snapshots); glue.put("numbering", numbering); @@ -930,6 +932,81 @@ static List> buildPostsForTest(IntentModel model) { return buildPosts(model, IntentEntities.byName(model), IntentEntities.compositionParents(model)); } + /** + * Build the {@code aggregates} glue collection: one descriptor per {@code aggregates:} rule + * ({@link org.eclipse.dirigible.components.intent.model.AggregateIntent}). Each drives a generated + * handler that maintains a running sum/count of a source entity's field, grouped by its to-one + * relations, upserted into a separate target entity keyed by that group. Structural glue; the + * keyed-upsert handler template is the next stage. See kf-catalog PROPOSAL_AGGREGATE_CHECKS.md. + */ + private static List> buildAggregates(IntentModel model, Map byName, + Map compositionParents) { + List> out = new ArrayList<>(); + for (org.eclipse.dirigible.components.intent.model.AggregateIntent a : model.getAggregates()) { + if (a.getName() == null || a.getName() + .isBlank() + || a.getOf() == null || a.getInto() == null || a.getField() == null || a.getBy() + .isEmpty()) { + continue; // malformed: name/of/into/field/by are required + } + EntityIntent source = byName.get(a.getOf()); + EntityIntent target = byName.get(a.getInto()); + if (source == null || target == null) { + continue; // bad source/target reference (v1: same-model) + } + String op = a.getOp() == null || a.getOp() + .isBlank() ? "sum" + : a.getOp() + .trim() + .toLowerCase(java.util.Locale.ROOT); + // The grouping keys must be to-one relations of BOTH the source and the target (the target + // is keyed by the same FKs). Emit the pascal FK names paired for the key match. + List> keys = new ArrayList<>(); + boolean keysOk = true; + for (String key : a.getBy()) { + String fk = IntentNaming.pascalCase(key); + boolean onSource = source.getRelations() + .stream() + .anyMatch(r -> fk.equals(IntentNaming.pascalCase(r.getName()))); + boolean onTarget = target.getRelations() + .stream() + .anyMatch(r -> fk.equals(IntentNaming.pascalCase(r.getName()))); + if (!onSource || !onTarget) { + LOGGER.warn("aggregate [{}]: key [{}] must be a to-one relation of both source [{}] and target [{}] - skipped", + a.getName(), key, a.getOf(), a.getInto()); + keysOk = false; + break; + } + Map pair = new LinkedHashMap<>(); + pair.put("key", fk); + keys.add(pair); + } + if (!keysOk) { + continue; + } + Map e = new LinkedHashMap<>(); + e.put("name", a.getName()); + e.put("className", IntentNaming.pascalIdentifier(a.getName())); + e.put("op", op); + e.put("sourceEntity", a.getOf()); + e.put("sourcePerspective", IntentEntities.resolvePerspective(a.getOf(), compositionParents)); + e.put("sourceKeyField", IntentEntities.keyFieldName(source)); + e.put("sumField", a.getSum() == null ? "" : IntentNaming.pascalCase(a.getSum())); + e.put("keys", keys); + e.put("targetEntity", a.getInto()); + e.put("targetPerspective", IntentEntities.resolvePerspective(a.getInto(), compositionParents)); + e.put("targetPk", IntentEntities.keyFieldName(target)); + e.put("targetField", IntentNaming.pascalCase(a.getField())); + out.add(e); + } + return out; + } + + /** Test hook: build the {@code aggregates} glue collection without a repository. */ + static List> buildAggregatesForTest(IntentModel model) { + return buildAggregates(model, IntentEntities.byName(model), IntentEntities.compositionParents(model)); + } + /** * Test hook: build the {@code generates} glue collection without a repository. With a null context * a cross-model target falls back to {@link CrossModelSupport}'s naming-convention defaults diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/AggregateIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/AggregateIntent.java new file mode 100644 index 0000000000..411ce101b2 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/AggregateIntent.java @@ -0,0 +1,114 @@ +/* + * 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.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * A keyed cross-entity aggregate: a running sum/count of a source entity's field, grouped by one or + * more of its to-one relations, materialised into a SEPARATE target entity keyed by that group (one + * target row per key-tuple). The two-key balance-rollup the ledger on-hand needs (CLAUDE.md ยง13) - + * a generalisation of {@link RollupIntent} (which is single-key, child -> composition parent). See + * kf-catalog PROPOSAL_AGGREGATE_CHECKS.md. + * + *

+ * Example - live on-hand per product+store from the signed stock ledger: + * + *

+ * aggregates:
+ *   - name: onHand
+ *     of: StockMovement        # the source rows
+ *     op: sum                  # sum (default) | count
+ *     sum: quantity            # the source field summed (signed)
+ *     by: [Product, Store]     # the grouping keys (the source's to-one relations)
+ *     into: ProductAvailability # the materialised target, keyed by the same relations
+ *     field: onHand            # the target field holding the sum
+ * 
+ */ +public class AggregateIntent { + + /** Stable name of this aggregate (also the generated handler class stem). */ + private String name; + + /** The source entity whose rows are aggregated. */ + private String of; + + /** The aggregation: {@code sum} (default) or {@code count}. */ + private String op; + + /** The source field summed (required for {@code op: sum}; ignored for {@code count}). */ + private String sum; + + /** The grouping keys - the source's to-one relation names; one target row per distinct tuple. */ + private List by = new ArrayList<>(); + + /** The target entity the aggregate is materialised into (keyed by {@link #by}). */ + private String into; + + /** The target field that holds the running aggregate value. */ + private String field; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getOf() { + return of; + } + + public void setOf(String of) { + this.of = of; + } + + public String getOp() { + return op; + } + + public void setOp(String op) { + this.op = op; + } + + public String getSum() { + return sum; + } + + public void setSum(String sum) { + this.sum = sum; + } + + public List getBy() { + return by; + } + + public void setBy(List by) { + this.by = by == null ? new ArrayList<>() : by; + } + + public String getInto() { + return into; + } + + public void setInto(String into) { + this.into = into; + } + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java index cafa225e68..da4bff4e54 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java @@ -71,11 +71,23 @@ public class IntentModel { * Declarative event-driven row posts - emit mapped rows into a target entity on a document event. */ private List posts = new ArrayList<>(); + /** + * Keyed cross-entity aggregates - a running sum/count grouped by FKs, materialised into a target. + */ + private List aggregates = new ArrayList<>(); public List getActions() { return actions; } + public List getAggregates() { + return aggregates; + } + + public void setAggregates(List aggregates) { + this.aggregates = aggregates == null ? new ArrayList<>() : aggregates; + } + public List getPosts() { return posts; } diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueAggregatesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueAggregatesTest.java new file mode 100644 index 0000000000..28521ef3e8 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueAggregatesTest.java @@ -0,0 +1,86 @@ +/* + * 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 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 aggregates} glue the {@link GlueIntentGenerator} emits: a two-key on-hand sum + * of the signed stock ledger materialised into a ProductAvailability target keyed by Product+Store. + * The keys must be to-one relations of BOTH source and target; the descriptor carries source/target + * coordinates + the summed field + the target field. Structural glue - the keyed-upsert handler + * template is a later stage (kf-catalog PROPOSAL_AGGREGATE_CHECKS.md). + */ +class GlueAggregatesTest { + + private static final String YAML = """ + name: inventory + entities: + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - name: Store + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - name: StockMovement + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: Product, kind: manyToOne, to: Product } + - { name: Store, kind: manyToOne, to: Store } + - name: ProductAvailability + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: onHand, type: decimal } + relations: + - { name: Product, kind: manyToOne, to: Product } + - { name: Store, kind: manyToOne, to: Store } + aggregates: + - name: onHand + of: StockMovement + op: sum + sum: quantity + by: [Product, Store] + into: ProductAvailability + field: onHand + """; + + @SuppressWarnings("unchecked") + @Test + void emitsTheKeyedAggregateGlue() { + IntentModel model = IntentParser.parse(YAML); + List> aggregates = GlueIntentGenerator.buildAggregatesForTest(model); + assertEquals(1, aggregates.size()); + Map a = aggregates.get(0); + + assertEquals("onHand", a.get("name")); + assertEquals("OnHand", a.get("className")); + assertEquals("sum", a.get("op")); + assertEquals("StockMovement", a.get("sourceEntity")); + assertEquals("Quantity", a.get("sumField")); + assertEquals("ProductAvailability", a.get("targetEntity")); + assertEquals("Id", a.get("targetPk")); + assertEquals("OnHand", a.get("targetField")); + + // both grouping keys resolved (relations of source AND target). + List> keys = (List>) a.get("keys"); + assertEquals(2, keys.size()); + assertEquals(Map.of("key", "Product"), keys.get(0)); + assertEquals(Map.of("key", "Store"), keys.get(1)); + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Aggregate.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Aggregate.java.template new file mode 100644 index 0000000000..1dc95c514e --- /dev/null +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Aggregate.java.template @@ -0,0 +1,61 @@ +package gen.events.${javaGenFolderName}; + +import org.eclipse.dirigible.components.data.store.java.repository.Criteria; +import org.eclipse.dirigible.sdk.component.Component; +import org.eclipse.dirigible.sdk.messaging.ListenerKind; +import org.eclipse.dirigible.sdk.messaging.MessageHandler; +import org.eclipse.dirigible.sdk.utils.Json; + +import gen.${javaGenFolderName}.data.${sourceJavaPerspective}.${sourceEntity}Entity; +import gen.${javaGenFolderName}.data.${sourceJavaPerspective}.${sourceEntity}Repository; +import gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity; +import gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Repository; + +/** + * Maintains the keyed aggregate ${targetEntity}.${targetField} = ${op}(${sourceEntity}) grouped by + * [${keyList}] - one ${targetEntity} row per distinct key-tuple. + * + * Generated from the intent aggregates block - do not edit; it is re-generated with the application. On + * each source ${sourceEntity} event the target row for the incoming row's key-tuple is upserted and the + * aggregate recomputed from the store over every source row sharing that tuple (self-healing + + * idempotent - re-delivery or replay converges to the same value). A source row with any grouping key + * null is ignored (it belongs to no tuple). v1: same-model source + target, sum/count into a decimal + * target field; a key change on update recomputes the NEW tuple (append-only ledgers - the primary use + * - never edit keys, so the old-tuple recompute is a documented follow-up). + */ +@Component("${javaGenFolderName}_${className}") +public class ${className} implements MessageHandler { + + @Override + public String destination() { + return "${projectName}-${sourcePerspective}-${sourceEntity}${topicSuffix}"; + } + + @Override + public ListenerKind kind() { + return ListenerKind.TOPIC; + } + + @Override + public void onMessage(String message) { + ${sourceEntity}Entity row = Json.parse(message, ${sourceEntity}Entity.class); + if (row == null${keyNullGuards}) { + return; + } + ${targetEntity}Repository targets = new ${targetEntity}Repository(); + java.util.List<${targetEntity}Entity> matches = targets.findAll(${keyCriteria}); + ${targetEntity}Entity target = matches.isEmpty() ? new ${targetEntity}Entity() : matches.get(0); + java.util.List<${sourceEntity}Entity> rows = new ${sourceEntity}Repository().findAll(${keyCriteria}); + java.math.BigDecimal agg = java.math.BigDecimal.ZERO; + for (${sourceEntity}Entity r : rows) { + ${aggregateStep} + } +${keyAssigns} + target.${targetField} = agg; + if (matches.isEmpty()) { + targets.save(target); + } else { + targets.update(target); + } + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js index eb19b25563..7d4aabf18b 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js @@ -184,6 +184,13 @@ export function getTemplate(parameters) { engine: "velocity", collection: "posts" }, + { + location: "/template-application-events-java/events/Aggregate.java.template", + action: "generate", + rename: "gen/events/{{javaGenFolderName}}/{{className}}.java", + engine: "velocity", + collection: "aggregates" + }, { location: "/template-application-events-java/project.json.mjs", action: "generate", diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index c861f296d1..89d9b03a06 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -1103,6 +1103,68 @@ export function generateFiles(model, parameters, templateSources) { } } break; + case "aggregates": + // Keyed cross-entity aggregate (intent aggregates:): a MessageHandler that + // maintains a materialised target row per key-tuple - target. = + // sum/count(source.) grouped by the shared FKs. Each logical aggregate + // expands here into THREE handlers (source create / -updated / -deleted); every + // one upserts the incoming row's tuple and recomputes from the store (idempotent, + // self-healing). All Java expressions are pre-rendered from the descriptor's keys + // so the template stays shape-only. v1: same-model, uniform BigDecimal accumulation + // into a decimal target field (sum adds the field, count adds ONE). + if (model.aggregates) { + const aggVariants = [ + { suffix: "", cls: "OnCreate" }, + { suffix: "-updated", cls: "OnUpdate" }, + { suffix: "-deleted", cls: "OnDelete" } + ]; + for (let i = 0; i < model.aggregates.length; i++) { + const ag = model.aggregates[i]; + const keys = ag.keys || []; + let keyNullGuards = ""; + let keyCriteria = "Criteria.create()"; + let keyAssigns = ""; + for (let k = 0; k < keys.length; k++) { + const key = keys[k].key; + keyNullGuards += " || row." + key + " == null"; + keyCriteria += ".eq(\"" + key + "\", row." + key + ")"; + keyAssigns += " target." + key + " = row." + key + ";\n"; + } + const aggregateStep = ag.op === "count" + ? "agg = agg.add(java.math.BigDecimal.ONE);" + : "if (r." + ag.sumField + " != null) { agg = agg.add(r." + ag.sumField + "); }"; + const keyList = keys.map(function (x) { return x.key; }).join(", "); + for (let v = 0; v < aggVariants.length; v++) { + const variant = aggVariants[v]; + const aggregateParameters = { + ...parameters, + className: ag.className + "Aggregate" + variant.cls, + topicSuffix: variant.suffix, + op: ag.op, + sourceEntity: ag.sourceEntity, + sourcePerspective: ag.sourcePerspective, + sourceJavaPerspective: sanitizeJavaIdentifier(ag.sourcePerspective), + targetEntity: ag.targetEntity, + targetPerspective: ag.targetPerspective, + targetJavaPerspective: sanitizeJavaIdentifier(ag.targetPerspective), + targetField: ag.targetField, + sumField: ag.sumField, + keyList: keyList, + keyNullGuards: keyNullGuards, + keyCriteria: keyCriteria, + keyAssigns: keyAssigns, + aggregateStep: aggregateStep + }; + const cleanAggregateParameters = cleanData(aggregateParameters); + generatedFiles.push({ + location: location, + content: getGenerationEngine(template).generate(location, content, cleanAggregateParameters), + path: templateEngines.getMustacheEngine().generate(location, template.rename, cleanAggregateParameters) + }); + } + } + } + break; case "postings": // Declarative postings (intent layer): a MessageHandler on the source's // -transitioned topic creating a local document + computed items. Everything is