From 3ec925210aa0a353460d12b5ab1e10214db79a13 Mon Sep 17 00:00:00 2001 From: Vasyl Khrystiuk Date: Mon, 8 Apr 2024 01:36:52 +0300 Subject: [PATCH] fix for #298 1) change rendering - now temporal accessors are rendered via pattern without conversion to zoned date time, so Instant is safe now 2) add more patterns support --- src/main/java/liqp/LValue.java | 25 ++- src/main/java/liqp/TemplateParser.java | 3 + src/main/java/liqp/filters/Date.java | 3 +- src/main/java/liqp/filters/date/Parser.java | 144 ++++++++++++------ src/main/java/liqp/nodes/BlockNode.java | 10 +- .../liqp/nodes/ComparingExpressionNode.java | 16 +- src/test/java/liqp/filters/DateTest.java | 8 + 7 files changed, 148 insertions(+), 61 deletions(-) diff --git a/src/main/java/liqp/LValue.java b/src/main/java/liqp/LValue.java index b09ad898..9e91fb84 100644 --- a/src/main/java/liqp/LValue.java +++ b/src/main/java/liqp/LValue.java @@ -7,6 +7,7 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoField; +import java.time.temporal.Temporal; import java.time.temporal.TemporalAccessor; import java.util.ArrayList; import java.util.Arrays; @@ -194,7 +195,23 @@ public static Object[] temporalAsArray(ZonedDateTime time) { return new Object[]{sec, min, hour, day, month, year, wday, yday, isdst, zone}; } - public static ZonedDateTime asTemporal(Object value, TemplateContext context) { + /** + * This one keeps an original temporal type as is, while `asRubyDate` converts it to ZonedDateTime. + */ + public static TemporalAccessor asTemporal(Object value, TemplateContext context) { + ZonedDateTime time = ZonedDateTime.now(); + if (value instanceof TemporalAccessor) { + return (TemporalAccessor) value; + } else if (CustomDateFormatRegistry.isCustomDateType(value)) { + time = CustomDateFormatRegistry.getFromCustomType(value); + } + return time; + } + + /** + * Ruby have a single date type, and its equivalent is ZonedDateTime. + */ + public static ZonedDateTime asRubyDate(Object value, TemplateContext context) { ZonedDateTime time = ZonedDateTime.now(); if (value instanceof TemporalAccessor) { time = getZonedDateTimeFromTemporalAccessor((TemporalAccessor) value, context.getParser().defaultTimeZone); @@ -306,7 +323,7 @@ public String asString(Object value, TemplateContext context) { } if (isTemporal(value)) { - ZonedDateTime time = asTemporal(value, context); + ZonedDateTime time = asRubyDate(value, context); return rubyDateTimeFormat.format(time); } @@ -339,7 +356,7 @@ public Object asAppendableObject(Object value, TemplateContext context) { } if (isTemporal(value)) { - ZonedDateTime time = asTemporal(value, context); + ZonedDateTime time = asRubyDate(value, context); return rubyDateTimeFormat.format(time); } @@ -380,7 +397,7 @@ public boolean isArray(Object value) { * @return true iff `value` is a whole number (Integer or Long). */ public boolean isInteger(Object value) { - return value != null && (value instanceof Long || value instanceof Integer); + return (value instanceof Long || value instanceof Integer); } /** diff --git a/src/main/java/liqp/TemplateParser.java b/src/main/java/liqp/TemplateParser.java index b6d954d5..df078d00 100644 --- a/src/main/java/liqp/TemplateParser.java +++ b/src/main/java/liqp/TemplateParser.java @@ -78,6 +78,9 @@ public enum ErrorMode { public final boolean showExceptionsFromInclude; public final TemplateParser.EvaluateMode evaluateMode; public final Locale locale; + /** + * Never null, if empty - system default timezone is used. + */ public final ZoneId defaultTimeZone; private final RenderTransformer renderTransformer; private final Consumer> environmentMapConfigurator; diff --git a/src/main/java/liqp/filters/Date.java b/src/main/java/liqp/filters/Date.java index f430b262..601822d8 100644 --- a/src/main/java/liqp/filters/Date.java +++ b/src/main/java/liqp/filters/Date.java @@ -9,6 +9,7 @@ import java.time.Instant; import java.time.ZonedDateTime; +import java.time.temporal.TemporalAccessor; import java.util.Locale; import static liqp.filters.date.Parser.datePatterns; @@ -35,7 +36,7 @@ public Object apply(Object value, TemplateContext context, Object... params) { value = asArray(value, context)[0]; } try { - final ZonedDateTime compatibleDate; + final TemporalAccessor compatibleDate; String valAsString = super.asString(value, context); if ("now".equals(valAsString) || "today".equals(valAsString)) { compatibleDate = ZonedDateTime.now(); diff --git a/src/main/java/liqp/filters/date/Parser.java b/src/main/java/liqp/filters/date/Parser.java index 1adaafde..165f2374 100644 --- a/src/main/java/liqp/filters/date/Parser.java +++ b/src/main/java/liqp/filters/date/Parser.java @@ -1,8 +1,6 @@ package liqp.filters.date; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; +import java.time.*; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.TemporalAccessor; @@ -12,13 +10,8 @@ import java.util.List; import java.util.Locale; -import static java.time.temporal.ChronoField.DAY_OF_MONTH; -import static java.time.temporal.ChronoField.HOUR_OF_DAY; -import static java.time.temporal.ChronoField.MINUTE_OF_HOUR; -import static java.time.temporal.ChronoField.MONTH_OF_YEAR; -import static java.time.temporal.ChronoField.NANO_OF_SECOND; -import static java.time.temporal.ChronoField.SECOND_OF_MINUTE; -import static java.time.temporal.ChronoField.YEAR; +import static java.time.temporal.ChronoField.*; +import static java.time.temporal.ChronoField.INSTANT_SECONDS; public class Parser { @@ -37,28 +30,74 @@ public class Parser { public static List datePatterns = new ArrayList<>(); static { + + datePatterns.add("EEE MMM dd hh:mm:ss yyyy"); + datePatterns.add("EEE MMM dd hh:mm yyyy"); + datePatterns.add("yyyy-MM-dd"); + datePatterns.add("dd-MM-yyyy"); + + // this is section without `T`, change here and do same change in section below with `T` + datePatterns.add("yyyy-MM-dd HH:mm"); + datePatterns.add("yyyy-MM-dd HH:mm X"); + datePatterns.add("yyyy-MM-dd HH:mm Z"); + datePatterns.add("yyyy-MM-dd HH:mm z"); + datePatterns.add("yyyy-MM-dd HH:mm'Z'"); + datePatterns.add("yyyy-MM-dd HH:mm:ss"); - datePatterns.add("yyyy-MM-dd'T'HH:mm:ss"); - datePatterns.add("yyyy-MM-dd HH:mm:ss Z"); - datePatterns.add("yyyy-MM-dd'T'HH:mm:ss Z"); datePatterns.add("yyyy-MM-dd HH:mm:ss X"); - datePatterns.add("yyyy-MM-dd'T'HH:mm:ss X"); + datePatterns.add("yyyy-MM-dd HH:mm:ss Z"); datePatterns.add("yyyy-MM-dd HH:mm:ss z"); - datePatterns.add("yyyy-MM-dd'T'HH:mm:ss z"); - datePatterns.add("EEE MMM dd hh:mm:ss yyyy"); + datePatterns.add("yyyy-MM-dd HH:mm:ss'Z'"); - datePatterns.add("yyyy-MM-dd HH:mm"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSS"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSS X"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSS Z"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSS z"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSS'Z'"); + + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSS"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSS X"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSS Z"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSS z"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSS'Z'"); + + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSSSSS"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSSSSS X"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSSSSS Z"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSSSSS z"); + datePatterns.add("yyyy-MM-dd HH:mm:ss.SSSSSSSSS'Z'"); + + // this is section with `T` datePatterns.add("yyyy-MM-dd'T'HH:mm"); - datePatterns.add("yyyy-MM-dd HH:mm Z"); - datePatterns.add("yyyy-MM-dd'T'HH:mm Z"); - datePatterns.add("yyyy-MM-dd HH:mm X"); datePatterns.add("yyyy-MM-dd'T'HH:mm X"); - datePatterns.add("yyyy-MM-dd HH:mm z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm Z"); datePatterns.add("yyyy-MM-dd'T'HH:mm z"); - datePatterns.add("EEE MMM dd hh:mm yyyy"); + datePatterns.add("yyyy-MM-dd'T'HH:mm'Z'"); + + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss X"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss Z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss'Z'"); + + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSS"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSS X"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSS Z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSS z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSS X"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSS Z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSS z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"); + + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS X"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS Z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS z"); + datePatterns.add("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'"); - datePatterns.add("yyyy-MM-dd"); - datePatterns.add("dd-MM-yyyy"); } public static ZonedDateTime parse(String str, Locale locale, ZoneId defaultZone) { @@ -86,31 +125,46 @@ public static ZonedDateTime parse(String str, Locale locale, ZoneId defaultZone) * Follow ruby rules: if some datetime part is missing, * the default is taken from `now` with default zone */ - public static ZonedDateTime getZonedDateTimeFromTemporalAccessor(TemporalAccessor temporalAccessor, ZoneId defaultZone) { - if (temporalAccessor instanceof ZonedDateTime) { - return (ZonedDateTime) temporalAccessor; + public static ZonedDateTime getZonedDateTimeFromTemporalAccessor(TemporalAccessor temporal, ZoneId defaultZone) { + if (temporal == null) { + return ZonedDateTime.now(defaultZone); } - LocalDateTime now = LocalDateTime.now(); - TemporalField[] copyThese = new TemporalField[]{ - YEAR, - MONTH_OF_YEAR, - DAY_OF_MONTH, - HOUR_OF_DAY, - MINUTE_OF_HOUR, - SECOND_OF_MINUTE, - NANO_OF_SECOND - }; - for (TemporalField tf: copyThese) { - if (temporalAccessor.isSupported(tf)) { - now = now.with(tf, temporalAccessor.get(tf)); - } + if (temporal instanceof ZonedDateTime) { + return (ZonedDateTime) temporal; + } + if (temporal instanceof Instant) { + return ZonedDateTime.ofInstant((Instant) temporal, defaultZone); } - ZoneId zoneId = temporalAccessor.query(TemporalQueries.zone()); + ZoneId zoneId = temporal.query(TemporalQueries.zone()); if (zoneId == null) { - zoneId = defaultZone; - } + LocalDate date = temporal.query(TemporalQueries.localDate()); + LocalTime time = temporal.query(TemporalQueries.localTime()); - return now.atZone(zoneId); + if (date == null) { + date = LocalDate.now(defaultZone); + } + if (time == null) { + time = LocalTime.now(defaultZone); + } + return ZonedDateTime.of(date, time, defaultZone); + } else { + LocalDateTime now = LocalDateTime.now(zoneId); + TemporalField[] copyThese = new TemporalField[]{ + YEAR, + MONTH_OF_YEAR, + DAY_OF_MONTH, + HOUR_OF_DAY, + MINUTE_OF_HOUR, + SECOND_OF_MINUTE, + NANO_OF_SECOND + }; + for (TemporalField tf: copyThese) { + if (temporal.isSupported(tf)) { + now = now.with(tf, temporal.get(tf)); + } + } + return now.atZone(zoneId); + } } } diff --git a/src/main/java/liqp/nodes/BlockNode.java b/src/main/java/liqp/nodes/BlockNode.java index e704e614..fadabcc1 100644 --- a/src/main/java/liqp/nodes/BlockNode.java +++ b/src/main/java/liqp/nodes/BlockNode.java @@ -1,11 +1,5 @@ package liqp.nodes; -import static liqp.LValue.BREAK; -import static liqp.LValue.CONTINUE; -import static liqp.LValue.asTemporal; -import static liqp.LValue.isTemporal; -import static liqp.LValue.rubyDateTimeFormat; - import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -13,6 +7,8 @@ import liqp.RenderTransformer.ObjectAppender; import liqp.TemplateContext; +import static liqp.LValue.*; + public class BlockNode implements LNode { private List children; @@ -67,7 +63,7 @@ public Object render(TemplateContext context) { private Object postprocess(Object value, TemplateContext context) { if (isTemporal(value)) { - ZonedDateTime time = asTemporal(value, context); + ZonedDateTime time = asRubyDate(value, context); return rubyDateTimeFormat.format(time); } else { return value; diff --git a/src/main/java/liqp/nodes/ComparingExpressionNode.java b/src/main/java/liqp/nodes/ComparingExpressionNode.java index a420d957..6a5a96ac 100644 --- a/src/main/java/liqp/nodes/ComparingExpressionNode.java +++ b/src/main/java/liqp/nodes/ComparingExpressionNode.java @@ -11,10 +11,18 @@ public abstract class ComparingExpressionNode extends LValue implements LNode { protected final LNode rhs; private final boolean relative; - public ComparingExpressionNode(LNode lhs, LNode rhs, boolean realtive) { + /** + * + * @param lhs - left-hand side + * @param rhs - right-hand side + * @param relative - expressions are two kinds: + * relative(>, >=, <, <=) + * and equality (==, <>, !=) and rules for comparing them different. + */ + public ComparingExpressionNode(LNode lhs, LNode rhs, boolean relative) { this.lhs = lhs; this.rhs = rhs; - this.relative = realtive; + this.relative = relative; } @Override @@ -41,10 +49,10 @@ public Object render(TemplateContext context) { Object a = lhs.render(context); Object b = rhs.render(context); if (isTemporal(a)) { - a = asTemporal(a, context); + a = asRubyDate(a, context); } if (isTemporal(b)) { - b = asTemporal(b, context); + b = asRubyDate(b, context); } if (a instanceof Number) { diff --git a/src/test/java/liqp/filters/DateTest.java b/src/test/java/liqp/filters/DateTest.java index 9d491e1a..066319b1 100644 --- a/src/test/java/liqp/filters/DateTest.java +++ b/src/test/java/liqp/filters/DateTest.java @@ -225,4 +225,12 @@ public void test240() { assertEquals("10-13", TemplateParser.DEFAULT.parse("{{ \"2022-10-13\" | date: \"%m-%e\" }}").render()); assertEquals("10-13", TemplateParser.DEFAULT.parse("{{ \"13-10-2022\" | date: \"%m-%e\" }}").render()); } + + @Test + public void test298InstantWhenEpochBeginAtUTC() { + Instant instant = Instant.ofEpochSecond(0); + TemplateParser parser = new TemplateParser.Builder().withDefaultTimeZone(ZoneOffset.UTC).build(); + String res = parser.parse("{{ val }}").render("val", instant); + assertEquals("1970-01-01 00:00:00 Z", res); + } }