From c1534278bae4120f24b426245b2fbc8f42d08d0b Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Mon, 3 Aug 2026 12:16:05 +0800 Subject: [PATCH 1/4] feat(java): support timezone in JSON date formats --- docs/guide/java/android-support.md | 3 +- docs/guide/java/graalvm-support.md | 7 +- docs/guide/java/json-support.md | 14 ++ .../android_tests/proguard-rules.pro | 1 + .../android/ForyAndroidInstrumentedTest.java | 5 + .../fory/android/AndroidJsonScenarios.java | 29 ++++ .../apache/fory/graalvm/ForyJsonExample.java | 30 +++++ .../processing/JsonTypeProcessorTest.java | 32 +++-- java/fory-json/README.md | 14 ++ .../fory/json/annotation/JsonFormat.java | 14 +- .../fory/json/codec/DateTimeFormatCodec.java | 51 ++++++- .../apache/fory/json/codec/ScalarCodecs.java | 5 +- .../fory/json/resolver/JsonTypeResolver.java | 3 +- .../fory/json/JsonAndroidRuntimeTest.java | 16 +++ .../fory/json/JsonFormatAnnotationTest.java | 126 ++++++++++++++++-- 15 files changed, 310 insertions(+), 40 deletions(-) diff --git a/docs/guide/java/android-support.md b/docs/guide/java/android-support.md index 0a90942f79..9a6ff8be32 100644 --- a/docs/guide/java/android-support.md +++ b/docs/guide/java/android-support.md @@ -162,7 +162,8 @@ annotations, and the Base64 codec constructor. Without `@JsonType`, these annota through reflection, but a release-minified application must keep the exact annotated members, annotation attributes, and codec constructor itself. A `JsonValue` method may use a non-JavaBean name, so its manual rule must name that method explicitly. `JsonFormat` keeps the same direct-field -and one-wrapper-level behavior as on the JVM. +and one-wrapper-level behavior as on the JVM, including `timezone` for `Instant`, `ZonedDateTime`, +and `OffsetDateTime`. Android Fory JSON requires a retained no-argument constructor for an ordinary mutable class; it may be non-public when Android reflection can make it accessible. `JsonCreator` constructor-backed diff --git a/docs/guide/java/graalvm-support.md b/docs/guide/java/graalvm-support.md index 824622f7a3..088ffed80e 100644 --- a/docs/guide/java/graalvm-support.md +++ b/docs/guide/java/graalvm-support.md @@ -163,9 +163,10 @@ JVM and Android. `JsonValue` fields and effective public zero-argument methods are supported, including matching one-String `JsonCreator` constructors and public static factories. Fixed `JsonRawValue` fields and getters support trusted raw String values, and fixed `JsonBase64` fields and getters support Base64 -`byte[]` values as on the JVM. `JsonFormat` date/time fields use the same direct-field and -one-wrapper-level behavior as on the JVM. For direct target annotations, annotate each reachable -owning model with `JsonType` so Native Image retains these members and the Base64 codec constructor. +`byte[]` values as on the JVM. `JsonFormat` date/time fields use the same direct-field, +one-wrapper-level, and `timezone` behavior as on the JVM. For direct target annotations, annotate +each reachable owning model with `JsonType` so Native Image retains these members and the Base64 +codec constructor. A directly annotated `JsonValue` Record uses its generated component accessor and canonical constructor operations. An effective declaration supplied by a Mixin uses the Mixin workflow above instead. diff --git a/docs/guide/java/json-support.md b/docs/guide/java/json-support.md index e26fe5c35f..2e5e8533c4 100644 --- a/docs/guide/java/json-support.md +++ b/docs/guide/java/json-support.md @@ -657,6 +657,7 @@ Use `JsonFormat` on a date/time field to select its JSON text pattern in both di use `DateTimeFormatter` syntax and the root locale: ```java +import java.time.Instant; import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -675,6 +676,9 @@ public final class Schedule { @JsonFormat(pattern = "dd/MM/uuuu") public Map daysByName; + + @JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai") + public Instant timestamp; } ``` @@ -691,6 +695,16 @@ Supported values are exact `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`, offset carried by the value. The pattern must contain enough information to reconstruct the declared type. +Set `timezone` to a valid `ZoneId` identifier to format and parse `Instant`, `ZonedDateTime`, or +`OffsetDateTime` in that zone. For example, the `timestamp` field above writes +`Instant.parse("2024-01-02T03:04:05Z")` as `"2024-01-02 11:04:05 +08:00"`. The parsed value keeps +the same instant for matching timezone text. The configured zone supplies missing zone or offset +information during parsing; an explicit zone or offset in the JSON text participates in the usual +`DateTimeFormatter` resolution. Include an offset in the pattern when an exact instant must survive +a daylight saving time overlap. Omitting `timezone` preserves the default behavior described +above. Invalid zone identifiers and a non-empty `timezone` on other supported date/time types are +rejected. + `JsonFormat` is a field annotation, not a type-use annotation. A record component works through its generated field. Nested wrappers, Map keys, raw or wildcard direct children, JSON Any values, and unwrapped values are intentionally rejected. Types with ambiguous formatting semantics, including diff --git a/integration_tests/android_tests/proguard-rules.pro b/integration_tests/android_tests/proguard-rules.pro index 80115ad83c..6e00f62daf 100644 --- a/integration_tests/android_tests/proguard-rules.pro +++ b/integration_tests/android_tests/proguard-rules.pro @@ -13,6 +13,7 @@ public static void generatedPlainRules(); public static void generatedRecord(); public static void generatedValueRecord(); + public static void generatedFormatTimezone(); public static void manualCodecs(); public static void generatedCodecs(); public static void generatedUnwrapped(); diff --git a/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java b/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java index ff713689a4..5555c29265 100644 --- a/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java +++ b/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java @@ -55,6 +55,11 @@ public void generatedValueRecordJson() { AndroidJsonScenarios.generatedValueRecord(); } + @Test + public void jsonFormatTimezone() { + AndroidJsonScenarios.generatedFormatTimezone(); + } + @Test public void manualJsonCodecs() { AndroidJsonScenarios.manualCodecs(); diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/AndroidJsonScenarios.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/AndroidJsonScenarios.java index 5b04999518..b1f02aab46 100644 --- a/integration_tests/android_tests/src/main/java/org/apache/fory/android/AndroidJsonScenarios.java +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/AndroidJsonScenarios.java @@ -19,12 +19,15 @@ package org.apache.fory.android; +import java.time.Instant; import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReferenceArray; import org.apache.fory.json.ForyJson; import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.annotation.JsonMixin; import org.apache.fory.json.annotation.JsonProperty; import org.apache.fory.json.annotation.JsonPropertyOrder; @@ -104,6 +107,23 @@ public static void generatedValueRecord() { json.fromJson("\"decoded-value\"", GeneratedJsonValueRecord.class).value()); } + public static void generatedFormatTimezone() { + ForyJson json = ForyJson.builder().build(); + Instant instant = Instant.parse("2024-01-02T03:04:05Z"); + FormatTimezoneModel value = new FormatTimezoneModel(); + value.instant = instant; + value.instants = Arrays.asList(instant, instant.plusSeconds(3600)); + String encoded = json.toJson(value); + checkEquals( + "{\"instant\":\"2024-01-02 11:04:05 +08:00\"," + + "\"instants\":[\"2024-01-02 11:04:05 +08:00\"," + + "\"2024-01-02 12:04:05 +08:00\"]}", + encoded); + FormatTimezoneModel decoded = json.fromJson(encoded, FormatTimezoneModel.class); + checkEquals(instant, decoded.instant); + checkEquals(value.instants, decoded.instants); + } + public static void manualCodecs() { ForyJson json = ForyJson.builder().build(); ManualJsonModel value = new ManualJsonModel(); @@ -428,6 +448,15 @@ public abstract static class GeneratedJsonMixinValueRecordAnnotations { GeneratedJsonMixinValueRecordAnnotations(String value) {} } + @JsonType + public static final class FormatTimezoneModel { + @JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai") + public Instant instant; + + @JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai") + public List instants; + } + private static void check(boolean condition) { if (!condition) { throw new AssertionError("check failed"); diff --git a/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java b/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java index 9003fdca2d..981b9af4ae 100644 --- a/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java +++ b/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java @@ -27,6 +27,7 @@ import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -50,6 +51,7 @@ import org.apache.fory.json.annotation.JsonBase64; import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.annotation.JsonIgnore; import org.apache.fory.json.annotation.JsonMixin; import org.apache.fory.json.annotation.JsonProperty; @@ -105,6 +107,7 @@ public static void main(String[] args) { testMixinCodec(); testBigDecimal(); testSqlTypes(); + testFormatTimezone(); testClosedPackage(); } finally { System.setOut(originalOut); @@ -540,6 +543,24 @@ private static void testSqlTypes() { Preconditions.checkArgument(decoded.timestamp.getTime() == 3_000L); } + private static void testFormatTimezone() { + ForyJson json = ForyJson.builder().build(); + Instant instant = Instant.parse("2024-01-02T03:04:05Z"); + FormatTimezoneValues value = new FormatTimezoneValues(); + value.instant = instant; + value.instants = List.of(instant, instant.plusSeconds(3600)); + String expected = + "{\"instant\":\"2024-01-02 11:04:05 +08:00\"," + + "\"instants\":[\"2024-01-02 11:04:05 +08:00\"," + + "\"2024-01-02 12:04:05 +08:00\"]}"; + Preconditions.checkArgument(json.toJson(value).equals(expected)); + byte[] bytes = json.toJsonBytes(value); + Preconditions.checkArgument(new String(bytes, StandardCharsets.UTF_8).equals(expected)); + FormatTimezoneValues decoded = json.fromJson(bytes, FormatTimezoneValues.class); + Preconditions.checkArgument(decoded.instant.equals(instant)); + Preconditions.checkArgument(decoded.instants.equals(value.instants)); + } + public interface InheritedJsonConfig { default ForyJson duplicateConfiguration() { return newProviderJson(); @@ -1201,6 +1222,15 @@ public static final class SqlValues { public Timestamp timestamp; } + @JsonType + public static final class FormatTimezoneValues { + @JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai") + public Instant instant; + + @JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai") + public List instants; + } + @JsonMixin(target = JsonMixinTarget.class) @JsonPropertyOrder({"id", "address"}) public interface JsonMixinModel { diff --git a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java index 80bfa2edba..4eedebd872 100644 --- a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java +++ b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java @@ -33,7 +33,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.time.LocalDate; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -790,20 +790,22 @@ public void formatRecordPipeline() throws Exception { compile( "test.FormatRecord", "package test;\n" - + "import java.time.LocalDate;\n" + + "import java.time.Instant;\n" + "import org.apache.fory.json.annotation.*;\n" + "@JsonType public record FormatRecord(\n" - + " @JsonFormat(pattern = \"dd/MM/uuuu\") LocalDate value) {}\n"); + + " @JsonFormat(pattern = \"uuuu-MM-dd HH:mm:ss XXX\", " + + "timezone = \"Asia/Shanghai\") Instant value) {}\n"); assertTrue(result.success, result.diagnostics()); String rules = result.generatedResource(RULE_PREFIX + "test.FormatRecord.pro"); assertTrue(rules.contains("@interface org.apache.fory.json.annotation.JsonFormat"), rules); ClassLoader loader = result.classLoader(); Class type = loader.loadClass("test.FormatRecord"); - Object value = type.getConstructor(LocalDate.class).newInstance(LocalDate.of(2024, 1, 2)); + Instant instant = Instant.parse("2024-01-02T03:04:05Z"); + Object value = type.getConstructor(Instant.class).newInstance(instant); for (ForyJson json : jsonRuntimes(loader)) { - assertEquals(json.toJson(value), "{\"value\":\"02/01/2024\"}"); - Object decoded = json.fromJson("{\"value\":\"03/01/2024\"}", type); - assertEquals(type.getMethod("value").invoke(decoded), LocalDate.of(2024, 1, 3)); + assertEquals(json.toJson(value), "{\"value\":\"2024-01-02 11:04:05 +08:00\"}"); + Object decoded = json.fromJson("{\"value\":\"2024-01-02 12:04:05 +08:00\"}", type); + assertEquals(type.getMethod("value").invoke(decoded), instant.plusSeconds(3600)); } } @@ -813,11 +815,12 @@ public void formatMixinPipeline() throws Exception { compile( "test.FormatTarget", "package test;\n" - + "import java.time.LocalDate;\n" + + "import java.time.Instant;\n" + "import org.apache.fory.json.annotation.*;\n" - + "public final class FormatTarget { public LocalDate value; }\n" + + "public final class FormatTarget { public Instant value; }\n" + "@JsonMixin(target = FormatTarget.class) abstract class FormatMixin {\n" - + " @JsonFormat(pattern = \"dd/MM/uuuu\") LocalDate value;\n" + + " @JsonFormat(pattern = \"uuuu-MM-dd HH:mm:ss XXX\", " + + "timezone = \"Asia/Shanghai\") Instant value;\n" + "}\n"); assertTrue(result.success, result.diagnostics()); String base = "FormatMixin_ForyJsonMixin_test_x2e_FormatTarget"; @@ -828,7 +831,8 @@ public void formatMixinPipeline() throws Exception { Class target = loader.loadClass("test.FormatTarget"); Class mixin = loader.loadClass("test.FormatMixin"); Object value = target.getConstructor().newInstance(); - target.getField("value").set(value, LocalDate.of(2024, 1, 2)); + Instant instant = Instant.parse("2024-01-02T03:04:05Z"); + target.getField("value").set(value, instant); for (boolean codegen : new boolean[] {false, true}) { ForyJson json = ForyJson.builder() @@ -837,9 +841,9 @@ public void formatMixinPipeline() throws Exception { .withClassLoader(loader) .registerMixin(mixin) .build(); - assertEquals(json.toJson(value), "{\"value\":\"02/01/2024\"}"); - Object decoded = json.fromJson("{\"value\":\"03/01/2024\"}", target); - assertEquals(target.getField("value").get(decoded), LocalDate.of(2024, 1, 3)); + assertEquals(json.toJson(value), "{\"value\":\"2024-01-02 11:04:05 +08:00\"}"); + Object decoded = json.fromJson("{\"value\":\"2024-01-02 12:04:05 +08:00\"}", target); + assertEquals(target.getField("value").get(decoded), instant.plusSeconds(3600)); } } diff --git a/java/fory-json/README.md b/java/fory-json/README.md index 33ba4942c8..933fe6970a 100644 --- a/java/fory-json/README.md +++ b/java/fory-json/README.md @@ -725,6 +725,7 @@ Use `JsonFormat` on a date/time field to select its JSON text pattern in both di use `DateTimeFormatter` syntax and the root locale: ```java +import java.time.Instant; import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -743,6 +744,9 @@ public final class Schedule { @JsonFormat(pattern = "dd/MM/uuuu") public Map daysByName; + + @JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai") + public Instant timestamp; } ``` @@ -759,6 +763,16 @@ Supported values are exact `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`, offset carried by the value. The pattern must contain enough information to reconstruct the declared type. +Set `timezone` to a valid `ZoneId` identifier to format and parse `Instant`, `ZonedDateTime`, or +`OffsetDateTime` in that zone. For example, the `timestamp` field above writes +`Instant.parse("2024-01-02T03:04:05Z")` as `"2024-01-02 11:04:05 +08:00"`. The parsed value keeps +the same instant for matching timezone text. The configured zone supplies missing zone or offset +information during parsing; an explicit zone or offset in the JSON text participates in the usual +`DateTimeFormatter` resolution. Include an offset in the pattern when an exact instant must survive +a daylight saving time overlap. Omitting `timezone` preserves the default behavior described +above. Invalid zone identifiers and a non-empty `timezone` on other supported date/time types are +rejected. + `JsonFormat` is a field annotation, not a type-use annotation. A record component works through its generated field. Nested wrappers, Map keys, raw or wildcard direct children, JSON Any values, and unwrapped values are intentionally rejected. Types with ambiguous formatting semantics, including diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonFormat.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonFormat.java index 329d554ef6..691452848f 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonFormat.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonFormat.java @@ -36,8 +36,10 @@ * java.time.MonthDay}, {@link java.time.OffsetTime}, {@link java.time.OffsetDateTime}, {@link * java.time.chrono.HijrahDate}, {@link java.time.chrono.JapaneseDate}, {@link * java.time.chrono.MinguoDate}, and {@link java.time.chrono.ThaiBuddhistDate}. Instant values use - * UTC; zoned and offset values use the zone or offset carried by the value. The pattern must retain - * enough information to reconstruct the declared type. + * UTC; zoned and offset values use the zone or offset carried by the value. An explicit {@link + * #timezone()} overrides that behavior for {@code Instant}, {@code ZonedDateTime}, and {@code + * OffsetDateTime}; it is rejected for other date/time types. The pattern must retain enough + * information to reconstruct the declared type. * *

Formatting is applied in both JSON directions. Arrays and collections apply it to their direct * element, maps to their direct value, and optional and atomic-reference wrappers to their direct @@ -50,4 +52,12 @@ public @interface JsonFormat { /** Returns the required date/time pattern. */ String pattern(); + + /** + * Returns the optional {@link java.time.ZoneId} identifier used for formatting and parsing {@link + * java.time.Instant}, {@link java.time.ZonedDateTime}, and {@link java.time.OffsetDateTime} + * values. An empty value keeps the declared value's zone or offset, except that {@code Instant} + * continues to use UTC. + */ + String timezone() default ""; } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java index 49faf931c8..4016bd76cd 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java @@ -19,6 +19,7 @@ package org.apache.fory.json.codec; +import java.time.DateTimeException; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -28,6 +29,8 @@ import java.time.OffsetTime; import java.time.Year; import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.chrono.HijrahChronology; import java.time.chrono.HijrahDate; @@ -39,6 +42,7 @@ import java.time.chrono.ThaiBuddhistDate; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalQueries; import java.util.Locale; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.reader.Latin1JsonReader; @@ -63,12 +67,13 @@ final class DateTimeFormatCodec implements JsonValueCodec { private static final int JAPANESE_DATE = 12; private static final int MINGUO_DATE = 13; private static final int THAI_BUDDHIST_DATE = 14; + private static final int OFFSET_DATE_TIME_WITH_ZONE = 15; private final Class type; private final int kind; private final DateTimeFormatter formatter; - static JsonValueCodec create(Class type, String pattern) { + static JsonValueCodec create(Class type, String pattern, String timezone) { if (pattern.isEmpty()) { throw invalidPattern(type, pattern, null); } @@ -79,9 +84,24 @@ static JsonValueCodec create(Class type, String pattern) { } catch (IllegalArgumentException e) { throw invalidPattern(type, pattern, e); } - if (kind == INSTANT) { - formatter = formatter.withZone(java.time.ZoneOffset.UTC); - } else if (kind == HIJRAH_DATE) { + if (timezone.isEmpty()) { + if (kind == INSTANT) { + formatter = formatter.withZone(ZoneOffset.UTC); + } + } else { + if (kind != INSTANT && kind != ZONED_DATE_TIME && kind != OFFSET_DATE_TIME) { + throw unsupportedTimezone(type); + } + try { + formatter = formatter.withZone(ZoneId.of(timezone)); + } catch (DateTimeException e) { + throw invalidTimezone(type, timezone, e); + } + if (kind == OFFSET_DATE_TIME) { + kind = OFFSET_DATE_TIME_WITH_ZONE; + } + } + if (kind == HIJRAH_DATE) { formatter = formatter.withChronology(HijrahChronology.INSTANCE); } else if (kind == JAPANESE_DATE) { formatter = formatter.withChronology(JapaneseChronology.INSTANCE); @@ -159,6 +179,8 @@ private Object parse(CharSequence value) { return OffsetTime.from(parsed); case OFFSET_DATE_TIME: return OffsetDateTime.from(parsed); + case OFFSET_DATE_TIME_WITH_ZONE: + return parseOffsetDateTime(parsed); case HIJRAH_DATE: return HijrahDate.from(parsed); case JAPANESE_DATE: @@ -175,6 +197,17 @@ private Object parse(CharSequence value) { } } + private OffsetDateTime parseOffsetDateTime(TemporalAccessor parsed) { + LocalDateTime dateTime = LocalDateTime.from(parsed); + ZoneOffset offset = parsed.query(TemporalQueries.offset()); + // An effective region ZoneId does not synthesize OFFSET_SECONDS. Keep an explicit offset + // authoritative and derive only a missing one from the effective parsed zone. + if (offset == null) { + offset = parsed.query(TemporalQueries.zone()).getRules().getOffset(dateTime); + } + return OffsetDateTime.of(dateTime, offset); + } + static boolean supports(Class type) { return type == LocalDate.class || type == LocalTime.class @@ -243,6 +276,16 @@ private static ForyJsonException invalidPattern(Class type, String pattern, T "Invalid @JsonFormat pattern for " + type.getTypeName() + ": " + pattern, cause); } + private static ForyJsonException invalidTimezone( + Class type, String timezone, Throwable cause) { + return new ForyJsonException( + "Invalid @JsonFormat timezone for " + type.getTypeName() + ": " + timezone, cause); + } + + private static ForyJsonException unsupportedTimezone(Class type) { + return new ForyJsonException("@JsonFormat timezone is not supported for " + type.getTypeName()); + } + private static ForyJsonException invalidValue( Class type, CharSequence value, Throwable cause) { return new ForyJsonException( diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java index c71e4113c4..422706b9b0 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java @@ -121,8 +121,9 @@ public static boolean supportsDateTimeFormat(Class type) { } @Internal - public static JsonValueCodec dateTimeFormatCodec(Class type, String pattern) { - return DateTimeFormatCodec.create(type, pattern); + public static JsonValueCodec dateTimeFormatCodec( + Class type, String pattern, String timezone) { + return DateTimeFormatCodec.create(type, pattern, timezone); } public static final class NaturalCodec implements JsonValueCodec { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java index 127557376e..b530cf8f11 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java @@ -555,7 +555,8 @@ private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonFo private JsonTypeInfo formatTypeInfo(Type type, Class rawType, JsonFormat annotation) { sharedRegistry.checkSecure(rawType); - JsonValueCodec codec = ScalarCodecs.dateTimeFormatCodec(rawType, annotation.pattern()); + JsonValueCodec codec = + ScalarCodecs.dateTimeFormatCodec(rawType, annotation.pattern(), annotation.timezone()); return newTypeInfo(type, rawType, JsonFieldKind.OBJECT, codec, true); } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java index 22d8649be2..0d46d828ca 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java @@ -28,6 +28,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; @@ -115,12 +116,21 @@ private static void assertRoundTrips(ForyJson json) { AndroidFormat format = new AndroidFormat(); format.value = LocalDate.of(2024, 1, 2); format.values = Arrays.asList(LocalDate.of(2024, 1, 3), LocalDate.of(2024, 1, 4)); + format.instant = Instant.parse("2024-01-02T03:04:05Z"); + format.instants = Arrays.asList(format.instant, format.instant.plusSeconds(3600)); String formatJson = json.toJson(format); assertTrue(formatJson.contains("\"value\":\"02/01/2024\""), formatJson); assertTrue(formatJson.contains("\"values\":[\"03/01/2024\",\"04/01/2024\"]"), formatJson); + assertTrue(formatJson.contains("\"instant\":\"2024-01-02 11:04:05 +08:00\""), formatJson); + assertTrue( + formatJson.contains( + "\"instants\":[\"2024-01-02 11:04:05 +08:00\"," + "\"2024-01-02 12:04:05 +08:00\"]"), + formatJson); AndroidFormat decodedFormat = json.fromJson(formatJson, AndroidFormat.class); assertEquals(decodedFormat.value, format.value); assertEquals(decodedFormat.values, format.values); + assertEquals(decodedFormat.instant, format.instant); + assertEquals(decodedFormat.instants, format.instants); } private static List javaCommand(String classPath, Class mainClass) { @@ -230,5 +240,11 @@ public static final class AndroidFormat { @JsonFormat(pattern = "dd/MM/uuuu") public List values; + + @JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai") + public Instant instant; + + @JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai") + public List instants; } } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java index 1eb837d6a1..57390faf13 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java @@ -71,6 +71,9 @@ public class JsonFormatAnnotationTest extends ForyJsonTestModels { private static final String DATE_PATTERN = "dd/MM/uuuu"; + private static final String LOCAL_TIMESTAMP_PATTERN = "uuuu-MM-dd HH:mm:ss"; + private static final String TIMESTAMP_PATTERN = "uuuu-MM-dd HH:mm:ss XXX"; + private static final String ZONED_TIMESTAMP_PATTERN = "uuuu-MM-dd HH:mm:ss VV"; @Factory(dataProvider = "enableCodegen") public JsonFormatAnnotationTest(boolean codegen) { @@ -138,6 +141,47 @@ public void temporalTypes() { assertGeneratedWhenSupported(json, TemporalFields.class); } + @Test + public void timezoneRoundTrip() { + ForyJson json = newJson(); + Instant instant = Instant.parse("2024-01-02T03:04:05Z"); + TimezoneFields value = new TimezoneFields(); + value.instant = instant; + value.zonedDateTime = instant.atZone(ZoneId.of("Europe/Paris")); + value.offsetDateTime = instant.atOffset(ZoneOffset.ofHours(-4)); + value.values = Arrays.asList(instant, instant.plusSeconds(3600)); + String expected = + "{\"instant\":\"2024-01-02 11:04:05\"," + + "\"zonedDateTime\":\"2024-01-02 11:04:05 +08:00\"," + + "\"offsetDateTime\":\"2024-01-02 11:04:05 +08:00\"," + + "\"values\":[\"2024-01-02 11:04:05 +08:00\"," + + "\"2024-01-02 12:04:05 +08:00\"]}"; + assertEquals(json.toJson(value), expected); + assertEquals(new String(json.toJsonBytes(value), StandardCharsets.UTF_8), expected); + assertTimezoneFields(json.fromJson(expected, TimezoneFields.class), instant); + assertTimezoneFields( + json.fromJson(expected.getBytes(StandardCharsets.UTF_8), TimezoneFields.class), instant); + assertGeneratedWhenSupported(json, TimezoneFields.class); + } + + @Test + public void offsetTimezoneParsing() { + ForyJson json = newJson(); + String input = + "{\"local\":\"2024-01-02 11:04:05\"," + + "\"explicit\":\"2024-01-02 07:04:05 +04:00\"," + + "\"explicitZone\":\"2024-01-02 04:04:05 Europe/Paris\"}"; + OffsetTimezoneFields value = json.fromJson(input, OffsetTimezoneFields.class); + Instant instant = Instant.parse("2024-01-02T03:04:05Z"); + assertEquals(value.local.toInstant(), instant); + assertEquals(value.local.getOffset(), ZoneOffset.ofHours(8)); + assertEquals(value.explicit.toInstant(), instant); + assertEquals(value.explicit.getOffset(), ZoneOffset.ofHours(4)); + assertEquals(value.explicitZone.toInstant(), instant); + assertEquals(value.explicitZone.getOffset(), ZoneOffset.ofHours(1)); + assertGeneratedWhenSupported(json, OffsetTimezoneFields.class); + } + @Test public void creatorRoundTrip() { ForyJson json = newJson(); @@ -158,15 +202,17 @@ public void recordRoundTrip() throws Exception { compileRecordClass( "JsonFormatRecord", "package org.apache.fory.json.records;\n" - + "import java.time.LocalDate;\n" + + "import java.time.Instant;\n" + "import org.apache.fory.json.annotation.JsonFormat;\n" + "public record JsonFormatRecord(" - + "@JsonFormat(pattern = \"dd/MM/uuuu\") LocalDate value) {}\n"); - Object value = type.getConstructor(LocalDate.class).newInstance(LocalDate.of(2024, 1, 2)); + + "@JsonFormat(pattern = \"uuuu-MM-dd HH:mm:ss XXX\", " + + "timezone = \"Asia/Shanghai\") Instant value) {}\n"); + Instant value = Instant.parse("2024-01-02T03:04:05Z"); + Object record = type.getConstructor(Instant.class).newInstance(value); for (ForyJson json : new ForyJson[] {newJson(), newJsonBuilder().withFieldMode(true).build()}) { - assertEquals(json.toJson(value), "{\"value\":\"02/01/2024\"}"); - Object decoded = json.fromJson("{\"value\":\"03/01/2024\"}", type); - assertEquals(type.getMethod("value").invoke(decoded), LocalDate.of(2024, 1, 3)); + assertEquals(json.toJson(record), "{\"value\":\"2024-01-02 11:04:05 +08:00\"}"); + Object decoded = json.fromJson("{\"value\":\"2024-01-02 12:04:05 +08:00\"}", type); + assertEquals(type.getMethod("value").invoke(decoded), value.plusSeconds(3600)); } } @@ -174,11 +220,11 @@ public void recordRoundTrip() throws Exception { public void mixinRoundTrip() { ForyJson mixinJson = newJsonBuilder().registerMixin(FormatMixin.class).build(); MixinTarget value = new MixinTarget(); - value.value = LocalDate.of(2024, 1, 2); - assertEquals(mixinJson.toJson(value), "{\"value\":\"02/01/2024\"}"); + value.value = Instant.parse("2024-01-02T03:04:05Z"); + assertEquals(mixinJson.toJson(value), "{\"value\":\"2024-01-02 11:04:05 +08:00\"}"); assertEquals( - mixinJson.fromJson("{\"value\":\"03/01/2024\"}", MixinTarget.class).value, - LocalDate.of(2024, 1, 3)); + mixinJson.fromJson("{\"value\":\"2024-01-02 12:04:05 +08:00\"}", MixinTarget.class).value, + value.value.plusSeconds(3600)); ForyJson removalJson = newJsonBuilder().registerMixin(RemoveFormatMixin.class).build(); IntrinsicTarget intrinsic = new IntrinsicTarget(); @@ -219,6 +265,11 @@ public void rejectInvalidDeclarations() { ForyJson json = newJson(); assertThrows(ForyJsonException.class, () -> json.toJson(new EmptyPattern())); assertThrows(ForyJsonException.class, () -> json.toJson(new InvalidPattern())); + ForyJsonException timezoneError = + expectThrows(ForyJsonException.class, () -> json.toJson(new InvalidTimezone())); + assertTrue(timezoneError.getMessage().contains("timezone"), timezoneError.getMessage()); + assertThrows(ForyJsonException.class, () -> json.toJson(new LocalTimezone())); + assertThrows(ForyJsonException.class, () -> json.toJson(new OffsetTimeTimezone())); assertThrows(ForyJsonException.class, () -> json.toJson(new StringFormat())); assertThrows(ForyJsonException.class, () -> json.toJson(new DurationFormat())); assertThrows(ForyJsonException.class, () -> json.toJson(new LegacyDateFormat())); @@ -254,6 +305,15 @@ private static void assertWrapperFields(WrapperFields value) { assertEquals(value.atomicArray.get(1), null); } + private static void assertTimezoneFields(TimezoneFields value, Instant instant) { + assertEquals(value.instant, instant); + assertEquals(value.zonedDateTime.toInstant(), instant); + assertEquals(value.zonedDateTime.getZone(), ZoneId.of("Asia/Shanghai")); + assertEquals(value.offsetDateTime.toInstant(), instant); + assertEquals(value.offsetDateTime.getOffset(), ZoneOffset.ofHours(8)); + assertEquals(value.values, Arrays.asList(instant, instant.plusSeconds(3600))); + } + private static TemporalFields temporalFields() { TemporalFields value = new TemporalFields(); Instant instant = Instant.parse("2024-01-02T03:04:05.006Z"); @@ -369,6 +429,31 @@ public static final class TemporalFields { public ThaiBuddhistDate thaiBuddhistDate; } + public static final class TimezoneFields { + @JsonFormat(pattern = LOCAL_TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + public Instant instant; + + @JsonFormat(pattern = TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + public ZonedDateTime zonedDateTime; + + @JsonFormat(pattern = TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + public OffsetDateTime offsetDateTime; + + @JsonFormat(pattern = TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + public List values; + } + + public static final class OffsetTimezoneFields { + @JsonFormat(pattern = LOCAL_TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + public OffsetDateTime local; + + @JsonFormat(pattern = TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + public OffsetDateTime explicit; + + @JsonFormat(pattern = ZONED_TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + public OffsetDateTime explicitZone; + } + public static final class CreatorField { @JsonFormat(pattern = DATE_PATTERN) public final LocalDate value; @@ -380,13 +465,13 @@ public CreatorField(LocalDate value) { } public static final class MixinTarget { - public LocalDate value; + public Instant value; } @JsonMixin(target = MixinTarget.class) public abstract static class FormatMixin { - @JsonFormat(pattern = DATE_PATTERN) - LocalDate value; + @JsonFormat(pattern = TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + Instant value; } public static final class IntrinsicTarget { @@ -464,6 +549,21 @@ public static final class InvalidPattern { public LocalDate value; } + public static final class InvalidTimezone { + @JsonFormat(pattern = TIMESTAMP_PATTERN, timezone = "Not/AZone") + public Instant value; + } + + public static final class LocalTimezone { + @JsonFormat(pattern = DATE_PATTERN, timezone = "UTC") + public LocalDate value; + } + + public static final class OffsetTimeTimezone { + @JsonFormat(pattern = "HH:mm:ss XXX", timezone = "UTC") + public OffsetTime value; + } + public static final class StringFormat { @JsonFormat(pattern = DATE_PATTERN) public String value; From 02b39aca787ebbaaa3fd5ea3a674260dc9832a12 Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Mon, 3 Aug 2026 12:49:24 +0800 Subject: [PATCH 2/4] fix(java): fix JSON timezone parsing and initialization --- .../fory/json/codec/DateTimeFormatCodec.java | 9 +- .../apache/fory/json/codec/ScalarCodecs.java | 91 +++++++++++-------- .../fory/json/JsonFormatAnnotationTest.java | 9 +- 3 files changed, 70 insertions(+), 39 deletions(-) diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java index 4016bd76cd..0e118451bf 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java @@ -41,6 +41,7 @@ import java.time.chrono.ThaiBuddhistChronology; import java.time.chrono.ThaiBuddhistDate; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoField; import java.time.temporal.TemporalAccessor; import java.time.temporal.TemporalQueries; import java.util.Locale; @@ -201,9 +202,15 @@ private OffsetDateTime parseOffsetDateTime(TemporalAccessor parsed) { LocalDateTime dateTime = LocalDateTime.from(parsed); ZoneOffset offset = parsed.query(TemporalQueries.offset()); // An effective region ZoneId does not synthesize OFFSET_SECONDS. Keep an explicit offset - // authoritative and derive only a missing one from the effective parsed zone. + // authoritative. The rule-owned offset is allocation-free for historical second-level offsets; + // only reconstruct it when a zone name selected the other offset during an overlap. if (offset == null) { offset = parsed.query(TemporalQueries.zone()).getRules().getOffset(dateTime); + long localEpochSeconds = dateTime.toEpochSecond(ZoneOffset.UTC); + long instantSeconds = parsed.getLong(ChronoField.INSTANT_SECONDS); + if (localEpochSeconds - offset.getTotalSeconds() != instantSeconds) { + offset = ZoneOffset.ofTotalSeconds(Math.toIntExact(localEpochSeconds - instantSeconds)); + } } return OffsetDateTime.of(dateTime, offset); } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java index 422706b9b0..75d6f06f54 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java @@ -100,19 +100,6 @@ * decimal timestamps. */ public final class ScalarCodecs { - private static final DateTimeFormatter YEAR_MONTH_FORMATTER = - DateTimeFormatter.ofPattern("uuuu-MM"); - private static final DateTimeFormatter MONTH_DAY_FORMATTER = - DateTimeFormatter.ofPattern("--MM-dd"); - private static final DateTimeFormatter HIJRAH_DATE_FORMATTER = - DateTimeFormatter.ISO_LOCAL_DATE.withChronology(HijrahChronology.INSTANCE); - private static final DateTimeFormatter JAPANESE_DATE_FORMATTER = - DateTimeFormatter.ISO_LOCAL_DATE.withChronology(JapaneseChronology.INSTANCE); - private static final DateTimeFormatter MINGUO_DATE_FORMATTER = - DateTimeFormatter.ISO_LOCAL_DATE.withChronology(MinguoChronology.INSTANCE); - private static final DateTimeFormatter THAI_BUDDHIST_DATE_FORMATTER = - DateTimeFormatter.ISO_LOCAL_DATE.withChronology(ThaiBuddhistChronology.INSTANCE); - private ScalarCodecs() {} @Internal @@ -2109,12 +2096,16 @@ public Year readUtf16(Utf16JsonReader reader) { public static final class YearMonthCodec implements JsonValueCodec { public static final YearMonthCodec INSTANCE = new YearMonthCodec(); + private static final class Formatter { + private static final DateTimeFormatter INSTANCE = DateTimeFormatter.ofPattern("uuuu-MM"); + } + @Override public void writeString(StringJsonWriter writer, YearMonth value) { if (value == null) { writer.writeNull(); } else { - writer.writeTemporal(value, YEAR_MONTH_FORMATTER); + writer.writeTemporal(value, Formatter.INSTANCE); } } @@ -2123,7 +2114,7 @@ public void writeUtf8(Utf8JsonWriter writer, YearMonth value) { if (value == null) { writer.writeNull(); } else { - writer.writeTemporal(value, YEAR_MONTH_FORMATTER); + writer.writeTemporal(value, Formatter.INSTANCE); } } @@ -2146,12 +2137,16 @@ public YearMonth readUtf16(Utf16JsonReader reader) { public static final class MonthDayCodec implements JsonValueCodec { public static final MonthDayCodec INSTANCE = new MonthDayCodec(); + private static final class Formatter { + private static final DateTimeFormatter INSTANCE = DateTimeFormatter.ofPattern("--MM-dd"); + } + @Override public void writeString(StringJsonWriter writer, MonthDay value) { if (value == null) { writer.writeNull(); } else { - writer.writeTemporal(value, MONTH_DAY_FORMATTER); + writer.writeTemporal(value, Formatter.INSTANCE); } } @@ -2160,7 +2155,7 @@ public void writeUtf8(Utf8JsonWriter writer, MonthDay value) { if (value == null) { writer.writeNull(); } else { - writer.writeTemporal(value, MONTH_DAY_FORMATTER); + writer.writeTemporal(value, Formatter.INSTANCE); } } @@ -2291,15 +2286,22 @@ public OffsetDateTime readUtf16(Utf16JsonReader reader) { } } + // Registry publication initializes chronology codec classes. Keep formatter setup behind + // method-use holders because JDK chronology initialization may require runtime environment state. public static final class HijrahDateCodec implements JsonValueCodec { public static final HijrahDateCodec INSTANCE = new HijrahDateCodec(); + private static final class Formatter { + private static final DateTimeFormatter INSTANCE = + DateTimeFormatter.ISO_LOCAL_DATE.withChronology(HijrahChronology.INSTANCE); + } + @Override public void writeString(StringJsonWriter writer, HijrahDate value) { if (value == null) { writer.writeNull(); } else { - writer.writeString(HIJRAH_DATE_FORMATTER.format(value)); + writer.writeString(Formatter.INSTANCE.format(value)); } } @@ -2308,7 +2310,7 @@ public void writeUtf8(Utf8JsonWriter writer, HijrahDate value) { if (value == null) { writer.writeNull(); } else { - writer.writeString(HIJRAH_DATE_FORMATTER.format(value)); + writer.writeString(Formatter.INSTANCE.format(value)); } } @@ -2319,7 +2321,7 @@ public HijrahDate readLatin1(Latin1JsonReader reader) { return null; } try { - return HijrahDate.from(HIJRAH_DATE_FORMATTER.parse(value)); + return HijrahDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(HijrahDate.class, value, e); } @@ -2332,7 +2334,7 @@ public HijrahDate readUtf16(Utf16JsonReader reader) { return null; } try { - return HijrahDate.from(HIJRAH_DATE_FORMATTER.parse(value)); + return HijrahDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(HijrahDate.class, value, e); } @@ -2345,7 +2347,7 @@ public HijrahDate readUtf8(Utf8JsonReader reader) { return null; } try { - return HijrahDate.from(HIJRAH_DATE_FORMATTER.parse(value)); + return HijrahDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(HijrahDate.class, value, e); } @@ -2355,12 +2357,17 @@ public HijrahDate readUtf8(Utf8JsonReader reader) { public static final class JapaneseDateCodec implements JsonValueCodec { public static final JapaneseDateCodec INSTANCE = new JapaneseDateCodec(); + private static final class Formatter { + private static final DateTimeFormatter INSTANCE = + DateTimeFormatter.ISO_LOCAL_DATE.withChronology(JapaneseChronology.INSTANCE); + } + @Override public void writeString(StringJsonWriter writer, JapaneseDate value) { if (value == null) { writer.writeNull(); } else { - writer.writeString(JAPANESE_DATE_FORMATTER.format(value)); + writer.writeString(Formatter.INSTANCE.format(value)); } } @@ -2369,7 +2376,7 @@ public void writeUtf8(Utf8JsonWriter writer, JapaneseDate value) { if (value == null) { writer.writeNull(); } else { - writer.writeString(JAPANESE_DATE_FORMATTER.format(value)); + writer.writeString(Formatter.INSTANCE.format(value)); } } @@ -2380,7 +2387,7 @@ public JapaneseDate readLatin1(Latin1JsonReader reader) { return null; } try { - return JapaneseDate.from(JAPANESE_DATE_FORMATTER.parse(value)); + return JapaneseDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(JapaneseDate.class, value, e); } @@ -2393,7 +2400,7 @@ public JapaneseDate readUtf16(Utf16JsonReader reader) { return null; } try { - return JapaneseDate.from(JAPANESE_DATE_FORMATTER.parse(value)); + return JapaneseDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(JapaneseDate.class, value, e); } @@ -2406,7 +2413,7 @@ public JapaneseDate readUtf8(Utf8JsonReader reader) { return null; } try { - return JapaneseDate.from(JAPANESE_DATE_FORMATTER.parse(value)); + return JapaneseDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(JapaneseDate.class, value, e); } @@ -2416,12 +2423,17 @@ public JapaneseDate readUtf8(Utf8JsonReader reader) { public static final class MinguoDateCodec implements JsonValueCodec { public static final MinguoDateCodec INSTANCE = new MinguoDateCodec(); + private static final class Formatter { + private static final DateTimeFormatter INSTANCE = + DateTimeFormatter.ISO_LOCAL_DATE.withChronology(MinguoChronology.INSTANCE); + } + @Override public void writeString(StringJsonWriter writer, MinguoDate value) { if (value == null) { writer.writeNull(); } else { - writer.writeString(MINGUO_DATE_FORMATTER.format(value)); + writer.writeString(Formatter.INSTANCE.format(value)); } } @@ -2430,7 +2442,7 @@ public void writeUtf8(Utf8JsonWriter writer, MinguoDate value) { if (value == null) { writer.writeNull(); } else { - writer.writeString(MINGUO_DATE_FORMATTER.format(value)); + writer.writeString(Formatter.INSTANCE.format(value)); } } @@ -2441,7 +2453,7 @@ public MinguoDate readLatin1(Latin1JsonReader reader) { return null; } try { - return MinguoDate.from(MINGUO_DATE_FORMATTER.parse(value)); + return MinguoDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(MinguoDate.class, value, e); } @@ -2454,7 +2466,7 @@ public MinguoDate readUtf16(Utf16JsonReader reader) { return null; } try { - return MinguoDate.from(MINGUO_DATE_FORMATTER.parse(value)); + return MinguoDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(MinguoDate.class, value, e); } @@ -2467,7 +2479,7 @@ public MinguoDate readUtf8(Utf8JsonReader reader) { return null; } try { - return MinguoDate.from(MINGUO_DATE_FORMATTER.parse(value)); + return MinguoDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(MinguoDate.class, value, e); } @@ -2477,12 +2489,17 @@ public MinguoDate readUtf8(Utf8JsonReader reader) { public static final class ThaiBuddhistDateCodec implements JsonValueCodec { public static final ThaiBuddhistDateCodec INSTANCE = new ThaiBuddhistDateCodec(); + private static final class Formatter { + private static final DateTimeFormatter INSTANCE = + DateTimeFormatter.ISO_LOCAL_DATE.withChronology(ThaiBuddhistChronology.INSTANCE); + } + @Override public void writeString(StringJsonWriter writer, ThaiBuddhistDate value) { if (value == null) { writer.writeNull(); } else { - writer.writeString(THAI_BUDDHIST_DATE_FORMATTER.format(value)); + writer.writeString(Formatter.INSTANCE.format(value)); } } @@ -2491,7 +2508,7 @@ public void writeUtf8(Utf8JsonWriter writer, ThaiBuddhistDate value) { if (value == null) { writer.writeNull(); } else { - writer.writeString(THAI_BUDDHIST_DATE_FORMATTER.format(value)); + writer.writeString(Formatter.INSTANCE.format(value)); } } @@ -2502,7 +2519,7 @@ public ThaiBuddhistDate readLatin1(Latin1JsonReader reader) { return null; } try { - return ThaiBuddhistDate.from(THAI_BUDDHIST_DATE_FORMATTER.parse(value)); + return ThaiBuddhistDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(ThaiBuddhistDate.class, value, e); } @@ -2515,7 +2532,7 @@ public ThaiBuddhistDate readUtf16(Utf16JsonReader reader) { return null; } try { - return ThaiBuddhistDate.from(THAI_BUDDHIST_DATE_FORMATTER.parse(value)); + return ThaiBuddhistDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(ThaiBuddhistDate.class, value, e); } @@ -2528,7 +2545,7 @@ public ThaiBuddhistDate readUtf8(Utf8JsonReader reader) { return null; } try { - return ThaiBuddhistDate.from(THAI_BUDDHIST_DATE_FORMATTER.parse(value)); + return ThaiBuddhistDate.from(Formatter.INSTANCE.parse(value)); } catch (RuntimeException e) { throw invalidString(ThaiBuddhistDate.class, value, e); } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java index 57390faf13..fbaeffa8d3 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java @@ -74,6 +74,7 @@ public class JsonFormatAnnotationTest extends ForyJsonTestModels { private static final String LOCAL_TIMESTAMP_PATTERN = "uuuu-MM-dd HH:mm:ss"; private static final String TIMESTAMP_PATTERN = "uuuu-MM-dd HH:mm:ss XXX"; private static final String ZONED_TIMESTAMP_PATTERN = "uuuu-MM-dd HH:mm:ss VV"; + private static final String ZONE_NAME_TIMESTAMP_PATTERN = "uuuu-MM-dd HH:mm:ss z"; @Factory(dataProvider = "enableCodegen") public JsonFormatAnnotationTest(boolean codegen) { @@ -170,7 +171,8 @@ public void offsetTimezoneParsing() { String input = "{\"local\":\"2024-01-02 11:04:05\"," + "\"explicit\":\"2024-01-02 07:04:05 +04:00\"," - + "\"explicitZone\":\"2024-01-02 04:04:05 Europe/Paris\"}"; + + "\"explicitZone\":\"2024-01-02 04:04:05 Europe/Paris\"," + + "\"zoneName\":\"2024-11-03 01:30:00 EST\"}"; OffsetTimezoneFields value = json.fromJson(input, OffsetTimezoneFields.class); Instant instant = Instant.parse("2024-01-02T03:04:05Z"); assertEquals(value.local.toInstant(), instant); @@ -179,6 +181,8 @@ public void offsetTimezoneParsing() { assertEquals(value.explicit.getOffset(), ZoneOffset.ofHours(4)); assertEquals(value.explicitZone.toInstant(), instant); assertEquals(value.explicitZone.getOffset(), ZoneOffset.ofHours(1)); + assertEquals(value.zoneName.toInstant(), Instant.parse("2024-11-03T06:30:00Z")); + assertEquals(value.zoneName.getOffset(), ZoneOffset.ofHours(-5)); assertGeneratedWhenSupported(json, OffsetTimezoneFields.class); } @@ -452,6 +456,9 @@ public static final class OffsetTimezoneFields { @JsonFormat(pattern = ZONED_TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") public OffsetDateTime explicitZone; + + @JsonFormat(pattern = ZONE_NAME_TIMESTAMP_PATTERN, timezone = "Asia/Shanghai") + public OffsetDateTime zoneName; } public static final class CreatorField { From f9beda5f07663ec00fcb5c8c3e124c4f56d61ce2 Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Mon, 3 Aug 2026 12:55:55 +0800 Subject: [PATCH 3/4] test(java): make timezone overlap expectation JDK-neutral --- .../fory/json/JsonFormatAnnotationTest.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java index fbaeffa8d3..c492e92173 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java @@ -43,11 +43,13 @@ import java.time.chrono.JapaneseDate; import java.time.chrono.MinguoDate; import java.time.chrono.ThaiBuddhistDate; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -168,11 +170,14 @@ public void timezoneRoundTrip() { @Test public void offsetTimezoneParsing() { ForyJson json = newJson(); + String zoneNameText = "2024-11-03 01:30:00 EST"; String input = "{\"local\":\"2024-01-02 11:04:05\"," + "\"explicit\":\"2024-01-02 07:04:05 +04:00\"," + "\"explicitZone\":\"2024-01-02 04:04:05 Europe/Paris\"," - + "\"zoneName\":\"2024-11-03 01:30:00 EST\"}"; + + "\"zoneName\":\"" + + zoneNameText + + "\"}"; OffsetTimezoneFields value = json.fromJson(input, OffsetTimezoneFields.class); Instant instant = Instant.parse("2024-01-02T03:04:05Z"); assertEquals(value.local.toInstant(), instant); @@ -181,8 +186,12 @@ public void offsetTimezoneParsing() { assertEquals(value.explicit.getOffset(), ZoneOffset.ofHours(4)); assertEquals(value.explicitZone.toInstant(), instant); assertEquals(value.explicitZone.getOffset(), ZoneOffset.ofHours(1)); - assertEquals(value.zoneName.toInstant(), Instant.parse("2024-11-03T06:30:00Z")); - assertEquals(value.zoneName.getOffset(), ZoneOffset.ofHours(-5)); + ZonedDateTime resolvedZoneName = + DateTimeFormatter.ofPattern(ZONE_NAME_TIMESTAMP_PATTERN, Locale.ROOT) + .withZone(ZoneId.of("Asia/Shanghai")) + .parse(zoneNameText, ZonedDateTime::from); + assertEquals(value.zoneName.toInstant(), resolvedZoneName.toInstant()); + assertEquals(value.zoneName.getOffset(), resolvedZoneName.getOffset()); assertGeneratedWhenSupported(json, OffsetTimezoneFields.class); } From cc6b71a4066cf75b79c343ad3d399f53f90fdebe Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Mon, 3 Aug 2026 13:30:14 +0800 Subject: [PATCH 4/4] fix(java): defer JSON chronology native initialization --- .../org.apache.fory/fory-json/native-image.properties | 9 ++++++--- .../fory/json/ForyJsonGraalVMFeatureJarVerifier.java | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties b/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties index 7feb4e41fb..4879b688de 100644 --- a/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties +++ b/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties @@ -16,8 +16,8 @@ # under the License. # Hosted codegen initializes only Fory JSON implementation classes. Application providers, models, -# and custom codecs retain their own initialization policy. ScalarCodecs stays runtime initialized -# because its temporal formatters capture JDK chronology state. +# and custom codecs retain their own initialization policy. Codec-local chronology formatter +# holders stay runtime initialized because their formatters capture JDK chronology state. Args=--features=org.apache.fory.json.ForyJsonGraalVMFeature \ --initialize-at-build-time=org.apache.fory.json.ForyJson,\ org.apache.fory.json.ForyJsonBuilder,\ @@ -29,4 +29,7 @@ Args=--features=org.apache.fory.json.ForyJsonGraalVMFeature \ org.apache.fory.json.reader,\ org.apache.fory.json.resolver,\ org.apache.fory.json.writer \ - --initialize-at-run-time=org.apache.fory.json.codec.ScalarCodecs + --initialize-at-run-time=org.apache.fory.json.codec.ScalarCodecs$HijrahDateCodec$Formatter,\ + org.apache.fory.json.codec.ScalarCodecs$JapaneseDateCodec$Formatter,\ + org.apache.fory.json.codec.ScalarCodecs$MinguoDateCodec$Formatter,\ + org.apache.fory.json.codec.ScalarCodecs$ThaiBuddhistDateCodec$Formatter diff --git a/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java b/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java index 2fee6272da..1f4f713fc6 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java @@ -66,7 +66,11 @@ public final class ForyJsonGraalVMFeatureJarVerifier { + "org.apache.fory.json.reader," + "org.apache.fory.json.resolver," + "org.apache.fory.json.writer"; - private static final String RUNTIME_TARGETS = "org.apache.fory.json.codec.ScalarCodecs"; + private static final String RUNTIME_TARGETS = + "org.apache.fory.json.codec.ScalarCodecs$HijrahDateCodec$Formatter," + + "org.apache.fory.json.codec.ScalarCodecs$JapaneseDateCodec$Formatter," + + "org.apache.fory.json.codec.ScalarCodecs$MinguoDateCodec$Formatter," + + "org.apache.fory.json.codec.ScalarCodecs$ThaiBuddhistDateCodec$Formatter"; private static final String NATIVE_IMAGE_ARGS = FEATURE_OPTION + " "