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
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ public void generate(IntentGenerationContext context) {
List<Map<String, Object>> transitions = buildTransitions(model, byName, compositionParents, settings);
List<Map<String, Object>> postings = buildPostings(model, byName, compositionParents, settings, context);
List<Map<String, Object>> posts = buildPosts(model, byName, compositionParents);
List<Map<String, Object>> aggregates = buildAggregates(model, byName, compositionParents);
List<Map<String, Object>> printFeeders = PrintFeederSupport.buildPrintFeeders(model, byName, compositionParents, context);
List<Map<String, Object>> snapshots = SnapshotSupport.buildSnapshots(model, byName, compositionParents);
List<Map<String, Object>> numbering = NumberingSupport.buildNumbering(model, compositionParents);
Expand All @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -930,6 +932,81 @@ static List<Map<String, Object>> 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<Map<String, Object>> buildAggregates(IntentModel model, Map<String, EntityIntent> byName,
Map<String, String> compositionParents) {
List<Map<String, Object>> 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<Map<String, String>> 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<String, String> pair = new LinkedHashMap<>();
pair.put("key", fk);
keys.add(pair);
}
if (!keysOk) {
continue;
}
Map<String, Object> 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<Map<String, Object>> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* Example - live on-hand per product+store from the signed stock ledger:
*
* <pre>
* 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
* </pre>
*/
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<String> 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<String> getBy() {
return by;
}

public void setBy(List<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostIntent> posts = new ArrayList<>();
/**
* Keyed cross-entity aggregates - a running sum/count grouped by FKs, materialised into a target.
*/
private List<AggregateIntent> aggregates = new ArrayList<>();

public List<ActionIntent> getActions() {
return actions;
}

public List<AggregateIntent> getAggregates() {
return aggregates;
}

public void setAggregates(List<AggregateIntent> aggregates) {
this.aggregates = aggregates == null ? new ArrayList<>() : aggregates;
}

public List<PostIntent> getPosts() {
return posts;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>> aggregates = GlueIntentGenerator.buildAggregatesForTest(model);
assertEquals(1, aggregates.size());
Map<String, Object> 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<Map<String, String>> keys = (List<Map<String, String>>) a.get("keys");
assertEquals(2, keys.size());
assertEquals(Map.of("key", "Product"), keys.get(0));
assertEquals(Map.of("key", "Store"), keys.get(1));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading