diff --git a/modules/parsers/document/src/main/java/org/eclipse/dirigible/parsers/document/binding/DataBinder.java b/modules/parsers/document/src/main/java/org/eclipse/dirigible/parsers/document/binding/DataBinder.java index a1f1d945406..255438eb2cf 100644 --- a/modules/parsers/document/src/main/java/org/eclipse/dirigible/parsers/document/binding/DataBinder.java +++ b/modules/parsers/document/src/main/java/org/eclipse/dirigible/parsers/document/binding/DataBinder.java @@ -12,6 +12,12 @@ import java.math.BigDecimal; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -57,9 +63,29 @@ * {@code {{document.Customer.NameLocal|document.Customer.Name}}} prints the * local name when the record has one and the canonical name when it does not, instead of leaving a * hole in a legal document. + * + *

+ * An operand may also carry an explicit format after its first {@code :} — a + * {@code DecimalFormat} pattern for numbers ({@code {{Price:#,##0.00}}}), a + * {@code DateTimeFormatter} pattern for temporals and their ISO string form + * ({@code {{document.Date:dd.MM.yyyy}}}). It exists because the default number + * rendering cannot know a bare integral JSON value ({@code 5390}) is money that lost its scale on + * the way through the browser — only the template author knows, and says so per placeholder. */ public final class DataBinder { + /** + * The generated forms' money symbols: ROOT locale (deterministic output), thousands grouped by a + * space. {@code DecimalFormat} clones the symbols it is constructed with, so sharing is safe. + */ + private static final DecimalFormatSymbols MONEY_SYMBOLS = moneySymbols(); + + private static DecimalFormatSymbols moneySymbols() { + DecimalFormatSymbols symbols = new DecimalFormatSymbols(Locale.ROOT); + symbols.setGroupingSeparator(' '); + return symbols; + } + private final TagRegistry registry; /** @@ -249,12 +275,31 @@ private static String substitute(String value, Scope scope) { * whitespace-only. The LAST operand is always rendered, whatever it resolves to, so a lone path * behaves exactly as it always has (all operands blank renders empty) and only the alternatives * before it can be skipped. + * + *

+ * An operand may carry an explicit format after its first {@code :} - the pattern applied to + * that operand's resolved value: a {@link DecimalFormat} pattern for a number + * ({@code {{Price:#,##0.00}}} prints a whole-figure {@code 5390} as + * {@code 5 390.00}, which the default rendering cannot - it never sees the lost scale of an + * integral JSON number), or a {@link DateTimeFormatter} pattern for a temporal or its ISO string + * form ({@code {{document.Date:dd.MM.yyyy}}}). The first colon splits, so a + * time pattern keeps its own colons ({@code {{At:HH:mm}}}). A pattern the value + * cannot satisfy - or a value of any other type - falls back to the default rendering: a printout + * never shows an exception, matching the parser's leniency contract. */ private static String resolvePlaceholder(String body, Scope scope) { String[] operands = body.split("\\|"); for (int i = 0; i < operands.length; i++) { - Object resolved = scope.resolve(operands[i].trim()); - String rendered = resolved == null ? "" : stringify(resolved); + String operand = operands[i]; + String pattern = null; + int colon = operand.indexOf(':'); + if (colon >= 0) { + pattern = operand.substring(colon + 1) + .trim(); + operand = operand.substring(0, colon); + } + Object resolved = scope.resolve(operand.trim()); + String rendered = resolved == null ? "" : stringify(resolved, pattern); if (i == operands.length - 1 || !rendered.isBlank()) { return rendered; } @@ -268,21 +313,74 @@ private static String resolvePlaceholder(String body, Scope scope) { * {@code Map} (a relation/object node) prints its {@code __label} value so a bare * {@code {{document.Customer}}} still renders the display label while * {@code {{document.Customer.Address}}} descends into the same node; every other value prints via - * {@code toString}. + * {@code toString}. An explicit placeholder format takes precedence when the value fits it (see + * {@link #resolvePlaceholder(String, Scope)}). */ - private static String stringify(Object resolved) { - if (resolved instanceof Double || resolved instanceof Float || resolved instanceof BigDecimal) { - DecimalFormatSymbols symbols = new DecimalFormatSymbols(Locale.ROOT); - symbols.setGroupingSeparator(' '); - return new DecimalFormat("###,###,###,##0.00", symbols).format(resolved); - } + private static String stringify(Object resolved, String pattern) { if (resolved instanceof Map map) { Object label = map.get("__label"); - return label == null ? "" : stringify(label); + return label == null ? "" : stringify(label, pattern); + } + if (pattern != null && !pattern.isBlank()) { + String formatted = tryFormat(resolved, pattern); + if (formatted != null) { + return formatted; + } + } + if (resolved instanceof Double || resolved instanceof Float || resolved instanceof BigDecimal) { + return new DecimalFormat("###,###,###,##0.00", MONEY_SYMBOLS).format(resolved); } return String.valueOf(resolved); } + /** + * The explicit format of one operand: a {@link DecimalFormat} pattern over any {@link Number} + * (integral ones included - the reason the specifier exists), a {@link DateTimeFormatter} pattern + * over a temporal or the ISO string a feeder emits for one. {@code null} when the value is of any + * other shape or cannot satisfy the pattern - the caller then renders the default way. + */ + private static String tryFormat(Object resolved, String pattern) { + try { + if (resolved instanceof Number number) { + return new DecimalFormat(pattern, MONEY_SYMBOLS).format(number); + } + TemporalAccessor temporal = asTemporal(resolved); + if (temporal != null) { + return DateTimeFormatter.ofPattern(pattern, Locale.ROOT) + .format(temporal); + } + } catch (RuntimeException ex) { + // an invalid pattern, or one asking for fields the value does not carry + } + return null; + } + + /** A temporal as-is, or an ISO date / date-time string parsed back into one; else {@code null}. */ + private static TemporalAccessor asTemporal(Object resolved) { + if (resolved instanceof TemporalAccessor temporal) { + return temporal; + } + if (resolved instanceof String string) { + String candidate = string.trim(); + try { + return LocalDate.parse(candidate); + } catch (DateTimeParseException notADate) { + // fall through to the date-time shapes + } + try { + return LocalDateTime.parse(candidate); + } catch (DateTimeParseException notALocalDateTime) { + // fall through + } + try { + return OffsetDateTime.parse(candidate); + } catch (DateTimeParseException notATemporal) { + // not a temporal string + } + } + return null; + } + private static boolean isTruthy(Object value) { return switch (value) { case null -> false; diff --git a/modules/parsers/document/src/test/java/org/eclipse/dirigible/parsers/document/binding/DataBinderTest.java b/modules/parsers/document/src/test/java/org/eclipse/dirigible/parsers/document/binding/DataBinderTest.java index e721e7747ad..805db28b714 100644 --- a/modules/parsers/document/src/test/java/org/eclipse/dirigible/parsers/document/binding/DataBinderTest.java +++ b/modules/parsers/document/src/test/java/org/eclipse/dirigible/parsers/document/binding/DataBinderTest.java @@ -373,4 +373,66 @@ public void aSingleWhitespaceOnlyPathStillRendersItsValue() { .get(0) .text()); } + + @Test + public void formatSpecifierMoneyFormatsIntegralNumbers() { + // The reason the specifier exists: a whole-figure money value arrives as an integral JSON + // number (the browser strips the trailing .00), lands as a Long and the default rendering + // rightly leaves integers alone - only the template author knows the column is money. + Node root = parser.parse("{{price:#,##0.00}} / {{big:#,##0.00}}"); + Node bound = binder.bind(root, Map.of("price", 5390L, "big", 1234567L)); + assertEquals("5 390.00 / 1 234 567.00", bound.children() + .get(0) + .text()); + } + + @Test + public void formatSpecifierOverridesTheDefaultMoneyPattern() { + Node root = parser.parse("{{total:0.00}}"); + Node bound = binder.bind(root, Map.of("total", 5390.5d)); + // the explicit pattern wins over the default space-grouped one + assertEquals("5390.50", bound.children() + .get(0) + .text()); + } + + @Test + public void formatSpecifierFormatsTemporalsAndTheirIsoStrings() { + // Feeders emit temporals as their ISO string - both the string and a real temporal reformat. + Node root = parser.parse("{{document.Date:dd.MM.yyyy}} / {{document.Day:dd.MM.yyyy}}"); + Node bound = binder.bind(root, Map.of("document", Map.of("Date", "2026-08-29", "Day", java.time.LocalDate.of(2026, 8, 29)))); + assertEquals("29.08.2026 / 29.08.2026", bound.children() + .get(0) + .text()); + } + + @Test + public void formatSpecifierSplitsAtTheFirstColonSoTimePatternsKeepTheirs() { + Node root = parser.parse("{{at:HH:mm}}"); + Node bound = binder.bind(root, Map.of("at", "2026-08-29T10:15:30")); + assertEquals("10:15", bound.children() + .get(0) + .text()); + } + + @Test + public void formatAPatternCannotSatisfyFallsBackToTheDefaultRendering() { + // A string is neither a number nor a temporal ('Widget'), and 'bb' is not a valid date + // pattern - both render exactly as without the specifier: a printout never shows an exception. + Node root = parser.parse("{{name:0.00}} / {{document.Date:bb}}"); + Node bound = binder.bind(root, Map.of("name", "Widget", "document", Map.of("Date", "2026-08-29"))); + assertEquals("Widget / 2026-08-29", bound.children() + .get(0) + .text()); + } + + @Test + public void formatSpecifierCombinesWithAlternativePaths() { + // Each operand carries its own format; blankness is judged on the rendered result. + Node root = parser.parse("{{document.Missing:0.00|document.Price:#,##0.00}}"); + Node bound = binder.bind(root, Map.of("document", Map.of("Price", 5390L))); + assertEquals("5 390.00", bound.children() + .get(0) + .text()); + } }