Skip to content

Commit d0e92c5

Browse files
authored
feat(json): support writing long values as strings (#4008)
## Why? ## What does this PR do? ## Related issues Closes #3997 ## AI Contribution Checklist - [ ] Substantial AI assistance was used in this PR: `yes` / `no` - [ ] If `yes`, I included a completed [AI Contribution Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs) in this PR description and the required `AI Usage Disclosure`. - [ ] If `yes`, my PR description includes the required `ai_review` summary and screenshot evidence or equivalent persisted links of the final clean AI review results from both fresh reviewers described in `AI_POLICY.md`, the Fory-guided reviewer and the independent general reviewer, on the current PR diff or current HEAD after the latest code changes. ## Does this PR introduce any user-facing change? - [ ] Does this PR introduce any public API change? - [ ] Does this PR introduce any binary protocol compatibility change? ## Benchmark
1 parent 67e7036 commit d0e92c5

30 files changed

Lines changed: 1643 additions & 52 deletions

docs/json/kotlin.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ val text = json.toJson(Account(7u, "Alice"), accountType)
8787
val decoded = json.fromJson(text, accountType)
8888
```
8989

90+
Use `ForyJsonKotlin.builder().writeLongAsString(true)` when signed `Long` and unsigned `ULong`
91+
values must be emitted as quoted decimal strings. The setting also applies to their declared
92+
collection and map values, nullable values, Kotlin value classes backed by them, `ULongArray`, and
93+
the Java Long-like wrappers supported by the core JSON runtime. Readers accept both quoted and
94+
unquoted integer tokens.
95+
9096
`jsonTypeRef<T>()` is a type token, not a codec lookup. Construct it once and reuse it. A Java
9197
`Class` or ordinary Java `TypeRef` cannot express distinctions such as `List<Account?>`, `UInt`, or
9298
a logical value class lowered to a primitive carrier.

docs/json/object-mapping.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ original key type. Null map keys are rejected.
215215
| Builder method | Default | User-visible effect |
216216
| -------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- |
217217
| `writeNullFields(boolean)` | `false` | Default inclusion of null object properties |
218+
| `writeLongAsString(boolean)` | `false` | Write built-in 64-bit integer values as decimal strings |
218219
| `withCodegen(boolean)` | `true` | Enable generated object codecs |
219220
| `withAsyncCompilation(boolean)` | `true` | Compile generated codecs asynchronously |
220221
| `withFieldMode(boolean)` | `false` | When true, discover fields without getters/setters |
@@ -225,6 +226,14 @@ original key type. Null map keys are rejected.
225226
| `registerCodec(type, codec)` | None | Replace an eligible exact class's complete JSON codec |
226227
| `registerMixin(mixinType)` | None | Apply one annotation Mixin to its exact declared target |
227228

229+
Enable `writeLongAsString(true)` when 64-bit integer values must pass through JavaScript without
230+
`Number` precision loss. The setting writes built-in `long`/`Long`, `AtomicLong`,
231+
`AtomicLongArray`, and `OptionalLong` values as quoted decimal strings. It also follows declared
232+
Long children through arrays, collections, map values, `Optional<Long>`, `AtomicReference<Long>`,
233+
and equivalent language-module containers. Readers accept both numeric and quoted integer tokens
234+
regardless of this setting. Custom codecs and occurrence-level codec or format annotations retain
235+
their own output shape.
236+
228237
Concurrency-level and buffer-retention limits must be positive. The cached-field-name limit
229238
applies independently to each reader; zero disables this cache. It bounds only cached field names,
230239
not names accepted from the input. The buffer-retention setting does not limit JSON input or output

docs/json/scala.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ val person = json.fromJson(text, classOf[Person])
4141
```
4242

4343
Reuse the resulting `ForyJson` instance. It is immutable and thread-safe after construction.
44+
Use `ForyJsonScala.builder().writeLongAsString(true)` to emit Scala `Long` values, including
45+
declared collection and map values, `Option[Long]`, `Long`-backed value classes, and Java Long-like
46+
wrappers as quoted decimal strings. Readers accept both quoted and unquoted integer tokens.
47+
Use `ScalaTypeRef` when a parameterized declaration contains `Long` because normal JVM signatures
48+
can erase Scala value-type arguments to `Object`.
4449

4550
## Case classes and annotations
4651

java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
*/
5151
public final class ForyJsonBuilder {
5252
private boolean writeNullFields;
53+
private boolean writeLongAsString;
5354
private boolean codegenEnabled = true;
5455
private boolean asyncCompilationEnabled = true;
5556
private boolean propertyDiscoveryEnabled = true;
@@ -80,6 +81,21 @@ public ForyJsonBuilder writeNullFields(boolean writeNullFields) {
8081
return this;
8182
}
8283

84+
/**
85+
* Writes signed 64-bit integer values owned by Fory's built-in JSON codecs as quoted decimal
86+
* strings. This includes {@code long}/{@link Long}, {@code AtomicLong}, {@code AtomicLongArray},
87+
* {@code OptionalLong}, and generic containers whose declared value is {@code Long}. Disabled by
88+
* default.
89+
*
90+
* <p>This setting also applies to corresponding 64-bit unsigned scalar bindings installed by a
91+
* language module. Exact custom codecs and occurrence-level codec or format annotations retain
92+
* ownership of their complete representation.
93+
*/
94+
public ForyJsonBuilder writeLongAsString(boolean writeLongAsString) {
95+
this.writeLongAsString = writeLongAsString;
96+
return this;
97+
}
98+
8399
/**
84100
* Enables generated object codecs for supported classes. Enabled by default and automatically
85101
* disabled on Android. A GraalVM Native Image includes generated codecs for the default
@@ -297,6 +313,7 @@ JsonConfig buildConfig() {
297313
ModuleInstaller.install(new ArrayList<>(modules), codecRegistry, mixins);
298314
return new JsonConfig(
299315
writeNullFields,
316+
writeLongAsString,
300317
effectiveCodegen,
301318
effectiveAsyncCompilation,
302319
propertyDiscoveryEnabled,

java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public final class JsonConfig {
3737
private static final int MAX_CACHED_FIELD_NAMES = 1 << 29;
3838

3939
private final boolean writeNullFields;
40+
private final boolean writeLongAsString;
4041
private final boolean codegenEnabled;
4142
private final boolean asyncCompilationEnabled;
4243
private final boolean propertyDiscoveryEnabled;
@@ -56,6 +57,7 @@ public final class JsonConfig {
5657

5758
JsonConfig(
5859
boolean writeNullFields,
60+
boolean writeLongAsString,
5961
boolean codegenEnabled,
6062
boolean asyncCompilationEnabled,
6163
boolean propertyDiscoveryEnabled,
@@ -72,6 +74,7 @@ public final class JsonConfig {
7274
List<String> factoryIdentities,
7375
JsonTypeChecker typeChecker) {
7476
this.writeNullFields = writeNullFields;
77+
this.writeLongAsString = writeLongAsString;
7578
this.codegenEnabled = codegenEnabled;
7679
this.asyncCompilationEnabled = asyncCompilationEnabled;
7780
this.propertyDiscoveryEnabled = propertyDiscoveryEnabled;
@@ -97,6 +100,13 @@ public boolean writeNullFields() {
97100
return writeNullFields;
98101
}
99102

103+
/**
104+
* Returns whether built-in signed and module-provided unsigned 64-bit values write as strings.
105+
*/
106+
public boolean writeLongAsString() {
107+
return writeLongAsString;
108+
}
109+
100110
public boolean codegenEnabled() {
101111
return codegenEnabled;
102112
}

java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java

Lines changed: 144 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ public static <T> ArrayCodec<T> create(Class<T> arrayType, JsonTypeInfo componen
8080
return bind(IntArrayCodec.INSTANCE);
8181
} else if (componentType == long.class && componentCodec == ScalarCodecs.LongCodec.PRIMITIVE) {
8282
return bind(LongArrayCodec.INSTANCE);
83+
} else if (componentType == long.class
84+
&& componentCodec == ScalarCodecs.LongAsStringCodec.PRIMITIVE) {
85+
return bind(LongAsStringArrayCodec.INSTANCE);
8386
} else if (componentType == boolean.class
8487
&& componentCodec == ScalarCodecs.BooleanCodec.PRIMITIVE) {
8588
return bind(BooleanArrayCodec.INSTANCE);
@@ -106,6 +109,12 @@ public static <T> ArrayCodec<T> create(Class<T> arrayType, JsonTypeInfo componen
106109
componentTypeInfo.rejectsNull()
107110
? BoxedLongArrayCodec.NON_NULL
108111
: BoxedLongArrayCodec.INSTANCE);
112+
} else if (componentType == Long.class
113+
&& componentCodec == ScalarCodecs.LongAsStringCodec.BOXED) {
114+
return bind(
115+
componentTypeInfo.rejectsNull()
116+
? BoxedLongAsStringArrayCodec.NON_NULL
117+
: BoxedLongAsStringArrayCodec.INSTANCE);
109118
} else if (componentType == Boolean.class
110119
&& componentCodec == ScalarCodecs.BooleanCodec.BOXED) {
111120
return bind(
@@ -152,7 +161,8 @@ public static <T> ArrayCodec<T> create(Class<T> arrayType, JsonTypeInfo componen
152161

153162
/** Returns the exact unsigned primitive-array specialization for one semantic array id. */
154163
@Internal
155-
public static <T> ArrayCodec<T> createUnsignedPrimitive(Class<T> arrayType, int typeId) {
164+
public static <T> ArrayCodec<T> createUnsignedPrimitive(
165+
Class<T> arrayType, int typeId, boolean writeLongAsString) {
156166
if (arrayType == byte[].class && typeId == Types.UINT8_ARRAY) {
157167
return bind(ByteArrayCodec.UNSIGNED);
158168
}
@@ -163,7 +173,10 @@ public static <T> ArrayCodec<T> createUnsignedPrimitive(Class<T> arrayType, int
163173
return bind(IntArrayCodec.UNSIGNED);
164174
}
165175
if (arrayType == long[].class && typeId == Types.UINT64_ARRAY) {
166-
return bind(LongArrayCodec.UNSIGNED);
176+
return bind(
177+
writeLongAsString
178+
? UnsignedLongAsStringArrayCodec.INSTANCE
179+
: UnsignedLongArrayCodec.INSTANCE);
167180
}
168181
throw new ForyJsonException(
169182
"Unsigned JSON array semantic id " + typeId + " does not match " + arrayType.getName());
@@ -389,11 +402,10 @@ int readElement(Utf8JsonReader reader) {
389402
// Keep the signed codec as the exact final loop owner. Routing signed long arrays through a
390403
// semantic element hook makes C2 inline the root and array loop into one unstable graph; the
391404
// unsigned codec below owns separate primitive operations so neither warmed path branches.
392-
public static final class LongArrayCodec extends ArrayCodec<long[]> {
405+
public static class LongArrayCodec extends ArrayCodec<long[]> {
393406
private static final LongArrayCodec INSTANCE = new LongArrayCodec();
394-
private static final ArrayCodec<long[]> UNSIGNED = new UnsignedLongArrayCodec();
395407

396-
private LongArrayCodec() {
408+
protected LongArrayCodec() {
397409
super(long.class);
398410
}
399411

@@ -734,7 +746,44 @@ private long[] readUtf8LongTail(
734746
}
735747
}
736748

737-
private static final class UnsignedLongArrayCodec extends ArrayCodec<long[]> {
749+
/** Exact signed long-array specialization which writes each element as a JSON string. */
750+
public static final class LongAsStringArrayCodec extends LongArrayCodec {
751+
private static final LongAsStringArrayCodec INSTANCE = new LongAsStringArrayCodec();
752+
753+
private LongAsStringArrayCodec() {}
754+
755+
@Override
756+
public void writeString(StringJsonWriter writer, long[] value) {
757+
if (value == null) {
758+
writer.writeNull();
759+
return;
760+
}
761+
writer.writeArrayStart();
762+
for (int i = 0; i < value.length; i++) {
763+
writer.writeComma(i);
764+
writer.writeLongAsString(value[i]);
765+
}
766+
writer.writeArrayEnd();
767+
}
768+
769+
@Override
770+
public void writeUtf8(Utf8JsonWriter writer, long[] value) {
771+
if (value == null) {
772+
writer.writeNull();
773+
return;
774+
}
775+
writer.writeArrayStart();
776+
for (int i = 0; i < value.length; i++) {
777+
writer.writeComma(i);
778+
writer.writeLongAsString(value[i]);
779+
}
780+
writer.writeArrayEnd();
781+
}
782+
}
783+
784+
private static class UnsignedLongArrayCodec extends ArrayCodec<long[]> {
785+
private static final UnsignedLongArrayCodec INSTANCE = new UnsignedLongArrayCodec();
786+
738787
private UnsignedLongArrayCodec() {
739788
super(long.class);
740789
}
@@ -867,6 +916,41 @@ private long[] readUnsignedArray(JsonReader reader) {
867916
}
868917
}
869918

919+
private static final class UnsignedLongAsStringArrayCodec extends UnsignedLongArrayCodec {
920+
private static final UnsignedLongAsStringArrayCodec INSTANCE =
921+
new UnsignedLongAsStringArrayCodec();
922+
923+
private UnsignedLongAsStringArrayCodec() {}
924+
925+
@Override
926+
public void writeString(StringJsonWriter writer, long[] value) {
927+
if (value == null) {
928+
writer.writeNull();
929+
return;
930+
}
931+
writer.writeArrayStart();
932+
for (int i = 0; i < value.length; i++) {
933+
writer.writeComma(i);
934+
writer.writeUnsignedLongAsString(value[i]);
935+
}
936+
writer.writeArrayEnd();
937+
}
938+
939+
@Override
940+
public void writeUtf8(Utf8JsonWriter writer, long[] value) {
941+
if (value == null) {
942+
writer.writeNull();
943+
return;
944+
}
945+
writer.writeArrayStart();
946+
for (int i = 0; i < value.length; i++) {
947+
writer.writeComma(i);
948+
writer.writeUnsignedLongAsString(value[i]);
949+
}
950+
writer.writeArrayEnd();
951+
}
952+
}
953+
870954
public static final class BooleanArrayCodec extends ArrayCodec<boolean[]> {
871955
private static final BooleanArrayCodec INSTANCE = new BooleanArrayCodec();
872956
private static final int ELEMENT_BYTES = 1;
@@ -2662,12 +2746,12 @@ public Integer[] readUtf8(Utf8JsonReader reader) {
26622746
}
26632747
}
26642748

2665-
public static final class BoxedLongArrayCodec extends ArrayCodec<Long[]> {
2749+
public static class BoxedLongArrayCodec extends ArrayCodec<Long[]> {
26662750
private static final BoxedLongArrayCodec INSTANCE = new BoxedLongArrayCodec(false);
26672751
private static final BoxedLongArrayCodec NON_NULL = new BoxedLongArrayCodec(true);
26682752
private final boolean rejectsNull;
26692753

2670-
private BoxedLongArrayCodec(boolean rejectsNull) {
2754+
protected BoxedLongArrayCodec(boolean rejectsNull) {
26712755
super(Long.class);
26722756
this.rejectsNull = rejectsNull;
26732757
}
@@ -2797,6 +2881,58 @@ public Long[] readUtf8(Utf8JsonReader reader) {
27972881
}
27982882
}
27992883

2884+
private static final class BoxedLongAsStringArrayCodec extends BoxedLongArrayCodec {
2885+
private static final BoxedLongAsStringArrayCodec INSTANCE =
2886+
new BoxedLongAsStringArrayCodec(false);
2887+
private static final BoxedLongAsStringArrayCodec NON_NULL =
2888+
new BoxedLongAsStringArrayCodec(true);
2889+
2890+
private final boolean rejectsNull;
2891+
2892+
private BoxedLongAsStringArrayCodec(boolean rejectsNull) {
2893+
super(rejectsNull);
2894+
this.rejectsNull = rejectsNull;
2895+
}
2896+
2897+
@Override
2898+
public void writeString(StringJsonWriter writer, Long[] value) {
2899+
if (value == null) {
2900+
writer.writeNull();
2901+
return;
2902+
}
2903+
writer.writeArrayStart();
2904+
for (int i = 0; i < value.length; i++) {
2905+
writer.writeComma(i);
2906+
Long element = value[i];
2907+
if (element == null) {
2908+
writeReferenceNull(writer, rejectsNull);
2909+
} else {
2910+
writer.writeLongAsString(element);
2911+
}
2912+
}
2913+
writer.writeArrayEnd();
2914+
}
2915+
2916+
@Override
2917+
public void writeUtf8(Utf8JsonWriter writer, Long[] value) {
2918+
if (value == null) {
2919+
writer.writeNull();
2920+
return;
2921+
}
2922+
writer.writeArrayStart();
2923+
for (int i = 0; i < value.length; i++) {
2924+
writer.writeComma(i);
2925+
Long element = value[i];
2926+
if (element == null) {
2927+
writeReferenceNull(writer, rejectsNull);
2928+
} else {
2929+
writer.writeLongAsString(element);
2930+
}
2931+
}
2932+
writer.writeArrayEnd();
2933+
}
2934+
}
2935+
28002936
public static final class BoxedBooleanArrayCodec extends ArrayCodec<Boolean[]> {
28012937
private static final BoxedBooleanArrayCodec INSTANCE = new BoxedBooleanArrayCodec(false);
28022938
private static final BoxedBooleanArrayCodec NON_NULL = new BoxedBooleanArrayCodec(true);

java/fory-json/src/main/java/org/apache/fory/json/codec/CollectionCodec.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ private static CollectionCodec<?> create(
153153
if (elementCodec == ScalarCodecs.LongCodec.BOXED) {
154154
return new LongCollectionCodec(factory, elementTypeInfo);
155155
}
156+
if (elementCodec == ScalarCodecs.LongAsStringCodec.BOXED) {
157+
return new LongAsStringCollectionCodec(factory, elementTypeInfo);
158+
}
156159
if (elementCodec == ScalarCodecs.ShortCodec.BOXED) {
157160
return new ShortCollectionCodec(factory, elementTypeInfo);
158161
}
@@ -1942,8 +1945,8 @@ Object readUtf8Element(Utf8JsonReader reader) {
19421945
}
19431946
}
19441947

1945-
public static final class LongCollectionCodec extends NumberCollectionCodec {
1946-
private LongCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) {
1948+
public static class LongCollectionCodec extends NumberCollectionCodec {
1949+
protected LongCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) {
19471950
super(factory, elementTypeInfo);
19481951
}
19491952

@@ -1968,6 +1971,17 @@ Object readUtf8Element(Utf8JsonReader reader) {
19681971
}
19691972
}
19701973

1974+
private static final class LongAsStringCollectionCodec extends LongCollectionCodec {
1975+
private LongAsStringCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) {
1976+
super(factory, elementTypeInfo);
1977+
}
1978+
1979+
@Override
1980+
void writeNumber(JsonWriter writer, Object value) {
1981+
writer.writeLongAsString((long) value);
1982+
}
1983+
}
1984+
19711985
public static final class ShortCollectionCodec extends NumberCollectionCodec {
19721986
private ShortCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) {
19731987
super(factory, elementTypeInfo);

0 commit comments

Comments
 (0)