This is an automated email from the ASF dual-hosted git repository. ddekany pushed a commit to branch FREEMARKER-35 in repository https://gitbox.apache.org/repos/asf/freemarker.git
commit e5cbaa391625f566c6f33a597fe0efb013924421 Author: ddekany <ddek...@apache.org> AuthorDate: Sun Mar 6 23:51:02 2022 +0100 [FREEMARKER-35] Added temporal format caching to Environment. TemplateTemporalFormat was adjusted for the needs of that. --- src/main/java/freemarker/core/Configurable.java | 4 +- src/main/java/freemarker/core/Environment.java | 336 ++++++++++++++++++++- .../ISOLikeTemplateTemporalTemporalFormat.java | 25 +- ...va => JavaOrISOLikeTemplateTemporalFormat.java} | 26 +- .../core/JavaTemplateTemporalFormat.java | 132 ++++---- .../java/freemarker/core/TemplateDateFormat.java | 4 +- .../freemarker/core/TemplateTemporalFormat.java | 16 +- .../getTemplateTemporalFormatCaching.ftl | 25 ++ ...pochMillisDivTemplateTemporalFormatFactory.java | 4 +- .../EpochMillisTemplateTemporalFormatFactory.java | 4 +- .../core/HTMLISOTemplateTemporalFormatFactory.java | 8 +- .../core/JavaTemplateTemporalFormatTest.java | 41 +-- ...ndTZSensitiveTemplateTemporalFormatFactory.java | 4 +- ...lateTemporalFormatCachingInEnvironmentTest.java | 248 +++++++++++++++ 14 files changed, 731 insertions(+), 146 deletions(-) diff --git a/src/main/java/freemarker/core/Configurable.java b/src/main/java/freemarker/core/Configurable.java index 525c25fe..57becfac 100644 --- a/src/main/java/freemarker/core/Configurable.java +++ b/src/main/java/freemarker/core/Configurable.java @@ -2950,9 +2950,9 @@ public class Configurable { } else if (DATETIME_FORMAT_KEY_SNAKE_CASE.equals(name) || DATETIME_FORMAT_KEY_CAMEL_CASE.equals(name)) { setDateTimeFormat(value); } else if (YEAR_FORMAT_KEY_SNAKE_CASE.equals(name) || YEAR_FORMAT_KEY_CAMEL_CASE.equals(name)) { - this.yearFormat = value; + setYearFormat(value); } else if (YEAR_MONTH_FORMAT_KEY_SNAKE_CASE.equals(name) || YEAR_MONTH_FORMAT_KEY_CAMEL_CASE.equals(name)) { - this.yearMonthFormat = value; + setYearMonthFormat(value); } else if (CUSTOM_DATE_FORMATS_KEY_SNAKE_CASE.equals(name) || CUSTOM_DATE_FORMATS_KEY_CAMEL_CASE.equals(name)) { Map map = (Map) _ObjectBuilderSettingEvaluator.eval( diff --git a/src/main/java/freemarker/core/Environment.java b/src/main/java/freemarker/core/Environment.java index b7153d26..cd2ce83e 100644 --- a/src/main/java/freemarker/core/Environment.java +++ b/src/main/java/freemarker/core/Environment.java @@ -29,7 +29,16 @@ import java.text.Collator; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Year; +import java.time.YearMonth; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.temporal.Temporal; import java.util.ArrayList; import java.util.Collection; @@ -141,9 +150,9 @@ public final class Environment extends Configurable { private ZoneId cachedZoneId; /** - * Stores the date/time/date-time formatters that are used when no format is explicitly given at the place of - * formatting. That is, in situations like ${lastModified} or even ${lastModified?date}, but not in situations like - * ${lastModified?string.iso}. + * Stores the date/time/date-time formatters that are used when no format is explicitly given where the value is + * converted to text. That is, it's used in situations like ${lastModified} or even ${lastModified?date}, but not in + * situations like ${lastModified?string.iso}. * * <p> * The index of the array is calculated from what kind of formatter we want (see @@ -159,6 +168,85 @@ public final class Environment extends Configurable { * first needed. */ private TemplateDateFormat[] cachedTempDateFormatArray; + + /** + * Similar to {@link #cachedTempDateFormatArray}, but for {@link TemplateTemporalFormat}-s. It's not an array as + * {@code java.time} classes have no numerical value, unlike legacy FreeMarker date types. + */ + private TemplateTemporalFormatCache cachedTemporalFormatCache; + private final class TemplateTemporalFormatCache { + // Notes: + // - "reusable" fields are set when the current cache field is set + // - non-reusable fields are cleared when any related setting is changed, but reusableXxx fields are only + // if the format string changes + // - When there's a cache-miss, we check if the "reusable" field has compatible timeZone, and locale, and if + // so, we copy it back into the non-reusable field, and use it. + + private TemplateTemporalFormat localDateTimeFormat; + private TemplateTemporalFormat reusableLocalDateTimeFormat; + private TemplateTemporalFormat offsetDateTimeFormat; + private TemplateTemporalFormat reusableOffsetDateTimeFormat; + private TemplateTemporalFormat zonedDateTimeFormat; + private TemplateTemporalFormat reusableZonedDateTimeFormat; + private TemplateTemporalFormat localDateFormat; + private TemplateTemporalFormat reusableLocalDateFormat; + private TemplateTemporalFormat localTimeFormat; + private TemplateTemporalFormat reusableLocalTimeFormat; + private TemplateTemporalFormat offsetTimeFormat; + private TemplateTemporalFormat reusableOffsetTimeFormat; + private TemplateTemporalFormat yearMonthFormat; + private TemplateTemporalFormat reusableYearMonthFormat; + private TemplateTemporalFormat yearFormat; + private TemplateTemporalFormat reusableYearFormat; + private TemplateTemporalFormat instantFormat; + private TemplateTemporalFormat reusableInstantFormat; + + private void evictAfterTimeZoneOrLocaleChange() { + localDateTimeFormat = null; + offsetDateTimeFormat = null; + zonedDateTimeFormat = null; + localDateFormat = null; + localTimeFormat = null; + offsetTimeFormat = null; + yearMonthFormat = null; + yearFormat = null; + instantFormat = null; + } + + private void evictAfterDateTimeFormatChange() { + localDateTimeFormat = null; + reusableLocalDateTimeFormat = null; + offsetDateTimeFormat = null; + reusableOffsetDateTimeFormat = null; + zonedDateTimeFormat = null; + reusableZonedDateTimeFormat = null; + instantFormat = null; + reusableInstantFormat = null; + } + + private void evictAfterDateFormatChange() { + localDateFormat = null; + reusableLocalDateFormat = null; + } + + private void evictAfterTimeFormatChange() { + localTimeFormat = null; + reusableLocalTimeFormat = null; + offsetTimeFormat = null; + reusableOffsetTimeFormat = null; + } + + private void evictAfterYearMonthFormatChange() { + yearMonthFormat = null; + reusableYearMonthFormat = null; + } + + private void evictAfterYearFormatChange() { + yearFormat = null; + reusableYearFormat = null; + } + } + /** Similar to {@link #cachedTempDateFormatArray}, but used when a formatting string was specified. */ private HashMap<String, TemplateDateFormat>[] cachedTempDateFormatsByFmtStrArray; private static final int CACHED_TDFS_ZONELESS_INPUT_OFFS = 4; @@ -1270,6 +1358,10 @@ public final class Environment extends Configurable { cachedTempDateFormatsByFmtStrArray = null; cachedCollator = null; + + if (cachedTemporalFormatCache != null) { + cachedTemporalFormatCache.evictAfterTimeZoneOrLocaleChange(); + } } } @@ -1294,6 +1386,10 @@ public final class Environment extends Configurable { } cachedSQLDateAndTimeTimeZoneSameAsNormal = null; + + if (cachedTemporalFormatCache != null) { + cachedTemporalFormatCache.evictAfterTimeZoneOrLocaleChange(); + } } } @@ -1729,6 +1825,10 @@ public final class Environment extends Configurable { cachedTempDateFormatArray[i + TemplateDateModel.TIME] = null; } } + + if (cachedTemporalFormatCache != null) { + cachedTemporalFormatCache.evictAfterTimeFormatChange(); + } } } @@ -1742,6 +1842,10 @@ public final class Environment extends Configurable { cachedTempDateFormatArray[i + TemplateDateModel.DATE] = null; } } + + if (cachedTemporalFormatCache != null) { + cachedTemporalFormatCache.evictAfterDateFormatChange(); + } } } @@ -1755,6 +1859,36 @@ public final class Environment extends Configurable { cachedTempDateFormatArray[i + TemplateDateModel.DATETIME] = null; } } + + if (cachedTemporalFormatCache != null) { + cachedTemporalFormatCache.evictAfterDateTimeFormatChange(); + } + } + } + + @Override + public void setYearFormat(String yearFormat) { + if (cachedTemporalFormatCache == null) { + super.setYearFormat(yearFormat); + } else { + String prevYearFormat = getYearFormat(); + super.setYearFormat(yearFormat); + if (!yearFormat.equals(prevYearFormat)) { + cachedTemporalFormatCache.evictAfterYearFormatChange(); + } + } + } + + @Override + public void setYearMonthFormat(String yearMonthFormat) { + if (cachedTemporalFormatCache == null) { + super.setYearMonthFormat(yearMonthFormat); + } else { + String prevYearMonthFormat = getYearMonthFormat(); + super.setYearMonthFormat(yearMonthFormat); + if (!yearMonthFormat.equals(prevYearMonthFormat)) { + cachedTemporalFormatCache.evictAfterYearMonthFormatChange(); + } } } @@ -2363,10 +2497,195 @@ public final class Environment extends Configurable { } } - TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> temporalClass) + /** + * Returns the current format for the given temporal class. + * + * @since 2.3.31 + */ + public TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> temporalClass) throws TemplateValueFormatException { - // TODO [FREEMARKER-35] Temporal class keyed cache, invalidated by temporalFormat (instantFormat, localDateFormat, etc.), locale, and timeZone change. - return getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + temporalClass = _TemporalUtils.normalizeSupportedTemporalClass(temporalClass); + if (cachedTemporalFormatCache == null) { + cachedTemporalFormatCache = new TemplateTemporalFormatCache(); + } + + TemplateTemporalFormat result; + + // BEGIN Generated with getTemplateTemporalFormatCaching.ftl + if (temporalClass == LocalDateTime.class) { + result = cachedTemporalFormatCache.localDateTimeFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableLocalDateTimeFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.localDateTimeFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.localDateTimeFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableLocalDateTimeFormat = result; + return result; + } + if (temporalClass == Instant.class) { + result = cachedTemporalFormatCache.instantFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableInstantFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.instantFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.instantFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableInstantFormat = result; + return result; + } + if (temporalClass == LocalDate.class) { + result = cachedTemporalFormatCache.localDateFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableLocalDateFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.localDateFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.localDateFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableLocalDateFormat = result; + return result; + } + if (temporalClass == LocalTime.class) { + result = cachedTemporalFormatCache.localTimeFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableLocalTimeFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.localTimeFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.localTimeFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableLocalTimeFormat = result; + return result; + } + if (temporalClass == ZonedDateTime.class) { + result = cachedTemporalFormatCache.zonedDateTimeFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableZonedDateTimeFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.zonedDateTimeFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.zonedDateTimeFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableZonedDateTimeFormat = result; + return result; + } + if (temporalClass == OffsetDateTime.class) { + result = cachedTemporalFormatCache.offsetDateTimeFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableOffsetDateTimeFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.offsetDateTimeFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.offsetDateTimeFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableOffsetDateTimeFormat = result; + return result; + } + if (temporalClass == OffsetTime.class) { + result = cachedTemporalFormatCache.offsetTimeFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableOffsetTimeFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.offsetTimeFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.offsetTimeFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableOffsetTimeFormat = result; + return result; + } + if (temporalClass == YearMonth.class) { + result = cachedTemporalFormatCache.yearMonthFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableYearMonthFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.yearMonthFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.yearMonthFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableYearMonthFormat = result; + return result; + } + if (temporalClass == Year.class) { + result = cachedTemporalFormatCache.yearFormat; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusableYearFormat; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.yearFormat = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.yearFormat = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusableYearFormat = result; + return result; + } + // END Generated with getTemplateTemporalFormatCaching.ftl + + throw new AssertionError("Unhandled case: " + temporalClass); } /** @@ -2406,8 +2725,7 @@ public final class Environment extends Configurable { private TemplateTemporalFormat getTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass) throws TemplateValueFormatException { - // TODO [FREEMARKER-35] format keyed cache, invalidated by locale, and timeZone change. - return getTemplateTemporalFormatWithoutCache(formatString, temporalClass, getLocale(), getTimeZone()); + return getTemplateTemporalFormat(formatString, temporalClass, getLocale(), getTimeZone()); } /** @@ -2422,7 +2740,7 @@ public final class Environment extends Configurable { * @param zonelessInput * See the similar parameter of {@link TemplateTemporalFormatFactory#get} */ - private TemplateTemporalFormat getTemplateTemporalFormatWithoutCache( + private TemplateTemporalFormat getTemplateTemporalFormat( String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone) throws TemplateValueFormatException { final int formatStringLen = formatString.length(); diff --git a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java index 07d6b8e4..680546dd 100644 --- a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java +++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java @@ -31,6 +31,7 @@ import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.time.temporal.Temporal; +import java.util.Locale; import java.util.TimeZone; import java.util.regex.Pattern; @@ -44,7 +45,7 @@ import freemarker.template.TemplateTemporalModel; * * @since 2.3.32 */ -final class ISOLikeTemplateTemporalTemporalFormat extends DateTimeFormatBasedTemplateTemporalFormat { +final class ISOLikeTemplateTemporalTemporalFormat extends JavaOrISOLikeTemplateTemporalFormat { private final DateTimeFormatter dateTimeFormatter; private final boolean instantConversion; private final String description; @@ -55,8 +56,8 @@ final class ISOLikeTemplateTemporalTemporalFormat extends DateTimeFormatBasedTem DateTimeFormatter dateTimeFormatter, DateTimeFormatter parserExtendedDateTimeFormatter, DateTimeFormatter parserBasicDateTimeFormatter, - Class<? extends Temporal> temporalClass, TimeZone zone, String formatString) { - super(temporalClass, zone.toZoneId()); + Class<? extends Temporal> temporalClass, TimeZone timeZone, String formatString) { + super(temporalClass, timeZone); temporalClass = normalizeSupportedTemporalClass(temporalClass); this.dateTimeFormatter = dateTimeFormatter; this.parserExtendedDateTimeFormatter = parserExtendedDateTimeFormatter; @@ -85,8 +86,6 @@ final class ISOLikeTemplateTemporalTemporalFormat extends DateTimeFormatBasedTem @Override public Object parse(String s, MissingTimeZoneParserPolicy missingTimeZoneParserPolicy) throws TemplateValueFormatException { - // TODO [FREEMARKER-35] Implement missingTimeZoneParserPolicy - final boolean extendedFormat; final boolean add1Day; if (temporalClass == LocalDate.class || temporalClass == YearMonth.class) { @@ -124,9 +123,11 @@ final class ISOLikeTemplateTemporalTemporalFormat extends DateTimeFormatBasedTem } } - DateTimeFormatter parserDateTimeFormatter = parserBasicDateTimeFormatter == null || extendedFormat - ? parserExtendedDateTimeFormatter : parserBasicDateTimeFormatter; - Temporal resultTemporal = parse(s, missingTimeZoneParserPolicy, parserDateTimeFormatter); + Temporal resultTemporal = parse( + s, missingTimeZoneParserPolicy, + parserBasicDateTimeFormatter == null || extendedFormat + ? parserExtendedDateTimeFormatter : parserBasicDateTimeFormatter); + if (add1Day) { resultTemporal = resultTemporal.plus(1, ChronoUnit.DAYS); } @@ -162,13 +163,7 @@ final class ISOLikeTemplateTemporalTemporalFormat extends DateTimeFormatBasedTem } @Override - public boolean isLocaleBound() { - return false; - } - - @Override - public boolean isTimeZoneBound() { - // TODO [FREEMARKER-35] Even for local temporals? + public boolean canBeUsedForLocale(Locale locale) { return true; } diff --git a/src/main/java/freemarker/core/DateTimeFormatBasedTemplateTemporalFormat.java b/src/main/java/freemarker/core/JavaOrISOLikeTemplateTemporalFormat.java similarity index 87% rename from src/main/java/freemarker/core/DateTimeFormatBasedTemplateTemporalFormat.java rename to src/main/java/freemarker/core/JavaOrISOLikeTemplateTemporalFormat.java index eb8ee951..b83ec05d 100644 --- a/src/main/java/freemarker/core/DateTimeFormatBasedTemplateTemporalFormat.java +++ b/src/main/java/freemarker/core/JavaOrISOLikeTemplateTemporalFormat.java @@ -33,21 +33,29 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoField; import java.time.temporal.Temporal; import java.time.temporal.TemporalAccessor; +import java.util.Objects; +import java.util.TimeZone; /** * Was created ad-hoc to contain whatever happens to be common between some of our {@link TemplateTemporalFormat}-s. */ -abstract class DateTimeFormatBasedTemplateTemporalFormat extends TemplateTemporalFormat { +abstract class JavaOrISOLikeTemplateTemporalFormat extends TemplateTemporalFormat { protected final Class<? extends Temporal> temporalClass; protected final boolean isLocalTemporalClass; + protected final TimeZone timeZone; protected final ZoneId zoneId; - public DateTimeFormatBasedTemplateTemporalFormat( - Class<? extends Temporal> temporalClass, ZoneId zoneId) { - temporalClass = _TemporalUtils.normalizeSupportedTemporalClass(temporalClass); - this.temporalClass = temporalClass; - this.isLocalTemporalClass = isLocalTemporalClass(temporalClass); - this.zoneId = zoneId; + public JavaOrISOLikeTemplateTemporalFormat( + Class<? extends Temporal> temporalClass, TimeZone timeZone) { + this.temporalClass = Objects.requireNonNull(_TemporalUtils.normalizeSupportedTemporalClass(temporalClass)); + this.isLocalTemporalClass = isLocalTemporalClass(this.temporalClass); + if (isLocalTemporalClass) { + this.zoneId = null; + this.timeZone = null; + } else { + this.timeZone = Objects.requireNonNull(timeZone); + this.zoneId = timeZone.toZoneId(); + } } protected Temporal parse( @@ -140,4 +148,8 @@ abstract class DateTimeFormatBasedTemplateTemporalFormat extends TemplateTempora e); } + @Override + public final boolean canBeUsedForTimeZone(TimeZone timeZone) { + return this.timeZone == null || this.timeZone.equals(timeZone); + } } diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java index 718f70bb..dc7845af 100644 --- a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java +++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java @@ -27,13 +27,13 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.OffsetTime; -import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.time.temporal.Temporal; import java.util.Locale; +import java.util.Objects; import java.util.TimeZone; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -47,9 +47,10 @@ import freemarker.template.utility.ClassUtil; * * @since 2.3.32 */ -class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalFormat { +class JavaTemplateTemporalFormat extends JavaOrISOLikeTemplateTemporalFormat { enum PreFormatValueConversion { + IDENTITY, INSTANT_TO_ZONED_DATE_TIME, AS_LOCAL_IN_CURRENT_ZONE, SET_ZONE_FROM_OFFSET, @@ -70,15 +71,16 @@ class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalForm private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile( "(?:" + ANY_FORMAT_STYLE + "(?:_" + ANY_FORMAT_STYLE + ")?)?"); + private final Locale locale; private final DateTimeFormatter dateTimeFormatter; - private final ZoneId zoneId; private final String formatString; private final PreFormatValueConversion preFormatValueConversion; JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> temporalClass, Locale locale, TimeZone timeZone) throws InvalidFormatParametersException { - super(temporalClass, timeZone.toZoneId()); + super(temporalClass, timeZone); + this.locale = Objects.requireNonNull(locale); final Matcher formatStylePatternMatcher = FORMAT_STYLE_PATTERN.matcher(formatString); final boolean isFormatStyleString = formatStylePatternMatcher.matches(); @@ -124,7 +126,7 @@ class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalForm // Handling of time zone related edge cases if (isLocalTemporalClass) { - this.preFormatValueConversion = null; + this.preFormatValueConversion = PreFormatValueConversion.IDENTITY; if (isFormatStyleString && (temporalClass == LocalTime.class || temporalClass == LocalDateTime.class)) { // The localized pattern possibly contains the time zone (for most locales, LONG and FULL does), so they // fail with local temporals that have a time part. To work this issue around, we decrease the verbosity @@ -163,7 +165,7 @@ class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalForm (temporalClass == OffsetDateTime.class || temporalClass == OffsetTime.class)) { preFormatValueConversion = PreFormatValueConversion.SET_ZONE_FROM_OFFSET; } else { - preFormatValueConversion = null; + preFormatValueConversion = PreFormatValueConversion.IDENTITY; } } else { // Doesn't show zone if (temporalClass == OffsetTime.class) { @@ -172,8 +174,8 @@ class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalForm preFormatValueConversion = PreFormatValueConversion.OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION; } else { - // As no zone is shown, but our temporal class is not local, we tell the formatter convert to - // the current time zone. Also, when parsing, that same time zone will be assumed. + // As no zone is shown, but our temporal class is not local, the formatter will convert to a local + // in the current time zone. preFormatValueConversion = PreFormatValueConversion.AS_LOCAL_IN_CURRENT_ZONE; } } @@ -183,7 +185,6 @@ class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalForm dateTimeFormatter = dateTimeFormatter.withLocale(locale); this.dateTimeFormatter = dateTimeFormatter; this.formatString = formatString; - this.zoneId = timeZone.toZoneId(); } @Override @@ -192,46 +193,46 @@ class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalForm DateTimeFormatter dateTimeFormatter = this.dateTimeFormatter; Temporal temporal = TemplateFormatUtil.getNonNullTemporal(tm); - if (preFormatValueConversion != null) { - switch (preFormatValueConversion) { - case INSTANT_TO_ZONED_DATE_TIME: - // Typical date-time formats will fail with "UnsupportedTemporalTypeException: Unsupported field: - // YearOfEra" if we leave the value as Instant. (But parse(String, Instant::from) has no similar - // issue.) - temporal = ((Instant) temporal).atZone(zoneId); - break; - case SET_ZONE_FROM_OFFSET: - // Formats like "long" want a time zone field, but oddly, they don't treat the zoneOffset as such. - if (temporal instanceof OffsetDateTime) { - OffsetDateTime offsetDateTime = (OffsetDateTime) temporal; - temporal = ZonedDateTime.of(offsetDateTime.toLocalDateTime(), offsetDateTime.getOffset()); - } else if (temporal instanceof OffsetTime) { - // There's no ZonedTime class, so we must manipulate the format. - dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset()); - } else { - throw new IllegalArgumentException( - "Formatter was created for OffsetTime or OffsetDateTime, but value was a " - + ClassUtil.getShortClassNameOfObject(temporal)); - } - break; - case AS_LOCAL_IN_CURRENT_ZONE: - // We could use dateTimeFormatter.withZone(zoneId) for these, but it's not obvious if that will - // always behave as a straightforward conversion to the local temporal type. - if (temporal instanceof OffsetDateTime) { - temporal = ((OffsetDateTime) temporal).atZoneSameInstant(zoneId).toLocalDateTime(); - } else if (temporal instanceof ZonedDateTime) { - temporal = ((ZonedDateTime) temporal).withZoneSameInstant(zoneId).toLocalDateTime(); - } else if (temporal instanceof Instant) { - temporal = ((Instant) temporal).atZone(zoneId).toLocalDateTime(); - } else { - throw new AssertionError("Unhandled case: " + temporal.getClass()); - } - break; - case OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION: - throw newOffsetTimeWithoutOffsetOnTheFormatException(); - default: - throw new BugException(); - } + switch (preFormatValueConversion) { + case IDENTITY: + break; + case INSTANT_TO_ZONED_DATE_TIME: + // Typical date-time formats will fail with "UnsupportedTemporalTypeException: Unsupported field: + // YearOfEra" if we leave the value as Instant. (But parse(String, Instant::from) has no similar + // issue.) + temporal = ((Instant) temporal).atZone(zoneId); + break; + case SET_ZONE_FROM_OFFSET: + // Formats like "long" want a time zone field, but oddly, they don't treat the zoneOffset as such. + if (temporal instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) temporal; + temporal = ZonedDateTime.of(offsetDateTime.toLocalDateTime(), offsetDateTime.getOffset()); + } else if (temporal instanceof OffsetTime) { + // There's no ZonedTime class, so we must manipulate the format. + dateTimeFormatter = dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset()); + } else { + throw new IllegalArgumentException( + "Formatter was created for OffsetTime or OffsetDateTime, but value was a " + + ClassUtil.getShortClassNameOfObject(temporal)); + } + break; + case AS_LOCAL_IN_CURRENT_ZONE: + // We could use dateTimeFormatter.withZone(zoneId) for these, but it's not obvious that that will + // always behave as a straightforward conversion to the local temporal type. + if (temporal instanceof OffsetDateTime) { + temporal = ((OffsetDateTime) temporal).atZoneSameInstant(zoneId).toLocalDateTime(); + } else if (temporal instanceof ZonedDateTime) { + temporal = ((ZonedDateTime) temporal).withZoneSameInstant(zoneId).toLocalDateTime(); + } else if (temporal instanceof Instant) { + temporal = ((Instant) temporal).atZone(zoneId).toLocalDateTime(); + } else { + throw new AssertionError("Unhandled case: " + temporal.getClass()); + } + break; + case OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION: + throw newOffsetTimeWithoutOffsetOnTheFormatException(); + default: + throw new BugException(); } try { @@ -255,25 +256,13 @@ class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalForm } @Override - public String getDescription() { - return formatString; - } - - /** - * Tells if this formatter should be re-created if the locale changes. - */ - @Override - public boolean isLocaleBound() { - return true; + public boolean canBeUsedForLocale(Locale locale) { + return this.locale.equals(locale); } - /** - * Tells if this formatter should be re-created if the time zone changes. - */ @Override - public boolean isTimeZoneBound() { - // TODO [FREEMARKER-35] Even for local temporals? - return true; + public String getDescription() { + return formatString; } private static final ZonedDateTime SHOWS_OFFSET_OR_ZONE_SAMPLE_TEMPORAL_1 = ZonedDateTime.of( @@ -286,19 +275,6 @@ class JavaTemplateTemporalFormat extends DateTimeFormatBasedTemplateTemporalForm .equals(dateTimeFormatter.format(SHOWS_OFFSET_OR_ZONE_SAMPLE_TEMPORAL_2)); } - private static FormatStyle getMoreVerboseStyle(FormatStyle style) { - switch (style) { - case SHORT: - return FormatStyle.MEDIUM; - case MEDIUM: - return FormatStyle.LONG; - case LONG: - return FormatStyle.FULL; - default: - return null; - } - } - private static FormatStyle getLessVerboseStyle(FormatStyle style) { switch (style) { case FULL: diff --git a/src/main/java/freemarker/core/TemplateDateFormat.java b/src/main/java/freemarker/core/TemplateDateFormat.java index ec75627a..da2cfab3 100644 --- a/src/main/java/freemarker/core/TemplateDateFormat.java +++ b/src/main/java/freemarker/core/TemplateDateFormat.java @@ -28,12 +28,14 @@ import freemarker.template.TemplateModelException; /** * Represents a date/time/dateTime format; used in templates for formatting and parsing with that format. This is * similar to Java's {@link DateFormat}, but made to fit the requirements of FreeMarker. Also, it makes easier to define - * formats that can't be represented with Java's existing {@link DateFormat} implementations. + * formats that can't be described with Java's existing {@link DateFormat} implementations. * * <p> * Implementations need not be thread-safe if the {@link TemplateDateFormatFactory} doesn't recycle them among * different {@link Environment}-s. As far as FreeMarker's concerned, instances are bound to a single * {@link Environment}, and {@link Environment}-s are thread-local objects. + * + * @see TemplateTemporalFormat * * @since 2.3.24 */ diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java b/src/main/java/freemarker/core/TemplateTemporalFormat.java index 2c1dc263..a282093d 100644 --- a/src/main/java/freemarker/core/TemplateTemporalFormat.java +++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java @@ -20,6 +20,8 @@ package freemarker.core; import java.time.format.DateTimeFormatter; import java.time.temporal.Temporal; +import java.util.Locale; +import java.util.TimeZone; import freemarker.template.TemplateModelException; import freemarker.template.TemplateTemporalModel; @@ -27,7 +29,7 @@ import freemarker.template.TemplateTemporalModel; /** * Represents a {@link Temporal} format; used in templates for formatting {@link Temporal}-s, and parsing strings to * {@link Temporal}-s. This is similar to Java's {@link DateTimeFormatter}, but made to fit the requirements of - * FreeMarker. Also, it makes it possible to define formats that can't be represented with {@link DateTimeFormatter}. + * FreeMarker. Also, it makes it possible to define formats that can't be described with {@link DateTimeFormatter}. * * <p>{@link TemplateTemporalFormat} instances are usually created by a {@link TemplateTemporalFormatFactory}. * @@ -37,6 +39,8 @@ import freemarker.template.TemplateTemporalModel; * {@link TemplateTemporalFormat} instances in multiple {@link Environment}-s, and an {@link Environment} is only used * in a single thread. * + * @see TemplateDateFormat + * * @since 2.3.32 */ public abstract class TemplateTemporalFormat extends TemplateValueFormat { @@ -59,16 +63,14 @@ public abstract class TemplateTemporalFormat extends TemplateValueFormat { } /** - * Tells if the same formatter can be used regardless of the desired locale (so for example after a - * {@link Environment#getLocale()} change we can keep using the old instance). + * Tells if this formatter can be used for the given locale. */ - public abstract boolean isLocaleBound(); + public abstract boolean canBeUsedForLocale(Locale locale); /** - * Tells if the same formatter can be used regardless of the desired time zone (so for example after a - * {@link Environment#getTimeZone()} change we can keep using the old instance). + * Tells if this formatter can be used for the given {@link TimeZone}. */ - public abstract boolean isTimeZoneBound(); + public abstract boolean canBeUsedForTimeZone(TimeZone timeZone); /** * Parser a string to a {@link Temporal}, according to this format. Some format implementations may throw diff --git a/src/main/misc/templateTemporalFormatCache/getTemplateTemporalFormatCaching.ftl b/src/main/misc/templateTemporalFormatCache/getTemplateTemporalFormatCaching.ftl new file mode 100644 index 00000000..5fd50ed2 --- /dev/null +++ b/src/main/misc/templateTemporalFormatCache/getTemplateTemporalFormatCaching.ftl @@ -0,0 +1,25 @@ +// BEGIN Generated with getTemplateTemporalFormatCaching.ftl +<#-- Classes are in order of frequency (guessed). --> +<#list ['LocalDateTime', 'Instant', 'LocalDate', 'LocalTime', 'ZonedDateTime', 'OffsetDateTime', 'OffsetTime', 'YearMonth', 'Year'] as TemporalClass> + <#assign temporalClass = TemporalClass[0]?lowerCase + TemporalClass[1..]> + if (temporalClass == ${TemporalClass}.class) { + result = cachedTemporalFormatCache.${temporalClass}Format; + if (result != null) { + return result; + } + + result = cachedTemporalFormatCache.reusable${TemporalClass}Format; + if (result != null + && result.canBeUsedForTimeZone(getTimeZone()) && result.canBeUsedForLocale(getLocale())) { + cachedTemporalFormatCache.${temporalClass}Format = result; + return result; + } + + result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass); + cachedTemporalFormatCache.${temporalClass}Format = result; + // We do this ahead of time, to decrease the cost of evictions: + cachedTemporalFormatCache.reusable${TemporalClass}Format = result; + return result; + } +</#list> +// END Generated with getTemplateTemporalFormatCaching.ftl diff --git a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java index 4c5174d3..414ae114 100644 --- a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java +++ b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java @@ -83,12 +83,12 @@ public class EpochMillisDivTemplateTemporalFormatFactory extends TemplateTempora } @Override - public boolean isLocaleBound() { + public boolean canBeUsedForLocale(Locale locale) { return false; } @Override - public boolean isTimeZoneBound() { + public boolean canBeUsedForTimeZone(TimeZone timeZone) { return false; } diff --git a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java index 4eaf4102..2094f1d6 100644 --- a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java +++ b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java @@ -69,12 +69,12 @@ public class EpochMillisTemplateTemporalFormatFactory extends TemplateTemporalFo } @Override - public boolean isLocaleBound() { + public boolean canBeUsedForLocale(Locale locale) { return false; } @Override - public boolean isTimeZoneBound() { + public boolean canBeUsedForTimeZone(TimeZone timeZone) { return false; } diff --git a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java index 36c3c7e5..b8492039 100644 --- a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java +++ b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java @@ -65,13 +65,13 @@ public class HTMLISOTemplateTemporalFormatFactory extends TemplateTemporalFormat } @Override - public boolean isLocaleBound() { - return false; + public boolean canBeUsedForLocale(Locale locale) { + return isoFormat.canBeUsedForLocale(locale); } @Override - public boolean isTimeZoneBound() { - return false; + public boolean canBeUsedForTimeZone(TimeZone timeZone) { + return isoFormat.canBeUsedForTimeZone(timeZone); } @Override diff --git a/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java b/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java index a81841f1..2c68309d 100644 --- a/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java +++ b/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java @@ -278,23 +278,30 @@ public class JavaTemplateTemporalFormatTest extends AbstractTemporalFormatTest { @Test public void testParseWrongFormat() throws TemplateException, TemplateValueFormatException { - try { - assertParsingResults( - conf -> conf.setDateTimeFormat("y-MM-dd HH:mm"), - "2020-12-10 01:14 PM", LocalDateTime.of(2020, 12, 10, 13, 14)); - fail("Parsing should have failed"); - } catch (UnparsableValueException e) { - assertThat( - e.getMessage(), - allOf( - containsString("\"2020-12-10 01:14 PM\""), - containsString("\"y-MM-dd HH:mm\""), - containsString("\"en_US\""), - containsString("\"UTC\""), - containsString("LocalDateTime") - ) - ); - } + assertParsingFails( + conf -> conf.setDateTimeFormat("y-MM-dd HH:mm"), + "2020-12-10 01:14 PM", + LocalDateTime.class, + e -> assertThat( + e.getMessage(), + allOf( + containsString("\"2020-12-10 01:14 PM\""), + containsString("\"y-MM-dd HH:mm\""), + containsString("\"en_US\""), + not(containsString("\"UTC\"")), // Because local formats don't depend on timeZone + containsString(LocalDateTime.class.getSimpleName())))); + assertParsingFails( + conf -> conf.setDateTimeFormat("y-MM-dd HH:mm X"), + "2020-12-10 01:14 PM", + ZonedDateTime.class, + e -> assertThat( + e.getMessage(), + allOf( + containsString("\"2020-12-10 01:14 PM\""), + containsString("\"y-MM-dd HH:mm X\""), + containsString("\"en_US\""), + containsString("\"UTC\""), // + containsString(ZonedDateTime.class.getSimpleName())))); } @Test diff --git a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java index b58ecd4d..7051b75d 100644 --- a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java +++ b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java @@ -74,12 +74,12 @@ public class LocAndTZSensitiveTemplateTemporalFormatFactory extends TemplateTemp } @Override - public boolean isLocaleBound() { + public boolean canBeUsedForLocale(Locale locale) { return true; } @Override - public boolean isTimeZoneBound() { + public boolean canBeUsedForTimeZone(TimeZone timeZone) { return true; } diff --git a/src/test/java/freemarker/core/TemplateTemporalFormatCachingInEnvironmentTest.java b/src/test/java/freemarker/core/TemplateTemporalFormatCachingInEnvironmentTest.java new file mode 100644 index 00000000..0268f92a --- /dev/null +++ b/src/test/java/freemarker/core/TemplateTemporalFormatCachingInEnvironmentTest.java @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package freemarker.core; + +import static org.junit.Assert.*; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZonedDateTime; +import java.time.temporal.Temporal; +import java.util.Locale; +import java.util.TimeZone; + +import org.junit.Test; + +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.utility.DateUtil; +import freemarker.template.utility.NullWriter; + +public class TemplateTemporalFormatCachingInEnvironmentTest { + + @Test + public void testTemporalClassSeparation() throws Exception { + Configuration cfg = new Configuration(Configuration.VERSION_2_3_31); + cfg.setLocale(Locale.US); + cfg.setTimeZone(DateUtil.UTC); + + Environment env = new Template(null, "", cfg) + .createProcessingEnvironment(null, NullWriter.INSTANCE); + + env.setDateTimeFormat("iso"); + TemplateTemporalFormat lastLocalDateTimeFormat = env.getTemplateTemporalFormat(LocalDateTime.class); + TemplateTemporalFormat lastOffsetDateTimeFormat = env.getTemplateTemporalFormat(OffsetDateTime.class); + TemplateTemporalFormat lastZonedDateTimeFormat = env.getTemplateTemporalFormat(ZonedDateTime.class); + TemplateTemporalFormat lastInstantDateTimeFormat = env.getTemplateTemporalFormat(Instant.class); + TemplateTemporalFormat lastOffsetTimeFormat = env.getTemplateTemporalFormat(OffsetTime.class); + TemplateTemporalFormat lastLocalTimeFormat = env.getTemplateTemporalFormat(LocalTime.class); + TemplateTemporalFormat lastLocalDateFormat = env.getTemplateTemporalFormat(LocalDate.class); + TemplateTemporalFormat lastYearFormat = env.getTemplateTemporalFormat(Year.class); + TemplateTemporalFormat lastYearMonthFormat = env.getTemplateTemporalFormat(YearMonth.class); + env.setDateTimeFormat("long"); + assertNotSame(lastLocalDateTimeFormat, env.getTemplateTemporalFormat(LocalDateTime.class)); + assertNotSame(lastOffsetDateTimeFormat, env.getTemplateTemporalFormat(OffsetDateTime.class)); + assertNotSame(lastZonedDateTimeFormat, env.getTemplateTemporalFormat(ZonedDateTime.class)); + assertNotSame(lastInstantDateTimeFormat, env.getTemplateTemporalFormat(Instant.class)); + assertSame(lastOffsetTimeFormat, env.getTemplateTemporalFormat(OffsetTime.class)); + assertSame(lastLocalTimeFormat, env.getTemplateTemporalFormat(LocalTime.class)); + assertSame(lastLocalDateFormat, env.getTemplateTemporalFormat(LocalDate.class)); + assertSame(lastYearFormat, env.getTemplateTemporalFormat(Year.class)); + assertSame(lastYearMonthFormat, env.getTemplateTemporalFormat(YearMonth.class)); + + lastLocalDateTimeFormat = env.getTemplateTemporalFormat(LocalDateTime.class); + lastOffsetDateTimeFormat = env.getTemplateTemporalFormat(OffsetDateTime.class); + lastZonedDateTimeFormat = env.getTemplateTemporalFormat(ZonedDateTime.class); + lastInstantDateTimeFormat = env.getTemplateTemporalFormat(Instant.class); + lastOffsetTimeFormat = env.getTemplateTemporalFormat(OffsetTime.class); + lastLocalTimeFormat = env.getTemplateTemporalFormat(LocalTime.class); + lastLocalDateFormat = env.getTemplateTemporalFormat(LocalDate.class); + lastYearFormat = env.getTemplateTemporalFormat(Year.class); + lastYearMonthFormat = env.getTemplateTemporalFormat(YearMonth.class); + env.setTimeFormat("short"); + assertSame(lastLocalDateTimeFormat, env.getTemplateTemporalFormat(LocalDateTime.class)); + assertSame(lastOffsetDateTimeFormat, env.getTemplateTemporalFormat(OffsetDateTime.class)); + assertSame(lastZonedDateTimeFormat, env.getTemplateTemporalFormat(ZonedDateTime.class)); + assertSame(lastInstantDateTimeFormat, env.getTemplateTemporalFormat(Instant.class)); + assertNotSame(lastOffsetTimeFormat, env.getTemplateTemporalFormat(OffsetTime.class)); + assertNotSame(lastLocalTimeFormat, env.getTemplateTemporalFormat(LocalTime.class)); + assertSame(lastLocalDateFormat, env.getTemplateTemporalFormat(LocalDate.class)); + assertSame(lastYearFormat, env.getTemplateTemporalFormat(Year.class)); + assertSame(lastYearMonthFormat, env.getTemplateTemporalFormat(YearMonth.class)); + } + + @Test + public void testForDateTime() throws Exception { + // Locale dependent formatters: + genericTest(LocalDateTime.class, + (cfg, first) -> cfg.setDateTimeFormat(first ? "yyyy-MM-dd HH:mm" : "yyyyMMddHHmm"), + true, false); + genericTest(ZonedDateTime.class, + (cfg, first) -> cfg.setDateTimeFormat(first ? "yyyy-MM-dd HH:mm" : "yyyyMMddHHmm"), + true, true); + genericTest(OffsetDateTime.class, + (cfg, first) -> cfg.setDateTimeFormat(first ? "yyyy-MM-dd HH:mm" : "yyyyMMddHHmm"), + true, true); + genericTest(Instant.class, + (cfg, first) -> cfg.setDateTimeFormat(first ? "yyyy-MM-dd HH:mm" : "yyyyMMddHHmm"), + true, true); + + // Locale independent formatters: + genericTest(LocalDateTime.class, + (cfg, first) -> cfg.setDateTimeFormat(first ? "iso" : "xs"), + false, false); + genericTest(ZonedDateTime.class, + (cfg, first) -> cfg.setDateTimeFormat(first ? "iso" : "xs"), + false, true); + } + + @Test + public void testForDate() throws Exception { + // Locale dependent formatters: + genericTest(LocalDate.class, + (cfg, first) -> cfg.setDateFormat(first ? "yyyy-MM-dd" : "yyyyMM-dd"), + true, false); + + // Locale independent formatters: + genericTest(LocalDate.class, + (cfg, first) -> cfg.setDateFormat(first ? "iso" : "xs"), + false, false); + } + + @Test + public void testForTime() throws Exception { + // Locale dependent formatters: + genericTest(LocalTime.class, + (cfg, first) -> cfg.setTimeFormat(first ? "HH:mm" : "HHmm"), + true, false); + genericTest(OffsetTime.class, + (cfg, first) -> cfg.setTimeFormat(first ? "HH:mm" : "HHmm"), + true, true); + + // Locale independent formatters: + genericTest(LocalTime.class, + (cfg, first) -> cfg.setTimeFormat(first ? "iso" : "xs"), + false, false); + genericTest(OffsetTime.class, + (cfg, first) -> cfg.setTimeFormat(first ? "iso" : "xs"), + false, true); + } + + @Test + public void testForYearMonth() throws Exception { + // Locale dependent formatters: + genericTest(YearMonth.class, + (cfg, first) -> cfg.setYearMonthFormat(first ? "yyyy-MM" : "yyyyMM"), + true, false); + + // Locale independent formatters: + genericTest(YearMonth.class, + (cfg, first) -> cfg.setYearMonthFormat(first ? "iso" : "xs"), + false, false); + } + + @Test + public void testForYear() throws Exception { + // Locale dependent formatters: + genericTest(Year.class, + (cfg, first) -> cfg.setYearFormat(first ? "yyyy" : "yy"), + true, false); + + // Locale independent formatters: + genericTest(Year.class, + (cfg, first) -> cfg.setYearFormat(first ? "iso" : "xs"), + false, false); + } + + private void genericTest( + Class<? extends Temporal> temporalClass, + SettingSetter settingSetter, + boolean localeDependent, boolean timeZoneDependent) + throws Exception { + Configuration cfg = new Configuration(Configuration.VERSION_2_3_31); + cfg.setLocale(Locale.GERMANY); + cfg.setTimeZone(DateUtil.UTC); + settingSetter.setSetting(cfg, true); + + Environment env = new Template(null, "", cfg) + .createProcessingEnvironment(null, NullWriter.INSTANCE); + + TemplateTemporalFormat lastFormat; + TemplateTemporalFormat newFormat; + + lastFormat = env.getTemplateTemporalFormat(temporalClass); + // Assert that it keeps returning the same instance from cache: + assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass)); + assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass)); + + settingSetter.setSetting(env, true); + // Assert that the cache wasn't cleared when the setting was set to the same value again: + assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass)); + + env.setLocale(Locale.JAPAN); // Possibly clears non-reusable TemplateTemporalFormatCache field + newFormat = env.getTemplateTemporalFormat(temporalClass); + if (localeDependent) { + assertNotSame(lastFormat, newFormat); + } else { + assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass)); + } + lastFormat = newFormat; + + env.setLocale(Locale.JAPAN); + assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass)); + + env.setLocale(Locale.GERMANY); // Possibly clears non-reusable TemplateTemporalFormatCache field + env.setLocale(Locale.JAPAN); + // Assert that it restores the same instance from TemplateTemporalFormatCache.reusableXxx field: + assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass)); + + TimeZone otherTimeZone = TimeZone.getTimeZone("GMT+01"); + env.setTimeZone(otherTimeZone); // Possibly clears non-reusable TemplateTemporalFormatCache field + newFormat = env.getTemplateTemporalFormat(temporalClass); + if (timeZoneDependent) { + assertNotSame(newFormat, lastFormat); + assertSame(newFormat, env.getTemplateTemporalFormat(temporalClass)); + } else { + assertSame(newFormat, lastFormat); + } + lastFormat = newFormat; + + env.setTimeZone(DateUtil.UTC); // Possibly clears non-reusable TemplateTemporalFormatCache field + env.setTimeZone(otherTimeZone); + // Assert that it restores the same instance from TemplateTemporalFormatCache.reusableXxx field: + assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass)); + + settingSetter.setSetting(env, false); // Clears even TemplateTemporalFormatCache.reusableXxx + newFormat = env.getTemplateTemporalFormat(temporalClass); + assertNotSame(lastFormat, newFormat); + } + + @FunctionalInterface + interface SettingSetter { + void setSetting(Configurable configurable, boolean firstValue); + } + +}