Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,17 @@ private AuthoredDefaults() {}
/**
* Whether an authored boolean default reads as true.
*
* <p>
* Read through {@link #unquote(String)}, so the SQL-quoted shape a working DB DEFAULT needs
* ({@code 'true'}) reads as the bare one - and reads the same way in every generated language,
* which is this class's whole reason to exist.
*
* @param defaultValue the authored default
* @return true when it does
*/
static boolean readsAsTrue(String defaultValue) {
return "true".equals(defaultValue) || "TRUE".equals(defaultValue) || "1".equals(defaultValue);
String value = unquote(defaultValue);
return "true".equals(value) || "TRUE".equals(value) || "1".equals(value);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
*/
package org.eclipse.dirigible.components.ide.template.service.model;

import java.util.function.Consumer;

/**
* Java literals for values a model carries as text.
*
Expand Down Expand Up @@ -69,31 +71,63 @@
* the property has no default that a Java literal can stand in for.
*
* <p>
* A numeric default is parsed from its authored text rather than inlined as a numeric literal, so
* an author's {@code "8.0"} on an integer column fails that one create instead of failing the whole
* generated build. A string default is read in either authoring shape ({@link AuthoredDefaults}),
* and both yield the string the column would hold. A date/time or binary column has no literal: its
* DEFAULT is emitted verbatim into the DDL and is typically a SQL expression ({@code CURRENT_DATE},
* {@code now()}).
* Every arm reads the authored text through {@link AuthoredDefaults}, so both authoring shapes -
* bare and SQL-quoted - yield the value the column would hold. Reading it in only one arm is how
* {@code defaultValue: "'20'"} on an integer column seeded {@code 20} in the item dialog and
* emitted {@code Integer.valueOf("'20'")} in the repository, a {@code NumberFormatException} on
* every create that relied on the default (dirigible #7293).
*
* <p>
* A numeric default is parsed from its text rather than inlined as a numeric literal - the same
* parse the generated expression performs, which is why it is run HERE: an unparsable numeric
* default is refused while the author is generating, naming the property, instead of compiling into
* an expression that throws on every create of that entity. A date/time or binary column has no
* literal: its DEFAULT is emitted verbatim into the DDL and is typically a SQL expression
* ({@code CURRENT_DATE}, {@code now()}).
*
* @param javaClass the property's Java class, as the parameter graph resolved it
* @param defaultValue the authored default, as the model carries it
* @param property the property the default is authored on, for the refusal message
* @return the Java expression, or null when there is none
* @throws IllegalArgumentException when a numeric property's default is not a value of its type
*/
public static String defaultValueExpression(String javaClass, String defaultValue) {
public static String defaultValueExpression(String javaClass, String defaultValue, String property) {
if (javaClass == null || defaultValue == null || defaultValue.isEmpty()) {
return null;
}
String value = AuthoredDefaults.unquote(defaultValue);
return switch (javaClass) {
case "java.math.BigDecimal" -> "new java.math.BigDecimal(\"" + escape(defaultValue) + "\")";
case "Double" -> "Double.valueOf(\"" + escape(defaultValue) + "\")";
case "Float" -> "Float.valueOf(\"" + escape(defaultValue) + "\")";
case "Long" -> "Long.valueOf(\"" + escape(defaultValue) + "\")";
case "Integer" -> "Integer.valueOf(\"" + escape(defaultValue) + "\")";
case "Short" -> "Short.valueOf(\"" + escape(defaultValue) + "\")";
case "java.math.BigDecimal" -> numericExpression("new java.math.BigDecimal", value, javaClass, property,
java.math.BigDecimal::new);
case "Double" -> numericExpression("Double.valueOf", value, javaClass, property, Double::valueOf);
case "Float" -> numericExpression("Float.valueOf", value, javaClass, property, Float::valueOf);
case "Long" -> numericExpression("Long.valueOf", value, javaClass, property, Long::valueOf);
case "Integer" -> numericExpression("Integer.valueOf", value, javaClass, property, Integer::valueOf);
case "Short" -> numericExpression("Short.valueOf", value, javaClass, property, Short::valueOf);
case "Boolean" -> AuthoredDefaults.readsAsTrue(defaultValue) ? "Boolean.TRUE" : "Boolean.FALSE";
case "String" -> "\"" + escape(AuthoredDefaults.unquote(defaultValue)) + "\"";
case "String" -> "\"" + escape(value) + "\"";
default -> null;
};
}

/**
* A numeric default as the factory call the generated code applies it through, refusing a text the
* very same factory cannot read.
*
* @param factory the factory the expression calls
* @param value the authored default, unquoted
* @param javaClass the property's Java class, for the refusal message
* @param property the property the default is authored on, for the refusal message
* @param parse the factory itself, run here on the authored text
* @return the factory call
*/
private static String numericExpression(String factory, String value, String javaClass, String property, Consumer<String> parse) {
try {
parse.accept(value);
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("Property [" + property + "] declares the default [" + value
+ "], which is not a value of its type [" + javaClass + "] - every create applying it would fail.", ex);
}
return factory + "(\"" + escape(value) + "\")";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ private static void processProperty(Map<String, Object> property, Map<String, Ob
property.put("widgetIsMajor", Boolean.FALSE);
}

resolveDefaultValueLiterals(property);
resolveDefaultValueLiterals(property, entity);

resolveWidgetLengths(property, entity, dataType);
// After the widget flags: the seed is emitted in the shape the draft holds, and a numeric
Expand Down Expand Up @@ -447,8 +447,9 @@ private static void processProperty(Map<String, Object> property, Map<String, Ob
* property has a default".
*
* @param property the property
* @param entity the owning entity, to name the property in a refusal
*/
private static void resolveDefaultValueLiterals(Map<String, Object> property) {
private static void resolveDefaultValueLiterals(Map<String, Object> property, Map<String, Object> entity) {
String defaultValue = str(property, "dataDefaultValue");
if (defaultValue == null || defaultValue.isEmpty()) {
return;
Expand All @@ -460,7 +461,8 @@ private static void resolveDefaultValueLiterals(Map<String, Object> property) {
if (Boolean.TRUE.equals(property.get("dataPrimaryKey")) || Boolean.TRUE.equals(property.get("dataAutoIncrement"))) {
return;
}
String expression = JavaLiterals.defaultValueExpression(str(property, "dataTypeJavaClass"), defaultValue);
String expression = JavaLiterals.defaultValueExpression(str(property, "dataTypeJavaClass"), defaultValue,
str(entity, "name") + "." + str(property, "name"));
if (expression != null) {
property.put("dataDefaultValueJavaLiteral", expression);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Tests the Java literals a model value is written into a generated source as.
Expand Down Expand Up @@ -50,36 +52,58 @@ void escapesTheControlCharactersThatWouldEndTheLine() {
}

/**
* A numeric default is parsed from its authored text rather than inlined, so an author's "8.0" on
* an integer column fails that one create instead of failing the whole generated build.
* A numeric default is parsed from its authored text rather than inlined as a numeric literal -
* which keeps BigDecimal exact and needs no per-type suffix.
*/
@Test
void parsesANumericDefaultFromItsAuthoredText() {
assertEquals("new java.math.BigDecimal(\"20.00\")", JavaLiterals.defaultValueExpression("java.math.BigDecimal", "20.00"));
assertEquals("Double.valueOf(\"1.5\")", JavaLiterals.defaultValueExpression("Double", "1.5"));
assertEquals("Float.valueOf(\"1.5\")", JavaLiterals.defaultValueExpression("Float", "1.5"));
assertEquals("Long.valueOf(\"7\")", JavaLiterals.defaultValueExpression("Long", "7"));
assertEquals("Integer.valueOf(\"7\")", JavaLiterals.defaultValueExpression("Integer", "7"));
assertEquals("Short.valueOf(\"7\")", JavaLiterals.defaultValueExpression("Short", "7"));
assertEquals("Integer.valueOf(\"8.0\")", JavaLiterals.defaultValueExpression("Integer", "8.0"));
assertEquals("new java.math.BigDecimal(\"20.00\")", JavaLiterals.defaultValueExpression("java.math.BigDecimal", "20.00", "E.P"));
assertEquals("Double.valueOf(\"1.5\")", JavaLiterals.defaultValueExpression("Double", "1.5", "E.P"));
assertEquals("Float.valueOf(\"1.5\")", JavaLiterals.defaultValueExpression("Float", "1.5", "E.P"));
assertEquals("Long.valueOf(\"7\")", JavaLiterals.defaultValueExpression("Long", "7", "E.P"));
assertEquals("Integer.valueOf(\"7\")", JavaLiterals.defaultValueExpression("Integer", "7", "E.P"));
assertEquals("Short.valueOf(\"7\")", JavaLiterals.defaultValueExpression("Short", "7", "E.P"));
assertEquals("Integer.valueOf(\"-7\")", JavaLiterals.defaultValueExpression("Integer", "-7", "E.P"));
}

/**
* A numeric default is the one place a malformed value cannot even be escaped into something that
* parses - so it must still compile, and fail at that one create.
* The defect: the SQL-quoted shape was read in the String arm only, so an integer column's
* {@code '20'} seeded 20 in the item dialog and emitted {@code Integer.valueOf("'20'")} in the
* repository - a NumberFormatException on every create that relied on the default (#7293).
*/
@Test
void escapesAMalformedNumericDefaultTooRatherThanBreakingTheCompile() {
assertEquals("Integer.valueOf(\"7\\\"\")", JavaLiterals.defaultValueExpression("Integer", "7\""));
void readsANumericDefaultInEitherAuthoringShape() {
assertEquals("Integer.valueOf(\"20\")", JavaLiterals.defaultValueExpression("Integer", "'20'", "E.P"));
assertEquals("Long.valueOf(\"20\")", JavaLiterals.defaultValueExpression("Long", "'20'", "E.P"));
assertEquals("new java.math.BigDecimal(\"20.00\")", JavaLiterals.defaultValueExpression("java.math.BigDecimal", "'20.00'", "E.P"));
}

/**
* A text the property's own factory cannot read is refused while the author is generating, naming
* the property - it used to compile into an expression that threw on every create of that entity.
*/
@Test
void refusesANumericDefaultThatIsNotAValueOfItsType() {
IllegalArgumentException refusal =
assertThrows(IllegalArgumentException.class, () -> JavaLiterals.defaultValueExpression("Integer", "8.0", "Order.Lines"));
assertTrue(refusal.getMessage()
.contains("Order.Lines"),
"the refusal must name the property, got: " + refusal.getMessage());
assertThrows(IllegalArgumentException.class, () -> JavaLiterals.defaultValueExpression("Integer", "7\"", "E.P"));
assertThrows(IllegalArgumentException.class, () -> JavaLiterals.defaultValueExpression("Integer", "N/A", "E.P"));
assertThrows(IllegalArgumentException.class, () -> JavaLiterals.defaultValueExpression("java.math.BigDecimal", "1 or 2", "E.P"));
assertThrows(IllegalArgumentException.class, () -> JavaLiterals.defaultValueExpression("Long", "nextval('s')", "E.P"));
}

@Test
void readsABooleanDefaultInEveryAuthoredShape() {
assertEquals("Boolean.TRUE", JavaLiterals.defaultValueExpression("Boolean", "true"));
assertEquals("Boolean.TRUE", JavaLiterals.defaultValueExpression("Boolean", "TRUE"));
assertEquals("Boolean.TRUE", JavaLiterals.defaultValueExpression("Boolean", "1"));
assertEquals("Boolean.FALSE", JavaLiterals.defaultValueExpression("Boolean", "false"));
assertEquals("Boolean.FALSE", JavaLiterals.defaultValueExpression("Boolean", "0"));
assertEquals("Boolean.TRUE", JavaLiterals.defaultValueExpression("Boolean", "true", "E.P"));
assertEquals("Boolean.TRUE", JavaLiterals.defaultValueExpression("Boolean", "TRUE", "E.P"));
assertEquals("Boolean.TRUE", JavaLiterals.defaultValueExpression("Boolean", "1", "E.P"));
assertEquals("Boolean.FALSE", JavaLiterals.defaultValueExpression("Boolean", "false", "E.P"));
assertEquals("Boolean.FALSE", JavaLiterals.defaultValueExpression("Boolean", "0", "E.P"));
assertEquals("Boolean.TRUE", JavaLiterals.defaultValueExpression("Boolean", "'true'", "E.P"));
assertEquals("Boolean.FALSE", JavaLiterals.defaultValueExpression("Boolean", "'false'", "E.P"));
}

/**
Expand All @@ -88,24 +112,24 @@ void readsABooleanDefaultInEveryAuthoredShape() {
*/
@Test
void readsAStringDefaultInEitherAuthoringShape() {
assertEquals("\"DRAFT\"", JavaLiterals.defaultValueExpression("String", "DRAFT"));
assertEquals("\"DRAFT\"", JavaLiterals.defaultValueExpression("String", "'DRAFT'"));
assertEquals("\"'\"", JavaLiterals.defaultValueExpression("String", "'"));
assertEquals("\"6\\\"\"", JavaLiterals.defaultValueExpression("String", "6\""));
assertEquals("\"6\\\"\"", JavaLiterals.defaultValueExpression("String", "'6\"'"));
assertEquals("\"DRAFT\"", JavaLiterals.defaultValueExpression("String", "DRAFT", "E.P"));
assertEquals("\"DRAFT\"", JavaLiterals.defaultValueExpression("String", "'DRAFT'", "E.P"));
assertEquals("\"'\"", JavaLiterals.defaultValueExpression("String", "'", "E.P"));
assertEquals("\"6\\\"\"", JavaLiterals.defaultValueExpression("String", "6\"", "E.P"));
assertEquals("\"6\\\"\"", JavaLiterals.defaultValueExpression("String", "'6\"'", "E.P"));
}

@Test
void hasNoExpressionForATypeWhoseDefaultIsASqlExpression() {
assertNull(JavaLiterals.defaultValueExpression("java.time.LocalDate", "CURRENT_DATE"));
assertNull(JavaLiterals.defaultValueExpression("java.time.Instant", "now()"));
assertNull(JavaLiterals.defaultValueExpression("byte[]", "x"));
assertNull(JavaLiterals.defaultValueExpression("java.time.LocalDate", "CURRENT_DATE", "E.P"));
assertNull(JavaLiterals.defaultValueExpression("java.time.Instant", "now()", "E.P"));
assertNull(JavaLiterals.defaultValueExpression("byte[]", "x", "E.P"));
}

@Test
void hasNoExpressionWithoutADefault() {
assertNull(JavaLiterals.defaultValueExpression("String", null));
assertNull(JavaLiterals.defaultValueExpression("String", ""));
assertNull(JavaLiterals.defaultValueExpression(null, "DRAFT"));
assertNull(JavaLiterals.defaultValueExpression("String", null, "E.P"));
assertNull(JavaLiterals.defaultValueExpression("String", "", "E.P"));
assertNull(JavaLiterals.defaultValueExpression(null, "DRAFT", "E.P"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ void seedsEachWidgetInTheShapeTheDraftHolds() {
assertEquals("true", JsLiterals.defaultValueExpression("CHECKBOX", false, "TRUE"));
assertEquals("true", JsLiterals.defaultValueExpression("CHECKBOX", false, "1"));
assertEquals("false", JsLiterals.defaultValueExpression("CHECKBOX", false, "false"));
assertEquals("true", JsLiterals.defaultValueExpression("CHECKBOX", false, "'true'"));
assertEquals("false", JsLiterals.defaultValueExpression("CHECKBOX", false, "'false'"));
assertEquals("20.00", JsLiterals.defaultValueExpression("TEXTBOX", true, "20.00"));
assertEquals("'DRAFT'", JsLiterals.defaultValueExpression("TEXTBOX", false, "DRAFT"));
// A dropdown's FK stays a string even though it reads as a number, so it matches an option's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,50 @@ void carriesTheAuthoredDefaultAsAnEscapedJavaScriptSeed() {
assertEquals("20", rate.get("dataDefaultValueJsLiteral"));
}

/**
* The defect: one authored default reaches several generated languages, and the SQL-quoted shape
* was read in the Java String arm only - so an integer column's {@code '20'} seeded 20 in the item
* dialog and emitted {@code Integer.valueOf("'20'")} in the repository, a NumberFormatException on
* every create that relied on it (#7293). The two literals are asserted together because agreeing
* is the whole invariant.
*/
@Test
void readsAQuotedNumericDefaultTheSameWayInBothLanguages() {
Map<String, Object> quantity = property("Quantity", "INTEGER");
quantity.put("dataDefaultValue", "'20'");
Map<String, Object> rate = property("VatRate", "DECIMAL");
rate.put("dataDefaultValue", "'20.00'");
Map<String, Object> billable = property("Billable", "BOOLEAN");
billable.put("widgetType", "CHECKBOX");
billable.put("dataDefaultValue", "'true'");
ModelParameterProcessor.process(model(entity("Line", "Lines", quantity, rate, billable)), parameters());

assertEquals("Integer.valueOf(\"20\")", quantity.get("dataDefaultValueJavaLiteral"));
assertEquals("20", quantity.get("dataDefaultValueJsLiteral"));
assertEquals("new java.math.BigDecimal(\"20.00\")", rate.get("dataDefaultValueJavaLiteral"));
assertEquals("20.00", rate.get("dataDefaultValueJsLiteral"));
assertEquals("Boolean.TRUE", billable.get("dataDefaultValueJavaLiteral"));
assertEquals("true", billable.get("dataDefaultValueJsLiteral"));
}

/**
* A numeric default the property's own type cannot read is refused while the author is generating,
* naming the property - it used to compile into an expression that threw on every create.
*/
@Test
void refusesANumericDefaultThatIsNotAValueOfItsType() {
Map<String, Object> quantity = property("Quantity", "INTEGER");
quantity.put("dataDefaultValue", "8.0");
Map<String, Object> model = model(entity("Line", "Lines", quantity));

IllegalArgumentException refusal =
assertThrows(IllegalArgumentException.class, () -> ModelParameterProcessor.process(model, parameters()));

assertTrue(refusal.getMessage()
.contains("Line.Quantity"),
"the refusal must name the property, got: " + refusal.getMessage());
}

/**
* The key's presence is what the template reads as "this property has a default to seed", so a
* property with none must leave it absent rather than null.
Expand Down
Loading