From 958276b23566ffe2e62acf893378f40e8d4e8a73 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 10 Sep 2026 17:15:34 +0300 Subject: [PATCH] intent: render a posts set constant for its target column's Java type, and print a remedy that survives YAML (#7287) Three neighbours of the #7246 refusal, all of them the same defect class it set out to close - a `posts: set:` value that reaches javac as something it cannot compile. 1. The remedy the refusal printed did not work. It named `"Receipt.Store"`, which YAML unquotes back into the very dotted path being refused, so an author who followed it got the identical refusal. It now names the one spelling that survives YAML, `'"Receipt.Store"'`, and says why the single quotes are there. PostSetSupport.QUOTED carried the same misunderstanding in its javadoc. 2. A numeric constant was rendered bare regardless of the target column. Every intent numeric-with-scale type is a BigDecimal in the generated entity and `long` is a Long, so `Quantity: -3.5` emitted `row.Quantity = -3.5;` and `Sequence: 2` emitted `row.Sequence = 2;` - compile errors of the whole generated module. PostSetSupport.targetType now reads the column's type off the target entity (a field by its own spelling, a to-one relation by its target's key) and expression() renders to it: `new java.math.BigDecimal("-3.5")`, `2L`, `2`, and `"2"` into a text column. A constant the column cannot hold at all - a text into a decimal, a fraction into a long, a number into a boolean, any constant into a date - is refused at parse by typeMismatch(), naming the column and the reason. 3. The escape loop had a fourth copy. PostSetSupport.escape was byte-for-byte JavaLiterals.escape, and GlueIntentGenerator.javaLiteral was a third, weaker vocabulary (a backslash-then-quote pass that a newline still closes). JavaLiterals is now public and both call it. Verified: engine-intent (1212) and ide-template (143) unit suites green; GluePostsTest drives the quoted remedy THROUGH IntentParser.parse and asserts the typed rendering off a parsed model; IntentEngineIT asserts the emitted `row.Factor = new java.math.BigDecimal("-1.5");` / `row.Sequence = 7L;`; and IntentPostsAtomicityIT now sets a decimal and a long constant, so its generate-publish-compile-post run is the proof they compile - both ITs green. Fixes #7287 Co-Authored-By: Claude Opus 5 --- .../intent/generator/GlueIntentGenerator.java | 24 +- .../intent/generator/PostSetSupport.java | 275 +++++++++++++++--- .../intent/parser/IntentParser.java | 29 +- .../intent/generator/GluePostsTest.java | 101 ++++++- .../template/service/model/JavaLiterals.java | 13 +- .../integration/tests/api/IntentEngineIT.java | 16 + .../tests/api/IntentPostsAtomicityIT.java | 16 + 7 files changed, 410 insertions(+), 64 deletions(-) diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 7e1b2fba321..52fc7076d29 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -17,6 +17,7 @@ import java.util.Set; import org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.ide.template.service.model.JavaLiterals; import org.eclipse.dirigible.components.intent.LoggedValue; import org.eclipse.dirigible.components.intent.generator.ProcessFieldLoadSupport.FieldLoad; import org.eclipse.dirigible.components.intent.generator.ProcessResolverSupport.Resolver; @@ -1946,7 +1947,7 @@ private static List> buildPosts(IntentModel model, Map pair = new LinkedHashMap<>(); pair.put("field", IntentNaming.pascalCase(f.getKey())); - pair.put("expr", postSetExpr(f.getValue())); + pair.put("expr", postSetExpr(f.getValue(), PostSetSupport.targetType(target, byName, f.getKey()))); assigns.add(pair); } e.put("assigns", assigns); @@ -1962,11 +1963,18 @@ private static List> buildPosts(IntentModel model, Map + * A constant is rendered for the TYPE of the column it is assigned to, which the target entity + * carries: an intent {@code decimal} / {@code double} column is a {@code BigDecimal} in the + * generated entity and a {@code long} one a {@code Long}, so the bare number the renderer used to + * emit did not compile (dirigible #7287). + * * @param raw the authored value + * @param type the target column's type * @return the Java expression */ - private static String postSetExpr(String raw) { - return PostSetSupport.expression(raw); + private static String postSetExpr(String raw, PostSetSupport.TargetType type) { + return PostSetSupport.expression(raw, type); } /** Test hook: build the {@code posts} glue collection without a repository. */ @@ -3062,9 +3070,9 @@ private static String firstHop(Map map) { /** A YAML scalar as a Java literal: numbers bare, everything else a quoted string. */ private static String javaLiteral(Object value) { // A Boolean written as a String would filter a boolean column with the text "true" and match - // nothing; the backslash is escaped before the quote so a value carrying either cannot close the - // literal early. Statuses arrive already resolved to ids, so a lifecycle filter takes the bare - // integer branch. + // nothing. Statuses arrive already resolved to ids, so a lifecycle filter takes the bare integer + // branch. The quoted branch goes through the ONE escape helper (dirigible #7287) - a local + // backslash-then-quote pass survived a quote but not a newline, which closes the literal too. if (value instanceof Boolean) { return String.valueOf(value); } @@ -3072,9 +3080,7 @@ private static String javaLiteral(Object value) { if (v.matches("-?\\d+")) { return v; } - return '"' + v.replace("\\", "\\\\") - .replace("\"", "\\\"") - + '"'; + return '"' + JavaLiterals.escape(v) + '"'; } /** diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PostSetSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PostSetSupport.java index 5024007516a..732aa83d45c 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PostSetSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PostSetSupport.java @@ -9,9 +9,16 @@ */ package org.eclipse.dirigible.components.intent.generator; +import java.util.Locale; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.eclipse.dirigible.components.ide.template.service.model.JavaLiterals; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.RelationIntent; + /** * The value vocabulary of a {@code posts:} {@code set:} entry - the forms the parser accepts and * the Java the generator renders, in one place so the two cannot drift. @@ -24,6 +31,15 @@ * rendered as an escaped Java string literal, and a value that reads as an EXPRESSION the renderer * cannot compile is refused at parse time rather than turned into a string that would silently be * the wrong value. + * + *

+ * A constant is also rendered for the TYPE of the column it is written into (dirigible #7287). + * Every intent numeric-with-scale type is a {@code java.math.BigDecimal} in the generated entity + * and {@code long} is a {@code Long}, so a bare {@code -3.5} / {@code 2} emitted the un-compilable + * {@code row.Quantity = -3.5;} / {@code row.Sequence = 2;} for exactly the same reason the text + * case did. {@link #targetType} reads the column's type off the target entity and + * {@link #expression} renders to it; a constant that column cannot hold at all ({@code issued} into + * a decimal, a fraction into a long) is named by {@link #typeMismatch} and refused at parse. */ public final class PostSetSupport { @@ -39,14 +55,108 @@ public final class PostSetSupport { /** A number - an integer (an FK id, a direction) or a decimal. */ private static final Pattern NUMBER = Pattern.compile("^-?\\d+(\\.\\d+)?$"); - /** A value the author quoted explicitly, to force the string reading of an expression-like text. */ + /** + * A value the author quoted so explicitly that the double quotes survived YAML, to force the string + * reading of an expression-like text. + * + *

+ * YAML strips one level of quoting before the parser ever sees the scalar, so the authored spelling + * that arrives here is {@code '"Receipt.Store"'} - single quotes wrapping the literal double quotes + * - and NOT {@code "Receipt.Store"}, which arrives as the bare dotted path and is refused. That is + * the spelling {@link #quotedSpelling} prints and the refusal names. + */ private static final Pattern QUOTED = Pattern.compile("^\"[^\"]*\"$"); + /** + * The Java type of the column a {@code set:} entry writes, as far as the rendering of a constant + * cares: the intent types collapse onto the handful of Java types the generated entity declares + * ({@code decimal} and {@code double} are both a {@code BigDecimal} column, a to-one relation is + * its target's integer key). + */ + public enum TargetType { + + /** A {@code String} column. */ + STRING, + + /** An {@code Integer} column - a plain {@code integer} field, or a to-one relation's FK. */ + INTEGER, + + /** A {@code Long} column. */ + LONG, + + /** A {@code java.math.BigDecimal} column - an intent {@code decimal} or {@code double}. */ + DECIMAL, + + /** A {@code Boolean} column. */ + BOOLEAN, + + /** A {@code LocalDate} / {@code Instant} column - no constant stands in for one here. */ + TEMPORAL, + + /** Not resolvable here (no such column on the target): rendered as it always was. */ + UNKNOWN + } + /** * Not instantiable. */ private PostSetSupport() {} + /** + * The type of the target column a {@code set:} key writes, or {@link TargetType#UNKNOWN} when the + * target entity does not declare it. + * + *

+ * The key is authored in the target's presentation spelling ({@code Quantity}) while the field is + * declared in the model's ({@code quantity}), so both sides are compared PascalCased - the same + * spelling the generated assignment uses. A to-one relation is its target's key column: an + * {@code Integer}, or a {@code Long} where that entity's key is a {@code long}. + * + * @param entity the target entity ({@code into}), or null + * @param byName every entity of the model by name, to read a relation target's key type + * @param key the authored {@code set:} key + * @return the column's type, never null + */ + public static TargetType targetType(EntityIntent entity, Map byName, String key) { + if (entity == null || key == null) { + return TargetType.UNKNOWN; + } + String property = IntentNaming.pascalCase(key); + for (FieldIntent field : entity.getFields()) { + if (field.getName() != null && property.equals(IntentNaming.pascalCase(field.getName()))) { + return ofIntentType(field.getType()); + } + } + for (RelationIntent relation : entity.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (!toOne || relation.getName() == null || !property.equals(IntentNaming.pascalCase(relation.getName()))) { + continue; + } + EntityIntent referenced = byName == null ? null : byName.get(relation.getTo()); + FieldIntent primaryKey = referenced == null ? null : IntentEntities.primaryKeyOf(referenced); + TargetType keyType = primaryKey == null ? TargetType.INTEGER : ofIntentType(primaryKey.getType()); + return keyType == TargetType.LONG ? TargetType.LONG : TargetType.INTEGER; + } + return TargetType.UNKNOWN; + } + + /** The intent type vocabulary, collapsed onto the Java type the generated entity declares. */ + private static TargetType ofIntentType(String type) { + String value = type == null ? "string" + : type.trim() + .toLowerCase(Locale.ROOT); + return switch (value) { + case "integer", "int", "relation" -> TargetType.INTEGER; + case "long" -> TargetType.LONG; + // `double` is a DECIMAL column like `decimal`, so both properties are BigDecimal. + case "decimal", "double" -> TargetType.DECIMAL; + case "boolean" -> TargetType.BOOLEAN; + case "date", "time", "timestamp", "datetime" -> TargetType.TEMPORAL; + case "string", "text" -> TargetType.STRING; + default -> TargetType.UNKNOWN; + }; + } + /** * Whether a value reads as an expression this renderer cannot compile - a dotted path off anything * but {@code item} / {@code source}, or a negation of anything but a per-item copy. @@ -64,14 +174,8 @@ public static boolean isUnsupportedExpression(String raw) { return false; } String value = raw.trim(); - if (NEGATED_ITEM.matcher(value) - .matches() - || ITEM.matcher(value) - .matches() - || SOURCE.matcher(value) - .matches() - || NUMBER.matcher(value) - .matches() + if (isCopy(value) || NUMBER.matcher(value) + .matches() || QUOTED.matcher(value) .matches()) { return false; @@ -79,23 +183,96 @@ public static boolean isUnsupportedExpression(String raw) { return value.indexOf('.') >= 0 || value.startsWith("-"); } + /** + * The remedy an unsupported expression's refusal prints: the authored value spelled so that the + * double quotes SURVIVE YAML and reach {@link #QUOTED} - single quotes wrapping double quotes. + * + *

+ * The refusal used to print {@code "Receipt.Store"}, which YAML unquotes back into the very dotted + * path being refused, so an author who followed the remedy got the identical refusal (dirigible + * #7287). + * + * @param raw the authored value + * @return the spelling to print + */ + public static String quotedSpelling(String raw) { + return "'\"" + (raw == null ? "" : raw.trim()) + "\"'"; + } + + /** + * Why the target column cannot hold this constant, phrased for a parse refusal, or null when it + * can. + * + *

+ * A constant the column's Java type cannot take is not renderable in any spelling: a text into a + * {@code BigDecimal}, a fraction into a {@code Long}, anything but {@code true} / {@code false} + * into a {@code Boolean}, any constant at all into a date. Each one used to reach {@code javac} as + * a bare literal or a string literal and fail the compile of the whole generated module, which is + * the class of defect the closed vocabulary exists to end. A copy ({@code item.} / {@code source.} + * / {@code null}) is not type-checked here - the two columns' types are the author's business, and + * a mismatch there is a compile error this rule cannot see. + * + * @param raw the authored value + * @param type the target column's type + * @return the reason, or null when the constant is renderable + */ + public static String typeMismatch(String raw, TargetType type) { + if (raw == null || type == null || type == TargetType.UNKNOWN || type == TargetType.STRING) { + return null; + } + String value = raw.trim(); + if (isCopy(value) || "null".equals(value)) { + return null; + } + boolean number = NUMBER.matcher(value) + .matches(); + return switch (type) { + case INTEGER, LONG -> { + if (!number) { + yield "the column is a whole number, and [" + value + "] is not one"; + } + yield value.indexOf('.') < 0 ? null : "the column is a whole number, and [" + value + "] has a fraction"; + } + case DECIMAL -> number ? null : "the column is a decimal, and [" + value + "] is not a number"; + case BOOLEAN -> "true".equals(value) || "false".equals(value) ? null + : "the column is a boolean, and [" + value + "] is not true or false"; + case TEMPORAL -> "the column is a date/time, which no constant can be written to here" + + " - copy it from item. or source."; + default -> null; + }; + } + /** * The Java expression a {@code set:} value renders to, over the {@code source} record and - in a - * {@code forEach} rule - the {@code item} row. + * {@code forEach} rule - the {@code item} row, for a target column whose type is not resolvable. + * + * @param raw the authored value + * @return the Java expression + */ + public static String expression(String raw) { + return expression(raw, TargetType.UNKNOWN); + } + + /** + * The Java expression a {@code set:} value renders to, over the {@code source} record and - in a + * {@code forEach} rule - the {@code item} row, TYPED to the target column. * *

* The recognised forms are {@code item.}, {@code -item.} (null-safe), * {@code source.}, a number, {@code true} / {@code false} / {@code null}, and a value the - * author quoted explicitly. Everything else is a plain constant and renders as an escaped Java - * string literal - never as a bare identifier, which cannot compile. + * author quoted explicitly. A constant renders as a literal of the column's own Java type - + * {@code new java.math.BigDecimal("-3.5")}, {@code 2L}, {@code 2}, {@code "2"} - never as a bare + * literal of the wrong type, and never as a bare identifier; neither compiles. * * @param raw the authored value + * @param type the target column's type * @return the Java expression */ - public static String expression(String raw) { + public static String expression(String raw, TargetType type) { if (raw == null) { return "null"; } + TargetType target = type == null ? TargetType.UNKNOWN : type; String value = raw.trim(); Matcher negated = NEGATED_ITEM.matcher(value); if (negated.matches()) { @@ -110,48 +287,58 @@ public static String expression(String raw) { if (source.matches()) { return "source." + IntentNaming.pascalCase(source.group(1)); } + if ("null".equals(value)) { + return "null"; + } if (NUMBER.matcher(value) .matches()) { - return value; + return number(value, target); } - if ("true".equals(value) || "false".equals(value) || "null".equals(value)) { - return value; + if ("true".equals(value) || "false".equals(value)) { + return target == TargetType.STRING ? literal(value) : value; } if (QUOTED.matcher(value) .matches()) { - return value; + return literal(value.substring(1, value.length() - 1)); } - return '"' + escape(value) + '"'; + return literal(value); } /** - * Escapes a constant for placement inside a Java string literal: the backslash and the double quote - * that would otherwise end the literal, and the control characters that would end the line. + * A numeric constant as a literal of the target column's Java type. * - * @param value the raw constant - * @return the escaped constant, ready to be placed between two double quotes + *

+ * A fraction into a whole-number column, and a number into a boolean or a date column, are refused + * at parse ({@link #typeMismatch}) - so those branches are unreachable through a parsed model. They + * still render something that COMPILES rather than something that does not, because the generator + * is also reachable from a model built in code, and one mis-valued column beats a generated module + * that will not build. */ - private static String escape(String value) { - StringBuilder escaped = new StringBuilder(value.length() + 8); - for (int index = 0; index < value.length(); index++) { - char character = value.charAt(index); - switch (character) { - case '\\' -> escaped.append("\\\\"); - case '"' -> escaped.append("\\\""); - case '\n' -> escaped.append("\\n"); - case '\r' -> escaped.append("\\r"); - case '\t' -> escaped.append("\\t"); - case '\b' -> escaped.append("\\b"); - case '\f' -> escaped.append("\\f"); - default -> { - if (character < 0x20 || character == 0x7f) { - escaped.append(String.format("\\u%04x", (int) character)); - } else { - escaped.append(character); - } - } - } - } - return escaped.toString(); + private static String number(String value, TargetType type) { + boolean whole = value.indexOf('.') < 0; + return switch (type) { + case DECIMAL -> "new java.math.BigDecimal(\"" + value + "\")"; + case LONG -> whole ? value + "L" : "new java.math.BigDecimal(\"" + value + "\").longValue()"; + case INTEGER -> whole ? value : "new java.math.BigDecimal(\"" + value + "\").intValue()"; + case STRING -> literal(value); + // A bare number is what an unresolvable column (an FK the target declares by another name) + // always got, and a boolean / temporal column is refused at parse before it reaches here. + default -> value; + }; + } + + /** A constant as an escaped Java string literal. */ + private static String literal(String value) { + return '"' + JavaLiterals.escape(value) + '"'; + } + + /** Whether the value is a copy off the source record or the per-item row, not a constant. */ + private static boolean isCopy(String value) { + return NEGATED_ITEM.matcher(value) + .matches() + || ITEM.matcher(value) + .matches() + || SOURCE.matcher(value) + .matches(); } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 34bc1f74208..d0422a81e6f 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -7270,21 +7270,40 @@ private static void validatePostings(IntentModel model, Set usesAliases, * It is refused rather than rendered because both other outcomes are silent: passing the text * through emits a bare Java identifier and breaks the compile of the whole generated module * (dirigible #7246), and rendering it as a string constant would put the text of the path into the - * ledger cell instead of the value it names. An author who really means the text quotes it. + * ledger cell instead of the value it names. An author who really means the text quotes it - in the + * one spelling that survives YAML, which the refusal now prints ({@code '"Receipt.Store"'}: YAML + * strips a single level of quoting, so the earlier remedy {@code "Receipt.Store"} arrived back as + * the same bare path and earned the same refusal, dirigible #7287). + * + *

+ * The second refusal here is a TYPE one: a constant the target column cannot hold - a text into a + * {@code decimal}, a fraction into a {@code long}, anything into a date - reaches {@code javac} as + * a literal of the wrong type and breaks the same compile the first refusal exists to protect. * * @param model the model * @param issues the collected issues */ private static void validatePostSets(IntentModel model, List issues) { + Map byName = IntentEntities.byName(model); for (PostIntent post : model.getPosts()) { String subject = "posts [" + post.getName() + "]"; + EntityIntent target = post.getInto() == null ? null : byName.get(post.getInto()); for (Map.Entry assignment : post.getSet() .entrySet()) { - if (PostSetSupport.isUnsupportedExpression(assignment.getValue())) { - issues.add(subject + " set [" + assignment.getKey() + "]: value [" + assignment.getValue() + String field = assignment.getKey(); + String value = assignment.getValue(); + if (PostSetSupport.isUnsupportedExpression(value)) { + issues.add(subject + " set [" + field + "]: value [" + value + "] is not a value this rule can render - write item., source.," - + " -item., a number, or a plain constant; quote it (\"" + assignment.getValue() - + "\") to mean that text."); + + " -item., a number, or a plain constant; write it as " + PostSetSupport.quotedSpelling(value) + + " to mean that text (YAML strips a single level of quoting, so the double quotes" + + " need the single ones around them to survive)."); + continue; + } + String mismatch = PostSetSupport.typeMismatch(value, PostSetSupport.targetType(target, byName, field)); + if (mismatch != null) { + issues.add(subject + " set [" + field + "]: value [" + value + "] does not fit [" + post.getInto() + "." + field + + "] - " + mismatch + "."); } } } diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostsTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostsTest.java index 4b2c32998f7..d394e5d7ecb 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostsTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostsTest.java @@ -16,6 +16,8 @@ import java.util.List; import java.util.Map; +import org.eclipse.dirigible.components.intent.generator.PostSetSupport.TargetType; +import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.parser.IntentParser; import org.eclipse.dirigible.components.intent.parser.IntentValidationException; @@ -42,6 +44,9 @@ class GluePostsTest { - { name: id, type: integer, primaryKey: true, generated: true } - { name: quantity, type: decimal } - { name: direction, type: integer } + - { name: sequence, type: long } + - { name: note, type: string } + - { name: postedOn, type: date } relations: - { name: Product, kind: manyToOne, to: Product } - { name: GoodsIssue, kind: manyToOne, to: GoodsIssue } @@ -73,6 +78,7 @@ class GluePostsTest { Product: item.Product Quantity: "-item.Quantity" Direction: 2 + Sequence: 10 Store: source.Store Note: issued """; @@ -102,14 +108,16 @@ void emitsTheResolvedPostGlueDescriptor() { assertEquals("GoodsIssue", p.get("backRef")); List> assigns = (List>) p.get("assigns"); - assertEquals(5, assigns.size()); + assertEquals(6, assigns.size()); // item copy, null-safe negation, integer constant, source copy - rendered to Java expressions. assertEquals(Map.of("field", "Product", "expr", "item.Product"), assigns.get(0)); assertEquals(Map.of("field", "Quantity", "expr", "item.Quantity == null ? null : item.Quantity.negate()"), assigns.get(1)); assertEquals(Map.of("field", "Direction", "expr", "2"), assigns.get(2)); - assertEquals(Map.of("field", "Store", "expr", "source.Store"), assigns.get(3)); + // a long column takes a long literal - a bare `10` would not assign to a Long. + assertEquals(Map.of("field", "Sequence", "expr", "10L"), assigns.get(3)); + assertEquals(Map.of("field", "Store", "expr", "source.Store"), assigns.get(4)); // a plain constant: a Java string literal, never the bare identifier that would not compile. - assertEquals(Map.of("field", "Note", "expr", "\"issued\""), assigns.get(4)); + assertEquals(Map.of("field", "Note", "expr", "\"issued\""), assigns.get(5)); } /** A constant carrying a quote or a backslash cannot end the literal it is written into. */ @@ -129,6 +137,93 @@ void rendersTheNonTextForms() { assertEquals("\"source.Store\"", PostSetSupport.expression("\"source.Store\"")); } + /** + * A numeric constant renders as a literal of the TARGET column's Java type: every intent + * numeric-with-scale type is a {@code BigDecimal} in the generated entity and {@code long} is a + * {@code Long}, so a bare number did not compile (dirigible #7287). + */ + @Test + void rendersANumberForTheTargetsJavaType() { + assertEquals("new java.math.BigDecimal(\"-3.5\")", PostSetSupport.expression("-3.5", TargetType.DECIMAL)); + assertEquals("new java.math.BigDecimal(\"2\")", PostSetSupport.expression("2", TargetType.DECIMAL)); + assertEquals("2L", PostSetSupport.expression("2", TargetType.LONG)); + assertEquals("2", PostSetSupport.expression("2", TargetType.INTEGER)); + // a number into a text column is that text, not a bare number assigned to a String + assertEquals("\"2\"", PostSetSupport.expression("2", TargetType.STRING)); + assertEquals("\"true\"", PostSetSupport.expression("true", TargetType.STRING)); + // an FK the target declares under another name is not resolvable: the bare integer, as before + assertEquals("2", PostSetSupport.expression("2", TargetType.UNKNOWN)); + // a copy is never retyped - the two columns' types are the author's business + assertEquals("item.Quantity", PostSetSupport.expression("item.Quantity", TargetType.DECIMAL)); + assertEquals("null", PostSetSupport.expression("null", TargetType.DECIMAL)); + } + + /** + * The target column's type is read off the model - a field by its own spelling, a relation by its + * key. + */ + @Test + void readsTheTargetColumnsTypeOffTheModel() { + IntentModel model = IntentParser.parse(YAML); + EntityIntent movement = IntentEntities.byName(model) + .get("StockMovement"); + assertEquals(TargetType.DECIMAL, PostSetSupport.targetType(movement, IntentEntities.byName(model), "Quantity")); + assertEquals(TargetType.LONG, PostSetSupport.targetType(movement, IntentEntities.byName(model), "Sequence")); + assertEquals(TargetType.INTEGER, PostSetSupport.targetType(movement, IntentEntities.byName(model), "Direction")); + assertEquals(TargetType.STRING, PostSetSupport.targetType(movement, IntentEntities.byName(model), "Note")); + assertEquals(TargetType.TEMPORAL, PostSetSupport.targetType(movement, IntentEntities.byName(model), "PostedOn")); + // a to-one relation is its target's integer key column + assertEquals(TargetType.INTEGER, PostSetSupport.targetType(movement, IntentEntities.byName(model), "Product")); + // no such column: rendered as it always was + assertEquals(TargetType.UNKNOWN, PostSetSupport.targetType(movement, IntentEntities.byName(model), "Nowhere")); + } + + /** + * A constant the target column cannot hold is refused at parse - it would otherwise reach javac as + * a literal of the wrong type and break the compile of the whole generated module, the class of + * defect the closed vocabulary exists to end. + */ + @Test + void refusesAConstantTheColumnCannotHold() { + assertRefused(YAML.replace("Sequence: 10", "Sequence: -3.5"), "set [Sequence]", "has a fraction"); + assertRefused(YAML.replace("Direction: 2", "Direction: issued"), "set [Direction]", "is not one"); + assertRefused(YAML.replace("Quantity: \"-item.Quantity\"", "Quantity: issued"), "set [Quantity]", "is not a number"); + assertRefused(YAML.replace("Note: issued", "PostedOn: today"), "set [PostedOn]", "date/time"); + } + + /** + * The remedy the refusal prints WORKS: the spelling it names survives YAML and reaches the parser + * with its double quotes, so following it renders the text as a string literal instead of earning + * the identical refusal a second time (dirigible #7287). + */ + @Test + @SuppressWarnings("unchecked") + void theQuotedRemedySurvivesYaml() { + String dotted = YAML.replace("Note: issued", "Note: Receipt.Store"); + IntentValidationException failure = assertThrows(IntentValidationException.class, () -> IntentParser.parse(dotted)); + String refusal = failure.getIssues() + .stream() + .filter(issue -> issue.contains("set [Note]")) + .findFirst() + .orElseThrow(); + assertTrue(refusal.contains("'\"Receipt.Store\"'"), "expected the remedy to be spelled for YAML, got " + refusal); + + // the remedy, verbatim, through the parser + IntentModel model = IntentParser.parse(YAML.replace("Note: issued", "Note: '\"Receipt.Store\"'")); + List> assigns = (List>) GlueIntentGenerator.buildPostsForTest(model) + .get(0) + .get("assigns"); + assertEquals(Map.of("field", "Note", "expr", "\"Receipt.Store\""), assigns.get(assigns.size() - 1)); + } + + private static void assertRefused(String yaml, String field, String reason) { + IntentValidationException failure = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(failure.getIssues() + .stream() + .anyMatch(issue -> issue.contains(field) && issue.contains(reason)), + "expected a refusal naming " + field + " and [" + reason + "], got " + failure.getIssues()); + } + /** * A value that reads as an expression the renderer cannot compile is refused at parse time, naming * the rule and the field - never rendered, since both other outcomes are silent. diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java index fda5dfd1636..1073353bbc8 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java @@ -18,8 +18,15 @@ * into and break the compile of the whole generated module - an authored {@code defaultValue: '6"'} * rendered {@code entity.Size = "6"";} (dirigible #7154). Escaping the value keeps the worst case * at one mis-valued field. + * + *

+ * {@link #escape(String)} is THE escape for "an authored value inside a generated Java literal", + * and it is public for that reason: the loop was copied into engine-intent twice before (dirigible + * #7287), where a copy that drifts is a compile error in a generated module nobody sees until a + * regen. Anything that renders a Java literal from a model value calls this - it does not grow a + * fourth copy. */ -final class JavaLiterals { +public final class JavaLiterals { /** * Not instantiable. @@ -33,7 +40,7 @@ private JavaLiterals() {} * @param value the raw value * @return the escaped value, ready to be placed between two double quotes */ - static String escape(String value) { + public static String escape(String value) { StringBuilder escaped = new StringBuilder(value.length() + 8); for (int index = 0; index < value.length(); index++) { char character = value.charAt(index); @@ -73,7 +80,7 @@ static String escape(String value) { * @param defaultValue the authored default, as the model carries it * @return the Java expression, or null when there is none */ - static String defaultValueExpression(String javaClass, String defaultValue) { + public static String defaultValueExpression(String javaClass, String defaultValue) { if (javaClass == null || defaultValue == null || defaultValue.isEmpty()) { return null; } 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 b265aea5d72..330138e04f3 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 @@ -3520,6 +3520,10 @@ void posts_writes_every_row_of_one_source_event_in_one_transaction() { fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: quantity, type: decimal, precision: 18, scale: 3 } + - { name: factor, type: decimal, precision: 18, scale: 2 } + - { name: sequence, type: long } + - { name: direction, type: integer } + - { name: ledger, type: string, length: 20 } relations: - { name: Product, kind: manyToOne, to: Product } - { name: GoodsIssue, kind: manyToOne, to: GoodsIssue } @@ -3539,6 +3543,10 @@ void posts_writes_every_row_of_one_source_event_in_one_transaction() { set: Product: item.Product Quantity: "-item.Quantity" + Factor: -1.5 + Sequence: 7 + Direction: 2 + Ledger: issued - name: goodsIssueNote forEntity: GoodsIssue event: create @@ -3573,6 +3581,14 @@ void posts_writes_every_row_of_one_source_event_in_one_transaction() { assertEquals(save, post.lastIndexOf("targetRepository.save(row)"), "there must be exactly ONE save site - a second one outside the block would write rows unprotected"); assertFalse(post.contains("${"), "the post template must render every placeholder"); + // #7287: a constant is rendered for the TARGET COLUMN's Java type. A decimal column is a + // BigDecimal and a long one a Long in the generated entity, so the bare `-1.5` / `7` this used + // to emit did not compile - and neither did a bare identifier for the text. + assertTrue(post.contains("row.Factor = new java.math.BigDecimal(\"-1.5\");"), + "a decimal column takes a BigDecimal, not a bare double literal: " + post); + assertTrue(post.contains("row.Sequence = 7L;"), "a long column takes a long literal, not a bare int: " + post); + assertTrue(post.contains("row.Direction = 2;"), "an integer column keeps the bare integer: " + post); + assertTrue(post.contains("row.Ledger = \"issued\";"), "a text column takes an escaped string literal: " + post); // The single-row mode (no forEach) writes one row through one repository call - a transaction on // its own, so it needs no unit of work and must not pretend to open one. diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentPostsAtomicityIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentPostsAtomicityIT.java index 8875e8d1a13..c0548265e43 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentPostsAtomicityIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentPostsAtomicityIT.java @@ -92,6 +92,8 @@ class IntentPostsAtomicityIT extends IntegrationTest { fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: quantity, type: decimal, precision: 18, scale: 2, required: true } + - { name: factor, type: decimal, precision: 18, scale: 2 } + - { name: sequence, type: long } relations: - { name: GoodsIssue, kind: manyToOne, to: GoodsIssue } @@ -116,6 +118,8 @@ class IntentPostsAtomicityIT extends IntegrationTest { idempotentBy: GoodsIssue set: Quantity: item.Quantity + Factor: -1.5 + Sequence: 7 - name: goodsIssueNote forEntity: GoodsIssue event: 2 @@ -177,6 +181,18 @@ void a_refused_row_leaves_no_post_and_the_redelivery_writes_the_whole_one() { .then() .statusCode(200) .body("findAll { it.GoodsIssue == " + issue + " }.Quantity.sum()", equalTo(8.0))); + + // #7287: the two constants are written to a DECIMAL and a LONG column. A bare `-1.5` / `7` in + // the generated assignment does not compile (the columns are a BigDecimal and a Long), so this + // whole run - generate, publish, compile, post - is the proof that they are rendered typed: + // nothing above would have produced a row at all. + restAssuredExecutor.execute(() -> given().when() + .get(API + "/stockmovement/StockMovementController") + .then() + .statusCode(200) + .body("findAll { it.GoodsIssue == " + issue + + " }.every { it.Factor.toString().startsWith('-1.5')" + + " && it.Sequence.toString() == '7' }", equalTo(true))); } private void transition(String name, int id) {