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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ public void generate(IntentGenerationContext context) {
List<Map<String, Object>> outbound = buildOutbound(model, byName, compositionParents, settings, context);
List<Map<String, Object>> stepEvents = buildStepEvents(model, compositionParents, settings);
List<Map<String, Object>> rollups = buildRollups(model, byName, compositionParents, settings, context);
List<Map<String, Object>> expansions = buildExpansions(model, byName, compositionParents, settings);
ExpansionHandlers expansionHandlers = buildExpansions(model, byName, compositionParents, settings);
List<Map<String, Object>> expansions = expansionHandlers.regenerations();
List<Map<String, Object>> expansionCleanups = expansionHandlers.cleanups();
List<Map<String, Object>> settlements = buildSettlements(model, byName, compositionParents, settings, context);
List<Map<String, Object>> generates = buildGenerates(model, byName, compositionParents, settings, context);
List<Map<String, Object>> transitions = buildTransitions(model, byName, compositionParents, settings, context);
Expand Down Expand Up @@ -154,6 +156,7 @@ public void generate(IntentGenerationContext context) {
glue.put("stepEvents", stepEvents);
glue.put("rollups", rollups);
glue.put("expansions", expansions);
glue.put("expansionCleanups", expansionCleanups);
glue.put("settlements", settlements);
glue.put("generates", generates);
// The event-driven subset (issue #6711) - the SAME descriptors, filtered, so the listener and
Expand Down Expand Up @@ -2475,15 +2478,17 @@ private static Map<String, Object> rollupEntry(Map<String, Object> base, String
}

/**
* Period expansions: per expansion, two handlers - on the master's create and update events - that
* (re)generate the child rows for the span. Everything type-dependent (the defaults literals, the
* count write-back) is pre-rendered here as Java lines so the template stays shape-only; the child
* rows go through the child repository, so their create/delete events fire and downstream
* roll-ups/guards run exactly as for hand-entered rows.
* Period expansions: per expansion, three handlers - on the master's create and update events, that
* (re)generate the child rows for the span, and on its delete event, that removes them again.
* Everything type-dependent (the defaults literals, the count write-back) is pre-rendered here as
* Java lines so the template stays shape-only; the child rows go through the child repository, so
* their create/delete events fire and downstream roll-ups/guards run exactly as for hand-entered
* rows.
*/
private static List<Map<String, Object>> buildExpansions(IntentModel model, Map<String, EntityIntent> byName,
private static ExpansionHandlers buildExpansions(IntentModel model, Map<String, EntityIntent> byName,
Map<String, String> compositionParents, IntentSettings settings) {
List<Map<String, Object>> expansions = new ArrayList<>();
List<Map<String, Object>> cleanups = new ArrayList<>();
for (ExpansionIntent expansion : model.getExpansions()) {
if (expansion.getName() == null || expansion.getName()
.isBlank()) {
Expand Down Expand Up @@ -2558,8 +2563,21 @@ private static List<Map<String, Object>> buildExpansions(IntentModel model, Map<
String className = IntentNaming.pascalIdentifier(expansion.getName()) + "Expansion";
expansions.add(rollupEntry(base, className + "OnCreate", ""));
expansions.add(rollupEntry(base, className + "OnUpdate", "-updated"));
cleanups.add(rollupEntry(base, className + "OnDelete", "-deleted"));
}
return expansions;
return new ExpansionHandlers(expansions, cleanups);
}

/**
* The handlers an intent's expansions contribute, split by the template that renders them: the
* (re)generation pair per expansion, and the cleanup that removes the generated rows when their
* master is deleted. They are two collections rather than one because a template source renders
* once per collection entry, and the cleanup's body shares nothing with the regeneration's.
*
* @param regenerations the create/update handlers
* @param cleanups the master-delete handlers
*/
private record ExpansionHandlers(List<Map<String, Object>> regenerations, List<Map<String, Object>> cleanups) {
}

/** Pre-rendered Java assignment lines for the expansion's literal child defaults. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ class GlueGenerator {
/** The names of the collections this generator handles. */
private static final List<String> COLLECTIONS = List.of("triggers", "resolvers", "fieldLoaders", "assignees", "timerLoaders", "waits",
"aborts", "setters", "writers", "notifications", "schedules", "integrations", "inbound", "inboundMessages", "inboundFiles",
"outbound", "stepEvents", "rollups", "expansions", "settlements", "generates", "generateEvents", "transitions", "sends",
"posts", "aggregates", "postings", "printFeeders", "snapshots", "numbering", "resolves");
"outbound", "stepEvents", "rollups", "expansions", "expansionCleanups", "settlements", "generates", "generateEvents",
"transitions", "sends", "posts", "aggregates", "postings", "printFeeders", "snapshots", "numbering", "resolves");

/** The renderer. */
private final ModelTemplateRenderer renderer;
Expand Down Expand Up @@ -102,6 +102,7 @@ List<GeneratedFile> generate(String collection, GenerationTemplateMetadataSource
case "outbound" -> each(collection, source, content, model, parameters, GlueGenerator::bindOutbound);
case "stepEvents" -> each(collection, source, content, model, parameters, GlueGenerator::bindStepEvent);
case "expansions" -> each(collection, source, content, model, parameters, GlueGenerator::bindExpansion);
case "expansionCleanups" -> each(collection, source, content, model, parameters, GlueGenerator::bindExpansionCleanup);
case "settlements" -> each(collection, source, content, model, parameters, GlueGenerator::bindSettlement);
// Both collections carry the SAME create-from descriptors (generateEvents is the
// event-driven subset), so they share one binding - the listener and the create-from it
Expand Down Expand Up @@ -525,6 +526,22 @@ private static void bindExpansion(Map<String, Object> item, Map<String, Object>
context.put("topicSuffix", strOr(item, "topicSuffix", ""));
}

/**
* Binds an expansion cleanup - the handler that removes an expansion's generated rows when their
* master is deleted. It needs only the master's identity and the child set's criteria, so the span,
* unit, defaults and spread the regeneration binds are deliberately absent.
*
* @param item the descriptor
* @param context the template context
* @param parameters the generation parameters
*/
private static void bindExpansionCleanup(Map<String, Object> item, Map<String, Object> context, Map<String, Object> parameters) {
copy(context, item, "className", "masterEntity", "masterPerspective", "masterPk", "childEntity", "criteriaExpression");
context.put("javaMasterPerspective", sanitize(item, "masterPerspective"));
context.put("javaChildPerspective", sanitize(item, "childPerspective"));
context.put("topicSuffix", strOr(item, "topicSuffix", "-deleted"));
}

/**
* Binds an auto-settlement - the listener and delegate pair that applies a payment to an invoice
* through their junction.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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.${javaMasterPerspective}.${masterEntity}Entity;
import gen.${javaGenFolderName}.data.${javaChildPerspective}.${childEntity}Entity;
import gen.${javaGenFolderName}.data.${javaChildPerspective}.${childEntity}Repository;

/**
* Removes the generated ${childEntity} rows of a deleted ${masterEntity}.
*
* Generated from the intent expansions block - do not edit; it is re-generated with the application.
* The expansion OWNS the child set, so the master's delete has to take that set with it. A foreign
* key never becomes a database constraint on this platform - referential integrity is a
* business-layer check - so nothing else would stop the rows from outliving their master: they would
* survive as orphans pointing at an id that no longer exists and keep feeding the roll-ups, reports
* and balances the live rows fed. Rows are removed through the child repository, so each one's
* delete event fires and downstream roll-ups and guards run exactly as for a hand-deleted row.
*/
@Component("${javaGenFolderName}_${className}")
public class ${className} implements MessageHandler {

@Override
public String destination() {
return "${projectName}-${masterPerspective}-${masterEntity}${topicSuffix}";
}

@Override
public ListenerKind kind() {
return ListenerKind.TOPIC;
}

@Override
public void onMessage(String message) {
${masterEntity}Entity master = Json.parse(message, ${masterEntity}Entity.class);
if (master == null || master.${masterPk} == null) {
return;
}
// The delete event is published after the master row is gone, so re-delivery finds an empty
// child set and is a no-op - the handler is idempotent without a guard of its own.
${childEntity}Repository children = new ${childEntity}Repository();
for (${childEntity}Entity row : children.findAll(${criteriaExpression})) {
children.delete(row);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ export function getTemplate(parameters) {
engine: "velocity",
collection: "expansions"
},
{
location: "/template-application-events-java/events/ExpansionCleanup.java.template",
action: "generate",
rename: "gen/events/{{javaGenFolderName}}/{{className}}.java",
engine: "velocity",
collection: "expansionCleanups"
},
{
location: "/template-application-events-java/events/PrintFeeder.java.template",
action: "generate",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,28 @@ class IntentEmissionCoverageIT extends IntegrationTest {
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: note, type: string, length: 200 }

# expansions: the generated child set is OWNED by the expansion, so the master's DELETE
# has to take it with it (#6821). Nothing else would - a foreign key never becomes a
# database constraint on this platform - so the rows would otherwise outlive the record
# and keep counting. Asserted at runtime, both halves: the rows appear on create and are
# gone once the master is deleted.
- name: Retainer
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: note, type: string, length: 200 }
- { name: startDate, type: date, required: true }
- { name: endDate, type: date, required: true }
- { name: fee, type: decimal, required: true }
- { name: periods, type: integer, readOnly: true }

- name: RetainerPeriod
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: dueDate, type: date }
- { name: amount, type: decimal }
relations:
- { name: Retainer, kind: manyToOne, to: Retainer, composition: true, required: true }

# BPM events wave 2 (abortOn): an approval whose confirm task is cancelled the moment
# the record is voided via the CancelApproval transition (reusing the EntryStatus seeds:
# DRAFT 1 / CANCELLED 3). Closes the orphaned-Inbox-task hole.
Expand Down Expand Up @@ -718,6 +740,16 @@ class IntentEmissionCoverageIT extends IntegrationTest {
rollups:
- { name: claimCost, entity: ClaimLine, via: Claim, field: totalCost, op: sum, of: cost }

expansions:
- name: retainer-periods
from: Retainer
into: RetainerPeriod
unit: month
between: { start: startDate, end: endDate }
map: { dueDate: period }
spread: { total: fee, into: amount, round: 2 }
count: periods

# collection-driven generation: the monthly job creates one Claim per Person and,
# under each, one ClaimLine per working day of the month (amount defaulted).
schedules:
Expand Down Expand Up @@ -3322,9 +3354,50 @@ private void assertRuntimeEnforcement() {
assertManyToManyRuntime();
assertInboundSourcesRuntime();
assertOutboundDepartureRuntime();
assertExpansionLifecycleRuntime();
assertBpmEventsRuntime();
}

/**
* An expansion's generated rows, over their whole life (#6821): they appear when the master is
* created and they are gone once it is deleted. The delete half is the one that was missing - the
* construct bound create and update only, and because a foreign key never becomes a database
* constraint here, the rows simply survived as orphans still counted by every roll-up and report.
* Only the runtime shows it: the emitted handler can be present and still be subscribed to a topic
* nothing publishes to.
*/
private void assertExpansionLifecycleRuntime() {
String retainerApi = API + "/retainer/RetainerController";
String periodApi = API + "/retainer/RetainerPeriodController";
AtomicInteger retainerId = new AtomicInteger();
restAssuredExecutor.execute(() -> retainerId.set(given().contentType("application/json")
.body("{\"Note\":\"expanded\",\"StartDate\":\"2026-01-15\",\"EndDate\":\"2026-03-15\",\"Fee\":300}")
.when()
.post(retainerApi)
.then()
.statusCode(200)
.extract()
.path("Id")));
// A month span over three months yields a row per month, each carrying its share of the fee.
restAssuredExecutor.execute(() -> given().when()
.get(periodApi + "?Retainer=" + retainerId.get())
.then()
.statusCode(200)
.body("$", hasSize(3)),
30);

restAssuredExecutor.execute(() -> given().when()
.delete(retainerApi + "/" + retainerId.get())
.then()
.statusCode(200));
restAssuredExecutor.execute(() -> given().when()
.get(periodApi + "?Retainer=" + retainerId.get())
.then()
.statusCode(200)
.body("$", hasSize(0)),
30);
}

/**
* The non-HTTP inbound arrivals end to end (#6537): a JSON record sent to the declared queue, and
* one dropped as a file into the polled folder, both turn into rows through the entity's own
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2139,11 +2139,14 @@ void expansion_generates_the_span_handlers_and_the_status_badge_stack() {
.then()
.statusCode(200));

// The glue carries the two per-event expansion handlers with the pre-rendered Java pieces.
// The glue carries the per-event expansion handlers with the pre-rendered Java pieces - the
// (re)generation pair plus the cleanup that takes the generated rows down with their master.
String glue = contentOf("loans.glue");
assertTrue(glue.contains("\"expansions\""), "the .glue should carry the expansions collection");
assertTrue(glue.contains("InstallmentsExpansionOnCreate"), "an OnCreate handler entry is expected");
assertTrue(glue.contains("InstallmentsExpansionOnUpdate"), "an OnUpdate handler entry is expected");
assertTrue(glue.contains("\"expansionCleanups\""), "the .glue should carry the expansionCleanups collection");
assertTrue(glue.contains("InstallmentsExpansionOnDelete"), "an OnDelete cleanup entry is expected");

// The EntityStatus relation lands as the DOCUMENT_STATUS widget on a NON-document entity.
String model = contentOf("loans.model");
Expand All @@ -2165,6 +2168,19 @@ void expansion_generates_the_span_handlers_and_the_status_badge_stack() {
String onUpdate = contentOf("gen/events/loans/InstallmentsExpansionOnUpdate.java");
assertTrue(onUpdate.contains("intent-test-Loan-Loan-updated\""), "the OnUpdate handler binds the -updated topic");

// The master's delete removes the rows the expansion generated. Nothing else would: a foreign
// key never becomes a database constraint, so the rows would otherwise outlive their master as
// orphans and keep feeding the roll-ups and reports. They go through the child repository, so
// each row's delete event still fires.
String onDelete = contentOf("gen/events/loans/InstallmentsExpansionOnDelete.java");
assertTrue(onDelete.contains("intent-test-Loan-Loan-deleted\""), "the OnDelete handler binds the -deleted topic");
assertTrue(onDelete.contains("LoanInstallmentRepository children = new LoanInstallmentRepository()"),
"the cleanup must delete through the child repository so the per-row delete events fire");
assertTrue(onDelete.contains("Criteria.create().eq(\"Loan\", master.Id)"), "the cleanup must scope to the master's own rows");
assertTrue(onDelete.contains("children.delete(row)"), "the cleanup must delete every generated row");
assertFalse(onDelete.contains("updateProperty"), "the cleanup must not write back to the master - the master row is gone");
assertFalse(onDelete.contains("${"), "the cleanup template must render every placeholder");

// Harmonia UI: the status renders as the title-bar badge (not an editable input) and the
// calculated field previews live via the calc evaluator with the date functions.
generateFromModel("template-application-ui-harmonia-java/template/template.js", "loans.model");
Expand Down
Loading