This is an automated email from the ASF dual-hosted git repository.
exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 452317dab64 NIFI-15973 Fixed Date Conversion for consistent use of
Proleptic Gregorian Calendar (#11283)
452317dab64 is described below
commit 452317dab64c5dfabcf941ae0dee51da2ab2d02b
Author: Kamil TrysiĆski <[email protected]>
AuthorDate: Thu Jun 4 04:55:12 2026 +0200
NIFI-15973 Fixed Date Conversion for consistent use of Proleptic Gregorian
Calendar (#11283)
Signed-off-by: David Handermann <[email protected]>
---
.../field/ObjectLocalDateFieldConverter.java | 6 +-
.../field/ObjectLocalDateTimeFieldConverter.java | 6 +-
.../record/field/ObjectStringFieldConverter.java | 12 ++--
.../field/ObjectTimestampFieldConverter.java | 12 +++-
.../serialization/record/util/DataTypeUtils.java | 11 +++-
.../field/ObjectLocalDateFieldConverterTest.java | 65 ++++++++++++++++++++++
.../field/ObjectStringFieldConverterTest.java | 14 +++++
.../field/ObjectTimestampFieldConverterTest.java | 20 +++++++
.../TestObjectLocalDateTimeFieldConverter.java | 19 +++++++
.../java/org/apache/nifi/avro/AvroTypeUtil.java | 18 ++++--
10 files changed, 169 insertions(+), 14 deletions(-)
diff --git
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectLocalDateFieldConverter.java
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectLocalDateFieldConverter.java
index 63f2633998f..25937bbcb27 100644
---
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectLocalDateFieldConverter.java
+++
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectLocalDateFieldConverter.java
@@ -49,7 +49,11 @@ class ObjectLocalDateFieldConverter implements
FieldConverter<Object, LocalDate>
return localDate;
}
case Date date -> {
- return date.toLocalDate();
+ // java.sql.Date#toInstant throws UnsupportedOperationException
+ final Instant instant = Instant.ofEpochMilli(date.getTime());
+
+ // Create LocalDate from Instant to preserve proleptic
Gregorian calendar for pre-1582 dates
+ return ofInstant(instant);
}
case java.util.Date date -> {
final Instant instant = date.toInstant();
diff --git
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectLocalDateTimeFieldConverter.java
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectLocalDateTimeFieldConverter.java
index 3c84ad8d3a6..78b6ee1adc8 100644
---
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectLocalDateTimeFieldConverter.java
+++
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectLocalDateTimeFieldConverter.java
@@ -64,11 +64,13 @@ class ObjectLocalDateTimeFieldConverter implements
FieldConverter<Object, LocalD
return localDateTime;
}
case Timestamp timestamp -> {
- return timestamp.toLocalDateTime();
+ return ofInstant(timestamp.toInstant());
}
case Date date -> {
- // java.sql.Date and java.sql.Time do not support the
toInstant() method so using getTime() is required
+ // java.sql.Date#toInstant throws UnsupportedOperationException
final Instant instant = Instant.ofEpochMilli(date.getTime());
+
+ // Create LocalDateTime from Instant to preserve proleptic
Gregorian calendar for pre-1582 dates
return ofInstant(instant);
}
case final Number number -> {
diff --git
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectStringFieldConverter.java
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectStringFieldConverter.java
index 65b9c92d34b..2bf36663b32 100644
---
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectStringFieldConverter.java
+++
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectStringFieldConverter.java
@@ -23,7 +23,6 @@ import java.nio.charset.StandardCharsets;
import java.sql.Clob;
import java.sql.Timestamp;
import java.time.Instant;
-import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
@@ -57,11 +56,14 @@ class ObjectStringFieldConverter implements
FieldConverter<Object, String> {
if (pattern.isEmpty()) {
return Long.toString(timestamp.getTime());
}
- final DateTimeFormatter formatter =
DateTimeFormatterRegistry.getDateTimeFormatter(pattern.get());
- final LocalDateTime localDateTime =
timestamp.toLocalDateTime();
- // Convert LocalDateTime to ZonedDateTime using system default
zone to support offsets in Date Time Formatter
- final ZonedDateTime dateTime = ZonedDateTime.of(localDateTime,
ZoneId.systemDefault());
+ // Convert via Instant to ZonedDateTime to preserve proleptic
Gregorian calendar for pre-1582 dates
+ final Instant instant = timestamp.toInstant();
+
+ // Convert to ZonedDateTime using system default zone to
support offsets in Date Time Formatter
+ final ZonedDateTime dateTime =
instant.atZone(ZoneId.systemDefault());
+
+ final DateTimeFormatter formatter =
DateTimeFormatterRegistry.getDateTimeFormatter(pattern.get());
return formatter.format(dateTime);
}
case Date date -> {
diff --git
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectTimestampFieldConverter.java
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectTimestampFieldConverter.java
index aa8f5b48b23..65ad97d42db 100644
---
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectTimestampFieldConverter.java
+++
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/field/ObjectTimestampFieldConverter.java
@@ -20,6 +20,8 @@ import
org.apache.nifi.serialization.record.util.IllegalTypeConversionException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
import java.util.Optional;
/**
@@ -40,6 +42,14 @@ class ObjectTimestampFieldConverter implements
FieldConverter<Object, Timestamp>
@Override
public Timestamp convertField(final Object field, final Optional<String>
pattern, final String name) {
final LocalDateTime localDateTime = CONVERTER.convertField(field,
pattern, name);
- return localDateTime == null ? null : Timestamp.valueOf(localDateTime);
+ if (localDateTime == null) {
+ return null;
+ }
+
+ // Convert to ZonedDateTime using system default zone to allow for
later conversion to Instant
+ final ZonedDateTime zonedDateTime =
localDateTime.atZone(ZoneId.systemDefault());
+
+ // Create Timestamp from Instant to preserve proleptic Gregorian
calendar for pre-1582 dates
+ return Timestamp.from(zonedDateTime.toInstant());
}
}
diff --git
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/util/DataTypeUtils.java
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/util/DataTypeUtils.java
index 8e02b4a1d1e..2805283eca6 100644
---
a/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/util/DataTypeUtils.java
+++
b/nifi-commons/nifi-record/src/main/java/org/apache/nifi/serialization/record/util/DataTypeUtils.java
@@ -200,7 +200,16 @@ public class DataTypeUtils {
case DATE:
final FieldConverter<Object, LocalDate> localDateConverter =
StandardFieldConverterRegistry.getRegistry().getFieldConverter(LocalDate.class);
final LocalDate localDate =
localDateConverter.convertField(value, dateFormat, fieldName);
- return localDate == null ? null : Date.valueOf(localDate);
+ if (localDate == null) {
+ return null;
+ }
+
+ // Convert to ZonedDateTime using system default zone to allow
for later conversion to Instant
+ final ZonedDateTime zonedDate =
localDate.atStartOfDay(ZoneId.systemDefault());
+
+ // Create Date from Instant epoch millis to preserve proleptic
Gregorian calendar for pre-1582 dates
+ final long epochMillis = zonedDate.toInstant().toEpochMilli();
+ return new Date(epochMillis);
case DECIMAL:
return toBigDecimal(value, fieldName);
case DOUBLE:
diff --git
a/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectLocalDateFieldConverterTest.java
b/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectLocalDateFieldConverterTest.java
new file mode 100644
index 00000000000..f8822b2e349
--- /dev/null
+++
b/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectLocalDateFieldConverterTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.nifi.serialization.record.field;
+
+import org.junit.jupiter.api.Test;
+
+import java.sql.Date;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class ObjectLocalDateFieldConverterTest {
+
+ private static final ObjectLocalDateFieldConverter CONVERTER = new
ObjectLocalDateFieldConverter();
+ private static final String FIELD_NAME = LocalDate.class.getSimpleName();
+
+ @Test
+ void testConvertFieldNull() {
+ assertNull(CONVERTER.convertField(null, Optional.empty(), FIELD_NAME));
+ }
+
+ @Test
+ void testConvertFieldLocalDate() {
+ final LocalDate input = LocalDate.of(2025, 5, 25);
+ assertEquals(input, CONVERTER.convertField(input, Optional.empty(),
FIELD_NAME));
+ }
+
+ @Test
+ void testConvertFieldSqlDateModernYear() {
+ final LocalDate localDate = LocalDate.of(2025, 5, 25);
+ final ZonedDateTime zonedDate =
localDate.atStartOfDay(ZoneId.systemDefault());
+ final Date sqlDate = new Date(zonedDate.toInstant().toEpochMilli());
+ final LocalDate result = CONVERTER.convertField(sqlDate,
Optional.empty(), FIELD_NAME);
+
+ assertEquals(localDate, result);
+ }
+
+ @Test
+ void testConvertFieldSqlDateYearOneIsProlepticGregorian() {
+ final LocalDate yearOne = LocalDate.of(1, 1, 1);
+ final ZonedDateTime zonedDate =
yearOne.atStartOfDay(ZoneId.systemDefault());
+ final Date sqlDate = new Date(zonedDate.toInstant().toEpochMilli());
+ final LocalDate result = CONVERTER.convertField(sqlDate,
Optional.empty(), FIELD_NAME);
+
+ assertEquals(yearOne, result);
+ }
+}
diff --git
a/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectStringFieldConverterTest.java
b/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectStringFieldConverterTest.java
index e73cc490f4a..bc1732832c3 100644
---
a/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectStringFieldConverterTest.java
+++
b/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectStringFieldConverterTest.java
@@ -24,8 +24,11 @@ import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
import java.time.zone.ZoneRules;
import java.util.Date;
+import java.util.Locale;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -158,6 +161,17 @@ class ObjectStringFieldConverterTest {
assertEquals(EMPTY_ARRAY_STRING, string);
}
+ @Test
+ void testConvertFieldTimestampYearOneIsProlepticGregorian() {
+ final LocalDateTime yearOne = LocalDateTime.of(1, 1, 1, 12, 0, 0);
+ final ZonedDateTime zonedYearOne =
yearOne.atZone(ZoneId.systemDefault());
+ final Timestamp timestamp = Timestamp.from(zonedYearOne.toInstant());
+ final String formatted = CONVERTER.convertField(timestamp,
Optional.of(DEFAULT_PATTERN), FIELD_NAME);
+ final String expected = DateTimeFormatter.ofPattern(DEFAULT_PATTERN,
Locale.ROOT).format(yearOne);
+
+ assertEquals(expected, formatted);
+ }
+
private String getDateTimeZoneOffset() {
final Timestamp inputTimestamp = Timestamp.valueOf(DATE_TIME_DEFAULT);
final LocalDateTime inputLocalDateTime =
inputTimestamp.toLocalDateTime();
diff --git
a/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectTimestampFieldConverterTest.java
b/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectTimestampFieldConverterTest.java
index 2d92724c2bd..7da62be6d22 100644
---
a/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectTimestampFieldConverterTest.java
+++
b/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/ObjectTimestampFieldConverterTest.java
@@ -162,6 +162,26 @@ public class ObjectTimestampFieldConverterTest {
assertEquals(expected, timestamp);
}
+ @Test
+ public void testConvertFieldStringYearOneIsProlepticGregorian() {
+ final LocalDateTime yearOne = LocalDateTime.of(1, 1, 1, 12, 0, 0);
+ final ZonedDateTime zonedYearOne =
yearOne.atZone(ZoneId.systemDefault());
+ final Timestamp timestamp = CONVERTER.convertField("0001-01-01
12:00:00", DEFAULT_PATTERN, FIELD_NAME);
+ final Timestamp expected = Timestamp.from(zonedYearOne.toInstant());
+
+ assertEquals(expected, timestamp);
+ }
+
+ @Test
+ public void testConvertFieldLocalDateTimeYearOneIsProlepticGregorian() {
+ final LocalDateTime yearOne = LocalDateTime.of(1, 1, 1, 12, 0, 0);
+ final ZonedDateTime zonedYearOne =
yearOne.atZone(ZoneId.systemDefault());
+ final Timestamp timestamp = CONVERTER.convertField(yearOne,
DEFAULT_PATTERN, FIELD_NAME);
+ final Timestamp expected = Timestamp.from(zonedYearOne.toInstant());
+
+ assertEquals(expected, timestamp);
+ }
+
private Timestamp getDateTimeCoordinatedUniversalTime() {
final Timestamp dateTime = Timestamp.valueOf(DATE_TIME_DEFAULT);
final LocalDateTime localDateTime = dateTime.toLocalDateTime();
diff --git
a/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/TestObjectLocalDateTimeFieldConverter.java
b/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/TestObjectLocalDateTimeFieldConverter.java
index 1aeabfc527f..b923c9d8771 100644
---
a/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/TestObjectLocalDateTimeFieldConverter.java
+++
b/nifi-commons/nifi-record/src/test/java/org/apache/nifi/serialization/record/field/TestObjectLocalDateTimeFieldConverter.java
@@ -19,9 +19,11 @@ package org.apache.nifi.serialization.record.field;
import org.junit.jupiter.api.Test;
+import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
+import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
@@ -100,4 +102,21 @@ public class TestObjectLocalDateTimeFieldConverter {
final LocalDateTime result =
converter.convertField(MICROS_TIMESTAMP_LONG,
Optional.of("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"), FIELD_NAME);
assertEquals(LOCAL_DATE_TIME_MICROS_PRECISION, result);
}
+
+ @Test
+ public void testConvertTimestampYearOneIsProlepticGregorian() {
+ final LocalDateTime yearOne = LocalDateTime.of(1, 1, 1, 12, 0, 0);
+ final ZonedDateTime zonedYearOne =
yearOne.atZone(ZoneId.systemDefault());
+ final Timestamp timestamp = Timestamp.from(zonedYearOne.toInstant());
+ final LocalDateTime result = converter.convertField(timestamp,
Optional.empty(), FIELD_NAME);
+
+ assertEquals(yearOne, result);
+ }
+
+ @Test
+ public void testConvertStringYearOneIsProlepticGregorian() {
+ final LocalDateTime result = converter.convertField("0001-01-01
12:00:00", Optional.of("yyyy-MM-dd HH:mm:ss"), FIELD_NAME);
+ final LocalDateTime expected = LocalDateTime.of(1, 1, 1, 12, 0, 0);
+ assertEquals(expected, result);
+ }
}
diff --git
a/nifi-extension-bundles/nifi-extension-utils/nifi-record-utils/nifi-avro-record-utils/src/main/java/org/apache/nifi/avro/AvroTypeUtil.java
b/nifi-extension-bundles/nifi-extension-utils/nifi-record-utils/nifi-avro-record-utils/src/main/java/org/apache/nifi/avro/AvroTypeUtil.java
index eeed128c7a6..aa0662884d5 100644
---
a/nifi-extension-bundles/nifi-extension-utils/nifi-record-utils/nifi-avro-record-utils/src/main/java/org/apache/nifi/avro/AvroTypeUtil.java
+++
b/nifi-extension-bundles/nifi-extension-utils/nifi-record-utils/nifi-avro-record-utils/src/main/java/org/apache/nifi/avro/AvroTypeUtil.java
@@ -63,6 +63,7 @@ import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.sql.Blob;
+import java.sql.Date;
import java.sql.Time;
import java.sql.Timestamp;
import java.time.Duration;
@@ -1153,11 +1154,20 @@ public class AvroTypeUtil {
final String logicalName = logicalType.getName();
if (LOGICAL_TYPE_DATE.equals(logicalName)) {
// date logical name means that the value is number of
days since Jan 1, 1970
- // Handle both Integer (legacy) and LocalDate (newer Avro
libraries)
- if (value instanceof LocalDate localDate) {
- return java.sql.Date.valueOf(localDate);
+ // Handle both Integer (legacy) and LocalDate (newer Avro
libraries).
+ final LocalDate localDate;
+ if (value instanceof LocalDate ld) {
+ localDate = ld;
+ } else {
+ localDate = LocalDate.ofEpochDay((int) value);
}
- return java.sql.Date.valueOf(LocalDate.ofEpochDay((int)
value));
+
+ // Convert to ZonedDateTime using system default zone to
allow for later conversion to Instant
+ final ZonedDateTime zonedDate =
localDate.atStartOfDay(ZoneId.systemDefault());
+
+ // Create Date from Instant epoch millis to preserve
proleptic Gregorian calendar for pre-1582 dates
+ final long epochMillis =
zonedDate.toInstant().toEpochMilli();
+ return new Date(epochMillis);
} else if (LOGICAL_TYPE_TIME_MILLIS.equals(logicalName)) {
// time-millis logical name means that the value is number
of milliseconds since midnight.
// Handle both Integer (legacy) and LocalTime (newer Avro
libraries)