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 @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>
* An operand may also carry an explicit <b>format</b> after its first {@code :} — a
* {@code DecimalFormat} pattern for numbers ({@code &#123;&#123;Price:#,##0.00&#125;&#125;}), a
* {@code DateTimeFormatter} pattern for temporals and their ISO string form
* ({@code &#123;&#123;document.Date:dd.MM.yyyy&#125;&#125;}). 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;

/**
Expand Down Expand Up @@ -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.
*
* <p>
* An operand may carry an explicit <b>format</b> after its first {@code :} - the pattern applied to
* that operand's resolved value: a {@link DecimalFormat} pattern for a number
* ({@code &#123;&#123;Price:#,##0.00&#125;&#125;} 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 &#123;&#123;document.Date:dd.MM.yyyy&#125;&#125;}). The first colon splits, so a
* time pattern keeps its own colons ({@code &#123;&#123;At:HH:mm&#125;&#125;}). 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;
}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<document><text>{{price:#,##0.00}} / {{big:#,##0.00}}</text></document>");
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("<document><text>{{total:0.00}}</text></document>");
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><text>{{document.Date:dd.MM.yyyy}} / {{document.Day:dd.MM.yyyy}}</text></document>");
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("<document><text>{{at:HH:mm}}</text></document>");
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("<document><text>{{name:0.00}} / {{document.Date:bb}}</text></document>");
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><text>{{document.Missing:0.00|document.Price:#,##0.00}}</text></document>");
Node bound = binder.bind(root, Map.of("document", Map.of("Price", 5390L)));
assertEquals("5 390.00", bound.children()
.get(0)
.text());
}
}
Loading