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 5c7c2b7bb fix(scala): supported nested case class in fory-json-scala 
(#4006)
5c7c2b7bb is described below

commit 5c7c2b7bbad299c99e3b6325decbce5e21034064
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Aug 31 03:35:36 2026 +0100

    fix(scala): supported nested case class in fory-json-scala (#4006)
    
    ## Why?
    
    Scala case classes defined inside Scala objects are not properly
    supported by fory-json-scala.
    
    On Scala 2.13 such a type was not recognized as a case class, fell
    through to the generic Java
    object model, and silently decoded every property to its default:
    
    ```
    Models.Inner(1, "a")  ->  {"id":1,"name":"a"}  ->  Inner(0, null)     // no 
error
    ```
    
    Recognition required a static `apply` forwarder. Scala 2 emits those
    only for a top-level companion;
    Scala 3 emits them for any statically owned module, which is why Scala 3
    was unaffected.
    
    ## What does this PR do?
    
    - `ScalaObjectModels` resolves `apply` and `$lessinit$greater$default$N`
    from the companion
    singleton when the case class carries no static forwarders. The static
    path stays the fast path.
    - `JsonObjectModel` carries the receiver of instance constructor
    defaults, `JsonCreatorInfo` binds
    it into the default invoker, and both reader codegen paths invoke the
    default on that receiver.
    - `ReflectionUtils.getLiteralName` skipped its nested-Scala-object
    correction for any canonical name
    ending in `$`, which is every companion module class, so a companion two
    or more levels inside an
    object produced a name the generated-code compiler cannot resolve. Fixed
    at the root.
    - `ForyJsonGraalVMFeature` registers the companion class, its `MODULE$`
    field, and the `apply` and
    constructor-default methods the Scala module queries when it rebuilds
    the object model at image
      runtime.
    - A case class that cannot be reconstructed is rejected with
    `UnsupportedJsonTypeException` instead
    of silently decoding: one enclosed by a class or trait, which needs an
    outer instance, and one
      declared in a method, whose companion is not reachable.
    
    `fory-json-kotlin` is not touched: the public `JsonObjectModel`
    constructors it calls keep their
    previous signatures.
    
    ### User-facing behavior change
    
    A case class enclosed by a class, trait, or method previously serialized
    (the generic model
    discovered its fields, including `$outer`) and silently decoded back to
    an all-default instance.
    Both directions now raise `UnsupportedJsonTypeException`. Writing a
    value that can never be read
    back is the trap this PR fixes, so the write is rejected with the read.
    
    ## Related issues
    
    ## AI Contribution Checklist
    
    - [x] Substantial AI assistance was used in this PR: `yes`
    - [x] If `yes`, I included the standardized `AI Usage Disclosure` block
    below.
    - [x] If `yes`, I can explain and defend all important changes without
    AI help.
    - [x] If `yes`, I reviewed AI-assisted code changes line by line before
    submission.
    - [x] If `yes`, I completed line-by-line self-review first and fixed
    issues before requesting AI review.
    - [x] If `yes`, I ran two fresh AI review agents on the current PR diff
    or current HEAD after the latest code changes: one Fory-guided reviewer
    using `AGENTS.md` and `.agents/ci-and-pr.md`, and one independent
    general reviewer in a separate clean-context session that was not
    pointed to `.agents/ci-and-pr.md` or any copied Fory-specific review
    checklist. If the independent reviewer's tooling auto-loaded
    `AGENTS.md`, it followed the independent-review carve-out there.
    - [x] If `yes`, I addressed all AI review comments and repeated the
    review loop until both ai reviewers reported no further actionable
    comments.
    - [x] If `yes`, I attached screenshot evidence or equivalent persisted
    links of the final clean AI review results from both fresh reviewers on
    the current PR diff or current HEAD after the latest code changes in
    this PR body.
    - [x] If `yes`, I ran adequate human verification and recorded evidence
    (checks run locally or in CI, pass/fail summary, and confirmation I
    reviewed results).
    - [x] If `yes`, I added/updated tests and specs where required.
    - [x] If `yes`, I validated protocol/performance impacts with evidence
    when applicable.
    - [x] If `yes`, I verified licensing and provenance compliance.
    
    AI review artifacts (verbatim final report of every review session, with
    an index):
    https://gist.github.com/pjfanning/6b9fcd687e22450399df72dabc368e99
    
    ```text
    AI Usage Disclosure
    - substantial_ai_assistance: yes
    - scope: design drafting, code drafting, tests, docs (Claude Opus 5, 
`claude-opus-5[1m]`, via
      Claude Code; both AI reviewers were the same model in separate 
clean-context sessions)
    - affected_files_or_subsystems: scala/fory-json-scala (ScalaObjectModels, 
ScalaJsonSuite,
      ScalaJsonEnumerationNativeImageMain), java/fory-json (JsonObjectModel, 
JsonCreatorInfo,
      ObjectCodecBuilder, JsonReaderCodegen, JsonCodegen, 
ForyJsonGraalVMFeature),
      java/fory-core (ReflectionUtils), docs/json/scala.md
    - ai_review: line-by-line self-review completed by the contributor. Six 
rounds of the two-reviewer
      loop, each on the diff as it then stood. Round 6, on the current head 
`13a62665c`, is the final
      clean round: the Fory-guided reviewer reported no blocking findings and 
the independent reviewer
      reported no actionable findings and "I would approve this". Earlier 
rounds each surfaced at least
      one real defect, including a blocker (the Kotlin module would not have 
compiled) found by the
      independent reviewer alone, a regression introduced by an earlier round's 
own fix, and two tests
      that passed for the wrong reason. One reviewer suggestion was 
implemented, broke three tests, and
      was reverted with a source comment recording why. Three optional round-6 
nits were reviewed and
      deliberately not actioned so the clean result stands on the head; they 
are listed in the gist
    - ai_review_artifacts: 
https://gist.github.com/pjfanning/6b9fcd687e22450399df72dabc368e99
    - human_verification: see Verification below; contributor reviewed the 
results
    - performance_verification: N/A. No hot-path change. Constructor-default 
resolution happens once at
      codec build time; the generated reader fetches the receiver only on a 
missing JSON property, on
      the branch that uses it; the static-forwarder path is unchanged for 
top-level case classes.
    - provenance_license_confirmation: Apache-2.0-compatible provenance 
confirmed; no third-party code
      introduced
    ```
    
    ## Verification
    
    | Suite | Scala 2.13 | Scala 3.3.8 |
    | --- | --- | --- |
    | `ScalaJsonSuite` | 20 passed | 20 passed |
    | `ScalaJsonEnumerationSuite` | 4 passed | 4 passed |
    | `ScalaJsonDerivationSuite` | n/a | 10 passed |
    
    Scala suites were run directly through the ScalaTest runner against
    locally built modules rather
    than via `sbt +test`: Scala 3.3.8 matches the cross-build, the 2.13 leg
    used 2.13.14 against the
    repo's 2.13.18 target. CI's cross-build is the authoritative check.
    
    - `fory-core` maven suite: 2294 passed, 0 failures. Re-run because
    `ReflectionUtils.getLiteralName`
      feeds every serializer's codegen, not only the JSON path.
    - `fory-scala` binary serializer suites: 88 passed, for the same reason,
    including
    `SingleObjectSerializerTest`, which exercises `object A { object B {
    case class C } }`.
    - `fory-json` maven suite: 964 passed, 0 failures.
    - `fory-json-kotlin` compiles against unmodified Kotlin sources.
    - Generated readers dumped with `FORY_CODE_DIR` and confirmed to contain
    the new call in both
    codegen paths, including a default that consumes a preceding constructor
    argument.
    - `prettier --write docs/json/scala.md` reports the file unchanged.
    
    ### Known gaps
    
    - **The GraalVM path is verified by inspection only.** No GraalVM in the
    dev environment, and
    nothing in the repo runs the Scala native-image mains —
    `ScalaJsonEnumerationNativeImageMain` and
    `ScalaJsonNativeImageMain` are referenced by no sbt task or workflow job
    (pre-existing; the
    `graalvm_json` job builds a Java main from
    `integration_tests/graalvm_tests`). The harness gained
    nested case-class round-trips so the coverage exists if it is wired up.
    - **`sbt +test`, `mvn spotless:check` and `mvn checkstyle:check` were
    not run locally.** Spotless
    cannot run in this environment: google-java-format throws
    `NoClassDefFoundError` under JDK 17, on
    a test file this PR does not touch. Formatting was checked by hand
    against the surrounding style
    and every added line is within 100 columns; CI's format job is the first
    real check.
    
    ## Does this PR introduce any user-facing change?
    
    - [ ] Does this PR introduce any public API change?
    - [ ] Does this PR introduce any binary protocol compatibility change?
    
    A case class enclosed by a class, trait, or method is now rejected
    rather than silently
    round-tripping to defaults, as described above. No public API or
    wire-format change.
    
    ## Benchmark
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 docs/json/scala.md                                 |   5 +
 .../org/apache/fory/reflect/ReflectionUtils.java   |  10 +-
 .../apache/fory/json/codec/JsonObjectModel.java    | 101 ++++++++++++++
 .../apache/fory/json/codec/ObjectCodecBuilder.java |   1 +
 .../org/apache/fory/json/codegen/JsonCodegen.java  |   7 +-
 .../fory/json/codegen/JsonReaderCodegen.java       |  52 +++++--
 .../org/apache/fory/json/meta/JsonCreatorInfo.java |  35 ++++-
 .../apache/fory/json/ForyJsonGraalVMFeature.java   |  74 +++++++++-
 .../json/scala/internal/ScalaObjectModels.scala    | 153 +++++++++++++++++++--
 .../ScalaJsonEnumerationNativeImageMain.scala      |  25 ++++
 .../apache/fory/json/scala/ScalaJsonSuite.scala    |  93 +++++++++++++
 11 files changed, 520 insertions(+), 36 deletions(-)

diff --git a/docs/json/scala.md b/docs/json/scala.md
index a988157bf..04b9637f3 100644
--- a/docs/json/scala.md
+++ b/docs/json/scala.md
@@ -50,6 +50,11 @@ or mutate constructor `val` fields. Defaults in later 
parameter lists receive th
 constructor arguments exactly as Scala defines them. A missing parameter 
without a default is an
 error. Mutable body properties are applied after construction.
 
+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.
+
 Fory JSON annotations can be placed directly on Scala constructor properties:
 
 ```scala
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/reflect/ReflectionUtils.java 
b/java/fory-core/src/main/java/org/apache/fory/reflect/ReflectionUtils.java
index 82647de3a..b201f0575 100644
--- a/java/fory-core/src/main/java/org/apache/fory/reflect/ReflectionUtils.java
+++ b/java/fory-core/src/main/java/org/apache/fory/reflect/ReflectionUtils.java
@@ -630,8 +630,14 @@ public class ReflectionUtils {
       // qualifier name of scala object type will ends with `.`
       canonicalName = clsName.substring(0, clsName.length() - 1).replace("$", 
".") + "$";
     } else {
-      if (!canonicalName.endsWith("$") && canonicalName.contains("$")) {
-        // nested scala object type can't be accessed in java by using 
canonicalName
+      if (canonicalName.contains("$")) {
+        // nested scala object type can't be accessed in java by using 
canonicalName. This includes
+        // a nested module class, whose own name ends with `$`: the canonical 
name of a companion
+        // declared two or more levels inside an object has a `$`-terminated 
segment in the
+        // middle of its canonical name, one per enclosing module class, and 
the generated-code
+        // compiler cannot resolve through those ("pkg.A$B$ declares no member 
type C$"). One level
+        // deep is enclosed by the mirror class instead, so `pkg.A.C$` ends 
with the only `$` and
+        // still resolves: the nesting-level bound below is load bearing in 
both directions.
         // see more detailed in
         // 
https://stackoverflow.com/questions/30809070/accessing-scala-nested-classes-from-java
         int nestedLevels = 0;
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 64c2422b8..05a12bf67 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
@@ -42,6 +42,7 @@ public final class JsonObjectModel {
   private final String[] parameterNames;
   private final Method[] accessors;
   private final Method[] defaultMethods;
+  private final Object defaultsReceiver;
   private final int[] defaultMaskBits;
   private final boolean[] parameterNullable;
   private final TypeRef<?>[] parameterTypes;
@@ -68,6 +69,43 @@ public final class JsonObjectModel {
       Method[] propertyGetters,
       Method[] propertySetters,
       TypeRef<?>[] propertyTypes) {
+    this(
+        constructor,
+        defaultConstructor,
+        parameterNames,
+        accessors,
+        defaultMethods,
+        null,
+        defaultMaskBits,
+        parameterNullable,
+        parameterTypes,
+        propertyNames,
+        propertyGetters,
+        propertySetters,
+        propertyTypes);
+  }
+
+  /**
+   * Creates one ordinary language object model whose constructor defaults are 
instance methods on
+   * {@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.
+   */
+  public JsonObjectModel(
+      Constructor<?> constructor,
+      Constructor<?> defaultConstructor,
+      String[] parameterNames,
+      Method[] accessors,
+      Method[] defaultMethods,
+      Object defaultsReceiver,
+      int[] defaultMaskBits,
+      boolean[] parameterNullable,
+      TypeRef<?>[] parameterTypes,
+      String[] propertyNames,
+      Method[] propertyGetters,
+      Method[] propertySetters,
+      TypeRef<?>[] propertyTypes) {
     this(
         (Executable) constructor,
         constructor,
@@ -75,6 +113,7 @@ public final class JsonObjectModel {
         parameterNames,
         accessors,
         defaultMethods,
+        defaultsReceiver,
         defaultMaskBits,
         parameterNullable,
         parameterTypes,
@@ -108,6 +147,7 @@ public final class JsonObjectModel {
         parameterNames,
         accessors,
         defaultMethods,
+        null,
         defaultMaskBits,
         parameterNullable,
         parameterTypes,
@@ -136,12 +176,49 @@ public final class JsonObjectModel {
       TypeRef<?>[] propertyTypes,
       boolean[] propertyReconstructible,
       boolean[] propertyRequired) {
+    this(
+        creator,
+        invocationCreator,
+        defaultConstructor,
+        parameterNames,
+        accessors,
+        defaultMethods,
+        null,
+        defaultMaskBits,
+        parameterNullable,
+        parameterTypes,
+        propertyNames,
+        propertyGetters,
+        propertySetters,
+        propertyTypes,
+        propertyReconstructible,
+        propertyRequired);
+  }
+
+  private JsonObjectModel(
+      Executable creator,
+      Executable invocationCreator,
+      Constructor<?> defaultConstructor,
+      String[] parameterNames,
+      Method[] accessors,
+      Method[] defaultMethods,
+      Object defaultsReceiver,
+      int[] defaultMaskBits,
+      boolean[] parameterNullable,
+      TypeRef<?>[] parameterTypes,
+      String[] propertyNames,
+      Method[] propertyGetters,
+      Method[] propertySetters,
+      TypeRef<?>[] propertyTypes,
+      boolean[] propertyReconstructible,
+      boolean[] propertyRequired) {
     this.creator = Objects.requireNonNull(creator, "creator");
     this.invocationCreator = Objects.requireNonNull(invocationCreator, 
"invocationCreator");
     this.defaultConstructor = defaultConstructor;
     this.parameterNames = parameterNames.clone();
     this.accessors = accessors.clone();
     this.defaultMethods = defaultMethods.clone();
+    this.defaultsReceiver = defaultsReceiver;
     this.defaultMaskBits = defaultMaskBits.clone();
     this.parameterNullable = parameterNullable.clone();
     this.parameterTypes = parameterTypes.clone();
@@ -169,6 +246,7 @@ public final class JsonObjectModel {
     parameterNames = new String[0];
     accessors = new Method[0];
     defaultMethods = new Method[0];
+    defaultsReceiver = null;
     defaultMaskBits = new int[0];
     parameterNullable = new boolean[0];
     parameterTypes = new TypeRef<?>[0];
@@ -294,6 +372,7 @@ public final class JsonObjectModel {
       }
     }
     HashSet<String> names = new HashSet<>();
+    boolean hasDefaultMethod = false;
     for (int i = 0; i < parameterNames.length; i++) {
       String name = parameterNames[i];
       if (name == null || name.isEmpty() || !names.add(name)) {
@@ -303,6 +382,23 @@ 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]);
+      }
+      hasDefaultMethod |= defaultMethods[i] != null;
+    }
+    if (defaultsReceiver != null && !hasDefaultMethod) {
+      throw new IllegalArgumentException(
+          "A JSON constructor default receiver requires at least one instance 
default");
     }
     names.clear();
     for (int i = 0; i < propertyNames.length; i++) {
@@ -390,6 +486,11 @@ public final class JsonObjectModel {
     return defaultMethods.clone();
   }
 
+  /** Returns the receiver of instance constructor-default methods, or null 
when they are static. */
+  public Object defaultsReceiver() {
+    return defaultsReceiver;
+  }
+
   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 eba59e9e3..0a8120e6d 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
@@ -1720,6 +1720,7 @@ final class ObjectCodecBuilder {
         creatorDefaults(rawTypes),
         generatedCodec,
         defaultMethods,
+        objectModel.defaultsReceiver(),
         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 1c3dde20b..5ff4c69a4 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,9 +1134,10 @@ public final class JsonCodegen {
         return false;
       }
       Method defaultMethod = creator.defaultMethod(i);
-      // JsonCreatorInfo guarantees that a default method belongs to the 
creator owner and that its
-      // dependency types are the preceding creator parameters. The generated 
reader still invokes
-      // that exact method, so validate its access from the final definition 
context as well.
+      // 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.
       if (defaultMethod != null && !canCall(defaultMethod)) {
         return false;
       }
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 1f150c290..4499f77bf 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
@@ -1307,14 +1307,36 @@ abstract class JsonReaderCodegen {
       inputs[i] = new Expression.Cast(arguments.values[i], 
TypeRef.of(dependencies[i])).inline();
     }
     Expression value =
-        new Expression.StaticInvoke(
-            method.getDeclaringClass(),
-            method.getName(),
-            TypeRef.of(method.getReturnType()),
-            inputs);
+        Modifier.isStatic(method.getModifiers())
+            ? new Expression.StaticInvoke(
+                method.getDeclaringClass(),
+                method.getName(),
+                TypeRef.of(method.getReturnType()),
+                inputs)
+            : new Expression.Invoke(
+                defaultsReceiver(method),
+                method.getName(),
+                TypeRef.of(method.getReturnType()),
+                inputs);
     return new Expression.Cast(value, TypeRef.of(parameterType));
   }
 
+  /**
+   * Reads the language singleton that owns instance constructor defaults, 
such as a Scala
+   * companion. Each defaulted parameter needs its own expression, because 
generated code for one
+   * expression instance is emitted once at its first use site, and every use 
site here is a
+   * separate missing-argument block, so a shared instance would reference a 
local declared in a
+   * sibling block.
+   */
+  private Expression defaultsReceiver(Method method) {
+    return new Expression.Cast(
+        new Expression.Invoke(
+            fieldRef("creator", JsonCreatorInfo.class),
+            "defaultsReceiver",
+            TypeRef.of(Object.class)),
+        TypeRef.of(method.getDeclaringClass()));
+  }
+
   private Expression finishCreator(
       JsonGeneratedCodecBuilder builder,
       Class<?> type,
@@ -1485,13 +1507,19 @@ abstract class JsonReaderCodegen {
               .append(";\n");
         }
       } else {
-        body.append("arguments[")
-            .append(i)
-            .append("] = ")
-            .append(ctx.type(method.getDeclaringClass()))
-            .append('.')
-            .append(method.getName())
-            .append('(');
+        body.append("arguments[").append(i).append("] = ");
+        if (Modifier.isStatic(method.getModifiers())) {
+          body.append(ctx.type(method.getDeclaringClass()));
+        } else {
+          // Fetched on the missing-argument branch, not once per 
construction: a creator whose
+          // properties are all present must not pay for a receiver it never 
reads.
+          body.append("((")
+              .append(ctx.type(method.getDeclaringClass()))
+              .append(") ")
+              .append(creatorExpression)
+              .append(".defaultsReceiver())");
+        }
+        body.append('.').append(method.getName()).append('(');
         Class<?>[] dependencies = method.getParameterTypes();
         for (int j = 0; j < dependencies.length; j++) {
           if (j != 0) {
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 06ae33646..b2a4b0b06 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
@@ -67,6 +67,7 @@ public final class JsonCreatorInfo {
   private final MethodHandle invoker;
   private final GeneratedJsonCodec<?> generatedCodec;
   private final Method[] defaultMethods;
+  private final Object defaultsReceiver;
   private final MethodHandle[] defaultInvokers;
   private final Constructor<?> defaultConstructor;
   private final MethodHandle defaultConstructorInvoker;
@@ -97,6 +98,7 @@ public final class JsonCreatorInfo {
         null,
         null,
         null,
+        null,
         null);
   }
 
@@ -109,6 +111,7 @@ public final class JsonCreatorInfo {
       Object[] defaults,
       GeneratedJsonCodec<?> generatedCodec,
       Method[] defaultMethods,
+      Object defaultsReceiver,
       String[] parameterNames,
       Constructor<?> defaultConstructor,
       int[] defaultMaskBits,
@@ -121,6 +124,7 @@ public final class JsonCreatorInfo {
         defaults,
         generatedCodec,
         defaultMethods,
+        defaultsReceiver,
         parameterNames,
         defaultConstructor,
         defaultMaskBits,
@@ -142,6 +146,7 @@ public final class JsonCreatorInfo {
         null,
         null,
         null,
+        null,
         instance);
   }
 
@@ -159,6 +164,7 @@ public final class JsonCreatorInfo {
       Object[] defaults,
       GeneratedJsonCodec<?> generatedCodec,
       Method[] defaultMethods,
+      Object defaultsReceiver,
       String[] parameterNames,
       Constructor<?> defaultConstructor,
       int[] defaultMaskBits,
@@ -178,10 +184,11 @@ public final class JsonCreatorInfo {
     this.fixedInstance = fixedInstance;
     this.parameterNames = parameterNames == null ? null : 
parameterNames.clone();
     this.defaultMethods = defaultMethods == null ? null : 
defaultMethods.clone();
+    this.defaultsReceiver = defaultsReceiver;
     defaultInvokers =
         this.defaultMethods == null
             ? null
-            : buildDefaultInvokers(ownerType, executable, this.defaultMethods);
+            : buildDefaultInvokers(ownerType, executable, this.defaultMethods, 
defaultsReceiver);
     defaultConstructorInvoker =
         defaultConstructor == null
             ? null
@@ -210,6 +217,7 @@ public final class JsonCreatorInfo {
     defaults = source.defaults;
     generatedCodec = source.generatedCodec;
     defaultMethods = source.defaultMethods;
+    defaultsReceiver = source.defaultsReceiver;
     defaultInvokers = source.defaultInvokers;
     defaultConstructor = source.defaultConstructor;
     defaultConstructorInvoker = source.defaultConstructorInvoker;
@@ -409,6 +417,12 @@ public final class JsonCreatorInfo {
     return defaultMethods == null ? null : defaultMethods[index];
   }
 
+  /** Returns the receiver of instance constructor defaults, or null when they 
are static. */
+  @Internal
+  public Object defaultsReceiver() {
+    return defaultsReceiver;
+  }
+
   /** Evaluates one prevalidated language-defined constructor default. */
   @Internal
   public Object defaultValue(int index, Object[] arguments) {
@@ -596,7 +610,7 @@ public final class JsonCreatorInfo {
   }
 
   private static MethodHandle[] buildDefaultInvokers(
-      Class<?> ownerType, Executable executable, Method[] defaultMethods) {
+      Class<?> ownerType, Executable executable, Method[] defaultMethods, 
Object defaultsReceiver) {
     if (defaultMethods.length != executable.getParameterCount()) {
       throw new ForyJsonException("Constructor default count does not match " 
+ executable);
     }
@@ -607,8 +621,15 @@ public final class JsonCreatorInfo {
       if (method == null) {
         continue;
       }
-      if ((method.getDeclaringClass() != ownerType
-              || !java.lang.reflect.Modifier.isStatic(method.getModifiers()))
+      // 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.
+      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))
           || method.getParameterCount() > i
           || !java.lang.reflect.Modifier.isPublic(method.getModifiers())
@@ -622,8 +643,10 @@ public final class JsonCreatorInfo {
         }
       }
       try {
-        MethodHandle target =
-            
_JDKAccess._trustedLookup(method.getDeclaringClass()).unreflect(method);
+        MethodHandle target = 
_JDKAccess._trustedLookup(declaringClass).unreflect(method);
+        if (instanceDefault) {
+          target = target.bindTo(defaultsReceiver);
+        }
         invokers[i] = workspaceInvoker(target, dependencyTypes);
       } catch (IllegalAccessException e) {
         throw new ForyJsonException("Cannot access JSON constructor default " 
+ method, e);
diff --git 
a/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java
 
b/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java
index 69f47379b..d5de194e4 100644
--- 
a/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java
+++ 
b/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java
@@ -114,6 +114,10 @@ final class ForyJsonGraalVMFeature implements Feature {
   private final Set<Class<?>> processedCodecs = ConcurrentHashMap.newKeySet();
   private final Set<Class<?>> processedContainers = 
ConcurrentHashMap.newKeySet();
   private final Set<Executable> processedCreators = new LinkedHashSet<>();
+  // Reflection-only registrations are tracked apart from processedCreators, 
whose membership also
+  // means a creator handle was retained.
+  private final Set<Method> processedReflectiveMethods = new LinkedHashSet<>();
+  private final Set<Class<?>> typesConsideredForScalaCompanion = new 
LinkedHashSet<>();
   private final Set<ObjectCodec<?>> processedObjectModels =
       Collections.newSetFromMap(new IdentityHashMap<>());
   private final ArrayList<HostedConfiguration> hostedConfigurations = new 
ArrayList<>();
@@ -437,10 +441,20 @@ final class ForyJsonGraalVMFeature implements Feature {
       }
       for (int i = 0; i < creator.argumentCount(); i++) {
         Method defaultMethod = creator.defaultMethod(i);
-        if (defaultMethod != null) {
+        if (defaultMethod == null) {
+          continue;
+        }
+        if (Modifier.isStatic(defaultMethod.getModifiers())) {
           registerCreator(defaultMethod);
+        } else if (processedReflectiveMethods.add(defaultMethod)) {
+          // An instance default is bound to its owning singleton when the 
creator metadata is
+          // rebuilt at image runtime. creatorHandle spreads an argument array 
over the exact
+          // parameter count, which does not describe a method that also takes 
a receiver, so the
+          // method is only made reflectively available here.
+          RuntimeReflection.register(defaultMethod);
         }
       }
+      registerScalaCompanion(creator.executable().getDeclaringClass());
     }
     for (JsonFieldInfo field : objectModel.writeFields()) {
       registerFieldAccessor(access, field.writeField(), field.writeGetter(), 
null);
@@ -859,6 +873,64 @@ final class ForyJsonGraalVMFeature implements Feature {
     registerCreator(constructor);
   }
 
+  /**
+   * Registers the Scala companion that owns a type's constructor metadata 
when the type carries no
+   * static forwarders for it. A case class declared inside an object keeps 
`apply` and its
+   * constructor defaults on the companion singleton, and the Scala module 
resolves that singleton
+   * reflectively while rebuilding the object model at image runtime.
+   */
+  private void registerScalaCompanion(Class<?> type) {
+    if (!typesConsideredForScalaCompanion.add(type) || 
hasScalaStaticFactory(type)) {
+      return;
+    }
+    Class<?> companion;
+    try {
+      companion = Class.forName(type.getName() + "$", false, 
type.getClassLoader());
+    } catch (ClassNotFoundException | LinkageError e) {
+      return;
+    }
+    Field field;
+    try {
+      field = companion.getField("MODULE$");
+    } catch (NoSuchFieldException e) {
+      return;
+    }
+    int modifiers = field.getModifiers();
+    if (field.getType() != companion
+        || !Modifier.isPublic(companion.getModifiers())
+        || !Modifier.isPublic(modifiers)
+        || !Modifier.isStatic(modifiers)
+        || !Modifier.isFinal(modifiers)) {
+      return;
+    }
+    RuntimeReflection.register(companion);
+    RuntimeReflection.register(field);
+    for (Method method : companion.getMethods()) {
+      // Exactly the members the language module looks up on the singleton: 
the factory it matches
+      // against the primary constructor, and the constructor defaults.
+      if (method.getDeclaringClass() == companion
+          && !Modifier.isStatic(method.getModifiers())
+          && (("apply".equals(method.getName()) && method.getReturnType() == 
type)
+              || method.getName().startsWith("$lessinit$greater$default$"))
+          && processedReflectiveMethods.add(method)) {
+        RuntimeReflection.register(method);
+      }
+    }
+  }
+
+  private static boolean hasScalaStaticFactory(Class<?> type) {
+    for (Method method : type.getMethods()) {
+      if (Modifier.isStatic(method.getModifiers())
+          && method.getReturnType() == type
+          && !method.isBridge()
+          && !method.isSynthetic()
+          && "apply".equals(method.getName())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   private void registerCreator(Executable executable) {
     if (processedCreators.add(executable)) {
       RuntimeReflection.register(executable);
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 71cb62dbd..1974ced6f 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
@@ -28,15 +28,48 @@ import org.apache.fory.reflect.TypeRef
 
 private[scala] object ScalaObjectModels {
   def isCaseClass(typeClass: Class[_]): Boolean = {
-    if (!classOf[Product].isAssignableFrom(typeClass) || 
typeClass.getName.startsWith("scala.Tuple")) {
+    val name = typeClass.getName
+    if (!classOf[Product].isAssignableFrom(typeClass) || 
name.startsWith("scala.Tuple")) {
       return false
     }
-    findPrimaryConstructor(typeClass) != null
+    // Recognition answers a predicate for every Product reaching this module, 
including types it
+    // does not own, and reflecting over a companion or a case class resolves 
member descriptors.
+    // A type whose members reference absent classes must simply be declined 
here; the owning path
+    // reports the failure. Ambiguity stays loud: it means this module does 
own the type and cannot
+    // pick a constructor.
+    try {
+      val companion = companionOwner(typeClass, committed = false)
+      if (companion != null) findPrimaryConstructor(typeClass, companion) != 
null
+      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
+        // `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.
+        !name.startsWith("scala.") && declaresCopy(typeClass) && 
declaresProductPrefix(typeClass)
+      }
+    } catch { case _: LinkageError => false }
   }
 
   def caseClassCodec(typeRef: TypeRef[_], resolver: JsonTypeResolver): 
ObjectCodec[_] = {
     val typeClass = typeRef.getRawType
-    val constructor = findPrimaryConstructor(typeClass)
+    if (outerField(typeClass) != null) {
+      throw ScalaTypeSupport.unsupported(
+        typeRef,
+        "case class declared inside a class or trait cannot be reconstructed 
without its outer instance"
+      )
+    }
+    val companion = companionOwner(typeClass, committed = true)
+    if (companion == null) {
+      throw ScalaTypeSupport.unsupported(
+        typeRef,
+        "case class companion is not reachable, such as a case class declared 
in a method"
+      )
+    }
+    val constructor = findPrimaryConstructor(typeClass, companion)
     if (constructor == null) {
       throw ScalaTypeSupport.unsupported(typeRef, "case class has no supported 
public primary constructor")
     }
@@ -78,7 +111,12 @@ private[scala] object ScalaObjectModels {
     // Constructor properties and their accessors are one logical occurrence. 
In particular,
     // @JsonEnumeration binds an erased Enumeration.Value parameter to the 
exact MODULE$ owner.
     val logicalParameterTypes = propertyTypes.take(names.length)
-    val defaults = constructorDefaults(typeClass, parameterTypes)
+    val defaults = constructorDefaults(typeClass, companion, parameterTypes)
+    // The receiver belongs to the model only when a default is actually bound 
to it, so a nested
+    // case class without defaults keeps a receiver-free model.
+    val defaultsReceiver =
+      if (companion.staticForwarders || defaults.forall(_ == null)) null
+      else companionInstance(typeRef, companion)
     resolver.createObjectCodec(
       typeRef,
       new JsonObjectModel(
@@ -87,6 +125,7 @@ private[scala] object ScalaObjectModels {
         names,
         accessors,
         defaults,
+        defaultsReceiver,
         Array.fill(names.length)(-1),
         Array.fill(names.length)(true),
         logicalParameterTypes,
@@ -165,6 +204,26 @@ private[scala] object ScalaObjectModels {
     }
   }
 
+  private def declaresProductPrefix(typeClass: Class[_]): Boolean = {
+    try {
+      val method = typeClass.getDeclaredMethod("productPrefix")
+      method.getReturnType == classOf[String] && !method.isSynthetic
+    } catch { case _: NoSuchMethodException => false }
+  }
+
+  private def declaresCopy(typeClass: Class[_]): Boolean = {
+    typeClass.getMethods.exists(method =>
+      method.getName == "copy" && !Modifier.isStatic(method.getModifiers) &&
+        !method.isBridge && !method.isSynthetic && method.getReturnType == 
typeClass
+    )
+  }
+
+  private def outerField(typeClass: Class[_]): Field = {
+    typeClass.getDeclaredFields
+      .find(field => field.getName == "$outer" && 
!Modifier.isStatic(field.getModifiers))
+      .orNull
+  }
+
   private def productFields(typeClass: Class[_]): Array[Field] = {
     typeClass.getDeclaredFields.filter { field =>
       val modifiers = field.getModifiers
@@ -172,17 +231,84 @@ private[scala] object ScalaObjectModels {
     }
   }
 
-  private def findPrimaryConstructor(typeClass: Class[_]): Constructor[_] = {
-    val constructors = typeClass.getConstructors
+  /**
+   * Owner of the compiler-generated `apply` and `$lessinit$greater$default$N` 
members of a case
+   * class. Scala mirrors those companion members as static forwarders on the 
case class itself
+   * only for a top-level companion, so a case class declared inside an 
`object` keeps them as
+   * instance members of the companion singleton.
+   */
+  private final class CompanionOwner(val owner: Class[_], val singleton: 
Field) {
+    def staticForwarders: Boolean = singleton == null
+  }
+
+  // `fory-json` mirrors this companion rule in two places that must stay in 
sync: the
+  // `ownerType + "$"` check in JsonCreatorInfo.buildDefaultInvokers, and the 
native-image
+  // registration in ForyJsonGraalVMFeature.
+  // Recognition must not initialize the companion. Resolving the owner keeps 
the singleton
+  // unloaded so deciding whether a type is a supported case class never runs 
a user object body;
+  // caseClassCodec reads MODULE$ only once it commits to building the model.
+  // `committed` separates recognition from binding. Recognition answers a 
predicate for every
+  // Product that reaches this module, including types it does not own, so a 
companion that exists
+  // but cannot link must not fail that type there. Only the owning path 
reports it.
+  private def companionOwner(typeClass: Class[_], committed: Boolean): 
CompanionOwner = {
     val methods = typeClass.getMethods
+    var index = 0
+    while (index < methods.length) {
+      val method = methods(index)
+      if (
+        method.getName == "apply" && Modifier.isStatic(method.getModifiers) &&
+        !method.isBridge && !method.isSynthetic && method.getReturnType == 
typeClass
+      ) return new CompanionOwner(typeClass, null)
+      index += 1
+    }
+    val companionName = typeClass.getName + "$"
+    val companionClass =
+      try Class.forName(companionName, false, typeClass.getClassLoader)
+      catch {
+        // Absence means the type has no companion. A companion that exists 
but cannot be linked,
+        // including one missing native-image reflection metadata, is a real 
failure and must not
+        // be reported as an unreachable companion.
+        case _: ClassNotFoundException => return null
+        case error: LinkageError =>
+          if (!committed) return null
+          throw new ForyJsonException(s"Cannot load Scala companion 
$companionName", error)
+      }
+    val field = singletonField(companionClass)
+    if (!Modifier.isPublic(companionClass.getModifiers) || field == null) null
+    else new CompanionOwner(companionClass, field)
+  }
+
+  private def companionInstance(typeRef: TypeRef[_], companion: 
CompanionOwner): AnyRef = {
+    val instance =
+      try companion.singleton.get(null)
+      catch {
+        case error: ReflectiveOperationException =>
+          throw new ForyJsonException(
+            s"Cannot read Scala companion singleton 
${companion.owner.getName}",
+            error
+          )
+      }
+    if (instance == null) {
+      throw ScalaTypeSupport.unsupported(typeRef, "case class companion 
singleton is not initialized")
+    }
+    instance
+  }
+
+  private def findPrimaryConstructor(
+      typeClass: Class[_],
+      companion: CompanionOwner
+  ): Constructor[_] = {
+    val constructors = typeClass.getConstructors
+    val methods = companion.owner.getMethods
+    val staticApply = companion.staticForwarders
     var selected: Constructor[_] = null
     var index = 0
     while (index < constructors.length) {
       val constructor = constructors(index)
       val parameterTypes = constructor.getParameterTypes
       val matchingApply = methods.exists { method =>
-        method.getName == "apply" && Modifier.isStatic(method.getModifiers) && 
!method.isBridge &&
-        !method.isSynthetic && method.getReturnType == typeClass &&
+        method.getName == "apply" && Modifier.isStatic(method.getModifiers) == 
staticApply &&
+        !method.isBridge && !method.isSynthetic && method.getReturnType == 
typeClass &&
         sameTypes(method.getParameterTypes, parameterTypes)
       }
       if (!constructor.isSynthetic && !constructor.isVarArgs && matchingApply) 
{
@@ -237,18 +363,21 @@ private[scala] object ScalaObjectModels {
 
   private def constructorDefaults(
       typeClass: Class[_],
+      companion: CompanionOwner,
       parameterTypes: Array[Class[_]]
   ): Array[Method] = {
-    // Scala emits constructor-default forwarders on the case-class owner. 
Using those exact
-    // methods keeps construction metadata owner-bound and avoids loading the 
companion singleton.
+    // Constructor defaults live on the same owner as `apply`: static 
forwarders on the case-class
+    // owner for a top-level companion, otherwise instance members of the 
companion singleton.
     // A default in a later parameter list receives the preceding parameter 
lists as arguments.
     val defaults = new Array[Method](parameterTypes.length)
+    val staticDefault = companion.staticForwarders
     var index = 0
     while (index < defaults.length) {
       val name = "$lessinit$greater$default$" + (index + 1)
-      val candidates = typeClass.getMethods.filter { method =>
+      val candidates = companion.owner.getMethods.filter { method =>
         val modifiers = method.getModifiers
-        method.getName == name && Modifier.isPublic(modifiers) && 
Modifier.isStatic(modifiers) &&
+        method.getName == name && Modifier.isPublic(modifiers) &&
+        Modifier.isStatic(modifiers) == staticDefault &&
         method.getParameterCount <= index &&
         compatibleDefaultParameters(method.getParameterTypes, parameterTypes) 
&&
         compatibleDefaultResult(method.getReturnType, parameterTypes(index))
diff --git 
a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonEnumerationNativeImageMain.scala
 
b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonEnumerationNativeImageMain.scala
index aa9abd216..1830ea67c 100644
--- 
a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonEnumerationNativeImageMain.scala
+++ 
b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonEnumerationNativeImageMain.scala
@@ -26,6 +26,21 @@ object NativeWeekday extends Enumeration {
   val Monday, Tuesday = Value
 }
 
+object NativeNested {
+  @JsonType
+  case class Reading(value: Int, unit: String = "px")
+
+  // No default: the companion is still needed to match `apply` against the 
primary constructor.
+  @JsonType
+  case class Plain(value: Int)
+
+  object Deep {
+    // Two levels inside an object: also depends on the nested-module literal 
name.
+    @JsonType
+    case class Nested(value: Int, unit: String = "em")
+  }
+}
+
 @JsonType
 case class NativeEnumerationSchedule(
     @JsonEnumeration(classOf[NativeWeekday.type]) day: NativeWeekday.Value,
@@ -45,6 +60,16 @@ object ScalaJsonEnumerationNativeImageMain {
       List(NativeWeekday.Monday, NativeWeekday.Tuesday)
     )
     require(json.fromJson(json.toJson(value), 
classOf[NativeEnumerationSchedule]) == value)
+    // A case class declared inside an object binds `apply` and its 
constructor defaults on the
+    // companion singleton, which the image must reach reflectively at runtime.
+    val reading = NativeNested.Reading(3, "em")
+    require(json.fromJson(json.toJson(reading), classOf[NativeNested.Reading]) 
== reading)
+    require(json.fromJson("{\"value\":3}", classOf[NativeNested.Reading]).unit 
== "px")
+    val plain = NativeNested.Plain(4)
+    require(json.fromJson(json.toJson(plain), classOf[NativeNested.Plain]) == 
plain)
+    val deep = NativeNested.Deep.Nested(5, "rem")
+    require(json.fromJson(json.toJson(deep), 
classOf[NativeNested.Deep.Nested]) == deep)
+    require(json.fromJson("{\"value\":5}", 
classOf[NativeNested.Deep.Nested]).unit == "em")
     println("Fory Scala 2 Enumeration native image succeeded")
   }
 }
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 95b7ce0ab..18ca60d32 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
@@ -25,6 +25,7 @@ import org.apache.fory.json.ForyJsonException
 import org.apache.fory.json.annotation.{JsonIgnore, JsonProperty, 
JsonUnwrapped}
 import org.apache.fory.json.codec.AbstractJsonValueCodec
 import org.apache.fory.json.reader.JsonReader
+import org.apache.fory.json.resolver.UnsupportedJsonTypeException
 import org.apache.fory.json.writer.JsonWriter
 import org.apache.fory.reflect.TypeRef
 import org.scalatest.funsuite.AnyFunSuite
@@ -56,6 +57,41 @@ case class UnwrappedState(
   var label: String = "default-label"
 }
 
+object NestedModels {
+  case class Point(x: Int, y: String)
+
+  case class Region(origin: Point, size: Int = 2)
+
+  case class Span(from: Int)(val to: Int = from + 1)
+
+  case class UnwrappedNested(code: Int = 5) {
+    var note: String = "default-note"
+  }
+
+  case class UnwrappedOwner(
+      id: Int = 3,
+      @JsonUnwrapped nested: UnwrappedNested = UnwrappedNested()
+  )
+
+  object Inner {
+    case class Depth(level: Int, unit: String = "px")
+  }
+}
+
+class OuterHolder {
+  case class Bound(id: Int)
+}
+
+// Declared in a method of an object, so it captures no outer instance and its 
companion is a
+// local module with no MODULE$. A method-local case class inside a class hits 
the outer check
+// instead.
+object MethodLocalHolder {
+  def create(): Any = {
+    case class MethodLocal(id: Int)
+    MethodLocal(1)
+  }
+}
+
 case class NullableRequired(value: String)
 
 case class UserId(value: Int) extends AnyVal
@@ -173,6 +209,63 @@ class ScalaJsonSuite extends AnyFunSuite {
     }
   }
 
+  test("case class declared inside an object") {
+    for (json <- Seq(
+        ForyJsonScala.builder().withCodegen(false).build(),
+        ForyJsonScala.builder().withAsyncCompilation(false).build()
+      )) {
+      val region = NestedModels.Region(NestedModels.Point(1, "a"), 4)
+      val encoded = json.toJson(region)
+      assert(encoded.contains("\"origin\""))
+      assert(json.fromJson(encoded, classOf[NestedModels.Region]) == region)
+      // Scala 2 keeps `apply` and the constructor defaults on the companion 
singleton because it
+      // emits static forwarders only for a top-level companion.
+      val defaulted = json.fromJson("{\"origin\":{\"x\":1,\"y\":\"a\"}}", 
classOf[NestedModels.Region])
+      assert(defaulted.size == 2)
+      // A doubly nested companion must also be spelled correctly by generated 
readers.
+      val depth = NestedModels.Inner.Depth(3, "em")
+      assert(json.fromJson(json.toJson(depth), 
classOf[NestedModels.Inner.Depth]) == depth)
+      assert(json.fromJson("{\"level\":3}", 
classOf[NestedModels.Inner.Depth]).unit == "px")
+    }
+  }
+
+  test("nested case class defaults use preceding parameter lists") {
+    for (json <- Seq(
+        ForyJsonScala.builder().withCodegen(false).build(),
+        ForyJsonScala.builder().withAsyncCompilation(false).build()
+      )) {
+      assert(json.fromJson("{\"from\":4}", classOf[NestedModels.Span]).to == 5)
+    }
+  }
+
+  test("nested unwrapped creators apply defaults") {
+    for (json <- Seq(
+        ForyJsonScala.builder().withCodegen(false).build(),
+        ForyJsonScala.builder().withAsyncCompilation(false).build()
+      )) {
+      val value =
+        json.fromJson("{\"note\":\"child\"}", 
classOf[NestedModels.UnwrappedOwner])
+      assert(value.id == 3)
+      assert(value.nested.code == 5)
+      assert(value.nested.note == "child")
+    }
+  }
+
+  test("case class declared inside a class is rejected") {
+    val json = ForyJsonScala.builder().withCodegen(false).build()
+    val holder = new OuterHolder
+    // Both rejections assert their message: an outer-bound case class also 
has no reachable
+    // companion, so only the message distinguishes the outer check from the 
companion check.
+    val error = 
intercept[UnsupportedJsonTypeException](json.toJson(holder.Bound(1)))
+    assert(error.getMessage.contains("without its outer instance"))
+  }
+
+  test("case class declared inside a method is rejected") {
+    val json = ForyJsonScala.builder().withCodegen(false).build()
+    val error = 
intercept[UnsupportedJsonTypeException](json.toJson(MethodLocalHolder.create()))
+    assert(error.getMessage.contains("companion is not reachable"))
+  }
+
   test("required constructor values cannot be omitted as null") {
     for (json <- Seq(
         ForyJsonScala.builder().withCodegen(false).build(),


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to