From 102fecdb180de956f833a9fe4b85ca8e4e499a60 Mon Sep 17 00:00:00 2001 From: Nikol Georgieva Date: Sat, 3 Oct 2026 19:44:08 +0300 Subject: [PATCH 1/2] checks: a check on a calculated field judges the value the write will store, not the payload's (#7544) Cause: the generated controllers run validate() and repository.warnings() before save()/update(), while the calculatedOnCreate / calculatedOnUpdate assignments happen inside them. A check on a calculated field - soft (severity: warn) or refusing - compared whatever the caller sent: nothing on a create, a stale value on an edit. The issue's "issued more than 5 days after the tax event" warning never fired for a REST write. Change (option (a) of the issue): - The generated repository gains calculatedForCreate / calculatedForUpdate: a field copy of the submitted record carrying what the write computes for its calculated fields - on create after the authored defaults, on update after taking the system-owned fields from the stored row and recomputing the document totals, as update() does. Only the neutral Calc expressions are evaluated; a calculated action runs on the write alone. The submitted record is untouched (dropSystemOwnedOnCreate and save()/update() see what they always saw); an entity with nothing calculated gets itself back. - All three controllers (power / my / partner, create and both update branches) run validate(), validateReferences() and warnings() on that candidate row. - The assistant guide documents checks on calculated fields. Verified: IntentCalculatedFieldChecksIT (new; the issue's declaration plus a refusing compare on the same field: create, stale and omitted payloads on update, a negative delay refused) - answered 200 instead of 428 against the unfixed templates, green with the fix; IntentSoftWarningsIT, IntentCheckMessageI18nIT, IntentEmissionCoverageIT, ModelGenerationIT and the two controller template ITs (anchors updated to the candidate-row call) green on H2; ide-template (216) and engine-intent (1479) unit suites; formatter:validate with the cache wiped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/resources/intent-assistant-guide.md | 11 + .../data/Repository.java.template | 82 +++++++ .../api/EntityController.java.template | 27 ++- .../api/EntityMyController.java.template | 18 +- .../api/EntityPartnerController.java.template | 18 +- .../api/IntentCalculatedFieldChecksIT.java | 223 ++++++++++++++++++ ...onalSurfaceCreateValidationTemplateIT.java | 4 +- ...fServiceReferenceValidationTemplateIT.java | 6 +- 8 files changed, 363 insertions(+), 26 deletions(-) create mode 100644 tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCalculatedFieldChecksIT.java 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/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); } } From 58496c9853fb340eb22ff34d82fb82ad1bbf1a9b Mon Sep 17 00:00:00 2001 From: Nikol Georgieva Date: Sun, 4 Oct 2026 10:34:44 +0300 Subject: [PATCH 2/2] tests: IntentEngineIT pins create-only defaults per method, not by a file-wide count (#7544) The checks' create-time candidate row (calculatedForCreate) applies the authored defaults as save() does, so the default literal now appears twice in the generated repository and the occurrence count failed the smoke gate. Assert what the test means instead: save() and calculatedForCreate() apply the default, update() and calculatedForUpdate() never do. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../integration/tests/api/IntentEngineIT.java | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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).