diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java index 062d890ac..06dff5212 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java @@ -35,6 +35,7 @@ import java.time.OffsetDateTime; import java.time.Year; import java.time.format.DateTimeFormatter; +import java.time.format.ResolverStyle; import java.util.Calendar; import java.util.Date; import java.util.HashMap; @@ -60,6 +61,12 @@ public class DateUtils { private static final int MAX_FORMAT_CACHE_SIZE = 64; + /** + * Prefix marking strict-parsing formatters in the format cache, so they never collide with the + * non-strict formatter of an equal pattern. + */ + private static final String STRICT_CACHE_PREFIX = "strict:"; + /** * Is a cache of dates */ @@ -136,7 +143,7 @@ public static LocalDateTime parseLocalDateTime(String dateString, String dateFor if (StringUtils.isEmpty(dateFormat)) { dateFormat = switchDateFormat(dateString); } - return LocalDateTime.parse(dateString, getCacheDateTimeFormat(dateFormat, local)); + return LocalDateTime.parse(dateString, getCacheDateTimeFormat(dateFormat, local, true)); } /** @@ -151,7 +158,7 @@ public static LocalDate parseLocalDate(String dateString, String dateFormat, Loc if (StringUtils.isEmpty(dateFormat)) { dateFormat = switchDateFormat(dateString); } - return LocalDate.parse(dateString, getCacheDateTimeFormat(dateFormat, local)); + return LocalDate.parse(dateString, getCacheDateTimeFormat(dateFormat, local, true)); } /** @@ -166,7 +173,7 @@ public static LocalTime parseLocalTime(String timeString, String timeFormat, Loc if (StringUtils.isEmpty(timeFormat)) { timeFormat = switchTimeFormat(timeString); } - return LocalTime.parse(timeString, getCacheDateTimeFormat(timeFormat, local)); + return LocalTime.parse(timeString, getCacheDateTimeFormat(timeFormat, local, true)); } /** @@ -451,6 +458,10 @@ public static boolean isDate1904(ExcelContentProperty contentProperty, GlobalCon } private static DateTimeFormatter getCacheDateTimeFormat(String dateFormat, Locale locale) { + return getCacheDateTimeFormat(dateFormat, locale, false); + } + + private static DateTimeFormatter getCacheDateTimeFormat(String dateFormat, Locale locale, boolean strictParsing) { Locale actualLocale = locale == null ? Locale.getDefault(Locale.Category.FORMAT) : locale; Map> localeCache = DATE_TIME_FORMATTER_THREAD_LOCAL.get(); if (localeCache == null) { @@ -462,14 +473,61 @@ private static DateTimeFormatter getCacheDateTimeFormat(String dateFormat, Local formatCache = MapUtils.newBoundedMap(MAX_FORMAT_CACHE_SIZE); localeCache.put(actualLocale, formatCache); } - DateTimeFormatter formatter = formatCache.get(dateFormat); + String cacheKey = strictParsing ? STRICT_CACHE_PREFIX + toStrictParsePattern(dateFormat) : dateFormat; + DateTimeFormatter formatter = formatCache.get(cacheKey); if (formatter == null) { - formatter = DateTimeFormatter.ofPattern(dateFormat, actualLocale); - formatCache.put(dateFormat, formatter); + String pattern = strictParsing ? cacheKey.substring(STRICT_CACHE_PREFIX.length()) : dateFormat; + formatter = DateTimeFormatter.ofPattern(pattern, actualLocale); + if (strictParsing) { + formatter = formatter.withResolverStyle(ResolverStyle.STRICT); + } + formatCache.put(cacheKey, formatter); } return formatter; } + /** + * Rewrites a date pattern for strict parsing. The year-of-era letter {@code y} cannot be + * resolved without era information that simple patterns do not carry, so it is replaced by the + * proleptic-year letter {@code u} unless the pattern already contains an era section. Quoted + * literals are kept as-is. + * + * @param dateFormat the original pattern + * @return a pattern equivalent to the original one but resolvable under {@code STRICT} + */ + private static String toStrictParsePattern(String dateFormat) { + boolean containsEra = false; + boolean containsYearOfEra = false; + boolean inQuote = false; + for (int i = 0; i < dateFormat.length(); i++) { + char currentChar = dateFormat.charAt(i); + if (currentChar == '\'') { + inQuote = !inQuote; + } else if (!inQuote) { + if (currentChar == 'G') { + containsEra = true; + } else if (currentChar == 'y') { + containsYearOfEra = true; + } + } + } + if (!containsYearOfEra || containsEra) { + return dateFormat; + } + StringBuilder strictPattern = new StringBuilder(dateFormat.length()); + inQuote = false; + for (int i = 0; i < dateFormat.length(); i++) { + char currentChar = dateFormat.charAt(i); + if (currentChar == '\'') { + inQuote = !inQuote; + } else if (!inQuote && currentChar == 'y') { + currentChar = 'u'; + } + strictPattern.append(currentChar); + } + return strictPattern.toString(); + } + private static DateFormat getCacheDateFormat(String dateFormat) { Map dateFormatMap = DATE_FORMAT_THREAD_LOCAL.get(); if (dateFormatMap == null) { @@ -482,6 +540,8 @@ private static DateFormat getCacheDateFormat(String dateFormat) { } } SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat); + // Strictly match the format, e.g. "2024-02-31" is invalid instead of being normalized to 2024-03-02. + simpleDateFormat.setLenient(false); dateFormatMap.put(dateFormat, simpleDateFormat); return simpleDateFormat; } diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java new file mode 100644 index 000000000..a6461fec4 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java @@ -0,0 +1,69 @@ +/* + * 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 org.apache.fesod.sheet.converters.date; + +import java.text.ParseException; +import org.apache.fesod.sheet.metadata.data.ReadCellData; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.util.DateUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DateStringConverter}. + */ +@Tag(Tags.UNIT) +class DateStringConverterTest { + + private final DateStringConverter converter = new DateStringConverter(); + + @AfterEach + void tearDown() { + DateUtils.removeThreadLocalCache(); + } + + @Test + void convertToJavaDataRejectsInvalidMonthEndInsteadOfNormalizingIt() { + Assertions.assertThrows( + ParseException.class, () -> converter.convertToJavaData(new ReadCellData<>("2024-02-31"), null, null)); + } + + @Test + void convertToJavaDataRejectsInvalidLeapDayInsteadOfNormalizingIt() { + Assertions.assertThrows( + ParseException.class, () -> converter.convertToJavaData(new ReadCellData<>("2023-02-29"), null, null)); + } + + @Test + void convertToJavaDataRejectsInvalidDayInDateTimeInsteadOfNormalizingIt() { + Assertions.assertThrows( + ParseException.class, + () -> converter.convertToJavaData(new ReadCellData<>("2024-02-31 10:20:30"), null, null)); + } + + @Test + void convertToJavaDataAcceptsValidLeapDay() throws ParseException { + String formatted = DateUtils.format( + converter.convertToJavaData(new ReadCellData<>("2024-02-29"), null, null), "yyyy-MM-dd"); + Assertions.assertEquals("2024-02-29", formatted); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java new file mode 100644 index 000000000..2037b4773 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java @@ -0,0 +1,91 @@ +/* + * 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 org.apache.fesod.sheet.converters.localdate; + +import java.text.ParseException; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import org.apache.fesod.sheet.metadata.GlobalConfiguration; +import org.apache.fesod.sheet.metadata.data.ReadCellData; +import org.apache.fesod.sheet.metadata.property.DateTimeFormatProperty; +import org.apache.fesod.sheet.metadata.property.ExcelContentProperty; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.util.DateUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link LocalDateStringConverter}. + */ +@Tag(Tags.UNIT) +class LocalDateStringConverterTest { + + private final LocalDateStringConverter converter = new LocalDateStringConverter(); + + @AfterEach + void tearDown() { + DateUtils.removeThreadLocalCache(); + } + + @Test + void convertToJavaDataRejectsInvalidMonthEndInsteadOfNormalizingIt() { + Assertions.assertThrows( + DateTimeParseException.class, + () -> converter.convertToJavaData(new ReadCellData<>("2024-02-31"), null, new GlobalConfiguration())); + } + + @Test + void convertToJavaDataRejectsInvalidLeapDayInsteadOfNormalizingIt() { + Assertions.assertThrows( + DateTimeParseException.class, + () -> converter.convertToJavaData(new ReadCellData<>("2023-02-29"), null, new GlobalConfiguration())); + } + + @Test + void convertToJavaDataAcceptsValidLeapDay() throws ParseException { + LocalDate actual = + converter.convertToJavaData(new ReadCellData<>("2024-02-29"), null, new GlobalConfiguration()); + Assertions.assertEquals(LocalDate.of(2024, 2, 29), actual); + } + + @Test + void convertToJavaDataRejectsInvalidDayWithCustomPattern() { + ExcelContentProperty contentProperty = new ExcelContentProperty(); + contentProperty.setDateTimeFormatProperty(new DateTimeFormatProperty("yyyy/MM/dd", null)); + + Assertions.assertThrows( + DateTimeParseException.class, () -> convert(new ReadCellData<>("2024/02/31"), contentProperty)); + } + + @Test + void convertToJavaDataAcceptsValidDayWithCustomPattern() throws ParseException { + ExcelContentProperty contentProperty = new ExcelContentProperty(); + contentProperty.setDateTimeFormatProperty(new DateTimeFormatProperty("yyyy/MM/dd", null)); + + LocalDate actual = convert(new ReadCellData<>("2024/02/29"), contentProperty); + Assertions.assertEquals(LocalDate.of(2024, 2, 29), actual); + } + + private LocalDate convert(ReadCellData cellData, ExcelContentProperty contentProperty) throws ParseException { + return converter.convertToJavaData(cellData, contentProperty, new GlobalConfiguration()); + } +}