Repository: PebbleTemplates/pebble (java)

Issue:
Explicitly setting the time zone to be used by date formatting
Formatting of dates using the `date` filter uses the current default time zone, which means that the same template might render differently on different machines if they are on different time zones. (I discovered this via a unit test that passed locally but failed in Jenkins.)

I wonder if it would make sense to add an optional parameter to `date` that would be a time zone and would be passed to `DateFormat::setTimeZone`, for cases where you would want to e.g. make sure that a certain date is rendered in UTC?

Pull request description:
Add timeZone parameter to date filter
Fixes #527 

Also reworded the `date` docs a little.

Also fixed a couple of cases that did not used to work (e.g. trying to format an instant with a format that needed time zone info).

Patch:
diff --git a/docs/src/orchid/resources/wiki/filter/date.md b/docs/src/orchid/resources/wiki/filter/date.md
index 2955fc6d..2cd981c7 100644
--- a/docs/src/orchid/resources/wiki/filter/date.md
+++ b/docs/src/orchid/resources/wiki/filter/date.md
@@ -1,14 +1,16 @@
 # `date`
-The `date` filter is used to format an existing `java.util.Date` object. The filter will construct a
-`java.text.SimpleDateFormat` using the provided pattern and then use this newly created
-`SimpleDateFormat` to format the provided `Date` or `java.lang.Number` object.
+The `date` filter formats a date in a variety of formats. It can handle old-school `java.util.Date`,
+Java 8 `java.time` constructs like `OffsetDateTime` and timestamps in milliseconds from the epoch.
+The filter will construct a `java.text.SimpleDateFormat` or `java.time.format.DateTimeFormatter` using the provided
+pattern and then use this newly created format to format the provided date object. If you don't provide a pattern,
+either `DateTimeFormatter.ISO_DATE_TIME` or `yyyy-MM-dd'T'HH:mm:ssZ` will be used.
 
 ```twig
 {{ user.birthday | date("yyyy-MM-dd") }}
 ```
 
-The alternative way to use this	filter is to use it on a string but then provide two arguments:
-first is the desired pattern for the output and the second is the existing format used to parse the
+An alternative way to use this filter is to use it on a string but then provide two arguments:
+the first is the desired pattern for the output, and the second is the existing format used to parse the
 input string into a `java.util.Date` object.
 ```twig
 {{ "July 24, 2001" | date("yyyy-MM-dd", existingFormat="MMMM dd, yyyy") }}
@@ -18,6 +20,19 @@ The above example will output the following:
 2001-07-24
 ```
 
+## Time zones
+
+If the provided date has time zone info (e.g. `OffsetDateTime`) then it will be used. If the provided date has no
+time zone info, by default the system time zone will be used. If you need to use a specific
+time zone then you can pass in a `timeZone` parameter any string that's understood by `ZoneId` / `ZoneInfo`:
+```twig
+{# the timeZone parameter will be ignored #}
+{{ someOffsetDateTime | date("yyyy-MM-dd'T'HH:mm:ssX", timeZone="UTC") }}
+{# the provided time zone will override the system default #}
+{{ someInstant | date("yyyy-MM-dd'T'HH:mm:ssX", timeZone="Pacific/Funafuti") }}
+```
+
 ## Arguments
 - format
-- existingFormat
\ No newline at end of file
+- existingFormat
+- timeZone
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/extension/core/DateFilter.java b/pebble/src/main/java/com/mitchellbosecke/pebble/extension/core/DateFilter.java
index ea5ba17d..6e6e4c36 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/extension/core/DateFilter.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/extension/core/DateFilter.java
@@ -8,24 +8,23 @@
  */
 package com.mitchellbosecke.pebble.extension.core;
 
-import static java.lang.String.format;
-
 import com.mitchellbosecke.pebble.error.PebbleException;
 import com.mitchellbosecke.pebble.extension.Filter;
 import com.mitchellbosecke.pebble.extension.escaper.SafeString;
 import com.mitchellbosecke.pebble.template.EvaluationContext;
 import com.mitchellbosecke.pebble.template.PebbleTemplate;
+
 import java.text.DateFormat;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.time.DateTimeException;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.temporal.TemporalAccessor;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
+import java.time.temporal.TemporalQueries;
+import java.util.*;
+
+import static java.lang.String.format;
 
 public class DateFilter implements Filter {
 
@@ -34,6 +33,7 @@ public class DateFilter implements Filter {
   public DateFilter() {
     this.argumentNames.add("format");
     this.argumentNames.add("existingFormat");
+    this.argumentNames.add("timeZone");
   }
 
   @Override
@@ -49,16 +49,18 @@ public class DateFilter implements Filter {
     }
     final Locale locale = context.getLocale();
     final String format = (String) args.get("format");
+    final String timeZone = (String) args.get("timeZone");
 
     if (TemporalAccessor.class.isAssignableFrom(input.getClass())) {
-      return this.applyTemporal((TemporalAccessor) input, self, locale, lineNumber, format);
+      return this.applyTemporal((TemporalAccessor) input, self, locale, lineNumber, format, timeZone);
     }
-    return this
-        .applyDate(input, self, locale, lineNumber, format, (String) args.get("existingFormat"));
+    return this.applyDate(
+            input, self, locale, lineNumber,
+            format, (String) args.get("existingFormat"), timeZone);
   }
 
   private Object applyDate(Object dateOrString, final PebbleTemplate self, final Locale locale,
-      int lineNumber, final String format, final String existingFormatString)
+      int lineNumber, final String format, final String existingFormatString, final String timeZone)
       throws PebbleException {
     Date date;
     DateFormat existingFormat;
@@ -82,22 +84,46 @@ public class DateFilter implements Filter {
                 dateOrString));
       }
     }
-    intendedFormat = new SimpleDateFormat(format, locale);
+    intendedFormat = new SimpleDateFormat(format == null ? "yyyy-MM-dd'T'HH:mm:ssZ" : format, locale);
+    if (timeZone != null) {
+      intendedFormat.setTimeZone(TimeZone.getTimeZone(timeZone));
+    }
     return new SafeString(intendedFormat.format(date));
   }
 
   private Object applyTemporal(final TemporalAccessor input, PebbleTemplate self,
       final Locale locale,
-      int lineNumber, final String format) throws PebbleException {
-    final DateTimeFormatter formatter = format != null
+      int lineNumber, final String format, final String timeZone) throws PebbleException {
+    DateTimeFormatter formatter = format != null
         ? DateTimeFormatter.ofPattern(format, locale)
         : DateTimeFormatter.ISO_DATE_TIME;
+
+    ZoneId zoneId = getZoneId(input, timeZone);
+    formatter = formatter.withZone(zoneId);
+
     try {
       return new SafeString(formatter.format(input));
     } catch (DateTimeException dte) {
-      throw new PebbleException(dte, String.format("Could not parse the string '%s' into a date.",
-          input.toString()), lineNumber, self.getName());
+      throw new PebbleException(
+              dte,
+              String.format("Could not format instance '%s' of type %s into a date.", input.toString(), input.getClass()),
+              lineNumber,
+              self.getName());
+    }
+  }
+
+  private ZoneId getZoneId(TemporalAccessor input, String timeZone) {
+    // First try the time zone of the input.
+    ZoneId zoneId = input.query(TemporalQueries.zone());
+    if (zoneId == null && timeZone != null) {
+      // Fallback to time zone provided as filter argument.
+      zoneId = ZoneId.of(timeZone);
+    }
+    if (zoneId == null) {
+      // Fallback to system time zone.
+      zoneId = ZoneId.systemDefault();
     }
+    return zoneId;
   }
 
 }


Test patch:
diff --git a/pebble/src/test/java/com/mitchellbosecke/pebble/CoreFiltersTest.java b/pebble/src/test/java/com/mitchellbosecke/pebble/CoreFiltersTest.java
index e371448b..7eb8ad42 100644
--- a/pebble/src/test/java/com/mitchellbosecke/pebble/CoreFiltersTest.java
+++ b/pebble/src/test/java/com/mitchellbosecke/pebble/CoreFiltersTest.java
@@ -23,19 +23,8 @@ import java.io.Writer;
 import java.text.DateFormat;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
+import java.time.*;
+import java.util.*;
 
 import static java.lang.Boolean.TRUE;
 import static java.util.Collections.emptyList;
@@ -232,6 +221,126 @@ class CoreFiltersTest {
     assertEquals("02/07/2018", writer.toString());
   }
 
+  @Test
+  void testDateWithDateAndExplicitTimeZone() throws IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().build();
+
+    PebbleTemplate template = pebble.getLiteralTemplate("{{ date | date(timeZone=\"Asia/Almaty\") }}");
+    Map<String, Object> context = new HashMap<>();
+    context.put("date", Date.from(Instant.ofEpochSecond(1595853935)));
+
+    Writer writer = new StringWriter();
+    template.evaluate(writer, context);
+
+    assertEquals("2020-07-27T18:45:35+0600", writer.toString());
+  }
+
+  @Test
+  void testDateWithDateAndFormatAndExplicitTimeZone() throws IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().build();
+
+    PebbleTemplate template = pebble.getLiteralTemplate("{{ date | date(\"yyyy-MM-dd'T'HH:mm:ssX\", timeZone=\"Asia/Almaty\") }}");
+    Map<String, Object> context = new HashMap<>();
+    context.put("date", Date.from(Instant.ofEpochSecond(1595853935)));
+
+    Writer writer = new StringWriter();
+    template.evaluate(writer, context);
+
+    assertEquals("2020-07-27T18:45:35+06", writer.toString());
+  }
+
+  @Test
+  void testDateWithTimestampAndExplicitTimeZone() throws IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().build();
+
+    PebbleTemplate template = pebble.getLiteralTemplate("{{ timestamp | date(timeZone=\"Asia/Almaty\") }}");
+    Map<String, Object> context = new HashMap<>();
+    context.put("timestamp", 1595853935000L);
+
+    Writer writer = new StringWriter();
+    template.evaluate(writer, context);
+
+    assertEquals("2020-07-27T18:45:35+0600", writer.toString());
+  }
+
+  @Test
+  void testDateWithOffsetDateTimeAndExplicitTimeZoneUsesTimeZoneOfInput() throws IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().build();
+
+    PebbleTemplate template = pebble.getLiteralTemplate("{{ offsetDateTime | date(timeZone=\"Asia/Almaty\") }}");
+    Map<String, Object> context = new HashMap<>();
+    context.put("offsetDateTime", OffsetDateTime.of(2020, 7, 27, 16, 12, 13, 0, ZoneOffset.ofHours(3)));
+
+    Writer writer = new StringWriter();
+    template.evaluate(writer, context);
+
+    assertEquals("2020-07-27T16:12:13+03:00", writer.toString());
+  }
+
+  @Test
+  void testDateWithOffsetDateTimeAndFormatAndExplicitTimeZoneUsesTimeZoneOfInput() throws IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().build();
+
+    PebbleTemplate template = pebble.getLiteralTemplate("{{ offsetDateTime | date(\"yyyy-MM-dd'T'HH:mm:ssX\", timeZone=\"Asia/Almaty\") }}");
+    Map<String, Object> context = new HashMap<>();
+    context.put("offsetDateTime", OffsetDateTime.of(2020, 7, 27, 16, 12, 13, 0, ZoneOffset.ofHours(3)));
+
+    Writer writer = new StringWriter();
+    template.evaluate(writer, context);
+
+    assertEquals("2020-07-27T16:12:13+03", writer.toString());
+  }
+
+  @Test
+  void testDateWithOffsetDateTimeAndFormatAndNoExplicitTimeZoneUsesTimeZoneOfInput() throws IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().build();
+
+    PebbleTemplate template = pebble.getLiteralTemplate("{{ offsetDateTime | date(\"yyyy-MM-dd'T'HH:mm:ssX\") }}");
+    Map<String, Object> context = new HashMap<>();
+    context.put("offsetDateTime", OffsetDateTime.of(2020, 7, 27, 16, 12, 13, 0, ZoneOffset.ofHours(5)));
+
+    Writer writer = new StringWriter();
+    template.evaluate(writer, context);
+
+    assertEquals("2020-07-27T16:12:13+05", writer.toString());
+  }
+
+  @Test
+  void testDateWithInstantAndExplicitTimeZone() throws IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().build();
+
+    PebbleTemplate template = pebble.getLiteralTemplate("{{ instant | date(timeZone=\"Asia/Almaty\") }}");
+    Map<String, Object> context = new HashMap<>();
+    context.put("instant", Instant.ofEpochSecond(1595853935));
+
+    Writer writer = new StringWriter();
+    template.evaluate(writer, context);
+
+    assertEquals("2020-07-27T18:45:35+06:00[Asia/Almaty]", writer.toString());
+  }
+
+  @Test
+  void testDateWithInstantAndNoExplicitTimeZoneUsesSystemTimeZone() throws IOException {
+    PebbleEngine pebble = new PebbleEngine.Builder().build();
+
+    TimeZone defaultTimeZone = TimeZone.getDefault();
+    try {
+      TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Funafuti"));
+
+      PebbleTemplate template = pebble.getLiteralTemplate("{{ instant | date() }}");
+      Map<String, Object> context = new HashMap<>();
+      context.put("instant", Instant.ofEpochSecond(1595853935));
+
+      Writer writer = new StringWriter();
+      template.evaluate(writer, context);
+
+      assertEquals("2020-07-28T00:45:35+12:00[Pacific/Funafuti]", writer.toString());
+    }
+    finally {
+      TimeZone.setDefault(defaultTimeZone);
+    }
+  }
+
   @Test
   void testDateWithUnsupportedInput() throws IOException {
     assertThrows(IllegalArgumentException.class, () -> {