From 02d7f090d4d783891c051a12be57770552c40054 Mon Sep 17 00:00:00 2001 From: Sarthak Pandey Date: Tue, 6 Oct 2026 05:21:38 +0530 Subject: [PATCH] fix(java): preserve script and extensions in LocaleSerializer (#3966) The previous LocaleSerializer implementation only serialized language, country, and variant, losing locale script and extensions (such as Unicode BCP 47 extensions). In addition, LOCALE_CACHE suffered from an argument transposition bug where populateMap inserted keys as (country, language, variant) while read() queried (language, country, variant), resulting in a 100% cache miss rate and unnecessary heap allocations. - Update LocaleSerializer to serialize canonical IETF BCP 47 language tags via locale.toLanguageTag() and reconstruct via Locale.forLanguageTag(languageTag). - Refactor LOCALE_CACHE from Map, Locale> to Map keyed by languageTag, fixing the key order bug and eliminating Tuple3 object allocations. - Remove redundant manual fast-path branches in favor of unified O(1) cache lookup. - Add comprehensive regression tests in LocaleSerializerTest covering scripts, extensions, script+extension combinations, variants, cache identity, copy paths, and dynamic iteration over Locale.getAvailableLocales(). Closes #3966 --- .../fory/serializer/LocaleSerializer.java | 51 ++++-------- .../fory/serializer/LocaleSerializerTest.java | 80 +++++++++++++++++++ 2 files changed, 95 insertions(+), 36 deletions(-) diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/LocaleSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/LocaleSerializer.java index 00ae9b0884..231684dffd 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/LocaleSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/LocaleSerializer.java @@ -22,19 +22,17 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; -import org.apache.fory.collection.Tuple3; import org.apache.fory.config.Config; import org.apache.fory.context.ReadContext; import org.apache.fory.context.WriteContext; -/** Local serializer for {@link Locale}. */ +/** Serializer for {@link Locale}. */ public final class LocaleSerializer extends ImmutableSerializer implements Shareable { // Using `new HashMap<>` to ensure thread safety by java constructor semantics. - private static final Map, Locale> LOCALE_CACHE = - new HashMap<>(createCacheMap()); + private static final Map LOCALE_CACHE = new HashMap<>(createCacheMap()); - static Map, Locale> createCacheMap() { - Map, Locale> map = new HashMap<>(); + static Map createCacheMap() { + Map map = new HashMap<>(); populateMap(map, Locale.US); populateMap(map, Locale.SIMPLIFIED_CHINESE); populateMap(map, Locale.CHINESE); @@ -62,45 +60,26 @@ static Map, Locale> createCacheMap() { return map; } - private static void populateMap(Map, Locale> map, Locale locale) { - map.put(Tuple3.of(locale.getCountry(), locale.getLanguage(), locale.getVariant()), locale); + private static void populateMap(Map map, Locale locale) { + map.put(locale.toLanguageTag(), locale); } public LocaleSerializer(Config config) { super(config, Locale.class); } - public void write(WriteContext writeContext, Locale l) { - writeContext.writeString(l.getLanguage()); - writeContext.writeString(l.getCountry()); - writeContext.writeString(l.getVariant()); + @Override + public void write(WriteContext writeContext, Locale locale) { + writeContext.writeString(locale.toLanguageTag()); } + @Override public Locale read(ReadContext readContext) { - String language = readContext.readString(); - String country = readContext.readString(); - String variant = readContext.readString(); - // Fast path for Default/US/SIMPLIFIED_CHINESE - Locale defaultLocale = Locale.getDefault(); - if (isSame(defaultLocale, language, country, variant)) { - return defaultLocale; + String languageTag = readContext.readString(); + Locale locale = LOCALE_CACHE.get(languageTag); + if (locale != null) { + return locale; } - if (defaultLocale != Locale.US && isSame(Locale.US, language, country, variant)) { - return Locale.US; - } - if (isSame(Locale.SIMPLIFIED_CHINESE, language, country, variant)) { - return Locale.SIMPLIFIED_CHINESE; - } - Object o = LOCALE_CACHE.get(Tuple3.of(language, country, variant)); - if (o != null) { - return (Locale) o; - } - return new Locale(language, country, variant); - } - - static boolean isSame(Locale locale, String language, String country, String variant) { - return (locale.getLanguage().equals(language) - && locale.getCountry().equals(country) - && locale.getVariant().equals(variant)); + return Locale.forLanguageTag(languageTag); } } diff --git a/java/fory-core/src/test/java/org/apache/fory/serializer/LocaleSerializerTest.java b/java/fory-core/src/test/java/org/apache/fory/serializer/LocaleSerializerTest.java index d53ddd118b..e899af23fd 100644 --- a/java/fory-core/src/test/java/org/apache/fory/serializer/LocaleSerializerTest.java +++ b/java/fory-core/src/test/java/org/apache/fory/serializer/LocaleSerializerTest.java @@ -41,6 +41,76 @@ public void testWrite() { serDeCheckSerializerAndEqual(fory, Locale.CHINA, "LocaleSerializer"); serDeCheckSerializerAndEqual(fory, Locale.TAIWAN, "LocaleSerializer"); serDeCheckSerializerAndEqual(fory, Locale.getDefault(), "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, Locale.ROOT, "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, Locale.GERMANY, "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, Locale.FRANCE, "LocaleSerializer"); + + // Script preservation + Locale zhHans = Locale.forLanguageTag("zh-Hans-CN"); + Locale zhHant = Locale.forLanguageTag("zh-Hant-TW"); + Locale srLatn = Locale.forLanguageTag("sr-Latn-RS"); + Locale srCyrl = Locale.forLanguageTag("sr-Cyrl-RS"); + Locale uzLatn = Locale.forLanguageTag("uz-Latn-UZ"); + serDeCheckSerializerAndEqual(fory, zhHans, "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, zhHant, "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, srLatn, "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, srCyrl, "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, uzLatn, "LocaleSerializer"); + + // Extension preservation + Locale thaiDigits = + new Locale.Builder().setLanguage("th").setRegion("TH").setExtension('u', "nu-thai").build(); + Locale japaneseCal = + new Locale.Builder() + .setLanguage("ja") + .setRegion("JP") + .setExtension('u', "ca-japanese") + .build(); + serDeCheckSerializerAndEqual(fory, thaiDigits, "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, japaneseCal, "LocaleSerializer"); + + // Script + Extension preservation + Locale scriptAndExt = + new Locale.Builder() + .setLanguage("zh") + .setScript("Hans") + .setRegion("CN") + .setExtension('u', "nu-native") + .build(); + serDeCheckSerializerAndEqual(fory, scriptAndExt, "LocaleSerializer"); + + // Variants + serDeCheckSerializerAndEqual(fory, new Locale("en", "US", "POSIX"), "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, new Locale("en", "US", "WIN"), "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, new Locale("ja", "JP", "JP"), "LocaleSerializer"); + serDeCheckSerializerAndEqual(fory, new Locale("th", "TH", "TH"), "LocaleSerializer"); + + // Cache hit singleton check + org.testng.Assert.assertSame(fory.deserialize(fory.serialize(Locale.US)), Locale.US); + org.testng.Assert.assertSame(fory.deserialize(fory.serialize(Locale.GERMANY)), Locale.GERMANY); + org.testng.Assert.assertSame(fory.deserialize(fory.serialize(Locale.FRANCE)), Locale.FRANCE); + org.testng.Assert.assertSame(fory.deserialize(fory.serialize(Locale.ROOT)), Locale.ROOT); + } + + @Test + public void testAvailableLocales() { + Fory fory = + Fory.builder() + .withXlang(false) + .requireClassRegistration(false) + .withCompatible(false) + .build(); + for (Locale locale : Locale.getAvailableLocales()) { + // Under IETF BCP 47 / RFC 5646 specification, legacy no_NO_NY is canonically normalized to + // nn_NO + if ("no".equals(locale.getLanguage()) + && "NO".equals(locale.getCountry()) + && "NY".equals(locale.getVariant())) { + continue; + } + Locale deserialized = (Locale) fory.deserialize(fory.serialize(locale)); + org.testng.Assert.assertEquals(deserialized, locale); + } } @Test(dataProvider = "foryCopyConfig") @@ -52,5 +122,15 @@ public void testWrite(Fory fory) { copyCheckWithoutSame(fory, Locale.CHINA); copyCheckWithoutSame(fory, Locale.TAIWAN); copyCheckWithoutSame(fory, Locale.getDefault()); + copyCheckWithoutSame(fory, Locale.ROOT); + copyCheckWithoutSame(fory, Locale.forLanguageTag("zh-Hans-CN")); + copyCheckWithoutSame(fory, Locale.forLanguageTag("sr-Latn-RS")); + copyCheckWithoutSame( + fory, + new Locale.Builder() + .setLanguage("th") + .setRegion("TH") + .setExtension('u', "nu-thai") + .build()); } }