You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fory.apache.org by ch...@apache.org on 2026/09/21 03:58:50 UTC
(fory) branch main updated: fix(json): correct Scala codecs and Scala/Kotlin missing-field defaults (#4072)
This is an automated email from the ASF dual-hosted git repository.
chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git
The following commit(s) were added to refs/heads/main by this push:
new 509a096aa fix(json): correct Scala codecs and Scala/Kotlin missing-field defaults (#4072)
509a096aa is described below
commit 509a096aa3ac68c0ca9ee248d085db7855523584
Author: Shawn Yang <sh...@gmail.com>
AuthorDate: Mon Sep 21 11:58:45 2026 +0800
fix(json): correct Scala codecs and Scala/Kotlin missing-field defaults (#4072)
## Why?
Several documented Scala JSON values cannot be written, and the shared
generic recursion guard rejects finite types such as
`Box<Box<Integer>>`. Missing constructor properties also need useful
language defaults while preserving Kotlin's non-null reference
constraints.
## What does this PR do?
- Default omitted Scala constructor parameters to `None`, numeric zero,
`false`, fresh empty collections/arrays, or `null` as appropriate.
Explicit constructor defaults take precedence.
- Default omitted Kotlin non-null scalars to zero/false and nullable
parameters to null. Explicit defaults still apply; non-null references
without defaults, such as `val name: String`, remain required.
- Support `ScalaTypeRef[Unit]` and nested Unit occurrences through the
existing JSON null representation. Java `void` root writes remain
rejected, and `Some(())` retains the documented value-or-null Option
representation.
- Fix populated immutable Queue, primitive mutable ArraySeq, specialized
Tuple1/Tuple2, Duration.Undefined, and primitive generic case-class
codegen writes.
- Accept finite nested instantiations of the same generic class while
continuing to reject expanding recursive declarations. Generated
readers/writers distinguish the complete child schema from self
references, and generated class keys preserve that distinction.
- Support parameterless Scala 3 enum keys in typed Scala maps. Reject
unsupported case-class constructors through the Scala model instead of
silently losing fields in the ordinary object path.
- Complete the relevant coverage from #4067 across generated/interpreted
operations, String/UTF-8 readers and writers, constructor shapes,
collection implementations, enum keys, and generic types; update the
Scala and Kotlin guides.
## Validation
- Java JSON focused regression suites: 293 passed (`JsonContainerTest`,
`JsonCreatorTest`, `JsonModuleTest`, `JsonGeneratedCapabilityKeyTest`,
`JsonAnyPropertyTest`, `JsonUnwrappedTest`).
- Scala JSON suites: 82 passed on Scala 2.13.18; 92 passed on Scala
3.3.8.
- Kotlin JSON suite: 122 passed on Kotlin 2.3.20.
- Java Spotless/Checkstyle, Kotlin Spotless, Markdown Prettier, and `git
diff --check` passed.
- Local runtime validation used GraalVM JDK 21.0.11 in JVM mode.
Native-image execution was not run locally.
## Related issues
Fixes #4067
Fixes #4068
Fixes #4069
## Does this PR introduce any user-facing change?
Missing Scala properties now use constructor or type defaults. Missing
Kotlin properties use explicit defaults first, then zero/false for
non-null scalars or null for nullable types. Kotlin non-null references
still reject missing values. The previously failing Scala values and
finite nested generic types now work as documented. No binary protocol
change.
---
.agents/languages/java.md | 5 +
.agents/languages/kotlin.md | 3 +
.agents/languages/scala.md | 13 +
docs/json/kotlin.md | 11 +-
docs/json/scala.md | 66 +++-
.../apache/fory/json/codec/JsonObjectModel.java | 48 ++-
.../apache/fory/json/codec/ObjectCodecBuilder.java | 7 +-
.../org/apache/fory/json/codegen/JsonCodegen.java | 21 +-
.../json/codegen/JsonGeneratedCodecBuilder.java | 39 ++-
.../fory/json/codegen/JsonReaderCodegen.java | 25 +-
.../fory/json/codegen/JsonWriterCodegen.java | 12 +-
.../org/apache/fory/json/meta/JsonCreatorInfo.java | 42 ++-
.../json/resolver/GeneratedCodecKeyBuilder.java | 7 +
.../fory/json/resolver/JsonTypeResolver.java | 28 +-
.../org/apache/fory/json/JsonContainerTest.java | 87 ++++-
.../fory/json/kotlin/KotlinMetadataModels.kt | 31 ++
.../apache/fory/json/kotlin/KotlinProductCodecs.kt | 2 +
.../apache/fory/json/kotlin/ForyJsonKotlinTest.kt | 48 +++
.../json/kotlin/KotlinNullabilityRuntimeTest.kt | 11 +-
.../json/scala/internal/ScalaTypeRefMacros.scala | 5 +
.../json/scala/internal/ScalaTypeRefMacros.scala | 5 +
.../scala/internal/ScalaCollectionCodecs.scala | 60 +++-
.../fory/json/scala/internal/ScalaEnumCodec.scala | 10 +-
.../json/scala/internal/ScalaObjectModels.scala | 107 +++++-
.../json/scala/internal/ScalaScalarCodecs.scala | 3 +-
.../fory/json/scala/internal/ScalaTupleCodec.scala | 5 +-
.../scala/internal/ScalaTypeCodecFactory.scala | 21 ++
.../fory/json/scala/ScalaJsonDerivationSuite.scala | 47 ++-
.../apache/fory/json/scala/ScalaJsonSuite.scala | 360 ++++++++++++++++++++-
29 files changed, 990 insertions(+), 139 deletions(-)
diff --git a/.agents/languages/java.md b/.agents/languages/java.md
index f2fa2a6a1..deaa98a2e 100644
--- a/.agents/languages/java.md
+++ b/.agents/languages/java.md
@@ -18,6 +18,11 @@ Load this file when changing anything under `java/` or when Java drives a cross-
tests solely because of that formatting pass. Verify formatting with `spotless:check` and inspect
the diff/status instead.
- Fory Java requires JDK `17+`.
+- JSON generated self calls must compare canonical ObjectCodec identity, not just the raw class.
+ Different instantiations such as Box<Box<Integer>> and Box<Integer> have different field codecs.
+ Apply the same rule to reader/writer dependency selection, any-properties, and unwrapped paths.
+ Generated class keys must distinguish self references from stored child capabilities because
+ they produce different constructor shapes, including for non-generic subtype occurrences.
- Place regressions in the existing test class that owns the behavior and use semantic fixture and
test names. Do not create standalone test classes named after issue numbers.
- Run Java `spotless` with JDK `21+`. If the current runtime is lower than 21, export `JAVA_HOME` to a JDK 21 installation before running `mvn spotless:check` or `mvn spotless:apply`.
diff --git a/.agents/languages/kotlin.md b/.agents/languages/kotlin.md
index 25f81c890..f0cc372c5 100644
--- a/.agents/languages/kotlin.md
+++ b/.agents/languages/kotlin.md
@@ -4,6 +4,9 @@ Load this file when changing `kotlin/` or compiler code that generates Kotlin so
## Rules
+- Missing JSON constructor parameters use explicit Kotlin defaults first; otherwise non-null
+ numeric and Boolean parameters use zero and false, and nullable parameters use null. Other
+ non-null reference parameters remain required. Explicit null never requests a default.
- Fory JSON Kotlin property inclusion follows the configured global or property-level rule,
independently of constructor defaults and deferred initializers. Do not override or reject
`NON_NULL` or `NON_EMPTY` to guarantee round trips. Missing-property defaults and nullability
diff --git a/.agents/languages/scala.md b/.agents/languages/scala.md
index ecfd3b07e..c048f4547 100644
--- a/.agents/languages/scala.md
+++ b/.agents/languages/scala.md
@@ -6,6 +6,19 @@ Load this file when changing `scala/`.
- Run Scala commands from within `scala/`.
- Scala serializers build on the Java implementation. If Java changed and the updated Java artifacts are not installed yet, run `cd ../java && mvn -T16 install -DskipTests` first.
+- Missing Scala JSON case-class constructor parameters use their type defaults: zero for numbers,
+ false for booleans, empty collections and arrays, None for Option, and null for other references.
+ Explicit constructor defaults take precedence. Mutable defaults must be fresh for each object.
+ Explicit JSON null keeps its existing decoding semantics. Preserve these rules in interpreted
+ and generated readers.
+- Scala JSON data occurrences of Unit use BoxedUnit, including ScalaTypeRef roots, nested
+ arguments, array components, and case-class properties. Normalize this at Scala type metadata
+ construction in both compiler versions and reuse ScalaUnitCodec's JSON null representation.
+ Do not special-case Option or change Java VoidCodec to accept Scala values. The Java Class
+ writer overload continues to reject void; Scala callers use ScalaTypeRef[Unit].
+- Recognize case classes independently of constructor support, including Scala 3 private copy
+ methods. Unsupported constructors must fail in the Scala model instead of falling through to
+ the ordinary object codec and silently dropping properties.
- Scala supports the JVM and GraalVM Native Image, not Android. Do not add Android-specific Scala
sources, tests, resources, R8 metadata, compiler plugins, macros, dependencies, or compatibility
design.
diff --git a/docs/json/kotlin.md b/docs/json/kotlin.md
index 1b75007d4..42be9fb6a 100644
--- a/docs/json/kotlin.md
+++ b/docs/json/kotlin.md
@@ -137,7 +137,7 @@ For this model:
- `{"id":1}` invokes both compiler defaults.
- `{"id":1,"label":null}` passes an explicit null and does not invoke the `label` default.
-- a missing `id` fails before constructor invocation.
+- a missing `id` uses `0`.
- `{"id":1,"retries":null}` fails; null never asks Kotlin to use a default.
Normal body `var` properties preserve their initializer when absent and are assigned after
@@ -172,7 +172,9 @@ val text = json.toJson(Response(1, items = emptyList())) // {"id":1}
Inclusion affects writing only. Reading the example's output uses the declared `items` default
of null, so the original empty list is not preserved. A missing constructor parameter uses its
-declared default, or fails if it has no default; a missing body property keeps its initializer.
+declared default first. Otherwise, non-null numeric and Boolean parameters use zero and `false`,
+nullable parameters use `null`, and other non-null parameters remain required. A missing body
+property keeps its initializer.
Explicit null remains distinct from a missing field and is rejected for a non-nullable property.
Choose an inclusion rule that retains values when exact round trips are required.
@@ -190,7 +192,10 @@ and generic children:
| Declaration | Missing member | Explicit JSON `null` |
| --------------------------------- | --------------------- | -------------------- |
| `val value: String` | Fails | Fails |
-| `val value: String?` | Fails | Passes null |
+| `val value: String?` | Passes null | Passes null |
+| `val value: Int` | Uses `0` | Fails |
+| `val value: Boolean` | Uses `false` | Fails |
+| `val value: Int?` | Passes null | Passes null |
| `val value: String = expression` | Evaluates the default | Fails |
| `val value: String? = expression` | Evaluates the default | Passes null |
diff --git a/docs/json/scala.md b/docs/json/scala.md
index 12fd656b4..74714f4f9 100644
--- a/docs/json/scala.md
+++ b/docs/json/scala.md
@@ -52,13 +52,34 @@ can erase Scala value-type arguments to `Object`.
Case classes are decoded by calling their full primary constructor. Fory invokes Scala's generated
constructor-default methods for missing defaulted parameters; it does not parse default expressions
or mutate constructor `val` fields. Defaults in later parameter lists receive the preceding
-constructor arguments exactly as Scala defines them. A missing parameter without a default is an
-error. Mutable body properties are applied after construction.
+constructor arguments exactly as Scala defines them. When a constructor parameter has no explicit
+default, an omitted property uses its type's default:
+
+- Numeric values use zero, and Boolean values use `false`.
+- Collections, maps, and arrays use empty values. Mutable defaults are fresh for each object.
+- `Option[A]` uses `None`.
+- Other reference values, including strings and nested objects, use `null`.
+
+Mutable body properties retain their initializers when omitted and are applied after construction
+when present.
+
+```scala
+case class Options(value: Option[Int], selected: Option[Int] = Some(7))
+case class Profile(age: Int, enabled: Boolean, tags: List[String], name: String)
+
+json.fromJson("{}", classOf[Options]) // Options(None, Some(7))
+json.fromJson("""{"selected":null}""", classOf[Options]) // Options(None, None)
+json.fromJson("{}", classOf[Profile]) // Profile(0, false, List(), null)
+```
+
+Explicit constructor defaults take precedence for omitted properties. An explicit JSON `null`
+decodes as `None` for `Option[A]`, even when its constructor default is `Some(...)`.
A case class may be declared at the top level, or inside an `object` at any nesting depth, as long
as every enclosing scope is itself an `object`. A case class enclosed by a `class`, a trait, or a
method is rejected for both reading and writing, because Fory cannot reach the enclosing instance
-or the companion it needs to rebuild the value.
+or the companion it needs to rebuild the value. Construction requires a public JVM constructor
+with a matching public companion `apply`; unsupported private constructor shapes are rejected.
Fory JSON annotations can be placed directly on Scala constructor properties:
@@ -78,11 +99,10 @@ parameters. `JsonCodec` child slots bind direct collection elements, `Option` co
or values. All other Fory JSON annotations retain the behavior described in
[Annotations](annotations.md).
-If a required non-defaulted reference parameter uses an inclusion rule that would omit `null`,
-serialization rejects a null value. This guarantees that JSON written by Fory remains readable by
-the same case-class schema. A global `defaultPropertyInclusion(NON_EMPTY)` retains empty values of
-required constructor parameters. An explicit `@JsonProperty(include = NON_EMPTY)` is rejected for
-a required parameter whose type can be empty; add a constructor default to allow omission.
+Property inclusion controls which values are written. Omitted properties use constructor or type
+defaults when read, so omitting an empty string can restore `null`. Use `ALWAYS` when those values
+must remain distinct. An explicit JSON `null` keeps the declared type's normal null behavior; it
+does not request a constructor default.
## Supported Scala types
@@ -142,6 +162,33 @@ val rangeType = ScalaTypeRef[scala.collection.immutable.NumericRange[Int]]
val range = json.fromJson("[1,3,5,7]", rangeType)
```
+Generic case classes preserve their type arguments, including finite nesting of the same class:
+
+```scala
+case class Box[A](value: A)
+
+val boxType = ScalaTypeRef[Box[Box[Int]]]
+val box = json.fromJson("""{"value":{"value":1}}""", boxType)
+json.toJson(box, boxType) // {"value":{"value":1}}
+```
+
+Recursive declarations that continually expand their type arguments, such as `Node[A]` containing
+`Node[List[A]]`, need a custom codec.
+
+Use `ScalaTypeRef[Unit]` for a `Unit` root value:
+
+```scala
+val unitType = ScalaTypeRef[Unit]
+json.toJson((), unitType) // "null"
+json.fromJson("null", unitType) // ()
+```
+
+`Unit` also works in case-class fields and nested types such as `List[Unit]`, `Array[Unit]`,
+and `Option[Unit]`. Each `Unit` value is encoded as JSON `null`. Under the value-or-null
+representation of `Option`, `Some(())` writes `null` and reads back as `None`.
+Do not pass `classOf[Unit]` to the Java `Class` overload: it denotes JVM `void`, which is
+rejected when writing a root value.
+
`Some[Int]` is a valid declared type when supplied with its complete type argument. A non-null JSON
value decodes to `Some(value)`; JSON `null` is rejected for `Some[Int]` but decodes to `None` for
`Option[Int]`.
@@ -257,7 +304,8 @@ non-finite number, and supported direct-wrapper behavior.
## Scala 3 closed enums and sealed hierarchies
-A parameterless Scala 3 enum uses its case name as a JSON string. Add `derives ScalaJsonCodec` to an
+A parameterless Scala 3 enum uses its case name as a JSON string, including as the key of a typed
+Scala map such as `Map[Color, String]`. Add `derives ScalaJsonCodec` to an
enum with parameterized cases to define one closed wrapper-object representation for every case:
```scala
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/JsonObjectModel.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/JsonObjectModel.java
index 05a12bf67..f713c5f4b 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/codec/JsonObjectModel.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/JsonObjectModel.java
@@ -30,6 +30,7 @@ import java.lang.reflect.WildcardType;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
+import java.util.function.Supplier;
import org.apache.fory.annotation.Internal;
import org.apache.fory.reflect.TypeRef;
@@ -43,6 +44,7 @@ public final class JsonObjectModel {
private final Method[] accessors;
private final Method[] defaultMethods;
private final Object defaultsReceiver;
+ private final Supplier<?>[] defaultFactories;
private final int[] defaultMaskBits;
private final boolean[] parameterNullable;
private final TypeRef<?>[] parameterTypes;
@@ -76,6 +78,7 @@ public final class JsonObjectModel {
accessors,
defaultMethods,
null,
+ null,
defaultMaskBits,
parameterNullable,
parameterTypes,
@@ -90,7 +93,10 @@ public final class JsonObjectModel {
* {@code defaultsReceiver}. Scala emits {@code $lessinit$greater$default$N} on the companion
* singleton and mirrors it as a static forwarder on the case class only for a top-level
* companion, so a case class declared inside an {@code object} binds its defaults on that
- * singleton. Pass {@code null} when the defaults are static members of the created type.
+ * singleton. A language module may also select a zero-argument static factory on the parameter's
+ * value type for an implicit default. Static defaults do not use {@code defaultsReceiver}; pass
+ * {@code null} when no instance default needs it. Type-default factories are evaluated only for
+ * missing parameters without a constructor default; mutable values must be newly allocated.
*/
public JsonObjectModel(
Constructor<?> constructor,
@@ -99,6 +105,7 @@ public final class JsonObjectModel {
Method[] accessors,
Method[] defaultMethods,
Object defaultsReceiver,
+ Supplier<?>[] defaultFactories,
int[] defaultMaskBits,
boolean[] parameterNullable,
TypeRef<?>[] parameterTypes,
@@ -114,6 +121,7 @@ public final class JsonObjectModel {
accessors,
defaultMethods,
defaultsReceiver,
+ defaultFactories,
defaultMaskBits,
parameterNullable,
parameterTypes,
@@ -148,6 +156,7 @@ public final class JsonObjectModel {
accessors,
defaultMethods,
null,
+ null,
defaultMaskBits,
parameterNullable,
parameterTypes,
@@ -175,7 +184,8 @@ public final class JsonObjectModel {
Method[] propertySetters,
TypeRef<?>[] propertyTypes,
boolean[] propertyReconstructible,
- boolean[] propertyRequired) {
+ boolean[] propertyRequired,
+ Supplier<?>[] defaultFactories) {
this(
creator,
invocationCreator,
@@ -184,6 +194,7 @@ public final class JsonObjectModel {
accessors,
defaultMethods,
null,
+ defaultFactories,
defaultMaskBits,
parameterNullable,
parameterTypes,
@@ -203,6 +214,7 @@ public final class JsonObjectModel {
Method[] accessors,
Method[] defaultMethods,
Object defaultsReceiver,
+ Supplier<?>[] defaultFactories,
int[] defaultMaskBits,
boolean[] parameterNullable,
TypeRef<?>[] parameterTypes,
@@ -219,6 +231,7 @@ public final class JsonObjectModel {
this.accessors = accessors.clone();
this.defaultMethods = defaultMethods.clone();
this.defaultsReceiver = defaultsReceiver;
+ this.defaultFactories = defaultFactories == null ? null : defaultFactories.clone();
this.defaultMaskBits = defaultMaskBits.clone();
this.parameterNullable = parameterNullable.clone();
this.parameterTypes = parameterTypes.clone();
@@ -247,6 +260,7 @@ public final class JsonObjectModel {
accessors = new Method[0];
defaultMethods = new Method[0];
defaultsReceiver = null;
+ defaultFactories = null;
defaultMaskBits = new int[0];
parameterNullable = new boolean[0];
parameterTypes = new TypeRef<?>[0];
@@ -330,6 +344,7 @@ public final class JsonObjectModel {
if (parameterNames.length != count
|| accessors.length != count
|| defaultMethods.length != count
+ || defaultFactories != null && defaultFactories.length != count
|| defaultMaskBits.length != count
|| parameterNullable.length != count
|| parameterTypes.length != count) {
@@ -372,7 +387,7 @@ public final class JsonObjectModel {
}
}
HashSet<String> names = new HashSet<>();
- boolean hasDefaultMethod = false;
+ boolean hasInstanceDefault = false;
for (int i = 0; i < parameterNames.length; i++) {
String name = parameterNames[i];
if (name == null || name.isEmpty() || !names.add(name)) {
@@ -382,21 +397,15 @@ public final class JsonObjectModel {
if (defaultMethods[i] != null && defaultMaskBits[i] >= 0) {
throw new IllegalArgumentException("A constructor parameter has two default mechanisms");
}
- if (defaultMethods[i] != null
- && Modifier.isStatic(defaultMethods[i].getModifiers()) == (defaultsReceiver != null)) {
- throw new IllegalArgumentException(
- "A JSON constructor default receiver is required exactly for instance defaults "
- + defaultMethods[i]);
- }
- if (defaultsReceiver != null
- && defaultMethods[i] != null
- && !defaultMethods[i].getDeclaringClass().isInstance(defaultsReceiver)) {
- throw new IllegalArgumentException(
- "JSON constructor default receiver does not own " + defaultMethods[i]);
+ if (defaultMethods[i] != null && !Modifier.isStatic(defaultMethods[i].getModifiers())) {
+ if (!defaultMethods[i].getDeclaringClass().isInstance(defaultsReceiver)) {
+ throw new IllegalArgumentException(
+ "JSON constructor default receiver does not own " + defaultMethods[i]);
+ }
+ hasInstanceDefault = true;
}
- hasDefaultMethod |= defaultMethods[i] != null;
}
- if (defaultsReceiver != null && !hasDefaultMethod) {
+ if (defaultsReceiver != null && !hasInstanceDefault) {
throw new IllegalArgumentException(
"A JSON constructor default receiver requires at least one instance default");
}
@@ -491,6 +500,13 @@ public final class JsonObjectModel {
return defaultsReceiver;
}
+ /**
+ * Returns type-default factories selected by the language module, or null if none are supplied.
+ */
+ public Supplier<?>[] defaultFactories() {
+ return defaultFactories == null ? null : defaultFactories.clone();
+ }
+
public int[] defaultMaskBits() {
return defaultMaskBits.clone();
}
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java
index ee8fb259a..6c2678035 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java
@@ -36,6 +36,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.function.Supplier;
import org.apache.fory.collection.Tuple2;
import org.apache.fory.json.ForyJsonException;
import org.apache.fory.json.PropertyNamingStrategy;
@@ -1664,6 +1665,7 @@ final class ObjectCodecBuilder {
Executable creator = objectModel.creator();
String[] names = objectModel.parameterNames();
Method[] defaultMethods = objectModel.defaultMethods();
+ Supplier<?>[] defaultFactories = objectModel.defaultFactories();
int[] defaultMaskBits = objectModel.defaultMaskBits();
TypeRef<?>[] logicalParameterTypes = objectModel.parameterTypes();
Type[] parameterTypes = creator.getGenericParameterTypes();
@@ -1715,7 +1717,9 @@ final class ObjectCodecBuilder {
bindCreatorType(ownerType, creator, i, parameterTypes[i], builder);
builder.mergeCreatorParameter(type, parameters[i]);
if (!builder.creatorReadAllowed()) {
- if (defaultMethods[i] == null && defaultMaskBits[i] < 0) {
+ if (defaultMethods[i] == null
+ && defaultMaskBits[i] < 0
+ && (defaultFactories == null || defaultFactories[i] == null)) {
throw new ForyJsonException(
"Ignored constructor property " + names[i] + " requires a language default");
}
@@ -1746,6 +1750,7 @@ final class ObjectCodecBuilder {
generatedCodec,
defaultMethods,
objectModel.defaultsReceiver(),
+ defaultFactories,
names,
objectModel.defaultConstructor(),
defaultMaskBits,
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegen.java
index 5ff4c69a4..462709f1c 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegen.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegen.java
@@ -1134,10 +1134,9 @@ public final class JsonCodegen {
return false;
}
Method defaultMethod = creator.defaultMethod(i);
- // JsonCreatorInfo guarantees that a default method belongs to the creator owner, or to the
- // language singleton that owns instance defaults, and that its dependency types are the
- // preceding creator parameters. The generated reader invokes that exact method on that exact
- // declaring class, so validate its access from the final definition context as well.
+ // JsonCreatorInfo validates compiler defaults and module-selected value factories. Their
+ // dependencies, if any, are preceding creator parameters. The generated reader invokes that
+ // exact method on its declaring class, so validate access from the final definition context.
if (defaultMethod != null && !canCall(defaultMethod)) {
return false;
}
@@ -1346,14 +1345,15 @@ public final class JsonCodegen {
if (any == null || any.readField() == null && any.readSetter() == null) {
return false;
}
- if (storesSelfReader(
- owner.type(), owner.readFields(), owner.creatorInfo() != null, any, resolver)) {
+ if (storesSelfReader(owner, owner.readFields(), owner.creatorInfo() != null, any, resolver)) {
return true;
}
JsonUnwrappedInfo unwrapped = owner.unwrappedInfo();
if (unwrapped != null) {
for (JsonUnwrappedInfo.ReadRoute route : unwrapped.readRoutes()) {
- if (route.field() != null && readNestedType(route.field(), resolver) == owner.type()) {
+ if (route.field() != null
+ && readNestedType(route.field(), resolver) != null
+ && resolver.canonicalObjectCodec(route.field().readTypeInfo()) == owner) {
return true;
}
}
@@ -1362,19 +1362,20 @@ public final class JsonCodegen {
}
static boolean storesSelfReader(
- Class<?> type,
+ ObjectCodec<?> owner,
JsonFieldInfo[] properties,
boolean creator,
AnyInfo any,
JsonTypeResolver resolver) {
- if (any.valueRawType() == type && resolver.canonicalObjectCodec(any.valueTypeInfo()) != null) {
+ if (resolver.canonicalObjectCodec(any.valueTypeInfo()) == owner) {
return true;
}
if (creator) {
return false;
}
for (JsonFieldInfo property : properties) {
- if (readNestedType(property, resolver) == type) {
+ if (readNestedType(property, resolver) != null
+ && resolver.canonicalObjectCodec(property.readTypeInfo()) == owner) {
return true;
}
}
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonGeneratedCodecBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonGeneratedCodecBuilder.java
index 8020c5cdf..5e1b148f3 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonGeneratedCodecBuilder.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonGeneratedCodecBuilder.java
@@ -104,23 +104,42 @@ final class JsonGeneratedCodecBuilder extends CodecBuilder {
Expression fieldValue(JsonFieldInfo property, Expression object) {
Method getter = property.writeGetter();
+ Expression value;
if (getter != null) {
+ // Scala 3 can emit a BoxedUnit descriptor with a void generic return signature.
+ TypeRef<?> returnType =
+ TypeRef.of(
+ getter.getGenericReturnType() == void.class
+ ? getter.getReturnType()
+ : getter.getGenericReturnType());
// JSON writers check the returned member value directly. Requesting expression-level null
// state here only emits an unused boolean for each nullable getter and bloats generated
// object writers enough to hurt C2 inlining.
+ Expression invocation;
if (DirectMethodCodegen.sourceNameable(getter)) {
- return new Expression.Invoke(
- object,
- getter.getName(),
- property.name(),
- TypeRef.of(getter.getGenericReturnType()),
- false);
+ invocation =
+ new Expression.Invoke(object, getter.getName(), property.name(), returnType, false);
+ } else {
+ String name = DirectMethodCodegen.getterName(getter);
+ addDirectMethod(name, getter.getReturnType(), getter.getDeclaringClass(), "target");
+ invocation = directInvoke(name, property.name(), returnType, object);
}
- String name = DirectMethodCodegen.getterName(getter);
- addDirectMethod(name, getter.getReturnType(), getter.getDeclaringClass(), "target");
- return directInvoke(name, property.name(), TypeRef.of(getter.getGenericReturnType()), object);
+ // Scala Unit getters can return void. Match reflective access, which adapts their absent
+ // result to null for the logical Unit codec, rather than generating a cast from void.
+ value =
+ getter.getReturnType() == void.class
+ ? new Expression.ListExpression(
+ invocation, new Expression.Null(TypeRef.of(property.writeRawType()), false))
+ : invocation;
+ } else {
+ value = getFieldValue(object, writeDescriptor(property));
}
- return getFieldValue(object, writeDescriptor(property));
+ // A resolved primitive type argument still has an erased Object accessor or field.
+ // Adapt it here so both fused object-start writes and ordinary fields receive primitives.
+ Class<?> writeType = property.writeRawType();
+ return writeType.isPrimitive() && writeType != void.class && !value.type().isPrimitive()
+ ? new Expression.Cast(value, TypeRef.of(writeType))
+ : value;
}
Expression anyValue(Field field, Expression object) {
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonReaderCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonReaderCodegen.java
index 528f6c206..b946e70ac 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonReaderCodegen.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonReaderCodegen.java
@@ -260,7 +260,7 @@ abstract class JsonReaderCodegen {
this.any = any;
ownerType = type;
storesSelfReader =
- JsonCodegen.storesSelfReader(type, properties, creatorInfo != null, any, resolver);
+ JsonCodegen.storesSelfReader(owner, properties, creatorInfo != null, any, resolver);
if (creatorInfo != null) {
return genAnyCreatorReaderCode(builder, type, creatorInfo);
}
@@ -1494,11 +1494,13 @@ abstract class JsonReaderCodegen {
if (method == null) {
int maskBit = creator.defaultMaskBit(i);
if (maskBit < 0) {
- body.append("throw ")
+ body.append("arguments[")
+ .append(i)
+ .append("] = ")
.append(creatorExpression)
- .append(".missingArgument(")
+ .append(".defaultValue(")
.append(i)
- .append(");\n");
+ .append(", arguments);\n");
} else {
body.append("arguments[")
.append(i)
@@ -1625,7 +1627,11 @@ abstract class JsonReaderCodegen {
if (type == char.class) {
return Expression.Literal.ofChar((char) 0);
}
- return new Expression.Literal(type == float.class ? 0F : 0D, TypeRef.of(type));
+ // A numeric conditional would promote 0F to Double before boxing the literal.
+ if (type == float.class) {
+ return new Expression.Literal(0F, TypeRef.of(type));
+ }
+ return new Expression.Literal(0D, TypeRef.of(type));
}
private Expression readCreatorValue(
@@ -4159,8 +4165,8 @@ abstract class JsonReaderCodegen {
}
private boolean storesAnyReader(Class<?> type) {
- return resolver.canonicalObjectCodec(any.valueTypeInfo()) == null
- || any.valueTypeInfo().rawType() != type;
+ return any.valueTypeInfo().rawType() != type
+ || resolver.canonicalObjectCodec(any.valueTypeInfo()) != objectOwner;
}
private Expression anyReaderRef() {
@@ -4571,7 +4577,9 @@ abstract class JsonReaderCodegen {
final boolean storesReadObjectCodec(Class<?> type, JsonFieldInfo property) {
Class<?> nestedType = readNestedType(property);
- return nestedType != null && nestedType != type;
+ return nestedType != null
+ && (nestedType != type
+ || resolver.canonicalObjectCodec(property.readTypeInfo()) != objectOwner);
}
private Expression readField(
@@ -4845,6 +4853,7 @@ abstract class JsonReaderCodegen {
final Expression readObjectValue(Class<?> type, JsonFieldInfo property, int id) {
Expression codec =
property.readRawType() == type
+ && resolver.canonicalObjectCodec(property.readTypeInfo()) == objectOwner
? nestedSelfReaderRef()
: usesReaderSlot(property.readTypeInfo())
? readerFromSlot(fieldRef("o" + id, JsonTypeInfo.class))
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java
index 0d2bd726b..ce60b9d17 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java
@@ -847,8 +847,7 @@ abstract class JsonWriterCodegen {
}
private boolean storesAnyWriter(AnyInfo any) {
- return resolver.canonicalObjectCodec(any.valueTypeInfo()) == null
- || any.valueRawType() != ownerType;
+ return resolver.canonicalObjectCodec(any.valueTypeInfo()) != objectOwner;
}
static final class PrefixFields {
@@ -1650,10 +1649,10 @@ abstract class JsonWriterCodegen {
private Expression writeCodec(
JsonFieldInfo property, int id, Expression value, Expression writer) {
- boolean object = resolver.canonicalObjectCodec(property.writeTypeInfo()) != null;
- Class<?> valueType = property.writeTypeInfo().rawType();
+ // Equal raw classes can have different generic bindings. Only the canonical object owner
+ // identifies a self edge; Box<Box<Integer>> must retain the child Box<Integer> writer.
Expression codec =
- object && valueType == ownerType
+ resolver.canonicalObjectCodec(property.writeTypeInfo()) == objectOwner
? new Reference("this", TypeRef.of(completeWriterType()))
: usesWriterSlot(property)
? writerFromSlot(fieldRef("w" + id, JsonTypeInfo.class))
@@ -1668,8 +1667,7 @@ abstract class JsonWriterCodegen {
private boolean storesWriteCodec(JsonFieldInfo property) {
return usesWriteCodec(property)
- && (resolver.canonicalObjectCodec(property.writeTypeInfo()) == null
- || property.writeTypeInfo().rawType() != ownerType);
+ && resolver.canonicalObjectCodec(property.writeTypeInfo()) != objectOwner;
}
private boolean usesWriterSlot(JsonFieldInfo property) {
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorInfo.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorInfo.java
index 0a9151c05..f745db4df 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorInfo.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorInfo.java
@@ -29,6 +29,7 @@ import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
+import java.util.function.Supplier;
import org.apache.fory.annotation.Internal;
import org.apache.fory.collection.ClassValueCache;
import org.apache.fory.json.ForyJsonException;
@@ -69,6 +70,7 @@ public final class JsonCreatorInfo {
private final GeneratedJsonCodec<?> generatedCodec;
private final Method[] defaultMethods;
private final Object defaultsReceiver;
+ private final Supplier<?>[] defaultFactories;
private final MethodHandle[] defaultInvokers;
private final Constructor<?> defaultConstructor;
private final MethodHandle defaultConstructorInvoker;
@@ -100,6 +102,7 @@ public final class JsonCreatorInfo {
null,
null,
null,
+ null,
null);
}
@@ -113,6 +116,7 @@ public final class JsonCreatorInfo {
GeneratedJsonCodec<?> generatedCodec,
Method[] defaultMethods,
Object defaultsReceiver,
+ Supplier<?>[] defaultFactories,
String[] parameterNames,
Constructor<?> defaultConstructor,
int[] defaultMaskBits,
@@ -126,6 +130,7 @@ public final class JsonCreatorInfo {
generatedCodec,
defaultMethods,
defaultsReceiver,
+ defaultFactories,
parameterNames,
defaultConstructor,
defaultMaskBits,
@@ -148,6 +153,7 @@ public final class JsonCreatorInfo {
null,
null,
null,
+ null,
instance);
}
@@ -166,6 +172,7 @@ public final class JsonCreatorInfo {
GeneratedJsonCodec<?> generatedCodec,
Method[] defaultMethods,
Object defaultsReceiver,
+ Supplier<?>[] defaultFactories,
String[] parameterNames,
Constructor<?> defaultConstructor,
int[] defaultMaskBits,
@@ -186,6 +193,7 @@ public final class JsonCreatorInfo {
this.parameterNames = parameterNames == null ? null : parameterNames.clone();
this.defaultMethods = defaultMethods == null ? null : defaultMethods.clone();
this.defaultsReceiver = defaultsReceiver;
+ this.defaultFactories = defaultFactories == null ? null : defaultFactories.clone();
defaultInvokers =
this.defaultMethods == null
? null
@@ -225,6 +233,7 @@ public final class JsonCreatorInfo {
generatedCodec = source.generatedCodec;
defaultMethods = source.defaultMethods;
defaultsReceiver = source.defaultsReceiver;
+ defaultFactories = source.defaultFactories;
defaultInvokers = source.defaultInvokers;
defaultConstructor = source.defaultConstructor;
defaultConstructorInvoker = source.defaultConstructorInvoker;
@@ -455,7 +464,8 @@ public final class JsonCreatorInfo {
@Internal
public boolean hasDefault(int index) {
return defaultInvokers != null && defaultInvokers[index] != null
- || defaultMaskBits != null && defaultMaskBits[index] >= 0;
+ || defaultMaskBits != null && defaultMaskBits[index] >= 0
+ || defaultFactories != null && defaultFactories[index] != null;
}
/** Returns one prevalidated language-defined constructor default method. */
@@ -475,6 +485,9 @@ public final class JsonCreatorInfo {
public Object defaultValue(int index, Object[] arguments) {
MethodHandle invoker = defaultInvokers == null ? null : defaultInvokers[index];
if (invoker == null) {
+ if (defaultFactories != null && defaultFactories[index] != null) {
+ return defaultFactories[index].get();
+ }
throw missingArgument(index);
}
try {
@@ -562,9 +575,10 @@ public final class JsonCreatorInfo {
if (argument == MISSING) {
int bit = defaultMaskBits[i];
if (bit < 0) {
- throw missingArgument(i);
+ arguments[i] = defaultValue(i, arguments);
+ } else {
+ useDefault = true;
}
- useDefault = true;
} else if (argument == null
&& parameterNullable != null
&& !parameterNullable[i]
@@ -668,16 +682,22 @@ public final class JsonCreatorInfo {
if (method == null) {
continue;
}
- // A default is either a static member of the created type or an instance member of the
- // language singleton that owns it, such as a Scala companion of a nested case class.
+ // Language modules select defaults during schema construction. Compiler defaults belong to
+ // the creator or its companion; implicit defaults can use a value type's static factory,
+ // such as Option.empty, alongside those compiler defaults.
boolean instanceDefault = !java.lang.reflect.Modifier.isStatic(method.getModifiers());
Class<?> declaringClass = method.getDeclaringClass();
- if ((instanceDefault
- ? defaultsReceiver == null
- || !declaringClass.isInstance(defaultsReceiver)
- || !declaringClass.getName().equals(ownerType.getName() + "$")
- : defaultsReceiver != null || declaringClass != ownerType)
- || !method.getName().equals("$lessinit$greater$default$" + (i + 1))
+ boolean constructorDefault =
+ method.getName().equals("$lessinit$greater$default$" + (i + 1))
+ && (instanceDefault
+ ? declaringClass.isInstance(defaultsReceiver)
+ && declaringClass.getName().equals(ownerType.getName() + "$")
+ : declaringClass == ownerType);
+ boolean valueFactory =
+ !instanceDefault
+ && method.getParameterCount() == 0
+ && method.getReturnType() == declaringClass;
+ if ((!constructorDefault && !valueFactory)
|| method.getParameterCount() > i
|| !java.lang.reflect.Modifier.isPublic(method.getModifiers())
|| !boxed(parameterTypes[i]).isAssignableFrom(boxed(method.getReturnType()))) {
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
index 6102357e3..df4fa3999 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
@@ -49,6 +49,7 @@ final class GeneratedCodecKeyBuilder {
EXACT_CODEC,
FACTORY,
MIXIN,
+ SELF_REFERENCE,
CYCLE_SLOT
}
@@ -168,6 +169,12 @@ final class GeneratedCodecKeyBuilder {
}
private void addRegistration(JsonTypeInfo typeInfo) {
+ // Self calls and stored child capabilities have different generated constructor shapes.
+ // Subtype occurrences can have distinct owners even when their raw classes are identical.
+ if (resolver.canonicalObjectCodec(typeInfo) == owner) {
+ keyParts.add(Part.SELF_REFERENCE);
+ keyParts.add(occurrence);
+ }
JsonSharedRegistry registry = resolver.sharedRegistry();
String factoryKey = typeInfo.factoryKey();
if (factoryKey != null) {
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 38329039a..8a919f98a 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
@@ -226,7 +226,7 @@ public final class JsonTypeResolver {
}
/**
- * Returns the stable metadata owner for a canonical raw-class object binding, or {@code null}.
+ * Returns the stable metadata owner for a complete declared-type object binding, or {@code null}.
*
* <p>This lookup never constructs metadata or requests compilation. Source generation may call it
* outside a root operation; the short resolver-local lock protects the ordinary owner maps
@@ -1829,7 +1829,7 @@ public final class JsonTypeResolver {
}
private boolean storesAnyCodec(ObjectCodec<?> owner, AnyInfo any) {
- return canonicalObjectCodec(any.valueTypeInfo()) == null || any.valueRawType() != owner.type();
+ return canonicalObjectCodec(any.valueTypeInfo()) != owner;
}
enum CapabilityKind {
@@ -1846,9 +1846,7 @@ public final class JsonTypeResolver {
utf8Writer
? JsonCodegen.usesUtf8WriteCodec(field, this)
: JsonCodegen.usesWriteCodec(field);
- return usesCodec
- && (field.writeRawType() != owner.type()
- || canonicalObjectOwner(field.writeTypeInfo()) == null);
+ return usesCodec && canonicalObjectOwner(field.writeTypeInfo()) != owner;
}
private boolean storesReadCapability(ObjectCodec<?> owner, JsonFieldInfo field) {
@@ -1856,7 +1854,7 @@ public final class JsonTypeResolver {
return true;
}
Class<?> nestedType = JsonCodegen.readNestedType(field, this);
- return nestedType != null && nestedType != owner.type();
+ return nestedType != null && canonicalObjectOwner(field.readTypeInfo()) != owner;
}
private ArrayList<JsonTypeInfo> capabilityChildren(ObjectCodec<?> owner, CapabilityKind kind) {
@@ -2727,12 +2725,28 @@ public final class JsonTypeResolver {
activeGenericBindings.put(rawType, type);
return true;
}
- if (!active.getTypeArguments().equals(type.getTypeArguments())) {
+ // A nested instantiation already contained in the active declaration contracts a finite
+ // type tree: Box<Box<Integer>> can bind Box<Integer>. Keep the outer binding as the owner
+ // so every admitted instantiation stays within that finite tree; an expanding declaration
+ // such as Node<T> -> Node<List<T>> still cannot introduce a new instantiation recursively.
+ if (!active.getTypeArguments().equals(type.getTypeArguments()) && !containsType(active, type)) {
throw expandingGenericType(rawType, active, type);
}
return false;
}
+ private static boolean containsType(TypeRef<?> enclosing, TypeRef<?> nested) {
+ if (enclosing.getType().equals(nested.getType())) {
+ return true;
+ }
+ for (TypeRef<?> argument : enclosing.getTypeArguments()) {
+ if (containsType(argument, nested)) {
+ return true;
+ }
+ }
+ return enclosing.isArray() && containsType(enclosing.getComponentType(), nested);
+ }
+
private void exitObjectBinding(TypeRef<?> type, boolean owner) {
if (owner) {
activeGenericBindings.remove(type.getRawType());
diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java
index 2665b8a52..8bf51f717 100644
--- a/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java
+++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java
@@ -69,6 +69,7 @@ import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.concurrent.atomic.AtomicLongArray;
import java.util.concurrent.atomic.AtomicReferenceArray;
+import org.apache.fory.json.annotation.JsonAnyProperty;
import org.apache.fory.json.codec.ArrayCodec;
import org.apache.fory.json.codec.JsonValueCodec;
import org.apache.fory.json.codec.MapCodec;
@@ -374,15 +375,83 @@ public class JsonContainerTest extends ForyJsonTestModels {
generatedUtf8WriterClass(json, strings), generatedUtf8WriterClass(json, values));
}
+ @Test
+ public void nestedGenericPojo() {
+ for (boolean codegen : new boolean[] {false, true}) {
+ ForyJson json = newJson(codegen);
+ TypeRef<GenericBox<GenericBox<Integer>>> type =
+ new TypeRef<GenericBox<GenericBox<Integer>>>() {};
+ GenericBox<GenericBox<Integer>> outer = new GenericBox<>();
+ outer.value = new GenericBox<>();
+ outer.value.value = 1;
+ outer.value.values = Arrays.asList(2, 3);
+ outer.values = Collections.singletonList(outer.value);
+ String encoded = json.toJson(outer, type);
+ assertEquals(new String(json.toJsonBytes(outer, type), StandardCharsets.UTF_8), encoded);
+ for (GenericBox<GenericBox<Integer>> decoded :
+ Arrays.asList(
+ json.fromJson(encoded, type),
+ json.fromJson(encoded.getBytes(StandardCharsets.UTF_8), type))) {
+ assertEquals(decoded.value.value, Integer.valueOf(1));
+ assertEquals(decoded.value.values, Arrays.asList(2, 3));
+ assertEquals(decoded.values.get(0).value, Integer.valueOf(1));
+ }
+ TypeRef<GenericBox<List<GenericBox<Integer>>>> listType =
+ new TypeRef<GenericBox<List<GenericBox<Integer>>>>() {};
+ GenericBox<List<GenericBox<Integer>>> list =
+ json.fromJson("{\"value\":[{\"value\":4}]}", listType);
+ assertEquals(list.value.get(0).value, Integer.valueOf(4));
+ assertEquals(
+ json.fromJson(json.toJsonBytes(list, listType), listType).value.get(0).value,
+ Integer.valueOf(4));
+ TypeRef<GenericBox<GenericBox<Integer>[]>> arrayType =
+ new TypeRef<GenericBox<GenericBox<Integer>[]>>() {};
+ GenericBox<GenericBox<Integer>[]> array =
+ json.fromJson("{\"value\":[{\"value\":5}]}", arrayType);
+ assertEquals(array.value[0].value, Integer.valueOf(5));
+ assertEquals(
+ json.fromJson(json.toJsonBytes(array, arrayType), arrayType).value[0].value,
+ Integer.valueOf(5));
+ }
+ }
+
+ @Test
+ public void nestedGenericAnyProperties() {
+ ForyJson json = newJson();
+ TypeRef<GenericProperties<GenericProperties<Integer>>> type =
+ new TypeRef<GenericProperties<GenericProperties<Integer>>>() {};
+ String text = "{\"nested\":{\"answer\":42}}";
+ GenericProperties<GenericProperties<Integer>> value = json.fromJson(text, type);
+ assertEquals(value.properties.get("nested").properties.get("answer"), Integer.valueOf(42));
+ assertEquals(json.toJson(value, type), text);
+ assertEquals(new String(json.toJsonBytes(value, type), StandardCharsets.UTF_8), text);
+ assertEquals(
+ json.fromJson(text.getBytes(StandardCharsets.UTF_8), type)
+ .properties
+ .get("nested")
+ .properties
+ .get("answer"),
+ Integer.valueOf(42));
+ }
+
@Test
public void rejectExpandingGeneric() {
- ForyJson json = newJson(true);
- assertThrows(
- ForyJsonException.class,
- () -> json.fromJson("{\"next\":null}", new TypeRef<Expanding<String>>() {}));
- GenericBox<String> value =
- json.fromJson("{\"value\":\"ready\",\"values\":[]}", new TypeRef<GenericBox<String>>() {});
- assertEquals(value.value, "ready");
+ for (boolean codegen : new boolean[] {false, true}) {
+ ForyJson json = newJson(codegen);
+ for (int attempt = 0; attempt < 2; attempt++) {
+ assertThrows(
+ ForyJsonException.class,
+ () -> json.fromJson("{\"next\":null}", new TypeRef<Expanding<String>>() {}));
+ assertThrows(
+ ForyJsonException.class,
+ () -> json.fromJson("{}", new TypeRef<Expanding<Expanding<String>>>() {}));
+ GenericBox<GenericBox<String>> value =
+ json.fromJson(
+ "{\"value\":{\"value\":\"ready\"}}",
+ new TypeRef<GenericBox<GenericBox<String>>>() {});
+ assertEquals(value.value.value, "ready");
+ }
+ }
}
@Test
@@ -1183,6 +1252,10 @@ public class JsonContainerTest extends ForyJsonTestModels {
public Expanding<List<T>> next;
}
+ public static final class GenericProperties<T> {
+ @JsonAnyProperty public Map<String, T> properties;
+ }
+
public static final class NoteList extends ArrayList<Note> {}
public static final class Note {
diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModels.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModels.kt
index be68e4738..90bfb59e1 100644
--- a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModels.kt
+++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModels.kt
@@ -26,6 +26,7 @@ import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import java.util.LinkedHashMap
+import java.util.function.Supplier
import kotlin.ExperimentalContextParameters
import kotlin.metadata.ClassKind
import kotlin.metadata.ExperimentalContextReceivers
@@ -195,9 +196,39 @@ internal object KotlinMetadataModels {
properties.map { it.type }.toTypedArray(),
BooleanArray(properties.size) { properties[it].reconstructible },
BooleanArray(properties.size) { properties[it].required },
+ Array(parameters.size) {
+ if (defaultMaskBits[it] < 0) typeDefault(parameterTypes[it]) else null
+ },
)
}
+ internal fun typeDefault(type: TypeRef<*>): Supplier<*>? {
+ // Nullable parameters default to null even when their non-null type has a scalar zero value.
+ // Other non-null references still require a constructor default or an explicit JSON property.
+ if (nullable(type)) return Supplier { null }
+ val value: Any =
+ when (type.rawType) {
+ java.lang.Boolean.TYPE,
+ java.lang.Boolean::class.java -> false
+ java.lang.Byte.TYPE,
+ java.lang.Byte::class.java -> 0.toByte()
+ java.lang.Short.TYPE,
+ java.lang.Short::class.java -> 0.toShort()
+ java.lang.Integer.TYPE,
+ java.lang.Integer::class.java -> 0
+ java.lang.Long.TYPE,
+ java.lang.Long::class.java -> 0L
+ java.lang.Float.TYPE,
+ java.lang.Float::class.java -> 0F
+ java.lang.Double.TYPE,
+ java.lang.Double::class.java -> 0.0
+ java.lang.Character.TYPE,
+ java.lang.Character::class.java -> '\u0000'
+ else -> return null
+ }
+ return Supplier { value }
+ }
+
private fun selectCreator(
ownerType: TypeRef<*>,
rawType: Class<*>,
diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProductCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProductCodecs.kt
index 45202cf3e..d8234ccf5 100644
--- a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProductCodecs.kt
+++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProductCodecs.kt
@@ -84,6 +84,8 @@ internal object KotlinProductCodecs {
names,
accessors,
arrayOfNulls(names.size),
+ null,
+ Array(names.size) { KotlinMetadataModels.typeDefault(types[it]) },
IntArray(names.size) { -1 },
BooleanArray(names.size) { nullable(types[it]) },
types,
diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlinTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlinTest.kt
index 1dbf15849..ceb78d486 100644
--- a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlinTest.kt
+++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlinTest.kt
@@ -36,6 +36,20 @@ class ForyJsonKotlinTest {
data class Required(val id: Long, val name: String)
+ data class MissingValues(
+ val age: Int,
+ val enabled: Boolean,
+ val optionalAge: Int?,
+ val optionalName: String?,
+ val name: String = "guest",
+ val byte: Byte,
+ val short: Short,
+ val long: Long,
+ val float: Float,
+ val double: Double,
+ val char: Char,
+ )
+
data class EvaluatedDefault(
val seed: Int,
val values: MutableList<Int> = mutableListOf(nextDefault++),
@@ -107,6 +121,40 @@ class ForyJsonKotlinTest {
}
}
+ @Test
+ fun missingTypeDefaults() {
+ for (codegen in listOf(false, true)) {
+ val json = ForyJsonKotlin.builder().withCodegen(codegen).withAsyncCompilation(false).build()
+ val expected = MissingValues(0, false, null, null, "guest", 0, 0, 0L, 0F, 0.0, '\u0000')
+ val type = jsonTypeRef<MissingValues>()
+ for (text in listOf("{}", """{"unknown":"中"}""")) {
+ assertEquals(expected, json.fromJson(text, type))
+ assertEquals(expected, json.fromJson(text.toByteArray(Charsets.UTF_8), type))
+ }
+ assertEquals(
+ expected.copy(
+ age = 8,
+ enabled = true,
+ optionalAge = 4,
+ optionalName = "ready",
+ name = "member"
+ ),
+ json.fromJson(
+ """{"age":8,"enabled":true,"optionalAge":4,"optionalName":"ready","name":"member"}""",
+ type
+ ),
+ )
+ assertEquals(ReferencedDefault(0, 1), json.fromJson("{}", jsonTypeRef<ReferencedDefault>()))
+ assertEquals(Box<Int?>(null), json.fromJson("{}", jsonTypeRef<Box<Int?>>()))
+ assertEquals(Box(0), json.fromJson("{}", jsonTypeRef<Box<Int>>()))
+ assertEquals(0 to null, json.fromJson("{}", jsonTypeRef<Pair<Int, String?>>()))
+ assertFailsWith<ForyJsonException> { json.fromJson("{}", jsonTypeRef<Required>()) }
+ assertFailsWith<ForyJsonException> { json.fromJson("""{"age":null}""", type) }
+ assertFailsWith<ForyJsonException> { json.fromJson("""{"name":null}""", type) }
+ assertEquals(expected, json.fromJson("{}", type))
+ }
+ }
+
@Test
fun evaluatesDefaultPerObject() {
val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build()
diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt
index 89cf78b9c..a287e9a94 100644
--- a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt
+++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt
@@ -258,9 +258,10 @@ class KotlinNullabilityRuntimeTest {
json.fromJson(explicitNull, type),
)
- assertFailsWith<ForyJsonException> {
- json.fromJson("""{"required":"value","count":1,"nullableCount":null}""", type)
- }
+ assertEquals(
+ ConstructorNulls("value", null, 1, null),
+ json.fromJson("""{"required":"value","count":1,"nullableCount":null}""", type),
+ )
assertFailsWith<ForyJsonException> {
json.fromJson(
"""{"required":null,"nullable":null,"count":1,"nullableCount":null}""",
@@ -416,8 +417,8 @@ class KotlinNullabilityRuntimeTest {
val text = json.toJson(value, type)
assertEquals("""{"id":1}""", text)
assertEquals(text, json.toJsonBytes(value, type).decodeToString())
- assertFailsWith<ForyJsonException> { json.fromJson(text, type) }
- assertFailsWith<ForyJsonException> { json.fromJson(text.toByteArray(), type) }
+ assertEquals(value, json.fromJson(text, type))
+ assertEquals(value, json.fromJson(text.toByteArray(), type))
val deferredType = jsonTypeRef<DeferredNullableModel>()
val deferred = DeferredNullableModel(2).also { it.value = null }
diff --git a/scala/fory-json-scala/src/main/scala-2/org/apache/fory/json/scala/internal/ScalaTypeRefMacros.scala b/scala/fory-json-scala/src/main/scala-2/org/apache/fory/json/scala/internal/ScalaTypeRefMacros.scala
index 07ec36612..53a47e251 100644
--- a/scala/fory-json-scala/src/main/scala-2/org/apache/fory/json/scala/internal/ScalaTypeRefMacros.scala
+++ b/scala/fory-json-scala/src/main/scala-2/org/apache/fory/json/scala/internal/ScalaTypeRefMacros.scala
@@ -37,6 +37,11 @@ private[scala] object ScalaTypeRefMacros {
def createTypeRef(tpe: Type): Tree = {
val normalized = tpe.dealias
+ // Unit data uses BoxedUnit; classOf[Unit] is JVM void and selects the wrong codec,
+ // including when it occurs inside a generic argument or an array component.
+ if (normalized =:= definitions.UnitTpe) {
+ return q"_root_.org.apache.fory.reflect.TypeRef.of(classOf[_root_.scala.runtime.BoxedUnit])"
+ }
// Value's JVM class is shared by every Enumeration. The stable prefix, when available,
// belongs to this occurrence and must survive inside arrays and generic arguments.
normalized match {
diff --git a/scala/fory-json-scala/src/main/scala-3/org/apache/fory/json/scala/internal/ScalaTypeRefMacros.scala b/scala/fory-json-scala/src/main/scala-3/org/apache/fory/json/scala/internal/ScalaTypeRefMacros.scala
index d53e60268..b0354d402 100644
--- a/scala/fory-json-scala/src/main/scala-3/org/apache/fory/json/scala/internal/ScalaTypeRefMacros.scala
+++ b/scala/fory-json-scala/src/main/scala-3/org/apache/fory/json/scala/internal/ScalaTypeRefMacros.scala
@@ -33,6 +33,11 @@ private[scala] object ScalaTypeRefMacros {
def typeRefExpr(tpe: TypeRepr): Expr[org.apache.fory.reflect.TypeRef[?]] = {
val normalized = tpe.dealias
+ // Unit data uses BoxedUnit; classOf[Unit] is JVM void and selects the wrong codec,
+ // including when it occurs inside a generic argument or an array component.
+ if (normalized =:= TypeRepr.of[Unit]) {
+ return '{ org.apache.fory.reflect.TypeRef.of(classOf[scala.runtime.BoxedUnit]) }
+ }
val raw = normalized.classSymbol.getOrElse {
report.errorAndAbort(s"${normalized.show} has no runtime class")
}
diff --git a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaCollectionCodecs.scala b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaCollectionCodecs.scala
index dff171a83..679684687 100644
--- a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaCollectionCodecs.scala
+++ b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaCollectionCodecs.scala
@@ -540,11 +540,7 @@ private[scala] final class ScalaMapCodec(kind: Int, ownerBytes: Int, runtimeType
runtimeType
)
val keyType = if (specializedKey == null) arguments(0) else specializedKey
- val keyRawType = ScalaTypeSupport.rawType(keyType)
- val enumerationKeyCodec = ScalaEnumerationTypes.mapKeyCodec(keyType)
- keyCodec =
- if (enumerationKeyCodec == null) resolver.getMapKeyCodec(keyRawType)
- else enumerationKeyCodec
+ keyCodec = defaultKeyCodec(keyType, resolver)
val valueType = arguments(if (specializedKey == null) 1 else 0)
valueInfo = resolver.getTypeInfo(valueType, ScalaTypeSupport.rawType(valueType))
}
@@ -566,11 +562,7 @@ private[scala] final class ScalaMapCodec(kind: Int, ownerBytes: Int, runtimeType
keyCodec =
if (childCodecs.keyCodec() != classOf[JsonCodec.NoMapKeyCodec])
resolver.getMapKeyCodec(keyRawType, childCodecs.keyCodec())
- else {
- val enumerationKeyCodec = ScalaEnumerationTypes.mapKeyCodec(keyType)
- if (enumerationKeyCodec == null) resolver.getMapKeyCodec(keyRawType)
- else enumerationKeyCodec
- }
+ else defaultKeyCodec(keyType, resolver)
val valueType = arguments(if (specializedKey == null) 1 else 0)
val valueRawType = ScalaTypeSupport.rawType(valueType)
valueInfo =
@@ -579,6 +571,17 @@ private[scala] final class ScalaMapCodec(kind: Int, ownerBytes: Int, runtimeType
else resolver.getTypeInfo(valueType, valueRawType, childCodecs.valueCodec())
}
+ private def defaultKeyCodec(keyType: java.lang.reflect.Type, resolver: JsonTypeResolver): MapKeyCodec = {
+ val enumerationCodec = ScalaEnumerationTypes.mapKeyCodec(keyType)
+ if (enumerationCodec != null) return enumerationCodec
+ val rawType = ScalaTypeSupport.rawType(keyType)
+ val enumRoot = ScalaEnumCodec.enumRoot(rawType)
+ if (enumRoot != null && enumRoot == rawType) {
+ resolver.checkMapKeySecure(rawType)
+ ScalaEnumCodec.create(enumRoot, TypeRef.of(keyType))
+ } else resolver.getMapKeyCodec(rawType)
+ }
+
override def writeString(writer: StringJsonWriter, value: scala.collection.Map[Any, Any]): Unit = {
if (value == null) {
writer.writeNull()
@@ -1237,6 +1240,31 @@ private[scala] object ScalaCollectionCodecs {
val MutableAnyRefMapKind = 27
val MutableLongMapKind = 28
+ def emptyValue(kind: Int, tag: ClassTag[Any]): AnyRef = kind match {
+ case ListKind => Nil
+ case VectorKind => Vector.empty
+ case ImmutableQueueKind => scala.collection.immutable.Queue.empty
+ case ImmutableArraySeqKind => scala.collection.immutable.ArraySeq.empty[Any](tag)
+ case MutableArrayBufferKind => scala.collection.mutable.ArrayBuffer.empty
+ case MutableListBufferKind => scala.collection.mutable.ListBuffer.empty
+ case MutableArraySeqKind => scala.collection.mutable.ArraySeq.make[Any](tag.newArray(0))
+ case MutableArrayDequeKind => scala.collection.mutable.ArrayDeque.empty
+ case MutableQueueKind => scala.collection.mutable.Queue.empty
+ case ImmutableHashSetKind => scala.collection.immutable.HashSet.empty
+ case ImmutableListSetKind => scala.collection.immutable.ListSet.empty
+ case MutableHashSetKind => scala.collection.mutable.HashSet.empty
+ case MutableLinkedHashSetKind => scala.collection.mutable.LinkedHashSet.empty
+ case ImmutableHashMapKind => scala.collection.immutable.HashMap.empty
+ case ImmutableVectorMapKind => scala.collection.immutable.VectorMap.empty
+ case ImmutableListMapKind => scala.collection.immutable.ListMap.empty
+ case ImmutableIntMapKind => scala.collection.immutable.IntMap.empty
+ case ImmutableLongMapKind => scala.collection.immutable.LongMap.empty
+ case MutableHashMapKind => scala.collection.mutable.HashMap.empty
+ case MutableLinkedHashMapKind => scala.collection.mutable.LinkedHashMap.empty
+ case MutableAnyRefMapKind => scala.collection.mutable.AnyRefMap.empty[AnyRef, Any]
+ case MutableLongMapKind => scala.collection.mutable.LongMap.empty
+ }
+
def writeBooleanPair(writer: Utf8JsonWriter, first: Boolean, second: Boolean): Unit = {
val firstBytes = if (first) 0x65_7572_742cL else 0x6573_6c61_662cL
val secondBytes = if (second) 0x65_7572_742cL else 0x6573_6c61_662cL
@@ -1278,12 +1306,22 @@ private[scala] object ScalaCollectionCodecs {
add(Vector(1))
add(Vector.tabulate(33)(identity))
add(scala.collection.immutable.Queue.empty)
+ add(scala.collection.immutable.Queue(1))
add(scala.collection.immutable.ArraySeq.empty[Any])
add(scala.collection.immutable.ArraySeq.empty[Int])
add(scala.collection.mutable.ArrayBuffer.empty)
add(scala.collection.mutable.ListBuffer.empty)
add(scala.collection.mutable.ArraySeq.empty[Any])
- add(scala.collection.mutable.ArraySeq.empty[Int])
+ // ArraySeq.empty ignores its ClassTag and always uses ofRef. Register each primitive
+ // backing class explicitly without admitting application subclasses of mutable.ArraySeq.
+ add(scala.collection.mutable.ArraySeq.make(Array.emptyBooleanArray))
+ add(scala.collection.mutable.ArraySeq.make(Array.emptyByteArray))
+ add(scala.collection.mutable.ArraySeq.make(Array.emptyShortArray))
+ add(scala.collection.mutable.ArraySeq.make(Array.emptyCharArray))
+ add(scala.collection.mutable.ArraySeq.make(Array.emptyIntArray))
+ add(scala.collection.mutable.ArraySeq.make(Array.emptyLongArray))
+ add(scala.collection.mutable.ArraySeq.make(Array.emptyFloatArray))
+ add(scala.collection.mutable.ArraySeq.make(Array.emptyDoubleArray))
add(scala.collection.mutable.ArrayDeque.empty)
add(scala.collection.mutable.Queue.empty)
add(scala.collection.immutable.Set.empty)
diff --git a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaEnumCodec.scala b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaEnumCodec.scala
index 20332b38f..c2698eb10 100644
--- a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaEnumCodec.scala
+++ b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaEnumCodec.scala
@@ -24,7 +24,7 @@ import java.util.{HashMap, IdentityHashMap}
import org.apache.fory.annotation.Internal
import org.apache.fory.json.ForyJsonException
-import org.apache.fory.json.codec.{JsonValueCodec, StringEnumCodec}
+import org.apache.fory.json.codec.{MapKeyCodec, StringEnumCodec}
import org.apache.fory.json.meta.JsonAsciiToken
import org.apache.fory.json.reader.{Latin1JsonReader, Utf16JsonReader, Utf8JsonReader}
import org.apache.fory.json.writer.StringJsonWriter
@@ -59,7 +59,7 @@ private[scala] object ScalaEnumCodec {
null
}
- def create(typeClass: Class[_], typeRef: TypeRef[_]): JsonValueCodec[_] = {
+ def create(typeClass: Class[_], typeRef: TypeRef[_]): ScalaEnumCodec = {
try {
val valuesMethod = typeClass.getMethod("values")
if (!Modifier.isPublic(valuesMethod.getModifiers) || !Modifier.isStatic(valuesMethod.getModifiers)) {
@@ -80,7 +80,7 @@ private[scala] object ScalaEnumCodec {
class ScalaEnumCodec(
typeClass: Class[_], values: Array[Object], names: Array[String]
)
- extends StringEnumCodec[Object](names) {
+ extends StringEnumCodec[Object](names) with MapKeyCodec {
private val byName = new HashMap[String, Object](values.length * 2)
private val indexByValue = new IdentityHashMap[Object, Integer](values.length * 2)
private val tokenPrefixes = new Array[Long](values.length)
@@ -121,6 +121,10 @@ class ScalaEnumCodec(
value
}
+ override final def toName(key: Object): String = names(valueIndex(key))
+
+ override final def fromName(name: String): Object = value(name)
+
override def writeString(writer: StringJsonWriter, v: Object): Unit = {
if (v == null) writer.writeNull() else writer.writeString(names(valueIndex(v)))
}
diff --git a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaObjectModels.scala b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaObjectModels.scala
index 1974ced6f..592c92485 100644
--- a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaObjectModels.scala
+++ b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaObjectModels.scala
@@ -20,13 +20,16 @@
package org.apache.fory.json.scala.internal
import java.lang.reflect.{Constructor, Field, Method, Modifier}
+import java.util.function.Supplier
import org.apache.fory.json.ForyJsonException
import org.apache.fory.json.codec.{JsonObjectModel, JsonValueCodec, ObjectCodec}
import org.apache.fory.json.resolver.JsonTypeResolver
-import org.apache.fory.reflect.TypeRef
+import org.apache.fory.reflect.{ReflectionUtils, TypeRef}
private[scala] object ScalaObjectModels {
+ private val optionDefault = classOf[Option[_]].getMethod("empty")
+
def isCaseClass(typeClass: Class[_]): Boolean = {
val name = typeClass.getName
if (!classOf[Product].isAssignableFrom(typeClass) || name.startsWith("scala.Tuple")) {
@@ -39,16 +42,13 @@ private[scala] object ScalaObjectModels {
// pick a constructor.
try {
val companion = companionOwner(typeClass, committed = false)
- if (companion != null) findPrimaryConstructor(typeClass, companion) != null
+ if (companion != null && findPrimaryConstructor(typeClass, companion) != null) true
else {
- // A case class that cannot reach its companion, such as one declared inside a class or a
- // method, is still a case class. Claim it so the codec reports the exact reason instead of
- // leaving it to a generic object model that silently drops every property. A generated
+ // A case class with an unreachable companion or unsupported constructor still belongs
+ // here. Otherwise the generic object model can silently drop every property. A generated
// `copy` returning the declaring class together with a declared `productPrefix`, which
// `Product` otherwise supplies by default, is the compiler marker of a case class.
- // Standard-library types keep their own mapping. A reachable companion whose constructor
- // this module does not support, such as a varargs or non-public primary constructor, keeps
- // its previous handling.
+ // Standard-library types keep their own mapping.
!name.startsWith("scala.") && declaresCopy(typeClass) && declaresProductPrefix(typeClass)
}
} catch { case _: LinkageError => false }
@@ -117,6 +117,18 @@ private[scala] object ScalaObjectModels {
val defaultsReceiver =
if (companion.staticForwarders || defaults.forall(_ == null)) null
else companionInstance(typeRef, companion)
+ // Missing properties use type defaults; explicit constructor defaults always take precedence.
+ // Factories run only for missing arguments and never share mutable empty values between objects.
+ val defaultFactories = new Array[Supplier[_]](defaults.length)
+ index = 0
+ while (index < defaults.length) {
+ if (defaults(index) == null && logicalParameterTypes(index).getRawType == classOf[Option[_]]) {
+ defaults(index) = optionDefault
+ } else if (defaults(index) == null) {
+ defaultFactories(index) = typeDefault(logicalParameterTypes(index), parameterTypes(index))
+ }
+ index += 1
+ }
resolver.createObjectCodec(
typeRef,
new JsonObjectModel(
@@ -126,6 +138,7 @@ private[scala] object ScalaObjectModels {
accessors,
defaults,
defaultsReceiver,
+ defaultFactories,
Array.fill(names.length)(-1),
Array.fill(names.length)(true),
logicalParameterTypes,
@@ -137,6 +150,81 @@ private[scala] object ScalaObjectModels {
)
}
+ private def typeDefault(typeRef: TypeRef[_], carrier: Class[_]): Supplier[_] = {
+ val rawType = typeRef.getRawType
+ val collection = ScalaTypeCodecFactory.collectionDefault(typeRef)
+ if (collection != null) return collection
+ if (rawType.isArray) return () => java.lang.reflect.Array.newInstance(rawType.getComponentType, 0)
+ if (rawType == classOf[BigInt]) return () => BigInt(0)
+ if (rawType == classOf[BigDecimal]) return () => BigDecimal(0)
+ if (rawType == classOf[java.math.BigInteger]) return () => java.math.BigInteger.ZERO
+ if (rawType == classOf[java.math.BigDecimal]) return () => java.math.BigDecimal.ZERO
+ if (rawType == classOf[scala.runtime.BoxedUnit]) return () => scala.runtime.BoxedUnit.UNIT
+ if (rawType == classOf[java.util.EnumSet[_]]) {
+ val element = ScalaTypeSupport.rawType(ScalaTypeSupport.arguments(typeRef, 1, "EnumSet")(0))
+ return () => java.util.EnumSet.noneOf[Nothing](element.asInstanceOf[Class[Nothing]])
+ }
+ if (rawType == classOf[java.util.EnumMap[_, _]]) {
+ val key = ScalaTypeSupport.rawType(ScalaTypeSupport.arguments(typeRef, 2, "EnumMap")(0))
+ return () => new java.util.EnumMap[Nothing, Any](key.asInstanceOf[Class[Nothing]])
+ }
+ if (classOf[java.util.Map[_, _]].isAssignableFrom(rawType)) {
+ if (rawType.isAssignableFrom(classOf[java.util.LinkedHashMap[_, _]]))
+ return () => new java.util.LinkedHashMap[AnyRef, AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.TreeMap[_, _]]))
+ return () => new java.util.TreeMap[AnyRef, AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.concurrent.ConcurrentHashMap[_, _]]))
+ return () => new java.util.concurrent.ConcurrentHashMap[AnyRef, AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.concurrent.ConcurrentSkipListMap[_, _]]))
+ return () => new java.util.concurrent.ConcurrentSkipListMap[AnyRef, AnyRef]()
+ }
+ if (classOf[java.util.Collection[_]].isAssignableFrom(rawType)) {
+ if (rawType.isAssignableFrom(classOf[java.util.ArrayList[_]]))
+ return () => new java.util.ArrayList[AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.LinkedHashSet[_]]))
+ return () => new java.util.LinkedHashSet[AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.TreeSet[_]]))
+ return () => new java.util.TreeSet[AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.LinkedList[_]]))
+ return () => new java.util.LinkedList[AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.ArrayDeque[_]]))
+ return () => new java.util.ArrayDeque[AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.concurrent.LinkedBlockingDeque[_]]))
+ return () => new java.util.concurrent.LinkedBlockingDeque[AnyRef]()
+ if (rawType.isAssignableFrom(classOf[java.util.concurrent.LinkedBlockingQueue[_]]))
+ return () => new java.util.concurrent.LinkedBlockingQueue[AnyRef]()
+ }
+ if (
+ classOf[java.util.Collection[_]].isAssignableFrom(rawType) ||
+ classOf[java.util.Map[_, _]].isAssignableFrom(rawType)
+ ) {
+ // Only types with the ordinary collection constructor can use this construction rule.
+ // Custom collection codecs must remain usable even when their type has no such constructor.
+ if (rawType.getConstructors.exists(_.getParameterCount == 0)) {
+ val constructor = ReflectionUtils.getCtrHandle(rawType)
+ return () => constructor.invoke().asInstanceOf[AnyRef]
+ }
+ }
+ val primitive = if (carrier.isPrimitive) carrier else rawType
+ if (primitive == java.lang.Boolean.TYPE || primitive == classOf[java.lang.Boolean])
+ () => java.lang.Boolean.FALSE
+ else if (primitive == java.lang.Byte.TYPE || primitive == classOf[java.lang.Byte])
+ () => java.lang.Byte.valueOf(0.toByte)
+ else if (primitive == java.lang.Short.TYPE || primitive == classOf[java.lang.Short])
+ () => java.lang.Short.valueOf(0.toShort)
+ else if (primitive == java.lang.Integer.TYPE || primitive == classOf[java.lang.Integer])
+ () => java.lang.Integer.valueOf(0)
+ else if (primitive == java.lang.Long.TYPE || primitive == classOf[java.lang.Long])
+ () => java.lang.Long.valueOf(0L)
+ else if (primitive == java.lang.Float.TYPE || primitive == classOf[java.lang.Float])
+ () => java.lang.Float.valueOf(0F)
+ else if (primitive == java.lang.Double.TYPE || primitive == classOf[java.lang.Double])
+ () => java.lang.Double.valueOf(0D)
+ else if (primitive == java.lang.Character.TYPE || primitive == classOf[java.lang.Character])
+ () => java.lang.Character.valueOf(0.toChar)
+ else () => null
+ }
+
def singletonCodec(
typeRef: TypeRef[_],
resolver: JsonTypeResolver
@@ -212,7 +300,8 @@ private[scala] object ScalaObjectModels {
}
private def declaresCopy(typeClass: Class[_]): Boolean = {
- typeClass.getMethods.exists(method =>
+ // Scala 3 keeps copy private when the primary constructor is private.
+ typeClass.getDeclaredMethods.exists(method =>
method.getName == "copy" && !Modifier.isStatic(method.getModifiers) &&
!method.isBridge && !method.isSynthetic && method.getReturnType == typeClass
)
diff --git a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaScalarCodecs.scala b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaScalarCodecs.scala
index 0447e6d04..298870274 100644
--- a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaScalarCodecs.scala
+++ b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaScalarCodecs.scala
@@ -199,7 +199,8 @@ private[scala] final class ScalaDurationCodec(finiteOnly: Boolean)
case scala.concurrent.duration.Duration.MinusInf =>
writer.writeFieldName("special")
writer.writeString("MINUS_INF")
- case scala.concurrent.duration.Duration.Undefined =>
+ // Undefined.equals is always false, so a stable-identifier pattern fails on Scala 2.
+ case undefined if undefined eq scala.concurrent.duration.Duration.Undefined =>
writer.writeFieldName("special")
writer.writeString("UNDEFINED")
case _ => throw invalidDuration()
diff --git a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTupleCodec.scala b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTupleCodec.scala
index 8093409f1..ded8a56e9 100644
--- a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTupleCodec.scala
+++ b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTupleCodec.scala
@@ -445,7 +445,7 @@ private[scala] final class ScalaTupleCodec(arity: Int, tupleType: Class[_], runt
}
private def requireArity(value: Product): Unit = {
- if (value.getClass != tupleType || value.productArity != arity) throw invalidArity()
+ if (!tupleType.isInstance(value) || value.productArity != arity) throw invalidArity()
}
private def invalidArity(): ForyJsonException =
@@ -456,6 +456,9 @@ private[scala] object ScalaTupleCodec {
def arity(rawType: Class[_]): Int = {
val name = rawType.getName
if (!name.startsWith("scala.Tuple")) return -1
+ // Tuple1 and Tuple2 use specialized subclasses for primitive combinations. Their
+ // superclass owns the tuple schema; the specialization only changes the JVM carrier.
+ if (name.endsWith("$sp") && rawType.getSuperclass != null) return arity(rawType.getSuperclass)
val suffix = name.substring("scala.Tuple".length)
if (suffix.isEmpty || !suffix.forall(_.isDigit)) return -1
val value = suffix.toInt
diff --git a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTypeCodecFactory.scala b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTypeCodecFactory.scala
index f393cd8ab..9f34fa167 100644
--- a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTypeCodecFactory.scala
+++ b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTypeCodecFactory.scala
@@ -20,6 +20,7 @@
package org.apache.fory.json.scala.internal
import java.lang.reflect.Modifier
+import java.util.function.Supplier
import org.apache.fory.json.JsonCodecFactory
import org.apache.fory.json.codec.JsonValueCodec
@@ -159,6 +160,26 @@ private[scala] object ScalaTypeCodecFactory extends JsonCodecFactory {
}
}
+ def collectionDefault(typeRef: TypeRef[_]): Supplier[_] = {
+ val rawType = typeRef.getRawType
+ if (rawType == classOf[List[_]] || rawType == Nil.getClass) return () => Nil
+ if (rawType == classOf[scala.collection.immutable.BitSet])
+ return () => scala.collection.immutable.BitSet.empty
+ if (rawType == classOf[scala.collection.mutable.BitSet])
+ return () => scala.collection.mutable.BitSet.empty
+ val selection =
+ if (classOf[scala.collection.Map[_, _]].isAssignableFrom(rawType)) mapKind(rawType, false)
+ else if (classOf[scala.collection.Iterable[_]].isAssignableFrom(rawType)) iterableKind(rawType, false)
+ else null
+ if (selection == null) return null
+ val kind = selection._1
+ val tag =
+ if (kind == ScalaCollectionCodecs.ImmutableArraySeqKind || kind == ScalaCollectionCodecs.MutableArraySeqKind)
+ ScalaTypeSupport.classTag(ScalaTypeSupport.rawType(ScalaTypeSupport.arguments(typeRef, 1, "Scala collection")(0)))
+ else null
+ () => ScalaCollectionCodecs.emptyValue(kind, tag)
+ }
+
private def iterableKind(rawType: Class[_], runtimeWrite: Boolean): (Int, Class[_]) = {
import ScalaCollectionCodecs._
diff --git a/scala/fory-json-scala/src/test/scala-3/org/apache/fory/json/scala/ScalaJsonDerivationSuite.scala b/scala/fory-json-scala/src/test/scala-3/org/apache/fory/json/scala/ScalaJsonDerivationSuite.scala
index 8009e387b..fcae5f728 100644
--- a/scala/fory-json-scala/src/test/scala-3/org/apache/fory/json/scala/ScalaJsonDerivationSuite.scala
+++ b/scala/fory-json-scala/src/test/scala-3/org/apache/fory/json/scala/ScalaJsonDerivationSuite.scala
@@ -79,6 +79,19 @@ enum Color {
case Red, Blue
}
+object Palette {
+ enum Shade {
+ case Light, Dark
+ }
+}
+
+case class EnumFields(name: String, color: Color, shade: Palette.Shade, colors: Map[Color, String])
+
+case class EnumMapValues(
+ @org.apache.fory.json.annotation.JsonCodec(valueCodec = classOf[TaggedStringCodec])
+ colors: Map[Color, String]
+)
+
enum DisplayColor {
case Red, Blue
@@ -268,11 +281,35 @@ class ScalaJsonDerivationSuite extends AnyFunSuite {
}
test("parameterless enum uses its declared root") {
- val json = ForyJsonScala.builder().withCodegen(false).build()
- assert(json.toJson(Color.Blue) == "\"Blue\"")
- assert(json.fromJson("\"Blue\"", classOf[Color]) == Color.Blue)
- assert(json.toJson(DisplayColor.Red) == "\"Red\"")
- assert(json.fromJson("\"Blue\"", classOf[DisplayColor]) == DisplayColor.Blue)
+ for (json <- Seq(
+ ForyJsonScala.builder().withCodegen(false).build(),
+ ForyJsonScala.builder().withAsyncCompilation(false).build()
+ )) {
+ assert(json.toJson(Color.Blue) == "\"Blue\"")
+ assert(json.fromJson("\"Blue\"", classOf[Color]) == Color.Blue)
+ assert(json.toJson(DisplayColor.Red) == "\"Red\"")
+ assert(json.fromJson("\"Blue\"", classOf[DisplayColor]) == DisplayColor.Blue)
+ assertThrows[ForyJsonException](json.fromJson("\"Green\"", classOf[Color]))
+ assert(json.fromJson("\"Dark\"", classOf[Palette.Shade]) == Palette.Shade.Dark)
+ val value = EnumFields("中", Color.Red, Palette.Shade.Dark, Map(Color.Blue -> "蓝"))
+ assert(json.fromJson(json.toJson(value), classOf[EnumFields]) == value)
+ assert(json.fromJson(json.toJsonBytes(value), classOf[EnumFields]) == value)
+ val mapType = ScalaTypeRef[Map[DisplayColor, String]]
+ val map = Map(DisplayColor.Red -> "red")
+ assert(json.toJson(map, mapType) == """{"Red":"red"}""")
+ assert(json.fromJson("""{"Red":"red"}""", mapType) == map)
+ val tagged = EnumMapValues(Map(Color.Blue -> "blue"))
+ val taggedText = """{"colors":{"Blue":"tag:blue"}}"""
+ assert(json.toJson(tagged) == taggedText)
+ assert(json.fromJson(taggedText, classOf[EnumMapValues]) == tagged)
+ assert(json.fromJson(json.toJsonBytes(tagged), classOf[EnumMapValues]) == tagged)
+ assertThrows[ForyJsonException](json.fromJson(
+ """{"colors":{"Green":"unknown"}}""", classOf[EnumFields]))
+ }
+ val denied = ForyJsonScala.builder()
+ .withTypeChecker((name, _) => name != classOf[Color].getName).build()
+ assertThrows[org.apache.fory.exception.InsecureException](
+ denied.fromJson("{}", ScalaTypeRef[Map[Color, String]]))
}
test("derived sealed hierarchy uses inferred subtype names") {
diff --git a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
index 61cd125a8..2dd712816 100644
--- a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
+++ b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
@@ -70,6 +70,8 @@ case class BodyState(id: Int) {
case class CurriedDefault(a: Int)(val b: Int = a + 1)
+case class MissingUnwrapped(@JsonUnwrapped point: NestedModels.Point)
+
case class UnwrappedDetails(code: Int = 5) {
var note: String = "default-note"
}
@@ -88,6 +90,12 @@ object NestedModels {
case class Span(from: Int)(val to: Int = from + 1)
+ case class Optional(value: Option[String], fallback: Option[String] = Some("default"))
+
+ case class OptionalOnly(value: Option[String])
+
+ case class OptionalDefault(value: Option[String])(val selected: String = value.getOrElse("default"))
+
case class UnwrappedNested(code: Int = 5) {
var note: String = "default-note"
}
@@ -142,6 +150,8 @@ case class LongStringValues(
case class UnitValue(value: Unit)
+case class UnitFields(value: Unit, option: Option[Unit], values: List[Unit], array: Array[Unit])
+
case class ExplicitNullable(
@JsonProperty(include = JsonProperty.Include.ALWAYS) value: String
)
@@ -262,18 +272,67 @@ case class CodecSlots(
case class Label(value: String) extends AnyVal
+case class TypedLabel[T](value: T) extends AnyVal
+
case class Box[T](value: T)
+case class NamedBox[T](name: String, value: T)
+
case class Boxes(ints: Box[Int], strings: Box[String])
case class VarParams(var a: Int, var b: String = "b")
+case class PrivateConstructor private(value: Int)
+
+case class SecondaryConstructor(value: Int) {
+ def this(value: String) = this(value.toInt)
+}
+
+case class AmbiguousConstructor(value: Int) {
+ def this(value: String) = this(value.toInt)
+}
+
+object AmbiguousConstructor {
+ def apply(value: String): AmbiguousConstructor = new AmbiguousConstructor(value)
+}
+
+trait NamedValue {
+ val name: String
+}
+
+case class InheritedValue(name: String) extends NamedValue
+
+class CustomBuffer extends scala.collection.mutable.ArrayBuffer[Int]
+
+case class AlgebraicFields[T](
+ name: String,
+ queue: scala.collection.immutable.Queue[T],
+ arraySeq: scala.collection.mutable.ArraySeq[T],
+ pair: (T, T),
+ option: Option[T],
+ either: Either[String, T],
+ range: NumericRange[T]
+)
+
case class Optionals(
a: Option[Int],
b: Option[String] = None,
c: Option[Option[Int]] = None
)
+case class OptionalFields(
+ value: Option[String],
+ fallback: Option[Int] = Some(7),
+ nested: Option[Option[Int]],
+ @JsonIgnore ignored: Option[String]
+)
+
+case class MissingValues(
+ byte: Byte, short: Short, int: Int, long: Long, float: Float, double: Double,
+ boolean: Boolean, char: Char, boxed: java.lang.Integer, big: BigInt, decimal: BigDecimal,
+ text: String, child: Node, @JsonIgnore ignored: Int, explicit: Int = 9
+)
+
case class ScalarFields(
decimal: BigDecimal,
builder: scala.collection.mutable.StringBuilder,
@@ -524,20 +583,19 @@ class ScalaJsonSuite extends AnyFunSuite {
assert(error.getMessage.contains("companion is not reachable"))
}
- test("required constructor values cannot be omitted as null") {
+ test("missing reference values use null") {
for (json <- Seq(
ForyJsonScala.builder().withCodegen(false).build(),
ForyJsonScala.builder().withAsyncCompilation(false).build()
)) {
- assertThrows[org.apache.fory.json.ForyJsonException] {
- json.toJson(NullableRequired(null))
- }
+ assert(json.toJson(NullableRequired(null)) == "{}")
+ assert(json.fromJson("{}", classOf[NullableRequired]) == NullableRequired(null))
assert(json.toJson(ExplicitNullable(null)) == "{\"value\":null}")
assert(json.fromJson("{\"value\":null}", classOf[ExplicitNullable]) == ExplicitNullable(null))
}
}
- test("required constructor values retain empty properties") {
+ test("constructor values follow empty inclusion") {
for (codegen <- Seq(false, true)) {
val json = ForyJsonScala.builder()
.withCodegen(codegen)
@@ -546,13 +604,14 @@ class ScalaJsonSuite extends AnyFunSuite {
.build()
val value = EmptyRequired("", new java.util.ArrayList[String](), Array.emptyIntArray)
val text = json.toJson(value)
- assert(text == "{\"value\":\"\",\"items\":[],\"numbers\":[]}")
+ assert(text == "{}")
assert(new String(json.toJsonBytes(value), UTF_8) == text)
val decoded = json.fromJson(text, classOf[EmptyRequired])
- assert(decoded.value == "")
+ assert(decoded.value == null)
assert(decoded.items.isEmpty)
assert(decoded.numbers.isEmpty)
- assertThrows[ForyJsonException](json.toJson(ExplicitEmptyRequired("")))
+ assert(json.toJson(ExplicitEmptyRequired("")) == "{}")
+ assert(json.fromJson("{}", classOf[ExplicitEmptyRequired]) == ExplicitEmptyRequired(null))
assert(json.toJson(EmptyDefault()) == "{}")
assert(json.fromJson("{}", classOf[EmptyDefault]) == EmptyDefault())
}
@@ -1418,6 +1477,8 @@ class ScalaJsonSuite extends AnyFunSuite {
for (json <- runtimes) {
assert(json.toJson(Duration.Inf, durationType) == """{"special":"INF"}""")
assert(json.toJson(Duration.MinusInf, durationType) == """{"special":"MINUS_INF"}""")
+ assert(json.toJson(Duration.Undefined, durationType) == """{"special":"UNDEFINED"}""")
+ assert(new String(json.toJsonBytes(Duration.Undefined, durationType), UTF_8) == """{"special":"UNDEFINED"}""")
assert(json.fromJson("""{"special":"INF"}""", durationType) eq Duration.Inf)
assert(json.fromJson("""{"special":"MINUS_INF"}""".getBytes(UTF_8), durationType) eq Duration.MinusInf)
assert(json.fromJson("""{"special":"UNDEFINED"}""", durationType) eq Duration.Undefined)
@@ -1433,6 +1494,11 @@ class ScalaJsonSuite extends AnyFunSuite {
}
val extreme = FiniteDuration(Long.MaxValue, TimeUnit.NANOSECONDS)
assert(json.fromJson(json.toJson(extreme, finiteType), finiteType) == extreme)
+ val negative = FiniteDuration(-Long.MaxValue, TimeUnit.NANOSECONDS)
+ assert(json.fromJson(json.toJsonBytes(negative, finiteType), finiteType) == negative)
+ // FiniteDuration's supported range excludes Long.MinValue nanoseconds.
+ assertThrows[RuntimeException](json.fromJson(
+ """{"length":-9223372036854775808,"unit":"NANOSECONDS"}""", finiteType))
assert(json.fromJson("null", finiteType) == null)
assert(json.toJson(null.asInstanceOf[Duration], durationType) == "null")
@@ -1531,6 +1597,7 @@ class ScalaJsonSuite extends AnyFunSuite {
test("declared collection kinds") {
import scala.collection.{immutable => im, mutable => mu}
val cases: Seq[(TypeRef[_ <: scala.collection.Iterable[Int]], Class[_])] = Seq(
+ (ScalaTypeRef[im.Queue[Int]], classOf[im.Queue[_]]),
(ScalaTypeRef[mu.ArrayBuffer[Int]], classOf[mu.ArrayBuffer[_]]),
(ScalaTypeRef[mu.ListBuffer[Int]], classOf[mu.ListBuffer[_]]),
(ScalaTypeRef[mu.ArrayDeque[Int]], classOf[mu.ArrayDeque[_]]),
@@ -1554,6 +1621,8 @@ class ScalaJsonSuite extends AnyFunSuite {
if (!decoded.isInstanceOf[scala.collection.Set[_]]) assert(decoded.toList == List(3, 1, 2))
assert(json.fromJson("[]", anyRef).isEmpty)
val encoded = json.toJson(decoded, anyRef)
+ assert(new String(json.toJsonBytes(decoded, anyRef), UTF_8) == encoded)
+ assert(json.toJson(decoded) == encoded)
assert(json.fromJson(encoded.getBytes(UTF_8), anyRef) == decoded, s"$typeRef")
assert(json.fromJson("null", anyRef) == null)
}
@@ -1592,6 +1661,8 @@ class ScalaJsonSuite extends AnyFunSuite {
}
assert(json.fromJson("{}", anyRef).isEmpty)
val encoded = json.toJson(decoded, anyRef)
+ assert(new String(json.toJsonBytes(decoded, anyRef), UTF_8) == encoded)
+ assert(json.toJson(decoded) == encoded)
assert(json.fromJson(encoded.getBytes(UTF_8), anyRef) == decoded, s"$typeRef")
assert(json.fromJson("null", anyRef) == null)
assertThrows[ForyJsonException](json.fromJson("[1]", anyRef))
@@ -1610,9 +1681,33 @@ class ScalaJsonSuite extends AnyFunSuite {
assert(roundTrip(json, List("a", null, "b"), nullableList) == List("a", null, "b"))
}
+ test("primitive mutable ArraySeq") {
+ import scala.collection.mutable.ArraySeq
+ val cases: Seq[(AnyRef, TypeRef[_], String)] = Seq(
+ (ArraySeq[Boolean](true, false), ScalaTypeRef[ArraySeq[Boolean]], "[true,false]"),
+ (ArraySeq[Byte](1, 2), ScalaTypeRef[ArraySeq[Byte]], "[1,2]"),
+ (ArraySeq[Short](1, 2), ScalaTypeRef[ArraySeq[Short]], "[1,2]"),
+ (ArraySeq[Char]('a', '中'), ScalaTypeRef[ArraySeq[Char]], """["a","中"]"""),
+ (ArraySeq[Int](1, 2), ScalaTypeRef[ArraySeq[Int]], "[1,2]"),
+ (ArraySeq[Long](1, 2), ScalaTypeRef[ArraySeq[Long]], "[1,2]"),
+ (ArraySeq[Float](1.5f), ScalaTypeRef[ArraySeq[Float]], "[1.5]"),
+ (ArraySeq[Double](1.5), ScalaTypeRef[ArraySeq[Double]], "[1.5]"),
+ (ArraySeq[String]("a"), ScalaTypeRef[ArraySeq[String]], """["a"]""")
+ )
+ for (json <- runtimes; (value, declared, expected) <- cases) {
+ val typeRef = declared.asInstanceOf[TypeRef[AnyRef]]
+ assert(roundTrip(json, value, typeRef) == value)
+ assert(json.toJson(value, typeRef) == expected)
+ assert(json.toJson(value) == expected)
+ assert(new String(json.toJsonBytes(value), UTF_8) == expected)
+ }
+ }
+
test("runtime collection writes and unsupported families") {
import scala.collection.{immutable => im, mutable => mu}
val json = runtimes.head
+ assert(json.toJson(im.Queue(1, 2)) == "[1,2]")
+ assert(new String(json.toJsonBytes(im.Queue(1, 2)), UTF_8) == "[1,2]")
assert(json.toJson(mu.ArrayBuffer(1, 2)) == "[1,2]")
assert(json.toJson(mu.ListBuffer(1, 2)) == "[1,2]")
assert(json.toJson(mu.ArrayDeque(1, 2)) == "[1,2]")
@@ -1629,6 +1724,10 @@ class ScalaJsonSuite extends AnyFunSuite {
assertThrows[ForyJsonException](json.toJson(im.TreeSet(1, 2)))
assertThrows[ForyJsonException](json.toJson(im.TreeMap("a" -> 1)))
assertThrows[ForyJsonException](json.toJson(mu.TreeSet(1, 2)))
+ val custom = new CustomBuffer
+ custom += 1
+ assertThrows[ForyJsonException](json.toJson(custom))
+ assertThrows[ForyJsonException](json.toJson(custom, ScalaTypeRef[mu.ArrayBuffer[Int]]))
assertThrows[ForyJsonException](json.fromJson("[1]", ScalaTypeRef[im.TreeSet[Int]]))
assertThrows[ForyJsonException](json.fromJson("""{"a":1}""", ScalaTypeRef[im.SortedMap[String, Int]]))
assertThrows[ForyJsonException](json.fromJson("[1]", ScalaTypeRef[im.SortedSet[Int]]))
@@ -1675,6 +1774,9 @@ class ScalaJsonSuite extends AnyFunSuite {
json.fromJson("null", typeRef.asInstanceOf[TypeRef[AnyRef]])
}
assert(error.getMessage.contains("runtime-state or lazy type"), s"$typeRef: ${error.getMessage}")
+ val fieldType = TypeRef.ofDeclaredTypeArguments(
+ classOf[Box[_]], null, java.util.Collections.singletonList(typeRef), null)
+ assertThrows[UnsupportedJsonTypeException](json.fromJson("{}", fieldType))
}
assertThrows[UnsupportedJsonTypeException](json.toJson(scala.util.Success(1)))
assertThrows[UnsupportedJsonTypeException](json.toJson(Symbol("x")))
@@ -1691,7 +1793,8 @@ class ScalaJsonSuite extends AnyFunSuite {
assert(json.toJson(Optionals(Some(1))) == """{"a":1,"b":null,"c":null}""")
assert(json.fromJson("""{"a":1}""", classOf[Optionals]) == Optionals(Some(1)))
assert(json.fromJson("""{"a":null}""", classOf[Optionals]) == Optionals(None))
- assertThrows[ForyJsonException](json.fromJson("{}", classOf[Optionals]))
+ assert(json.fromJson("{}", classOf[Optionals]) == Optionals(None))
+ assert(json.fromJson("{}".getBytes(UTF_8), classOf[Optionals]) == Optionals(None))
assert(
json.fromJson("""{"a":1,"b":"x","c":2}""", classOf[Optionals]) ==
Optionals(Some(1), Some("x"), Some(Some(2)))
@@ -1708,6 +1811,97 @@ class ScalaJsonSuite extends AnyFunSuite {
}
}
+ test("Option constructor defaults") {
+ for (json <- runtimes) {
+ val empty = OptionalFields(None, Some(7), None, None)
+ for (text <- Seq("{}", """{"unknown":"中"}""")) {
+ assert(json.fromJson(text, classOf[OptionalFields]) == empty)
+ assert(json.fromJson(text.getBytes(UTF_8), classOf[OptionalFields]) == empty)
+ assert(json.fromJson(text, classOf[NestedModels.Optional]) == NestedModels.Optional(None))
+ assert(
+ json.fromJson(text.getBytes(UTF_8), classOf[NestedModels.Optional]) ==
+ NestedModels.Optional(None))
+ assert(
+ json.fromJson(text, classOf[NestedModels.OptionalOnly]) == NestedModels.OptionalOnly(None))
+ assert(json.fromJson(text, classOf[NestedModels.OptionalDefault]).selected == "default")
+ }
+ val present = """{"value":"中","fallback":null,"nested":2}"""
+ val expected = OptionalFields(Some("中"), None, Some(Some(2)), None)
+ assert(json.fromJson(present, classOf[OptionalFields]) == expected)
+ assert(json.fromJson(present.getBytes(UTF_8), classOf[OptionalFields]) == expected)
+ assert(
+ json.fromJson("""{"value":"present"}""", classOf[NestedModels.OptionalDefault]).selected ==
+ "present")
+ val boxType = ScalaTypeRef[Box[Option[String]]]
+ assert(json.fromJson("{}", boxType) == Box(None))
+ assert(json.fromJson("{}".getBytes(UTF_8), boxType) == Box(None))
+ assert(json.fromJson("""{"value":1}""", classOf[Node]) == Node(1, None))
+ assert(json.fromJson("{}", classOf[Node]) == Node(0, None))
+ assert(json.fromJson(json.toJson(empty), classOf[OptionalFields]) == empty)
+ }
+ }
+
+ test("missing constructor values use type defaults") {
+ for (json <- runtimes; text <- Seq("{}", """{"unknown":"中"}""")) {
+ val expected = MissingValues(0, 0, 0, 0L, 0F, 0D, false, 0.toChar, 0, BigInt(0), BigDecimal(0), null, null, 0)
+ assert(json.fromJson(text, classOf[MissingValues]) == expected)
+ assert(json.fromJson(text.getBytes(UTF_8), classOf[MissingValues]) == expected)
+ assert(json.fromJson(text, classOf[CurriedDefault]).b == 1)
+ assert(json.fromJson(text, classOf[NestedModels.Span]).to == 1)
+ assert(json.fromJson(text, classOf[BodyState]).id == 0)
+ val numbers = json.fromJson(text, ScalaTypeRef[Box[Array[Int]]]).value
+ assert(numbers.isEmpty)
+ val objects = json.fromJson(text, ScalaTypeRef[Box[Array[Node]]]).value
+ assert(objects.isEmpty)
+ assert(json.fromJson(text, classOf[MissingUnwrapped]) == MissingUnwrapped(null))
+ val flattened = """{"x":2,"unknown":"中"}"""
+ val point = MissingUnwrapped(NestedModels.Point(2, null))
+ assert(json.fromJson(flattened, classOf[MissingUnwrapped]) == point)
+ assert(json.fromJson(flattened.getBytes(UTF_8), classOf[MissingUnwrapped]) == point)
+ }
+ }
+
+ test("missing collections use independent empty values") {
+ import scala.collection.{immutable => im, mutable => mu}
+ val types = Seq[TypeRef[_]](
+ ScalaTypeRef[Box[List[String]]], ScalaTypeRef[Box[Vector[String]]],
+ ScalaTypeRef[Box[Seq[String]]], ScalaTypeRef[Box[im.Queue[String]]],
+ ScalaTypeRef[Box[im.ArraySeq[String]]], ScalaTypeRef[Box[im.HashSet[String]]],
+ ScalaTypeRef[Box[im.ListSet[String]]], ScalaTypeRef[Box[im.BitSet]],
+ ScalaTypeRef[Box[mu.ArrayBuffer[String]]], ScalaTypeRef[Box[mu.ListBuffer[String]]],
+ ScalaTypeRef[Box[mu.ArraySeq[String]]], ScalaTypeRef[Box[mu.ArrayDeque[String]]],
+ ScalaTypeRef[Box[mu.Queue[String]]], ScalaTypeRef[Box[mu.HashSet[String]]],
+ ScalaTypeRef[Box[mu.LinkedHashSet[String]]], ScalaTypeRef[Box[mu.BitSet]],
+ ScalaTypeRef[Box[Map[String, String]]], ScalaTypeRef[Box[im.VectorMap[String, String]]],
+ ScalaTypeRef[Box[im.ListMap[String, String]]], ScalaTypeRef[Box[im.IntMap[String]]],
+ ScalaTypeRef[Box[im.LongMap[String]]], ScalaTypeRef[Box[mu.HashMap[String, String]]],
+ ScalaTypeRef[Box[mu.LinkedHashMap[String, String]]],
+ ScalaTypeRef[Box[mu.AnyRefMap[String, String]]], ScalaTypeRef[Box[mu.LongMap[String]]]
+ )
+ for (json <- runtimes) {
+ for (kind <- types) {
+ val first = json.fromJson("{}", kind).asInstanceOf[Box[scala.collection.Iterable[_]]].value
+ val second = json.fromJson("{}".getBytes(UTF_8), kind).asInstanceOf[Box[scala.collection.Iterable[_]]].value
+ assert(first.isEmpty && second.isEmpty)
+ if (first.isInstanceOf[mu.Iterable[_]]) assert(first ne second)
+ }
+ val kind = ScalaTypeRef[Box[mu.ArrayBuffer[String]]]
+ val first = json.fromJson("{}", kind).value
+ first += "changed"
+ assert(json.fromJson("{}", kind).value.isEmpty)
+ val queue = json.fromJson("{}", ScalaTypeRef[Box[java.util.PriorityQueue[String]]]).value
+ assert(queue.isEmpty)
+ val mapType = ScalaTypeRef[Box[java.util.concurrent.ConcurrentMap[String, String]]]
+ val map = json.fromJson("{}", mapType).value
+ map.put("key", "changed")
+ assert(json.fromJson("{}", mapType).value.isEmpty)
+ val enumSet = ScalaTypeRef[Box[java.util.EnumSet[java.util.concurrent.TimeUnit]]]
+ val enumMap = ScalaTypeRef[Box[java.util.EnumMap[java.util.concurrent.TimeUnit, String]]]
+ assert(json.fromJson("{}", enumSet).value.isEmpty)
+ assert(json.fromJson("{}", enumMap).value.isEmpty)
+ }
+ }
+
test("tuple shapes") {
for (json <- runtimes) {
val tuple1Type = ScalaTypeRef[Tuple1[String]]
@@ -1752,17 +1946,121 @@ class ScalaJsonSuite extends AnyFunSuite {
assert(roundTrip(json, Map("k" -> Label("v")), mapType) == Map("k" -> Label("v")))
val optionType = ScalaTypeRef[Option[Label]]
assert(roundTrip(json, Some(Label("v")): Option[Label], optionType) == Some(Label("v")))
+ val genericType = ScalaTypeRef[TypedLabel[String]]
+ assert(roundTrip(json, TypedLabel("中"), genericType) == TypedLabel("中"))
+ assertThrows[UnsupportedJsonTypeException](json.fromJson("null", classOf[TypedLabel[_]]))
+ }
+ }
+
+ test("specialized tuples") {
+ val cases: Seq[(Product, TypeRef[_], String)] = Seq(
+ (Tuple1(1), ScalaTypeRef[Tuple1[Int]], "[1]"),
+ ((1, 2), ScalaTypeRef[(Int, Int)], "[1,2]"),
+ ((1L, 2.5), ScalaTypeRef[(Long, Double)], "[1,2.5]"),
+ ((true, '中'), ScalaTypeRef[(Boolean, Char)], """[true,"中"]""")
+ )
+ for (json <- runtimes; (value, declared, expected) <- cases) {
+ val typeRef = declared.asInstanceOf[TypeRef[Product]]
+ assert(roundTrip(json, value, typeRef) == value)
+ assert(json.toJson(value, typeRef) == expected)
+ assert(json.toJson(value) == expected)
+ assert(new String(json.toJsonBytes(value), UTF_8) == expected)
}
}
- test("Unit and singletons") {
+ test("primitive generic case classes") {
+ val cases: Seq[(Any, TypeRef[_], TypeRef[_])] = Seq(
+ (true, ScalaTypeRef[Box[Boolean]], ScalaTypeRef[NamedBox[Boolean]]),
+ (1.toByte, ScalaTypeRef[Box[Byte]], ScalaTypeRef[NamedBox[Byte]]),
+ (2.toShort, ScalaTypeRef[Box[Short]], ScalaTypeRef[NamedBox[Short]]),
+ ('中', ScalaTypeRef[Box[Char]], ScalaTypeRef[NamedBox[Char]]),
+ (3, ScalaTypeRef[Box[Int]], ScalaTypeRef[NamedBox[Int]]),
+ (4L, ScalaTypeRef[Box[Long]], ScalaTypeRef[NamedBox[Long]]),
+ (1.5f, ScalaTypeRef[Box[Float]], ScalaTypeRef[NamedBox[Float]]),
+ (2.5, ScalaTypeRef[Box[Double]], ScalaTypeRef[NamedBox[Double]])
+ )
+ for (json <- runtimes; (value, boxType, namedType) <- cases) {
+ assert(roundTrip(json, Box(value), boxType.asInstanceOf[TypeRef[Box[Any]]]) == Box(value))
+ val named = NamedBox("中", value)
+ assert(roundTrip(json, named, namedType.asInstanceOf[TypeRef[NamedBox[Any]]]) == named)
+ }
+ }
+
+ test("nested generic case classes") {
+ val typeRef = ScalaTypeRef[Box[Box[Option[Int]]]]
+ val value = Box(Box(Some(1): Option[Int]))
for (json <- runtimes) {
- val unitType = classOf[scala.runtime.BoxedUnit]
- assert(json.toJson(scala.runtime.BoxedUnit.UNIT, unitType) == "null")
- assert(json.fromJson("null", unitType) != null)
+ assert(roundTrip(json, value, typeRef) == value)
+ assert(json.toJson(value, typeRef) == """{"value":{"value":1}}""")
+ }
+ }
+
+ test("Unit type tokens") {
+ type Completion = Unit
+ val unitType = ScalaTypeRef[Completion]
+ assert(unitType.getRawType == classOf[scala.runtime.BoxedUnit])
+ for (json <- runtimes) {
+ assert(json.toJson((), unitType) == "null")
+ assert(new String(json.toJsonBytes((), unitType), UTF_8) == "null")
+ json.fromJson("null", unitType)
+ val boxedType = unitType.asInstanceOf[TypeRef[AnyRef]]
+ assert(json.fromJson("null", boxedType) eq scala.runtime.BoxedUnit.UNIT)
+ assert(json.fromJson("null".getBytes(UTF_8), boxedType) eq scala.runtime.BoxedUnit.UNIT)
assertThrows[ForyJsonException](json.fromJson("1", unitType))
+ assertThrows[IllegalArgumentException](json.toJson((), classOf[Unit]))
+ }
+ }
+
+ test("Unit in parameterized types") {
+ val optionType = ScalaTypeRef[Option[Unit]]
+ val listType = ScalaTypeRef[List[Unit]]
+ val arrayType = ScalaTypeRef[Array[Unit]]
+ val boxType = ScalaTypeRef[Box[Unit]]
+ val nestedType = ScalaTypeRef[List[Option[Unit]]]
+ for (json <- runtimes) {
+ assert(json.toJson(Some(()): Option[Unit], optionType) == "null")
+ assert(json.fromJson("null", optionType) == None)
+ assertThrows[ForyJsonException](json.fromJson("1", optionType))
+ val values = roundTrip(json, List((), ()), listType)
+ assert(values.asInstanceOf[List[AnyRef]].forall(_ eq scala.runtime.BoxedUnit.UNIT))
+ assert(json.toJson(values, listType) == "[null,null]")
+ assert(json.toJson(Array((), ()), arrayType) == "[null,null]")
+ assert(new String(json.toJsonBytes(Array((), ()), arrayType), UTF_8) == "[null,null]")
+ for (array <- Seq(
+ json.fromJson("[null,null]", arrayType),
+ json.fromJson("[null,null]".getBytes(UTF_8), arrayType)
+ )) {
+ assert(array.length == 2)
+ assert(array.asInstanceOf[Array[AnyRef]].forall(_ eq scala.runtime.BoxedUnit.UNIT))
+ }
+ val box = roundTrip(json, Box(()), boxType)
+ assert(box.asInstanceOf[Box[AnyRef]].value eq scala.runtime.BoxedUnit.UNIT)
+ assert(roundTrip(json, List(Some(()), None), nestedType) == List(None, None))
+ }
+ }
+
+ test("Unit case-class fields") {
+ for (json <- runtimes) {
+ val value = UnitFields((), Some(()), List(()), Array(()))
+ val text = """{"option":null,"values":[null],"array":[null],"value":null}"""
+ assert(json.toJson(value) == text)
+ assert(new String(json.toJsonBytes(value), UTF_8) == text)
+ for (decoded <- Seq(
+ json.fromJson(text, classOf[UnitFields]),
+ json.fromJson(text.getBytes(UTF_8), classOf[UnitFields])
+ )) {
+ assert(decoded.productElement(0) == (()))
+ assert(decoded.option == None)
+ assert(decoded.values.asInstanceOf[List[AnyRef]].head eq scala.runtime.BoxedUnit.UNIT)
+ assert(decoded.array.asInstanceOf[Array[AnyRef]].head eq scala.runtime.BoxedUnit.UNIT)
+ }
+ assert(json.fromJson("{}", classOf[UnitValue]) == UnitValue(()))
assertThrows[ForyJsonException](json.fromJson("""{"value":1}""", classOf[UnitValue]))
+ }
+ }
+ test("singletons") {
+ for (json <- runtimes) {
assert(json.toJson(Marker) == "{}")
assert(json.fromJson("{}", Marker.getClass) eq Marker)
assert(json.fromJson("{}".getBytes(UTF_8), Marker.getClass) eq Marker)
@@ -1799,8 +2097,8 @@ class ScalaJsonSuite extends AnyFunSuite {
assert(json.fromJson(text.getBytes(UTF_8), classOf[Wide]) == wide)
val withoutDefault = text.replace(""","f24":24""", "")
assert(json.fromJson(withoutDefault, classOf[Wide]) == wide)
- val missing = intercept[ForyJsonException](json.fromJson("""{"f1":1}""", classOf[Wide]))
- assert(missing.getMessage != null)
+ val missing = json.fromJson("""{"f1":1}""", classOf[Wide])
+ assert(missing.f1 == 1 && missing.f2 == 0 && missing.f23 == 0 && missing.f24 == 24)
// Unknown properties are skipped for case classes like other object codecs.
assert(json.fromJson("""{"a":2,"zzz":{"deep":[1,2]}}""", classOf[VarParams]) == VarParams(2))
@@ -1827,6 +2125,38 @@ class ScalaJsonSuite extends AnyFunSuite {
}
}
+ test("case class constructor shapes") {
+ for (json <- runtimes) {
+ // Scala 2 exposes this private constructor and its apply as public bytecode; Scala 3
+ // hides apply, so JSON cannot identify a supported public primary constructor there.
+ if (classOf[PrivateConstructor].getMethods.exists(_.getName == "apply")) {
+ assert(json.fromJson("""{"value":1}""", classOf[PrivateConstructor]).value == 1)
+ } else {
+ assertThrows[ForyJsonException](json.fromJson("""{"value":1}""", classOf[PrivateConstructor]))
+ }
+ assertThrows[ForyJsonException](json.fromJson("""{"value":1}""", classOf[AmbiguousConstructor]))
+ val secondary = new SecondaryConstructor("2")
+ assert(json.fromJson(json.toJson(secondary), classOf[SecondaryConstructor]) == secondary)
+ val inherited = InheritedValue("中")
+ assert(json.fromJson(json.toJsonBytes(inherited), classOf[InheritedValue]) == inherited)
+ assert(json.fromJson("""{"value":1}""", classOf[Box[_]]).value == 1L)
+ }
+ val bounded = ForyJsonScala.builder().maxDepth(2).build()
+ val nested = """{"value":1,"next":{"value":2,"next":{"value":3,"next":null}}}"""
+ assertThrows[ForyJsonException](bounded.fromJson(nested, classOf[Node]))
+ assert(bounded.fromJson("""{"value":4,"next":null}""", classOf[Node]) == Node(4, None))
+ }
+
+ test("algebraic fields with codegen") {
+ val typeRef = ScalaTypeRef[AlgebraicFields[Int]]
+ val value = AlgebraicFields(
+ "中", scala.collection.immutable.Queue(1, 2), scala.collection.mutable.ArraySeq(3, 4),
+ (5, 6), Some(7), Right(8), NumericRange.inclusive(1, 3, 1))
+ for (json <- runtimes) {
+ assert(roundTrip(json, value, typeRef) == value)
+ }
+ }
+
test("Enumeration codec registered directly") {
val codec = new HueCodec
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@fory.apache.org
For additional commands, e-mail: commits-help@fory.apache.org