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
4 changes: 3 additions & 1 deletion components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,9 @@ expansions:
count: periods
```

A span change replaces the generated child set; never mix hand-entered rows into an expanded child.
A span change is reconciled as a diff - the missing periods are inserted, the ones that fell out of
the span are deleted, every other row is left alone; never mix hand-entered rows into an expanded
child.

## rollups - denormalised parent totals

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public void generate(IntentGenerationContext context) {
List<Map<String, Object>> stepEvents = buildStepEvents(model, compositionParents, settings);
List<Map<String, Object>> rollups = buildRollups(model, byName, compositionParents, settings, context);
ExpansionHandlers expansionHandlers = buildExpansions(model, byName, compositionParents, settings);
List<Map<String, Object>> expansions = expansionHandlers.regenerations();
List<Map<String, Object>> expansions = expansionHandlers.reconciliations();
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);
Expand Down Expand Up @@ -1150,6 +1150,12 @@ static List<Map<String, Object>> buildSettersForTest(IntentModel model) {
return buildSetters(model, IntentSettings.parse("{}"));
}

/** Test hook: build the {@code expansions} glue collection without a repository. */
static List<Map<String, Object>> buildExpansionsForTest(IntentModel model) {
return buildExpansions(model, IntentEntities.byName(model), IntentEntities.compositionParents(model),
IntentSettings.parse("{}")).reconciliations();
}

/** Test hook: build the {@code rollups} glue collection without a repository. */
static List<Map<String, Object>> buildRollupsForTest(IntentModel model) {
return buildRollups(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}"),
Expand Down Expand Up @@ -2495,11 +2501,11 @@ private static Map<String, Object> rollupEntry(Map<String, Object> base, String

/**
* 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.
* reconcile the child rows for the span as a diff (insert the missing periods, delete the ones that
* fell out of it, keep the rest), 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 ExpansionHandlers buildExpansions(IntentModel model, Map<String, EntityIntent> byName,
Map<String, String> compositionParents, IntentSettings settings) {
Expand Down Expand Up @@ -2549,6 +2555,9 @@ private static ExpansionHandlers buildExpansions(IntentModel model, Map<String,
base.put("masterPk", IntentEntities.keyFieldName(master));
base.put("childEntity", expansion.getInto());
base.put("childPerspective", IntentEntities.resolvePerspective(expansion.getInto(), compositionParents, model));
// The child's key: the reconciliation keeps the rows whose period survives a span change and
// re-spreads their share by id, so it needs the primary key property to address them.
base.put("childPk", IntentEntities.keyFieldName(child));
base.put("fkProperty", fkProperty);
base.put("startProperty", IntentNaming.pascalCase(expansion.getBetween()
.getStart()));
Expand Down Expand Up @@ -2586,14 +2595,14 @@ private static ExpansionHandlers buildExpansions(IntentModel model, Map<String,

/**
* 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
* reconciliation 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.
* once per collection entry, and the cleanup's body shares nothing with the reconciliation's.
*
* @param regenerations the create/update handlers
* @param reconciliations the create/update handlers
* @param cleanups the master-delete handlers
*/
private record ExpansionHandlers(List<Map<String, Object>> regenerations, List<Map<String, Object>> cleanups) {
private record ExpansionHandlers(List<Map<String, Object>> reconciliations, 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 @@ -34,10 +34,11 @@
* </pre>
*
* The generator emits client-Java {@code MessageHandler}s on the master's create and update events
* that (re)generate the child set through the child's repository - so the per-row create/delete
* events fire and any roll-ups or capacity guards on the child run as for hand-entered rows. The
* expansion OWNS the child set: a regeneration replaces every row pointing at the master. It is
* idempotent (an unchanged span is detected and skipped), which also bounds the event cascade.
* that reconcile the child set through the child's repository - so the per-row create/delete events
* fire and any roll-ups or capacity guards on the child run as for hand-entered rows. The expansion
* OWNS the child set, and reconciles it as a DIFF: the missing periods are inserted, the rows that
* fell out of the span are deleted, and the rest are left untouched. It is idempotent (an unchanged
* span is detected and skipped), which also bounds the event cascade.
*/
public class ExpansionIntent {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2611,11 +2611,14 @@ expansions:
defaults: { days: 1 } # literal child field defaults
```

Semantics: two generated handlers ((re)generate on the master's create AND update events) own the
child set - a span change REPLACES every child row pointing at the master, so never mix hand-entered
rows into an expanded child. Rows are written through the child repository (create/delete events
Semantics: two generated handlers (reconcile on the master's create AND update events) own the
child set - a span change is applied as a DIFF: the periods that are missing are inserted, the rows
that fell out of the span are deleted, and every row whose period survives is kept (with whatever was
edited on it). Never mix hand-entered rows into an expanded child - a row on a period the span does
not cover is deleted as stale. Rows are written through the child repository (create/delete events
fire; roll-ups and capacity guards run as for hand-entered rows). With `spread`, the last row absorbs
the rounding remainder so the shares always sum to the total. The `count` write-back and the
the rounding remainder so the shares always sum to the total, and a kept row's share is re-spread
when the row count changes. The `count` write-back and the
regeneration are idempotent and event-safe (no cascades). All span/map fields must be `date` typed;
`spread`/`count` fields numeric.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 expansions} glue the {@link GlueIntentGenerator} emits: one descriptor per
* master event, carrying the coordinates the handler needs to RECONCILE the child set rather than
* rebuild it (dirigible #6817).
*
* <p>
* The child primary key is what makes the reconciliation possible: a row whose period survives the
* span change is addressed by id - re-spread in place - instead of being deleted and recreated. A
* descriptor missing it would emit {@code child.} with no member and take the whole client-Java
* registry down with it, so it is asserted here and not only end-to-end.
*/
class GlueExpansionsTest {

private static final String YAML = """
name: loans
entities:
- name: Loan
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: startDate, type: date }
- { name: endDate, type: date }
- { name: principal, type: decimal }
- { name: periods, type: integer }
- name: LoanInstallment
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: dueDate, type: date }
- { name: amount, type: decimal }
relations:
- { name: Loan, kind: manyToOne, to: Loan, composition: true, required: true }
expansions:
- name: installments
from: Loan
into: LoanInstallment
unit: month
between: { start: startDate, end: endDate }
map: { dueDate: period }
spread: { total: principal, into: amount, round: 2 }
count: periods
""";

@Test
void emitsOneHandlerPerMasterEventWithTheReconciliationCoordinates() {
IntentModel model = IntentParser.parse(YAML);
List<Map<String, Object>> expansions = GlueIntentGenerator.buildExpansionsForTest(model);

assertEquals(2, expansions.size(), "one handler on the master's create event and one on its update event");
assertEquals("InstallmentsExpansionOnCreate", expansions.get(0)
.get("className"));
assertEquals("", expansions.get(0)
.get("topicSuffix"));
assertEquals("InstallmentsExpansionOnUpdate", expansions.get(1)
.get("className"));
assertEquals("-updated", expansions.get(1)
.get("topicSuffix"));

Map<String, Object> onCreate = expansions.get(0);
assertEquals("Loan", onCreate.get("masterEntity"));
assertEquals("Id", onCreate.get("masterPk"));
assertEquals("LoanInstallment", onCreate.get("childEntity"));
assertEquals("Id", onCreate.get("childPk"), "the child key addresses a kept row for the in-place re-spread");
assertEquals("Loan", onCreate.get("fkProperty"));
assertEquals("DueDate", onCreate.get("mapProperty"));
assertEquals("Principal", onCreate.get("spreadTotalProperty"));
assertEquals("Amount", onCreate.get("spreadIntoProperty"));
assertEquals("Periods", onCreate.get("countProperty"));
assertEquals("Integer.valueOf(periods.size())", onCreate.get("countValue"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -530,16 +530,16 @@ private static void bindStepEvent(Map<String, Object> item, Map<String, Object>
}

/**
* Binds a period expansion - the handler that regenerates a master's child rows across a date span.
* Binds a period expansion - the handler that reconciles a master's child rows across a date span.
* Only the period step is derived here; the type-dependent pieces arrive pre-rendered.
*
* @param item the descriptor
* @param context the template context
* @param parameters the generation parameters
*/
private static void bindExpansion(Map<String, Object> item, Map<String, Object> context, Map<String, Object> parameters) {
copy(context, item, "className", "masterEntity", "masterPerspective", "masterPk", "childEntity", "fkProperty", "startProperty",
"endProperty", "mapProperty", "unit", "criteriaExpression");
copy(context, item, "className", "masterEntity", "masterPerspective", "masterPk", "childEntity", "childPk", "fkProperty",
"startProperty", "endProperty", "mapProperty", "unit", "criteriaExpression");
context.put("javaMasterPerspective", sanitize(item, "masterPerspective"));
context.put("javaChildPerspective", sanitize(item, "childPerspective"));
String unit = str(item, "unit");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ import gen.${javaGenFolderName}.data.${javaChildPerspective}.${childEntity}Repos
* rows - one per ${unit}.
*
* Generated from the intent expansions block - do not edit; it is re-generated with the application.
* The expansion OWNS the child set: a span change replaces every ${childEntity} pointing at the
* master. Rows are written through the child repository, so their create/delete events fire and any
* roll-ups or capacity guards run exactly as for hand-entered rows. An unchanged span is detected
* and skipped, which both makes re-delivery idempotent and bounds the event cascade (a roll-up's
* write-back to the master re-triggers this handler once; the skip terminates it).
* The expansion OWNS the child set, and reconciles it as a DIFF: a span change inserts the periods
* that are missing and deletes the ones that fell out of the span, leaving every other
* ${childEntity} untouched. Rows are written through the child repository, so their create/delete
* events fire and any roll-ups or capacity guards run exactly as for hand-entered rows. An unchanged
* span is detected and skipped, which both makes re-delivery idempotent and bounds the event cascade
* (a roll-up's write-back to the master re-triggers this handler once; the skip terminates it).
*/
@Component("${javaGenFolderName}_${className}")
public class ${className} implements MessageHandler {
Expand Down Expand Up @@ -86,26 +87,66 @@ public class ${className} implements MessageHandler {
}
}

// Diff, do not rebuild: the rows whose period survives the span change are KEPT. Deleting the
// whole set first and recreating it opened a destructive window - a client-Java handler has no
// transaction boundary, so every delete and every insert commits on its own and a failure
// partway through the recreation left the deletes committed with only some of the inserts
// applied: rows gone for good, a stale count, and roll-ups that consumed a shrink which never
// really happened. A diff touches only what changed, so a failure can leave the set incomplete
// - repaired on the next delivery - but can no longer destroy a row the span still wants.
java.util.Set<java.time.LocalDate> wanted = new java.util.HashSet<>(periods);
java.util.Map<java.time.LocalDate, ${childEntity}Entity> kept = new java.util.HashMap<>();
int deleted = 0;
for (${childEntity}Entity row : existing) {
children.delete(row);
// Out of the new span, undated, or a second row on a period already kept - the last case is
// the repair path for a set an earlier partial run left inconsistent.
if (row.${mapProperty} == null || !wanted.contains(row.${mapProperty})
|| kept.putIfAbsent(row.${mapProperty}, row) != null) {
children.delete(row);
deleted++;
}
}
#if($spreadTotalProperty != "")
java.math.BigDecimal total = master.${spreadTotalProperty} == null ? java.math.BigDecimal.ZERO : master.${spreadTotalProperty};
java.math.BigDecimal share = periods.isEmpty() ? java.math.BigDecimal.ZERO
: total.divide(java.math.BigDecimal.valueOf(periods.size()), ${spreadRound}, java.math.RoundingMode.HALF_UP);
int reshared = 0;
#end
int created = 0;
for (int i = 0; i < periods.size(); i++) {
${childEntity}Entity child = new ${childEntity}Entity();
child.${fkProperty} = master.${masterPk};
child.${mapProperty} = periods.get(i);
${defaultsBlock}#if($spreadTotalProperty != "")
#if($spreadTotalProperty != "")
// The last row absorbs the rounding remainder so the shares always add up to the total.
child.${spreadIntoProperty} = i == periods.size() - 1
java.math.BigDecimal periodShare = i == periods.size() - 1
? total.subtract(share.multiply(java.math.BigDecimal.valueOf(periods.size() - 1)))
: share;
#end
${childEntity}Entity child = kept.get(periods.get(i));
if (child != null) {
#if($spreadTotalProperty != "")
// A kept row keeps its identity and whatever was edited on it, but the share is DERIVED
// from the master's total and the row count - re-spread it when it no longer matches, as
// a targeted single-column write that still publishes "-updated" so a roll-up over these
// rows recomputes exactly as it did when the row was deleted and recreated.
if (child.${spreadIntoProperty} == null || child.${spreadIntoProperty}.compareTo(periodShare) != 0) {
java.util.Map<String, Object> reshare = new java.util.LinkedHashMap<>();
reshare.put("${spreadIntoProperty}", periodShare);
children.updateDerived(child.${childPk}, reshare);
reshared++;
}
#end
continue;
}
child = new ${childEntity}Entity();
child.${fkProperty} = master.${masterPk};
child.${mapProperty} = periods.get(i);
${defaultsBlock}#if($spreadTotalProperty != "")
child.${spreadIntoProperty} = periodShare;
#end
children.save(child);
created++;
}
LOG.debug("Expansion ${className}: ${masterEntity} [{}] spans [{}] ${unit}(s) - created [{}], deleted [{}]#if($spreadTotalProperty != ""), re-spread [{}]#end",
master.${masterPk}, periods.size(), created, deleted#if($spreadTotalProperty != ""), reshared#end);
#if($countProperty != "")

// Write the generated row count back to the master as a TARGETED single-column write - only
Expand Down
Loading
Loading