From 6d7741b65b73782e01648605bc062e8b782ae5ab Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 19 Aug 2026 13:54:40 +0300 Subject: [PATCH] fix(intent): an expansion reconciles its child set instead of rebuilding it Closes #6817. The generated expansions handler regenerated a master's child rows by deleting every existing row and then recreating the set. A client-Java handler has no transaction boundary - each delete and each save commits on its own - so a failure partway through the recreation left the deletes committed with only some of the inserts applied: rows gone for good, a stale count column, and roll-ups that had consumed a shrink which never really happened. Nothing read as inconsistent, because no individual write failed. The handler now diffs: a period with no row gains one, a row whose period fell out of the span is deleted, and a row the span still covers is kept - same row, same identifier, with whatever was edited on it. A second row on an already kept period is deleted, which is what makes a set left inconsistent by an earlier partial run converge. A failure can now leave the set incomplete, never destroy a row the span still wants; and it is cheaper, since most span edits move an edge. With spread, a kept row's share is derived from the master's total and the row count, so it is re-spread through updateDerived - the targeted write that still publishes "-updated", so a roll-up over these rows recomputes exactly as it did when the row was deleted and recreated. Asserted on both layers: IntentEngineIT on the generated source, and IntentEmissionCoverageIT at runtime - an edited span must answer with the same row identifiers for the days it still covers, re-spread shares and a count that followed the new row count. Co-Authored-By: Claude Opus 5 --- components/engine/engine-intent/README.md | 4 +- .../intent/generator/GlueIntentGenerator.java | 29 +++-- .../intent/model/ExpansionIntent.java | 9 +- .../main/resources/intent-assistant-guide.md | 11 +- .../intent/generator/GlueExpansionsTest.java | 89 +++++++++++++ .../template/service/model/GlueGenerator.java | 6 +- .../events/Expansion.java.template | 63 +++++++-- .../tests/api/IntentEmissionCoverageIT.java | 120 ++++++++++++++++++ .../integration/tests/api/IntentEngineIT.java | 13 +- 9 files changed, 310 insertions(+), 34 deletions(-) create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueExpansionsTest.java diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 19f45c4052f..56761fdbd8b 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -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 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 105a9d66a71..fee3df52954 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 @@ -108,7 +108,7 @@ public void generate(IntentGenerationContext context) { List> stepEvents = buildStepEvents(model, compositionParents, settings); List> rollups = buildRollups(model, byName, compositionParents, settings, context); ExpansionHandlers expansionHandlers = buildExpansions(model, byName, compositionParents, settings); - List> expansions = expansionHandlers.regenerations(); + List> expansions = expansionHandlers.reconciliations(); List> expansionCleanups = expansionHandlers.cleanups(); List> settlements = buildSettlements(model, byName, compositionParents, settings, context); List> generates = buildGenerates(model, byName, compositionParents, settings, context); @@ -1150,6 +1150,12 @@ static List> buildSettersForTest(IntentModel model) { return buildSetters(model, IntentSettings.parse("{}")); } + /** Test hook: build the {@code expansions} glue collection without a repository. */ + static List> 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> buildRollupsForTest(IntentModel model) { return buildRollups(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}"), @@ -2495,11 +2501,11 @@ private static Map rollupEntry(Map 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 byName, Map compositionParents, IntentSettings settings) { @@ -2549,6 +2555,9 @@ private static ExpansionHandlers buildExpansions(IntentModel model, Map> regenerations, List> cleanups) { + private record ExpansionHandlers(List> reconciliations, List> cleanups) { } /** Pre-rendered Java assignment lines for the expansion's literal child defaults. */ diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ExpansionIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ExpansionIntent.java index 7064d49c65a..33644e0d71d 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ExpansionIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ExpansionIntent.java @@ -34,10 +34,11 @@ * * * 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 { diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index b7f4ec7febc..85ee2b367a5 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -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. diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueExpansionsTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueExpansionsTest.java new file mode 100644 index 00000000000..c06d9eca56d --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueExpansionsTest.java @@ -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). + * + *

+ * 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> 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 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")); + } +} diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java index dcf01ae9212..32b96fdfb5a 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java @@ -530,7 +530,7 @@ private static void bindStepEvent(Map item, Map } /** - * 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 @@ -538,8 +538,8 @@ private static void bindStepEvent(Map item, Map * @param parameters the generation parameters */ private static void bindExpansion(Map item, Map context, Map 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"); diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Expansion.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Expansion.java.template index e0613823f3a..1d5bf3f57dc 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Expansion.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Expansion.java.template @@ -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 { @@ -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 wanted = new java.util.HashSet<>(periods); + java.util.Map 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 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 diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 8388555a8da..1c599423543 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -11,6 +11,7 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.everyItem; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasSize; @@ -29,6 +30,7 @@ import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -745,6 +747,25 @@ class IntentEmissionCoverageIT extends IntegrationTest { relations: - { name: Shipment, kind: manyToOne, to: Shipment } + # expansions: the stay's from-to span generates one night row per day and spreads the + # total across them. Asserted at RUNTIME because what the reconciliation promises is a + # property of the rows, not of the source: an edited span keeps the rows whose day + # survives it (dirigible #6817). + - name: Stay + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: fromDate, type: date } + - { name: toDate, type: date } + - { name: total, type: decimal } + - { name: nights, type: integer, readOnly: true } + - name: StayNight + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: day, type: date } + - { name: amount, type: decimal } + relations: + - { name: Stay, kind: manyToOne, to: Stay, composition: true, required: true } + aggregates: - name: ledgerTotal of: Ledger @@ -777,6 +798,13 @@ class IntentEmissionCoverageIT extends IntegrationTest { map: { dueDate: period } spread: { total: fee, into: amount, round: 2 } count: periods + - name: nights + from: Stay + into: StayNight + between: { start: fromDate, end: toDate } + map: { day: period } + spread: { total: total, into: amount, round: 2 } + count: nights # collection-driven generation: the monthly job creates one Claim per Person and, # under each, one ClaimLine per working day of the month (amount defaulted). @@ -3812,6 +3840,98 @@ private void assertManyToManyRuntime() { .statusCode(200) .body("$", hasSize(1)) .body("[0].Tag", equalTo(tagId.get()))); + + assertExpansionReconcilesTheChildSet(); + } + + /** + * The expansion RECONCILES its child set instead of rebuilding it (dirigible #6817). A client-Java + * handler has no transaction boundary - every delete and every insert commits on its own - so + * wiping the whole set before recreating it meant a failure partway through the recreation had + * already committed the deletes: rows lost for good, with a stale count and roll-ups that consumed + * a shrink which never happened. + * + *

+ * The observable promise of the diff is a property of the ROWS, which is why it is asserted here + * and not only on the generated source: a day that survives an edited span keeps its identity, so + * whatever referenced that night row still references the same row. The dropped day goes, the new + * days arrive, and every kept row's share is re-spread for the new row count. + */ + private void assertExpansionReconcilesTheChildSet() { + AtomicInteger stayId = new AtomicInteger(); + restAssuredExecutor.execute(() -> stayId.set(given().contentType("application/json") + .body("{\"FromDate\":\"2026-03-10\",\"ToDate\":\"2026-03-12\",\"Total\":300}") + .when() + .post(API + "/stay/StayController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + // The expansion runs off the master's create event, so the rows arrive asynchronously. + AtomicReference> before = new AtomicReference<>(); + restAssuredExecutor.execute(() -> { + Map nights = stayNights(stayId.get()); + assertEquals(3, nights.size(), "a three-day span expands into one night row per day, got: " + nights); + before.set(nights); + }, 60); + + // The span moves one day forward and two days out: 10 falls out, 11 and 12 survive, 13 and 14 + // are new. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + stayId.get() + + ",\"FromDate\":\"2026-03-11\",\"ToDate\":\"2026-03-14\",\"Total\":300}") + .when() + .put(API + "/stay/StayController/" + stayId.get()) + .then() + .statusCode(200)); + restAssuredExecutor.execute(() -> { + Map after = stayNights(stayId.get()); + assertEquals(4, after.size(), "the edited span expands into four night rows, got: " + after); + // The surviving days KEPT their rows - the whole point of the diff. Two of the three + // original days are still in the span, and both must answer with the identifier they had. + int survivors = 0; + for (Map.Entry night : before.get() + .entrySet()) { + if (after.containsKey(night.getKey())) { + survivors++; + assertEquals(night.getValue(), after.get(night.getKey()), + "a day the edited span still covers must keep its row, day " + night.getKey()); + } + } + assertEquals(2, survivors, "two of the three original days stay in the span, got before " + before.get() + " after " + after); + }, 60); + + // A kept row's share is DERIVED from the total and the row count, so it is re-spread rather + // than left at the value the old count produced (300 over four nights, not three). + restAssuredExecutor.execute(() -> given().when() + .get(API + "/stay/StayNightController?Stay=" + stayId.get()) + .then() + .statusCode(200) + .body("Amount", everyItem(equalTo(75.0F))), + 60); + // ... and the count write-back followed the new row count. + restAssuredExecutor.execute(() -> given().when() + .get(API + "/stay/StayController/" + stayId.get()) + .then() + .statusCode(200) + .body("Nights", equalTo(4)), + 60); + } + + /** The stay's night rows as day (as serialized) to row identifier. */ + private Map stayNights(int stayId) { + List> rows = given().when() + .get(API + "/stay/StayNightController?Stay=" + stayId) + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("$"); + Map nights = new LinkedHashMap<>(); + for (Map row : rows) { + nights.put(String.valueOf(row.get("Day")), (Integer) row.get("Id")); + } + return nights; } /** diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 9ebc18547a4..92900a3f113 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -2235,7 +2235,7 @@ void expansion_generates_the_span_handlers_and_the_status_badge_stack() { .statusCode(200)); // 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. + // reconciliation 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"); @@ -2256,6 +2256,17 @@ void expansion_generates_the_span_handlers_and_the_status_badge_stack() { assertTrue(onCreate.contains("intent-test-Loan-Loan\""), "the OnCreate handler binds the master's create topic"); assertTrue(onCreate.contains("d.plusMonths(1)"), "unit month steps by month"); assertTrue(onCreate.contains("total.subtract(share.multiply("), "the last row absorbs the rounding remainder"); + + // The child set is RECONCILED, not rebuilt (#6817): a handler has no transaction boundary, so + // wiping every row before recreating the set meant a failure partway through committed the + // deletes and only some of the inserts - rows destroyed for good. Only the rows that fell out + // of the span may be deleted, the rows whose period survives are kept and re-spread in place. + assertTrue(onCreate.contains("kept.putIfAbsent(row.DueDate, row)"), "a row whose period survives the span change must be kept"); + assertTrue(onCreate.contains("!wanted.contains(row.DueDate)"), "only a row outside the new span may be deleted"); + assertTrue(onCreate.contains("children.updateDerived(child.Id, reshare)"), + "a kept row's share is re-spread in place, as a targeted write that still publishes -updated for the roll-ups"); + assertFalse(onCreate.matches("(?s).*for \\(LoanInstallmentEntity row : existing\\) \\{\\s*children\\.delete\\(row\\);.*"), + "the unconditional wipe of the whole child set must be gone"); assertTrue(onCreate.contains("new LoanRepository().updateProperty(master.Id, \"Periods\", Integer.valueOf(periods.size()))"), "the count write-back must be a targeted single-column updateProperty"); assertFalse(onCreate.contains("updateWithoutEvent"),