From addced05d56ad0c40ccf38956455b9f56fbed068 Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Wed, 9 Sep 2026 17:13:45 +0300 Subject: [PATCH 1/2] templates: escape an authored check/guard/unique/refuse message into the Java literal it lands in (#7241) #7205 (#7154) added JavaLiterals.escape and wired it into exactly one site - the authored dataDefaultValue. Every other authored string the templates write into a Java string literal was still interpolated verbatim, so a quote or a backslash in one ended that literal and failed javac for the WHOLE generated module, not just the class carrying the message: the repository's guard and document checks, the row checks and unique-key messages of all three controllers, and a create-from's items refusal. A message in the shape the DSL's own examples suggest - `A "due" date is never before the invoice date` - therefore took every entity of the module down, at publish, in the Problems view. ModelParameterProcessor now puts an escaped twin on every check map it already walks (messageJavaLiteral, reaching the row / guard / document lists alike) and on each unique constraint (messageJavaLiteral + nameJavaLiteral), and GlueGenerator does the same for itemRefuse; the raw value stays for the surfaces that render it as text, and only the Java sites read the twin. A holder carrying no message is left without a twin, as the default-value literal is - the key's absence is what a template reads. The .edm XML and the .model JSON already escape these values, so the fix is confined to the Java emission. Verified: mvn formatter:validate over the reactor with the caches wiped; the ide-template unit suite (two new ModelParameterProcessorTest cases pinning the twins and their absence); IntentEmissionCoverageIT, whose fixture now carries a quote in a document check, a row check, a unique key, a guard and an items refusal - it generates, compiles and RUNS the module, and asserts both the escaped form in the emitted source and the authored form in the runtime message the caller is answered with. With one template site reverted to the raw value the IT fails on that assertion, so the pin bites. The release-profile javadoc build passes on ide-template. Not verified: the rest of the integration suite, and PostgreSQL. Co-Authored-By: Claude Opus 5 (1M context) --- .../template/service/model/GlueGenerator.java | 3 ++ .../model/ModelParameterProcessor.java | 43 +++++++++++++++++ .../model/ModelParameterProcessorTest.java | 47 +++++++++++++++++++ .../data/Repository.java.template | 8 ++-- .../events/Generate.java.template | 2 +- .../api/EntityController.java.template | 8 ++-- .../api/EntityMyController.java.template | 8 ++-- .../api/EntityPartnerController.java.template | 8 ++-- .../tests/api/IntentEmissionCoverageIT.java | 26 +++++----- 9 files changed, 123 insertions(+), 30 deletions(-) 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 d184cf70f73..043b718c42d 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 @@ -709,6 +709,9 @@ private static void bindGenerate(Map item, Map c // always had rather than its own literal into Java that would not compile. context.put("itemWhere", strOr(item, "itemWhere", "")); context.put("itemRefuse", strOr(item, "itemRefuse", "")); + // The refusal is written into a Java string literal, so a quote or a backslash in the authored + // message would end that literal and fail the compile of the whole generated module (#7241). + context.put("itemRefuseJavaLiteral", JavaLiterals.escape(strOr(item, "itemRefuse", ""))); // The one-hop `relation.field` map sources: one load per distinct relation, which the template // emits before the mapping reads a field off it. A .glue written before this key existed carries // none, and the loop renders nothing - the direct-property mapping it always had. diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java index e657ebf3813..19755ba1e3a 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java @@ -123,6 +123,7 @@ private static void processEntity(Map entity, List()); splitChecks(entity, parameters); + resolveUniqueConstraintLiterals(entity); resolveDataOrder(entity); for (Map property : asMaps(entity.get("properties"))) { @@ -218,6 +219,7 @@ private static void splitChecks(Map entity, Map List documentChecks = new ArrayList<>(); for (Map check : checks) { String kind = str(check, "kind"); + resolveMessageLiteral(check); resolveCheckPathLoads(check, parameters); if ("exactlyOne".equals(kind) || "compare".equals(kind)) { rowChecks.add(check); @@ -237,6 +239,47 @@ private static void splitChecks(Map entity, Map entity.put("documentChecks", documentChecks); } + /** + * Derives the escaped twin of an authored message, for the templates that write it into a Java + * string literal. + * + *

+ * A check's, a guard's or a unique key's message is prose an author writes - and the very messages + * the DSL's own examples suggest quote a field name ({@code A "due" date is never before the + * invoice date}). Interpolated verbatim, that quote ends the literal it is written into and fails + * the compile of every generated class of the module, not just the one carrying the message (#7241, + * the sibling of #7154). The raw value is left in place for the surfaces that render it as text; + * only the Java sites read the twin. + * + *

+ * A holder carrying no message is left untouched rather than given an empty twin, as the default + * value literal is: the key's absence is what a template reads. + * + * @param holder the check or unique constraint + */ + private static void resolveMessageLiteral(Map holder) { + String message = str(holder, "message"); + if (message != null) { + holder.put("messageJavaLiteral", JavaLiterals.escape(message)); + } + } + + /** + * Derives the escaped twins of a unique key's authored name and message, both of which the REST + * controllers write into Java string literals when they translate a constraint violation. + * + * @param entity the entity + */ + private static void resolveUniqueConstraintLiterals(Map entity) { + for (Map constraint : asMaps(entity.get("uniqueConstraints"))) { + resolveMessageLiteral(constraint); + String name = str(constraint, "name"); + if (name != null) { + constraint.put("nameJavaLiteral", JavaLiterals.escape(name)); + } + } + } + /** * Resolves a check's declared path hops to the generated classes that load them - the reader of a * {@code Relation.field} value must fetch the related record before it can read the field. diff --git a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java index 2e317fec074..09da21ee1fc 100644 --- a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java +++ b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java @@ -294,6 +294,53 @@ void splitsTheDeclarativeChecksByTheScopeThatEnforcesThem() { .size()); } + @Test + void carriesEveryAuthoredMessageAsAnEscapedJavaLiteralToo() { + Map row = new LinkedHashMap<>(); + row.put("kind", "compare"); + row.put("message", "A \"due\" date is never before the invoice date"); + Map guard = new LinkedHashMap<>(); + guard.put("kind", "guard"); + guard.put("message", "Insufficient balance in C:\\ledger"); + Map document = new LinkedHashMap<>(); + document.put("kind", "itemsMin"); + document.put("message", "An invoice needs at least one \"line\""); + Map unique = new LinkedHashMap<>(); + unique.put("name", "INVOICE_\"NUMBER\""); + unique.put("message", "This \"number\" is already registered"); + Map entity = entity("Invoice", "Invoices", property("Total", "DECIMAL")); + entity.put("checks", List.of(row, guard, document)); + entity.put("uniqueConstraints", List.of(unique)); + + ModelParameterProcessor.process(model(entity), parameters()); + + // The raw value stays for the surfaces that render it as text; only the Java sites read the + // twin, whose quote is escaped rather than ending the literal it is written into (#7241). + assertEquals("A \"due\" date is never before the invoice date", row.get("message")); + assertEquals("A \\\"due\\\" date is never before the invoice date", row.get("messageJavaLiteral")); + assertEquals("Insufficient balance in C:\\\\ledger", guard.get("messageJavaLiteral")); + assertEquals("An invoice needs at least one \\\"line\\\"", document.get("messageJavaLiteral")); + assertEquals("This \\\"number\\\" is already registered", unique.get("messageJavaLiteral")); + assertEquals("INVOICE_\\\"NUMBER\\\"", unique.get("nameJavaLiteral")); + } + + @Test + void leavesTheMessageLiteralAbsentWhereNoMessageIsAuthored() { + Map check = new LinkedHashMap<>(); + check.put("kind", "exactlyOne"); + Map unique = new LinkedHashMap<>(); + unique.put("name", "INVOICE_NUMBER"); + Map entity = entity("Invoice", "Invoices", property("Total", "DECIMAL")); + entity.put("checks", List.of(check)); + entity.put("uniqueConstraints", List.of(unique)); + + ModelParameterProcessor.process(model(entity), parameters()); + + assertFalse(check.containsKey("messageJavaLiteral")); + assertFalse(unique.containsKey("messageJavaLiteral")); + assertEquals("INVOICE_NUMBER", unique.get("nameJavaLiteral")); + } + @Test void resolvesTheHopsAConditionalRequirementReadsItsValueThrough() { Map hop = new LinkedHashMap<>(); 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 9bee867b124..b48b30dfc20 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 @@ -208,7 +208,7 @@ package gen.${javaGenFolderName}.data.${javaPerspectiveName}; } #else if (!guardWithin) { - throw new ValidationException("${guard.message}"); + throw new ValidationException("${guard.messageJavaLiteral}"); } #end } @@ -1030,7 +1030,7 @@ public class ${name}Repository extends JavaRepository<${name}Entity> { #end Object requiredValue = ${check.valueExpression}; if ((${check.guard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { - throw new ValidationException("${check.message}"); + throw new ValidationException("${check.messageJavaLiteral}"); } #elseif($check.kind == "itemsSumEqual") java.math.BigDecimal sum${check.overA} = java.math.BigDecimal.ZERO; @@ -1045,12 +1045,12 @@ public class ${name}Repository extends JavaRepository<${name}Entity> { } } if (sum${check.overA}.compareTo(sum${check.overB}) != 0) { - throw new ValidationException("${check.message}"); + throw new ValidationException("${check.messageJavaLiteral}"); } #else if (new ${check.itemsEntity}Repository().findAll( Criteria.create().eq("${check.itemsFk}", entity.#foreach($property in $properties)#if($property.dataPrimaryKey)${property.name}#end#end)).size() < ${check.count}) { - throw new ValidationException("${check.message}"); + throw new ValidationException("${check.messageJavaLiteral}"); } #end } diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template index 911f6ff6267..9f1d40faf8b 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template @@ -261,7 +261,7 @@ public class ${className}Generate { } if (!unqualified.isEmpty()) { throw new org.eclipse.dirigible.sdk.db.ValidationException( - "${itemRefuse} (${fromItemEntity} " + unqualified + ")"); + "${itemRefuseJavaLiteral} (${fromItemEntity} " + unqualified + ")"); } #end if (qualifying.isEmpty()) { 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 e95bb622390..78b28054fb9 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 @@ -406,7 +406,7 @@ public class ${name}Controller { .thenComparing(java.util.Comparator.naturalOrder())); #if($uniqueConstraints) #foreach($uniqueConstraint in $uniqueConstraints) - messages.put("${uniqueConstraint.name}".toUpperCase(Locale.ROOT), "${uniqueConstraint.message}"); + messages.put("${uniqueConstraint.nameJavaLiteral}".toUpperCase(Locale.ROOT), "${uniqueConstraint.messageJavaLiteral}"); #end #end #foreach($property in $properties) @@ -885,7 +885,7 @@ public class ${name}Controller { #end Object requiredValue = ${check.valueExpression}; if ((${check.guard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } } #elseif($check.kind == "compare") @@ -899,7 +899,7 @@ public class ${name}Controller { #else && !(entity.${check.field}.compareTo(entity.${check.than}) ${check.op} 0)) { #end - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } #else // Row-level check (intent `checks: exactlyOne`). @@ -911,7 +911,7 @@ public class ${name}Controller { } #end if (assigned != 1) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } } #end 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 005da9de4cf..46759ef0c40 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 @@ -626,7 +626,7 @@ public class ${name}MyController { #end Object requiredValue = ${check.valueExpression}; if ((${check.guard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } } #elseif($check.kind == "compare") @@ -640,7 +640,7 @@ public class ${name}MyController { #else && !(entity.${check.field}.compareTo(entity.${check.than}) ${check.op} 0)) { #end - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } #else // Row-level check (intent `checks: exactlyOne`). @@ -652,7 +652,7 @@ public class ${name}MyController { } #end if (assigned != 1) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } } #end @@ -747,7 +747,7 @@ public class ${name}MyController { .thenComparing(java.util.Comparator.naturalOrder())); #if($uniqueConstraints) #foreach($uniqueConstraint in $uniqueConstraints) - messages.put("${uniqueConstraint.name}".toUpperCase(Locale.ROOT), "${uniqueConstraint.message}"); + messages.put("${uniqueConstraint.nameJavaLiteral}".toUpperCase(Locale.ROOT), "${uniqueConstraint.messageJavaLiteral}"); #end #end #foreach($property in $properties) 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 2f08e3a30f0..1c5b804dc5e 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 @@ -582,7 +582,7 @@ public class ${name}PartnerController { #end Object requiredValue = ${check.valueExpression}; if ((${check.guard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } } #elseif($check.kind == "compare") @@ -596,7 +596,7 @@ public class ${name}PartnerController { #else && !(entity.${check.field}.compareTo(entity.${check.than}) ${check.op} 0)) { #end - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } #else // Row-level check (intent `checks: exactlyOne`). @@ -608,7 +608,7 @@ public class ${name}PartnerController { } #end if (assigned != 1) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.message}"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "${check.messageJavaLiteral}"); } } #end @@ -702,7 +702,7 @@ public class ${name}PartnerController { .thenComparing(java.util.Comparator.naturalOrder())); #if($uniqueConstraints) #foreach($uniqueConstraint in $uniqueConstraints) - messages.put("${uniqueConstraint.name}".toUpperCase(Locale.ROOT), "${uniqueConstraint.message}"); + messages.put("${uniqueConstraint.nameJavaLiteral}".toUpperCase(Locale.ROOT), "${uniqueConstraint.messageJavaLiteral}"); #end #end #foreach($property in $properties) 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 13a0f2faf07..16db7c6a720 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 @@ -218,7 +218,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { history: true immutableWhen: "Status == 2" checks: - - { kind: itemsMin, count: 1, status: 2, message: "Entry needs at least one line" } + - { kind: itemsMin, count: 1, status: 2, message: 'An entry needs at least one "line"' } - { kind: itemsSumEqual, over: [debit, credit], status: 2, message: "Debits must equal credits" } # requiredWhen (#7094), gated + over a relation hop: the value lives on the related # account, so the generated repository loads it by FK before it can read it, and the @@ -227,7 +227,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { message: "An audited entry must be booked against a named account" } # Two values of the SAME row, related (#7095) - one temporal pair and one numeric, # the two comparison families the generated code emits differently. - - { kind: compare, field: due, op: ge, than: date, message: "Due cannot be before the entry date" } + - { kind: compare, field: due, op: ge, than: date, message: 'A "due" date is never before the entry date' } - { kind: compare, field: paid, op: le, than: debit, message: "Paid cannot exceed the debit total" } fields: - { name: id, type: integer, primaryKey: true, generated: true } @@ -293,7 +293,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { # answered with the authored message rather than a server error. - name: PartyCode unique: - - { fields: [party, code], message: "This code is already registered for the party" } + - { fields: [party, code], message: 'This "code" is already registered for the party' } fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: code, type: string, required: true, length: 50 } @@ -804,7 +804,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { - kind: guard aggregate: ledgerTotal minimum: 0 - message: Insufficient balance + message: 'Insufficient "balance"' enabledBy: EMISSION_BLOCK_NEGATIVE_LEDGER - name: LedgerTotal fields: @@ -1452,7 +1452,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { to: BillLine where: - { field: amount, op: gt, value: 0 } - refuse: "Stay night carries no amount" + refuse: 'Stay night carries no "amount"' map: Amount: amount defaults: @@ -1827,7 +1827,7 @@ private void assertEmission() { assertTrue( entryController.contains("if (entity.Due != null && entity.Date != null") && entryController.contains("!(entity.Due.compareTo(entity.Date) >= 0)") - && entryController.contains("Due cannot be before the entry date"), + && entryController.contains("A \\\"due\\\" date is never before the entry date"), "checks: compare over two dates must emit a compareTo comparison in the REST controller, got: " + entryController); assertTrue(entryController.contains( "!(new java.math.BigDecimal(entity.Paid.toString()).compareTo(new java.math.BigDecimal(entity.Debit.toString())) <= 0)"), @@ -1940,8 +1940,8 @@ private void assertEmission() { "an ungated check is not the repository's - a gate it does not carry cannot be tested there"); String entryRepository = contentOf("gen/emission/data/entry/EntryRepository.java"); - assertTrue(entryRepository.contains("Entry needs at least one line"), - "checks: itemsMin must emit its authored message into the repository gate"); + assertTrue(entryRepository.contains("An entry needs at least one \\\"line\\\""), + "checks: itemsMin must emit its authored message into the repository gate, escaped for the literal it lands in"); assertTrue(entryRepository.contains("Debits must equal credits"), "checks: itemsSumEqual must emit its authored message into the repository gate"); // A value required only under a condition (#7094). The rule reaches the value THROUGH the @@ -2050,7 +2050,7 @@ private void assertEmission() { // layer dropped it. assertTrue(schema.contains("\"PartyCode_Party_Code\""), "the composite business key must be emitted into the schema: " + schema); String partyCodeController = contentOf("gen/emission/api/partycode/PartyCodeController.java"); - assertTrue(partyCodeController.contains("This code is already registered for the party"), + assertTrue(partyCodeController.contains("This \\\"code\\\" is already registered for the party"), "the generated controller must carry the authored conflict message"); assertTrue(schema.contains("EMISSION_UNIT_LANG"), "multilingual must emit the _LANG translation table into the schema"); // manyToMany: the link entity is an ordinary entity from parse time on, so it must reach the @@ -2320,7 +2320,7 @@ private void assertEmission() { // both PERSIST the row and mark it instead of throwing. assertTrue(ledgerRepository.contains("Criteria.create().eq(\"Person\", entity.Person).eq(\"Unit\", entity.Unit)"), "a guard must recompute its aggregate over the incoming row's full key-tuple"); - assertTrue(ledgerRepository.contains("throw new ValidationException(\"Insufficient balance\")"), + assertTrue(ledgerRepository.contains("throw new ValidationException(\"Insufficient \\\"balance\\\"\")"), "outcome block must fail the write with the authored message"); assertTrue(ledgerRepository.contains("Configurations.get(\"EMISSION_BLOCK_NEGATIVE_LEDGER\""), "enabledBy must wrap the guard in a config gate, so a tenant can turn it off"); @@ -3311,7 +3311,7 @@ private void assertEmission() { // The other reading: the refusal names the rows, so the caller knows which of a hundred lines // to go and fix - the whole question they have. String checkedBillFromStay = contentOf("gen/events/emission/CheckedBillFromStayGenerate.java"); - assertTrue(checkedBillFromStay.contains("\"Stay night carries no amount (StayNight \" + unqualified + \")\""), + assertTrue(checkedBillFromStay.contains("\"Stay night carries no \\\"amount\\\" (StayNight \" + unqualified + \")\""), "refuse: must throw the authored message carrying the keys of the offending rows"); // generates on the step axis + mode: append (#6800): the listener binds the step-scoped topic @@ -3880,7 +3880,7 @@ private void assertRuntimeEnforcement() { .post(API + "/entry/EntryController") .then() .statusCode(400) - .body("message", containsString("Due cannot be before the entry date"))); + .body("message", containsString("A \"due\" date is never before the entry date"))); restAssuredExecutor.execute(() -> given().contentType("application/json") .body("{\"Date\":\"2026-01-15\",\"Due\":\"2026-01-15\",\"Account\":2}") .when() @@ -4898,7 +4898,7 @@ private void assertGeneratesItemsRuleRuntime() { .post("/services/java/" + PROJECT + "/gen/events/emission/CheckedBillFromStayGenerate/run") .then() .statusCode(400) - .body(containsString("Stay night carries no amount"))); + .body("message", containsString("Stay night carries no \"amount\""))); // No rule qualifies a row, so the document would have no lines at all - refused, not committed. restAssuredExecutor.execute(() -> given().contentType("application/json") From 2ef872ddb8c49b34ccff0e3ae035dac06bae3fa5 Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 9 Sep 2026 21:25:20 +0300 Subject: [PATCH 2/2] tests: the hand-built check and unique-key fixtures carry the escaped twin (#7241) Two template ITs build the parameter map by hand rather than through ModelParameterProcessor, so they kept the pre-#7241 shape and rendered an empty message at the Java site: - PersonalSurfaceCreateValidationTemplateIT's row check now carries `messageJavaLiteral` alongside the raw message. - UniqueFieldConflictControllerTemplateIT's composite key now carries `nameJavaLiteral` + `messageJavaLiteral`, and its authored message QUOTES the field it is about - the shape the defect is about. That test compiles and runs the rendered mapping, so the fixture is a real pin, not a restatement: with the controller template reverted to `${uniqueConstraint.message}` the extracted mapping fails to compile, and with the twin restored it is green again. Co-Authored-By: Claude Opus 5 --- ...onalSurfaceCreateValidationTemplateIT.java | 7 ++++++- ...iqueFieldConflictControllerTemplateIT.java | 20 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) 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 69d60a02b62..72d2394bdce 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 @@ -236,11 +236,16 @@ private static Map note() { return property; } - /** intent `checks: exactlyOne` - a row-level refusal, the same one the power surface applies. */ + /** + * intent `checks: exactlyOne` - a row-level refusal, the same one the power surface applies. The + * escaped twin is the key the surfaces read at the Java site (#7241); the raw message stays for the + * surfaces that render it as text. + */ private static Map rowCheck() { Map check = new LinkedHashMap<>(); check.put("fields", List.of("FromDate", "Note")); check.put("message", "exactly one of FromDate / Note must be set"); + check.put("messageJavaLiteral", "exactly one of FromDate / Note must be set"); return check; } } diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/UniqueFieldConflictControllerTemplateIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/UniqueFieldConflictControllerTemplateIT.java index a384aa6b5b8..cd18253db74 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/UniqueFieldConflictControllerTemplateIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/UniqueFieldConflictControllerTemplateIT.java @@ -104,6 +104,13 @@ class UniqueFieldConflictControllerTemplateIT { "ERROR: duplicate key value violates unique constraint \"vacations_public_holiday_pkey\"\n" + " Detail: Key (PUBLIC_HOLIDAY_ID)=(5) already exists."; + /** + * The authored message of the composite key, quoting the field it is about - the shape the DSL's + * own examples suggest, and the one that ends the Java literal it lands in unless the twin the + * processor derives is what the template writes (#7241). + */ + private static final String COMPOSITE_KEY_MESSAGE = "This \"day\" is already a holiday of the company"; + private final VelocityGenerationEngine velocityGenerationEngine = new VelocityGenerationEngine(); @Test @@ -220,7 +227,7 @@ void aCompositeKeyStillAnswersWithItsAuthoredMessage() throws Exception { context.put("properties", List.of(primaryKey(), column("Company", "PUBLIC_HOLIDAY_COMPANY"), column("Day", "PUBLIC_HOLIDAY_DAY"))); context.put("uniqueConstraints", List.of(compositeKey())); - assertEquals("This day is already a holiday of the company", + assertEquals(COMPOSITE_KEY_MESSAGE, mapping(context).answerFor("duplicate key value violates unique constraint \"PublicHoliday_Company_Day\"", UNIQUE_VIOLATION), "the authored message must still be the answer for the constraint the model named"); @@ -462,12 +469,19 @@ private static Map context() { return parameters; } - /** The shape {@code EdmIntentGenerator} puts on a composite {@code unique:} declaration. */ + /** + * The shape {@code EdmIntentGenerator} plus {@code ModelParameterProcessor} put on a composite + * {@code unique:} declaration. The message carries a quote on purpose: the name and the message are + * both written into Java string literals, so only the escaped twins the processor derives (#7241) + * leave the mapping compilable - and this test compiles what it renders. + */ private static Map compositeKey() { Map constraint = new LinkedHashMap<>(); constraint.put("name", "PublicHoliday_Company_Day"); + constraint.put("nameJavaLiteral", "PublicHoliday_Company_Day"); constraint.put("columns", List.of(Map.of("name", "PUBLIC_HOLIDAY_COMPANY"), Map.of("name", "PUBLIC_HOLIDAY_DAY"))); - constraint.put("message", "This day is already a holiday of the company"); + constraint.put("message", COMPOSITE_KEY_MESSAGE); + constraint.put("messageJavaLiteral", COMPOSITE_KEY_MESSAGE.replace("\"", "\\\"")); return constraint; }