diff --git a/docs/json/scala.md b/docs/json/scala.md index a988157bff..04b9637f31 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 82647de3ae..b201f05756 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 static String getLiteralName(Class cls) { // 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 64c2422b86..05a12bf67a 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 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 JsonObjectModel( parameterNames, accessors, defaultMethods, + defaultsReceiver, defaultMaskBits, parameterNullable, parameterTypes, @@ -108,6 +147,7 @@ public JsonObjectModel( parameterNames, accessors, defaultMethods, + null, defaultMaskBits, parameterNullable, parameterTypes, @@ -136,12 +176,49 @@ public 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 @@ private 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 @@ private void validate() { } } HashSet 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 @@ private void validate() { 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 Method[] defaultMethods() { 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 eba59e9e3f..0a8120e6d9 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 @@ private static JsonCreatorInfo buildObjectModelCreatorInfo( 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 1c3dde20b2..5ff4c69a45 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 @@ private boolean canCompileCreator(JsonCreatorInfo creator) { 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 1f150c290a..4499f77bf1 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 @@ private Expression creatorDefaultValue( 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 @@ private void appendWorkspaceDefaults( .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 06ae336461..b2a4b0b06d 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 JsonCreatorInfo( null, null, null, + null, null); } @@ -109,6 +111,7 @@ public JsonCreatorInfo( Object[] defaults, GeneratedJsonCodec generatedCodec, Method[] defaultMethods, + Object defaultsReceiver, String[] parameterNames, Constructor defaultConstructor, int[] defaultMaskBits, @@ -121,6 +124,7 @@ public JsonCreatorInfo( defaults, generatedCodec, defaultMethods, + defaultsReceiver, parameterNames, defaultConstructor, defaultMaskBits, @@ -142,6 +146,7 @@ public static JsonCreatorInfo fixedInstance(Class ownerType, Object instance) null, null, null, + null, instance); } @@ -159,6 +164,7 @@ private JsonCreatorInfo( Object[] defaults, GeneratedJsonCodec generatedCodec, Method[] defaultMethods, + Object defaultsReceiver, String[] parameterNames, Constructor defaultConstructor, int[] defaultMaskBits, @@ -178,10 +184,11 @@ private 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 @@ private 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 Method defaultMethod(int index) { 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 @@ private void applyDeferred(Object value, Object[] arguments) { } 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 @@ private static MethodHandle[] buildDefaultInvokers( 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 @@ private static MethodHandle[] buildDefaultInvokers( } } 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 69f47379bc..d5de194e4b 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> processedCodecs = ConcurrentHashMap.newKeySet(); private final Set> processedContainers = ConcurrentHashMap.newKeySet(); private final Set processedCreators = new LinkedHashSet<>(); + // Reflection-only registrations are tracked apart from processedCreators, whose membership also + // means a creator handle was retained. + private final Set processedReflectiveMethods = new LinkedHashSet<>(); + private final Set> typesConsideredForScalaCompanion = new LinkedHashSet<>(); private final Set> processedObjectModels = Collections.newSetFromMap(new IdentityHashMap<>()); private final ArrayList hostedConfigurations = new ArrayList<>(); @@ -437,10 +441,20 @@ private void registerObjectModel(DuringAnalysisAccess access, ObjectCodec obj } 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 @@ private void registerRecord(Class type) { 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 71cb62dbdb..1974ced6f3 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 aa9abd2168..1830ea67c9 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 95b7ce0ab9..18ca60d32c 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(),