From 195b1fc22c53e638a5ee41ce9961c1374df7488b Mon Sep 17 00:00:00 2001 From: superdachuan Date: Sat, 11 Apr 2026 16:42:13 +0800 Subject: [PATCH 1/3] NIFI-14572 Fix time offset issue in DateTimeAdapter#unmarshal Align DateTimeAdapter#unmarshal with TimestampAdapter by replacing ZonedDateTime with LocalDateTime to avoid timezone ambiguity (e.g., CST) --- .../web/api/dto/util/DateTimeAdapter.java | 5 ++- .../web/api/dto/util/TestDateTimeAdapter.java | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/DateTimeAdapter.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/DateTimeAdapter.java index 9ba1ff76033d..b656d30f5c92 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/DateTimeAdapter.java +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/DateTimeAdapter.java @@ -18,6 +18,7 @@ import jakarta.xml.bind.annotation.adapters.XmlAdapter; +import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -29,7 +30,7 @@ public class DateTimeAdapter extends XmlAdapter { private static final String DEFAULT_DATE_TIME_FORMAT = "MM/dd/yyyy HH:mm:ss z"; - private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT).withZone(ZoneId.systemDefault()); @Override public String marshal(Date date) throws Exception { @@ -39,7 +40,7 @@ public String marshal(Date date) throws Exception { @Override public Date unmarshal(String date) throws Exception { - final ZonedDateTime zonedDateTime = ZonedDateTime.parse(date, DATE_TIME_FORMATTER); + final ZonedDateTime zonedDateTime = LocalDateTime.parse(date, DATE_TIME_FORMATTER).atZone(ZoneId.systemDefault()); return Date.from(zonedDateTime.toInstant()); } diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java new file mode 100644 index 000000000000..528a18bbb273 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java @@ -0,0 +1,43 @@ +/* + * 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.web.api.dto.util; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TestDateTimeAdapter { + + private static final String TEST_DATE_TIME = "2026-01-02T03:04:56Z"; + + @Test + public void testMarshal() throws Exception { + DateTimeAdapter adapter = new DateTimeAdapter(); + Date date = Date.from(Instant.parse(TEST_DATE_TIME)); + assertEquals(date, adapter.unmarshal(adapter.marshal(date))); + } + + @Test + public void testUnmarshal() throws Exception { + DateTimeAdapter adapter = new DateTimeAdapter(); + String dateStr = adapter.marshal(Date.from(Instant.parse(TEST_DATE_TIME))); + assertEquals(dateStr, adapter.marshal(adapter.unmarshal(dateStr))); + } +} From 0182f810befff9f592b69148e19bc169a114cdc3 Mon Sep 17 00:00:00 2001 From: superdachuan Date: Fri, 17 Apr 2026 11:07:13 +0800 Subject: [PATCH 2/3] NIFI-14572 Refine DateTimeAdapter tests --- .../web/api/dto/util/TestDateTimeAdapter.java | 131 ++++++++++++++++-- 1 file changed, 121 insertions(+), 10 deletions(-) diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java index 528a18bbb273..788c674b4ba2 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java @@ -18,26 +18,137 @@ import org.junit.jupiter.api.Test; -import java.time.Instant; +import java.io.IOException; +import java.io.InputStream; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.Date; +import java.util.TimeZone; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; public class TestDateTimeAdapter { - private static final String TEST_DATE_TIME = "2026-01-02T03:04:56Z"; + private static final DateTimeAdapter DEFAULT_TIME_ZONE_ADAPTER = new DateTimeAdapter(); + private static final String DATE_TIME_ADAPTER_CLASS_NAME = "org.apache.nifi.web.api.dto.util.DateTimeAdapter"; + // Month, day, hour, minute, and second values are distinct for verification. + private static final long TEST_DATE_TIME_MILLISECONDS = 1767323096000L; // Represents 2026-01-02T03:04:56Z + private static final Date TEST_DATE = new Date(TEST_DATE_TIME_MILLISECONDS); + private static final DateTimeFormatter TEST_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss z") + .withZone(ZoneId.systemDefault()); + private static final String TEST_DATE_TIME = TEST_DATE_TIME_FORMATTER.format(TEST_DATE.toInstant()); @Test - public void testMarshal() throws Exception { - DateTimeAdapter adapter = new DateTimeAdapter(); - Date date = Date.from(Instant.parse(TEST_DATE_TIME)); - assertEquals(date, adapter.unmarshal(adapter.marshal(date))); + public void testMarshalThenUnmarshal() throws Exception { + assertEquals(TEST_DATE, DEFAULT_TIME_ZONE_ADAPTER.unmarshal(DEFAULT_TIME_ZONE_ADAPTER.marshal(TEST_DATE))); } @Test - public void testUnmarshal() throws Exception { - DateTimeAdapter adapter = new DateTimeAdapter(); - String dateStr = adapter.marshal(Date.from(Instant.parse(TEST_DATE_TIME))); - assertEquals(dateStr, adapter.marshal(adapter.unmarshal(dateStr))); + public void testUnmarshalThenMarshal() throws Exception { + assertEquals(TEST_DATE_TIME, DEFAULT_TIME_ZONE_ADAPTER.marshal(DEFAULT_TIME_ZONE_ADAPTER.unmarshal(TEST_DATE_TIME))); + } + + @Test + public void testMarshalAcrossTimeZones() throws Exception { + assertAll( + () -> assertMarshalInTimeZone("UTC", "01/02/2026 03:04:56 UTC"), + () -> assertMarshalInTimeZone("Asia/Shanghai", "01/02/2026 11:04:56 CST"), + () -> assertMarshalInTimeZone("America/Chicago", "01/01/2026 21:04:56 CST"), + () -> assertMarshalInTimeZone("Asia/Kolkata", "01/02/2026 08:34:56 IST"), + () -> assertMarshalInTimeZone("America/St_Johns", "01/01/2026 23:34:56 NST"), + () -> assertMarshalInTimeZone("America/New_York", "01/01/2026 22:04:56 EST"), + () -> assertMarshalInTimeZone("Asia/Tokyo", "01/02/2026 12:04:56 JST"), + () -> assertMarshalInTimeZone("Australia/Adelaide", "01/02/2026 13:34:56 ACDT"), + () -> assertMarshalInTimeZone("Pacific/Auckland", "01/02/2026 16:04:56 NZDT"), + () -> assertMarshalInTimeZone("Europe/Berlin", "01/02/2026 04:04:56 CET") + ); + } + + @Test + public void testUnmarshalAcrossTimeZones() throws Exception { + assertAll( + () -> assertUnmarshalInTimeZone("UTC", "01/02/2026 03:04:56 UTC"), + () -> assertUnmarshalInTimeZone("Asia/Shanghai", "01/02/2026 11:04:56 CST"), + () -> assertUnmarshalInTimeZone("America/Chicago", "01/01/2026 21:04:56 CST"), + () -> assertUnmarshalInTimeZone("Asia/Kolkata", "01/02/2026 08:34:56 IST"), + () -> assertUnmarshalInTimeZone("America/St_Johns", "01/01/2026 23:34:56 NST"), + () -> assertUnmarshalInTimeZone("America/New_York", "01/01/2026 22:04:56 EST"), + () -> assertUnmarshalInTimeZone("Asia/Tokyo", "01/02/2026 12:04:56 JST"), + () -> assertUnmarshalInTimeZone("Australia/Adelaide", "01/02/2026 13:34:56 ACDT"), + () -> assertUnmarshalInTimeZone("Pacific/Auckland", "01/02/2026 16:04:56 NZDT"), + () -> assertUnmarshalInTimeZone("Europe/Berlin", "01/02/2026 04:04:56 CET") + ); + } + + private static void assertMarshalInTimeZone(final String timeZoneId, final String expectedDateTime) throws Exception { + assertEquals(expectedDateTime, invokeInTimeZone(timeZoneId, "marshal", Date.class, TEST_DATE)); + } + + private static void assertUnmarshalInTimeZone(final String timeZoneId, final String dateTime) throws Exception { + assertEquals(TEST_DATE, invokeInTimeZone(timeZoneId, "unmarshal", String.class, dateTime)); + } + + @SuppressWarnings("unchecked") + private static T invokeInTimeZone(final String timeZoneId, final String methodName, final Class parameterType, final Object argument) throws Exception { + final TimeZone originalTimeZone = TimeZone.getDefault(); + + try { + TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId)); + + // DateTimeAdapter initializes its static formatter with ZoneId.systemDefault() during class loading. + // Creating a new adapter after changing the default time zone is not sufficient because the formatter + // remains bound to the zone captured during the first initialization. Loading the class through a + // dedicated ClassLoader forces static initialization to run again for each configured time zone. + final Class adapterClass = Class.forName(DATE_TIME_ADAPTER_CLASS_NAME, true, new DateTimeAdapterClassLoader()); + final Object adapter = adapterClass.getDeclaredConstructor().newInstance(); + // The reloaded class is isolated from the test class loader, so reflective invocation is required. + return (T) adapterClass.getMethod(methodName, parameterType).invoke(adapter, argument); + } finally { + TimeZone.setDefault(originalTimeZone); + } + } + + private static final class DateTimeAdapterClassLoader extends ClassLoader { + private DateTimeAdapterClassLoader() { + super(TestDateTimeAdapter.class.getClassLoader()); + } + + @Override + protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + if (DATE_TIME_ADAPTER_CLASS_NAME.equals(name)) { + Class loadedClass = findLoadedClass(name); + if (loadedClass == null) { + loadedClass = findClass(name); + } + if (resolve) { + resolveClass(loadedClass); + } + return loadedClass; + } + } + + return super.loadClass(name, resolve); + } + + @Override + protected Class findClass(final String name) throws ClassNotFoundException { + if (!DATE_TIME_ADAPTER_CLASS_NAME.equals(name)) { + throw new ClassNotFoundException(name); + } + + final String resourceName = name.replace('.', '/') + ".class"; + try (InputStream inputStream = getParent().getResourceAsStream(resourceName)) { + if (inputStream == null) { + throw new ClassNotFoundException(name); + } + + final byte[] classBytes = inputStream.readAllBytes(); + return defineClass(name, classBytes, 0, classBytes.length); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } } } From 9e7daa00e404516f2d4b0b6038cfad04591d2788 Mon Sep 17 00:00:00 2001 From: superdachuan Date: Tue, 21 Apr 2026 15:34:13 +0800 Subject: [PATCH 3/3] NIFI-14572 Simplify DateTimeAdapter time zone handling tests --- .../web/api/dto/util/DateTimeAdapter.java | 2 +- .../web/api/dto/util/TestDateTimeAdapter.java | 73 +++---------------- 2 files changed, 13 insertions(+), 62 deletions(-) diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/DateTimeAdapter.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/DateTimeAdapter.java index b656d30f5c92..ec1a72cfc78c 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/DateTimeAdapter.java +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/DateTimeAdapter.java @@ -30,7 +30,7 @@ public class DateTimeAdapter extends XmlAdapter { private static final String DEFAULT_DATE_TIME_FORMAT = "MM/dd/yyyy HH:mm:ss z"; - private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT).withZone(ZoneId.systemDefault()); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT); @Override public String marshal(Date date) throws Exception { diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java index 788c674b4ba2..4682f954a99e 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/test/java/org/apache/nifi/web/api/dto/util/TestDateTimeAdapter.java @@ -18,8 +18,6 @@ import org.junit.jupiter.api.Test; -import java.io.IOException; -import java.io.InputStream; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -31,7 +29,6 @@ public class TestDateTimeAdapter { private static final DateTimeAdapter DEFAULT_TIME_ZONE_ADAPTER = new DateTimeAdapter(); - private static final String DATE_TIME_ADAPTER_CLASS_NAME = "org.apache.nifi.web.api.dto.util.DateTimeAdapter"; // Month, day, hour, minute, and second values are distinct for verification. private static final long TEST_DATE_TIME_MILLISECONDS = 1767323096000L; // Represents 2026-01-02T03:04:56Z private static final Date TEST_DATE = new Date(TEST_DATE_TIME_MILLISECONDS); @@ -39,6 +36,7 @@ public class TestDateTimeAdapter { .withZone(ZoneId.systemDefault()); private static final String TEST_DATE_TIME = TEST_DATE_TIME_FORMATTER.format(TEST_DATE.toInstant()); + // Verify round-trip consistency for both marshal-then-unmarshal and unmarshal-then-marshal operations. @Test public void testMarshalThenUnmarshal() throws Exception { assertEquals(TEST_DATE, DEFAULT_TIME_ZONE_ADAPTER.unmarshal(DEFAULT_TIME_ZONE_ADAPTER.marshal(TEST_DATE))); @@ -49,6 +47,7 @@ public void testUnmarshalThenMarshal() throws Exception { assertEquals(TEST_DATE_TIME, DEFAULT_TIME_ZONE_ADAPTER.marshal(DEFAULT_TIME_ZONE_ADAPTER.unmarshal(TEST_DATE_TIME))); } + // Verify behavior after switching the JVM default time zone before invoking the adapter. @Test public void testMarshalAcrossTimeZones() throws Exception { assertAll( @@ -82,73 +81,25 @@ public void testUnmarshalAcrossTimeZones() throws Exception { } private static void assertMarshalInTimeZone(final String timeZoneId, final String expectedDateTime) throws Exception { - assertEquals(expectedDateTime, invokeInTimeZone(timeZoneId, "marshal", Date.class, TEST_DATE)); + final TimeZone originalTimeZone = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId)); + final DateTimeAdapter dateTimeAdapter = new DateTimeAdapter(); + assertEquals(expectedDateTime, dateTimeAdapter.marshal(TEST_DATE)); + } finally { + TimeZone.setDefault(originalTimeZone); + } } private static void assertUnmarshalInTimeZone(final String timeZoneId, final String dateTime) throws Exception { - assertEquals(TEST_DATE, invokeInTimeZone(timeZoneId, "unmarshal", String.class, dateTime)); - } - - @SuppressWarnings("unchecked") - private static T invokeInTimeZone(final String timeZoneId, final String methodName, final Class parameterType, final Object argument) throws Exception { final TimeZone originalTimeZone = TimeZone.getDefault(); - try { TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId)); - - // DateTimeAdapter initializes its static formatter with ZoneId.systemDefault() during class loading. - // Creating a new adapter after changing the default time zone is not sufficient because the formatter - // remains bound to the zone captured during the first initialization. Loading the class through a - // dedicated ClassLoader forces static initialization to run again for each configured time zone. - final Class adapterClass = Class.forName(DATE_TIME_ADAPTER_CLASS_NAME, true, new DateTimeAdapterClassLoader()); - final Object adapter = adapterClass.getDeclaredConstructor().newInstance(); - // The reloaded class is isolated from the test class loader, so reflective invocation is required. - return (T) adapterClass.getMethod(methodName, parameterType).invoke(adapter, argument); + final DateTimeAdapter dateTimeAdapter = new DateTimeAdapter(); + assertEquals(TEST_DATE, dateTimeAdapter.unmarshal(dateTime)); } finally { TimeZone.setDefault(originalTimeZone); } } - private static final class DateTimeAdapterClassLoader extends ClassLoader { - private DateTimeAdapterClassLoader() { - super(TestDateTimeAdapter.class.getClassLoader()); - } - - @Override - protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException { - synchronized (getClassLoadingLock(name)) { - if (DATE_TIME_ADAPTER_CLASS_NAME.equals(name)) { - Class loadedClass = findLoadedClass(name); - if (loadedClass == null) { - loadedClass = findClass(name); - } - if (resolve) { - resolveClass(loadedClass); - } - return loadedClass; - } - } - - return super.loadClass(name, resolve); - } - - @Override - protected Class findClass(final String name) throws ClassNotFoundException { - if (!DATE_TIME_ADAPTER_CLASS_NAME.equals(name)) { - throw new ClassNotFoundException(name); - } - - final String resourceName = name.replace('.', '/') + ".class"; - try (InputStream inputStream = getParent().getResourceAsStream(resourceName)) { - if (inputStream == null) { - throw new ClassNotFoundException(name); - } - - final byte[] classBytes = inputStream.readAllBytes(); - return defineClass(name, classBytes, 0, classBytes.length); - } catch (IOException e) { - throw new ClassNotFoundException(name, e); - } - } - } }