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)

Reply via email to