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 bab82f2de8d..da39c04adcf 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 @@ -692,6 +692,17 @@ field may declare: when the document is saved for all the lines that break it - not once per line. `{count}` in the message is replaced with the number of lines. Always a warning; a hard per-line rule is a `compare` on the items entity. + **A check may read a calculated field (#7544).** The row-level checks - refusing and `severity: + warn` alike - judge the record as the write will store it: a field's `calculatedOnCreate` / + `calculatedOnUpdate` expression is evaluated first (on an update, over the stored values of the + system-owned fields such as a roll-up's `paid`), so a client never has to send the computed value. + "Warn when an invoice is issued more than 5 days after its tax event" is a calculated delay plus a + soft compare on it: + `{ name: issueDelayDays, type: integer, calculatedOnCreate: "daysBetween(TaxEventDate, Date)", + calculatedOnUpdate: "daysBetween(TaxEventDate, Date)" }` and + `{ kind: compare, field: issueDelayDays, op: le, value: 5, severity: warn, message: "..." }`. Only + the neutral expressions are evaluated for the checks; a `calculatedAction*` runs on the write + itself, so a check on an action-calculated field still reads the value it was sent. **Translating check messages (#7611).** A check message is translated exactly like a label: write it ONCE, in the module's default language, and it reaches every reader in their own. The generator writes each message into the module's en-US catalog (`i18n/en-US/.model.json`) under diff --git a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template index 74159696c23..459a6afab89 100644 --- a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template +++ b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template @@ -27,6 +27,14 @@ package gen.${javaGenFolderName}.data.${javaPerspectiveName}; #if($property.isCalculatedProperty && ($property.calculatedActionOnCreate || $property.calculatedActionOnUpdate)) #set($haveCalculatedPropertyAction = "true") #end +## The expression-calculated fields the checks' candidate row computes (#7544) - an action wins over an +## expression on the same field, exactly as in save() / update(), and an action is never run there. + #if($property.isCalculatedProperty && $property.calculatedPropertyExpressionCreate && !$property.calculatedActionOnCreate) + #set($haveCalculatedExpressionOnCreate = "true") + #end + #if($property.isCalculatedProperty && $property.calculatedPropertyExpressionUpdate && !$property.calculatedActionOnUpdate) + #set($haveCalculatedExpressionOnUpdate = "true") + #end #if($property.widgetType == "MULTISELECT") #set($haveSubsets = "true") #end @@ -1283,6 +1291,80 @@ public class ${name}Repository extends JavaRepository<${name}Entity>#if($hasTarg #end } #end + + /** + * The record this create would store, as far as its calculated fields go - the row the controller's + * checks read before the write (#7544). The calculated expressions are assigned inside save(), so a + * check reading the submitted record saw whatever the caller sent for a calculated field (usually + * nothing, or the value of a previous save) and a soft or hard check on one never fired for a REST + * write. The defaults the create applies come first, as in save(), since an expression may read + * them. Only the neutral expressions are evaluated: a calculated ACTION may mint or mutate state, + * and runs on the write path alone. The submitted record itself is not touched - what is persisted + * is still computed by save(). + * + * @param submitted the record about to be created + * @return a copy carrying the computed values, or the record itself when nothing is calculated + */ + public ${name}Entity calculatedForCreate(${name}Entity submitted) { +#if($haveCalculatedExpressionOnCreate) + ${name}Entity entity = copyOf(submitted); +#applyDefaults() +#foreach ($property in $properties) +#if($property.isCalculatedProperty && $property.calculatedPropertyExpressionCreate && !$property.calculatedActionOnCreate) +#calcAssign($property $property.calculatedPropertyExpressionCreate) +#end +#end + return entity; +#else + return submitted; +#end + } + + /** + * The record this update would store, as far as its calculated fields go - the row the controller's + * checks read before the write (#7544). Built as update() builds it: the system-owned fields taken + * from the stored row, the document totals recomputed, then the update-time expressions - so an + * expression such as Balance = Total - Paid reads the stored Paid, never the payload's. Only the + * neutral expressions are evaluated (an action runs on the write path alone), and the submitted + * record itself is not touched. + * + * @param submitted the record about to be updated, carrying its id + * @return a copy carrying the computed values, or the record itself when nothing is calculated + */ + public ${name}Entity calculatedForUpdate(${name}Entity submitted) { +#if($haveCalculatedExpressionOnUpdate) + ${name}Entity entity = copyOf(submitted); +#if($preservedOnUpdate.size() > 0) + ${name}Entity stored = entity.${pkPropertyName} == null ? null : findById(entity.${pkPropertyName}); + if (stored != null) { +#foreach($property in $preservedOnUpdate) + entity.${property.name} = stored.${property.name}; +#end + } +#end +#if($documentMaster) + recalculate(entity); +#end +#foreach ($property in $properties) +#if($property.isCalculatedProperty && $property.calculatedPropertyExpressionUpdate && !$property.calculatedActionOnUpdate) +#calcAssign($property $property.calculatedPropertyExpressionUpdate) +#end +#end + return entity; +#else + return submitted; +#end + } +#if($haveCalculatedExpressionOnCreate || $haveCalculatedExpressionOnUpdate) + + private static ${name}Entity copyOf(${name}Entity source) { + ${name}Entity copy = new ${name}Entity(); +#foreach ($property in $properties) + copy.${property.name} = source.${property.name}; +#end + return copy; + } +#end #if($warningChecks && $warningChecks.size() > 0) /** diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template index bf29253a389..d741018779d 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template @@ -256,14 +256,17 @@ public class ${name}Controller { #if($workflowStatusProperty) requireWorkflowStatusOnCreate(entity); #end - validate(entity); + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + ${name}Entity checked = repository.calculatedForCreate(entity); + validate(checked); #if($hasReferenceValidations) - validateReferences(entity); + validateReferences(checked); #end #if($warningChecks && $warningChecks.size() > 0) // The soft checks (severity: warn, #7466): asked of the person saving - a 428 listing them // until the request confirms their codes - and only then persisted. - org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(entity)); + org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(checked)); #end #if($systemOwnedOnCreate.size() > 0) // After every check above, so what a create refuses is unchanged; before the save, so the @@ -362,14 +365,17 @@ public class ${name}Controller { ${name}Entity existing = repository.findOne(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "${name} not found")); mergeWritable(existing, entity); - validate(existing); + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + ${name}Entity checked = repository.calculatedForUpdate(existing); + validate(checked); #if($hasReferenceValidations) - validateReferences(existing); + validateReferences(checked); #end #if($warningChecks && $warningChecks.size() > 0) // The soft checks (severity: warn, #7466): asked of the person saving - a 428 listing them // until the request confirms their codes - and only then persisted. - org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(existing)); + org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(checked)); #end #if($hasDuplicateKeys) try { @@ -382,14 +388,17 @@ public class ${name}Controller { #end #else entity.#foreach($property in $properties)#if($property.dataPrimaryKey)${property.name}#end#end = id; - validate(entity); + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + ${name}Entity checked = repository.calculatedForUpdate(entity); + validate(checked); #if($hasReferenceValidations) - validateReferences(entity); + validateReferences(checked); #end #if($warningChecks && $warningChecks.size() > 0) // The soft checks (severity: warn, #7466): asked of the person saving - a 428 listing them // until the request confirms their codes - and only then persisted. - org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(entity)); + org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(checked)); #end #if($hasDuplicateKeys) try { diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template index 7c1dc372154..5837b6aa73b 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template @@ -253,14 +253,17 @@ public class ${name}MyController { // sensitive / role-scoped fields are cleared, so validating earlier would judge a payload the // server has not finished deciding. Before the save, so a refused create never reaches the // insert - and never allocates a document number. - validate(entity); + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + ${name}Entity checked = repository.calculatedForCreate(entity); + validate(checked); #if($hasReferenceValidations) - validateReferences(entity); + validateReferences(checked); #end #if($warningChecks && $warningChecks.size() > 0) // The soft checks (severity: warn, #7466): asked of the person saving - a 428 listing them // until the request confirms their codes - and only then persisted. - org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(entity)); + org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(checked)); #end #if($systemOwnedOnCreate.size() > 0) // After every check above, so what a create refuses is unchanged; before the save, so the @@ -314,14 +317,17 @@ public class ${name}MyController { } #end #end - validate(entity); + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + ${name}Entity checked = repository.calculatedForUpdate(entity); + validate(checked); #if($hasReferenceValidations) - validateReferences(entity); + validateReferences(checked); #end #if($warningChecks && $warningChecks.size() > 0) // The soft checks (severity: warn, #7466): asked of the person saving - a 428 listing them // until the request confirms their codes - and only then persisted. - org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(entity)); + org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(checked)); #end #if($hasDuplicateKeys) try { diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template index d3562bf3c82..8ee1506e897 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template @@ -218,14 +218,17 @@ public class ${name}PartnerController { // sensitive / role-scoped fields are cleared, so validating earlier would judge a payload the // server has not finished deciding. Before the save, so a refused create never reaches the // insert - and never allocates a document number. - validate(entity); + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + ${name}Entity checked = repository.calculatedForCreate(entity); + validate(checked); #if($hasReferenceValidations) - validateReferences(entity); + validateReferences(checked); #end #if($warningChecks && $warningChecks.size() > 0) // The soft checks (severity: warn, #7466): asked of the person saving - a 428 listing them // until the request confirms their codes - and only then persisted. - org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(entity)); + org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(checked)); #end #if($systemOwnedOnCreate.size() > 0) // After every check above, so what a create refuses is unchanged; before the save, so the @@ -279,14 +282,17 @@ public class ${name}PartnerController { } #end #end - validate(entity); + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + ${name}Entity checked = repository.calculatedForUpdate(entity); + validate(checked); #if($hasReferenceValidations) - validateReferences(entity); + validateReferences(checked); #end #if($warningChecks && $warningChecks.size() > 0) // The soft checks (severity: warn, #7466): asked of the person saving - a 428 listing them // until the request confirms their codes - and only then persisted. - org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(entity)); + org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(checked)); #end #if($hasDuplicateKeys) try { diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCalculatedFieldChecksIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCalculatedFieldChecksIT.java new file mode 100644 index 00000000000..84d36744664 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCalculatedFieldChecksIT.java @@ -0,0 +1,223 @@ +/* + * 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.integration.tests.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.repository.api.IResource; +import org.eclipse.dirigible.tests.base.IntegrationTest; +import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.annotation.DirtiesContext; + +import io.restassured.specification.RequestSpecification; + +/** + * A check on a calculated field reads the value the write will store, not the one the caller sent + * (#7544). + * + *

+ * The rule is the issue's own: warn - never refuse - when an invoice is issued more than 5 days + * after its tax event (a legal deadline for issuing; late issue happens and must still go through). + * It is declared as a calculated delay and a soft compare on it. The controllers ran their checks + * BEFORE the write, while the calculated expressions are assigned inside it, so the warning + * compared the payload's value: absent on a create, stale on an edit, and the soft tier was + * enforced only when a client happened to send the computed number. A refusing compare on the same + * calculated field was blind the same way, which the second check here pins. + */ +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class IntentCalculatedFieldChecksIT extends IntegrationTest { + + private static final String WORKSPACE = "workspace"; + private static final String PROJECT = "calcchecks"; + private static final String PROJECT_PATH = IRepositoryStructure.PATH_USERS + "/admin/" + WORKSPACE + "/" + PROJECT; + private static final String INVOICES = "/services/java/" + PROJECT + "/gen/" + PROJECT + "/api/invoice/InvoiceController"; + private static final String CONFIRM = "X-Confirm-Warnings"; + private static final String LATE_ISSUE = "Invoice.compare.1"; + private static final long TIMEOUT_SECONDS = 90; + + private static final String INTENT_YAML = """ + name: calcchecks + description: calculated-field checks fixture - the checks judge the computed delay, not the payload + + entities: + - name: Invoice + checks: + - { kind: compare, field: issueDelayDays, op: ge, value: 0, message: "An invoice cannot predate its tax event" } + - { kind: compare, field: issueDelayDays, op: le, value: 5, severity: warn, + message: "Issued more than 5 days after the tax event" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - { name: taxEventDate, type: date } + - { name: issueDelayDays, type: integer, + calculatedOnCreate: "daysBetween(TaxEventDate, Date)", + calculatedOnUpdate: "daysBetween(TaxEventDate, Date)" } + """; + + @Autowired + private IRepository repository; + + @Autowired + private RestAssuredExecutor restAssuredExecutor; + + @Autowired + private SynchronizationProcessor synchronizationProcessor; + + @Test + void the_checks_judge_the_computed_delay_on_create_and_on_update() { + generateProject(); + publishProject(); + synchronizationProcessor.forceProcessSynchronizers(); + + // Within the deadline: nothing to ask, and the delay is stored as computed. + int onTime = create("{\"Date\":\"2026-09-29\",\"TaxEventDate\":\"2026-09-26\"}", null); + assertStoredDelay(onTime, 3); + + // Nine days late, the payload carrying no delay at all: the person saving is asked, and nothing + // is written until they confirm. + restAssuredExecutor.execute(() -> warned(post("{\"Date\":\"2026-09-29\",\"TaxEventDate\":\"2026-09-20\"}", null)), TIMEOUT_SECONDS); + assertCount(1); + int late = create("{\"Date\":\"2026-09-29\",\"TaxEventDate\":\"2026-09-20\"}", LATE_ISSUE); + assertStoredDelay(late, 9); + + // An edit moving the tax event back, the payload still carrying a stale, harmless delay: the + // warning reads the delay the update computes (19), not the 2 it was sent. + String moved = "{\"Date\":\"2026-09-29\",\"TaxEventDate\":\"2026-09-10\",\"IssueDelayDays\":2}"; + restAssuredExecutor.execute(() -> warned(put(onTime, moved, null)), TIMEOUT_SECONDS); + assertStoredDelay(onTime, 3); + // ...and the same edit with the delay omitted is asked the same way. + restAssuredExecutor.execute(() -> warned(put(onTime, "{\"Date\":\"2026-09-29\",\"TaxEventDate\":\"2026-09-10\"}", null)), + TIMEOUT_SECONDS); + restAssuredExecutor.execute(() -> put(onTime, moved, LATE_ISSUE).statusCode(200), TIMEOUT_SECONDS); + assertStoredDelay(onTime, 19); + + // The refusing check on the same calculated field is not blind either: an invoice dated before + // its tax event is refused outright, whatever delay the payload claims. + restAssuredExecutor.execute( + () -> post("{\"Date\":\"2026-09-20\",\"TaxEventDate\":\"2026-09-29\",\"IssueDelayDays\":0}", LATE_ISSUE).statusCode(400), + TIMEOUT_SECONDS); + assertCount(2); + } + + private io.restassured.response.ValidatableResponse post(String body, String confirm) { + return request(confirm).body(body) + .when() + .post(INVOICES) + .then(); + } + + private io.restassured.response.ValidatableResponse put(int id, String body, String confirm) { + return request(confirm).body(body) + .when() + .put(INVOICES + "/" + id) + .then(); + } + + private void warned(io.restassured.response.ValidatableResponse response) { + response.statusCode(428) + .body("errorType", equalTo("ConfirmationRequired")) + .body("warnings", hasSize(1)) + .body("warnings[0].code", equalTo(LATE_ISSUE)); + } + + private int create(String body, String confirm) { + AtomicInteger id = new AtomicInteger(); + restAssuredExecutor.execute(() -> id.set(post(body, confirm).statusCode(200) + .extract() + .path("Id")), + TIMEOUT_SECONDS); + return id.get(); + } + + private void assertStoredDelay(int id, int expected) { + restAssuredExecutor.execute(() -> given().when() + .get(INVOICES + "/" + id) + .then() + .statusCode(200) + .body("IssueDelayDays", equalTo(expected)), + TIMEOUT_SECONDS); + } + + private void assertCount(int expected) { + restAssuredExecutor.execute(() -> given().when() + .get(INVOICES) + .then() + .statusCode(200) + .body("size()", equalTo(expected)), + TIMEOUT_SECONDS); + } + + private static RequestSpecification request(String confirm) { + RequestSpecification request = given().contentType("application/json"); + return confirm == null ? request : request.header(CONFIRM, confirm); + } + + private void generateProject() { + writeIntent(); + AtomicReference>> plan = new AtomicReference<>(); + restAssuredExecutor.execute(() -> plan.set(given().when() + .post("/services/ide/intent/generate?workspace=" + WORKSPACE + "&project=" + + PROJECT + "&path=app.intent") + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("codeGenerations"))); + for (Map codeGeneration : plan.get()) { + assertEquals(Boolean.TRUE, codeGeneration.get("generated"), + "generating code from " + codeGeneration.get("path") + " failed: " + codeGeneration.get("error")); + } + } + + private void publishProject() { + restAssuredExecutor.execute(() -> given().when() + .post("/services/ide/publisher/" + WORKSPACE + "/" + PROJECT + "/") + .then() + .statusCode(200)); + } + + private void writeIntent() { + String path = PROJECT_PATH + "/app.intent"; + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + } else { + repository.createResource(path, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + } + } + + @AfterEach + void cleanup() { + restAssuredExecutor.execute(() -> given().when() + .delete("/services/ide/publisher/" + WORKSPACE + "/" + PROJECT) + .then() + .statusCode(greaterThanOrEqualTo(200))); + if (repository.hasCollection(PROJECT_PATH)) { + repository.removeCollection(PROJECT_PATH); + } + synchronizationProcessor.forceProcessSynchronizers(); + } +} 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 3ddc3294f3d..717a5e9f04c 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 @@ -4493,10 +4493,18 @@ void an_authored_default_is_applied_before_the_create_time_calculations() { "Calc.eval(\"Net * VatRate / 100\""), "the default must be applied before the create-time calculation that reads it: " + repository); - // An existing row is never re-defaulted: the defaults belong to save() alone, so a value the - // user deliberately cleared stays cleared through update(). - assertEquals(1, occurrencesOf(repository, "entity.VatRate = new java.math.BigDecimal(\"20\")"), + // An existing row is never re-defaulted: the defaults belong to the create path alone, so a value + // the user deliberately cleared stays cleared through update(). The create path is save() and + // the checks' candidate row (#7544), which applies them too since an expression may read them. + String vatRateDefault = "entity.VatRate = new java.math.BigDecimal(\"20\")"; + assertTrue(methodOf(repository, "public OrderItemEntity save(").contains(vatRateDefault), + "save() must apply the default: " + repository); + assertTrue(methodOf(repository, "public OrderItemEntity calculatedForCreate(").contains(vatRateDefault), + "the checks' create-time candidate row must apply the default save() applies: " + repository); + assertFalse(methodOf(repository, "public OrderItemEntity update(").contains(vatRateDefault), "the default must be applied on create only, never re-applied by update(): " + repository); + assertFalse(methodOf(repository, "public OrderItemEntity calculatedForUpdate(").contains(vatRateDefault), + "the checks' update-time candidate row must not re-apply the default either: " + repository); } @Test @@ -5352,6 +5360,17 @@ private static int onlyIndexOf(String code, String anchor) { return first; } + /** + * One generated member method, from its signature to its closing brace - the first brace at the + * member indentation, since every nested block is indented deeper. + */ + private static String methodOf(String code, String signature) { + int start = onlyIndexOf(code, signature); + int end = code.indexOf("\n }\n", start); + assertTrue(end >= 0, "no closing brace after [" + signature + "] in the generated code"); + return code.substring(start, end); + } + /** * How many times a snippet occurs in the generated code - for the assertions whose point is that * something is emitted ONCE (two operands reading through the same relation share one load). diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/PersonalSurfaceCreateValidationTemplateIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/PersonalSurfaceCreateValidationTemplateIT.java index 56fc739cb95..03b0f743462 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/PersonalSurfaceCreateValidationTemplateIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/PersonalSurfaceCreateValidationTemplateIT.java @@ -87,13 +87,13 @@ void aSelfServiceSurfaceValidatesTheFinalPayloadAndDoesItBeforeTheWrite() throws String rendered = render(template, context()); int owner = rendered.indexOf("// The owner is the server's decision"); - int validate = rendered.indexOf(" validate(entity);"); + int validate = rendered.indexOf(" validate(checked);"); int save = rendered.indexOf("repository.save(entity)"); assertTrue(owner >= 0 && validate > owner, template + " must validate what it decided, not the raw payload: " + rendered); assertTrue(save > validate, template + " must validate before the insert: " + rendered); int update = rendered.indexOf("repository.update(entity)"); - assertTrue(rendered.lastIndexOf(" validate(entity);") < update, template + " must validate before the update too"); + assertTrue(rendered.lastIndexOf(" validate(checked);") < update, template + " must validate before the update too"); } } diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/SelfServiceReferenceValidationTemplateIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/SelfServiceReferenceValidationTemplateIT.java index 74e949976ac..099a3cc9067 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/SelfServiceReferenceValidationTemplateIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/SelfServiceReferenceValidationTemplateIT.java @@ -90,14 +90,14 @@ void theReferenceChecksRunAfterTheValueChecksAndBeforeTheWrite() throws Exceptio for (String template : SELF_SERVICE_SURFACES) { String rendered = render(template); - int validate = rendered.indexOf(" validate(entity);"); - int references = rendered.indexOf(" validateReferences(entity);"); + int validate = rendered.indexOf(" validate(checked);"); + int references = rendered.indexOf(" validateReferences(checked);"); int save = rendered.indexOf("repository.save(entity)"); assertTrue(validate >= 0 && references > validate, template + " must check references after the values: " + rendered); assertTrue(save > references, template + " must check references before the insert: " + rendered); int update = rendered.indexOf("repository.update(entity)"); - assertTrue(rendered.lastIndexOf(" validateReferences(entity);") < update, + assertTrue(rendered.lastIndexOf(" validateReferences(checked);") < update, template + " must check references before the update too: " + rendered); } }