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 a6aead8ee feat(scala): support scala object with default values (#2412) a6aead8ee is described below commit a6aead8eeed8ba5f1dd2e778dffee9e6d52abfee Author: Shawn Yang <shawn.ck.y...@gmail.com> AuthorDate: Tue Jul 15 01:35:40 2025 +0800 feat(scala): support scala object with default values (#2412) ## What does this PR do? Support scala case default values. The implementation consists of: 1. **`ScalaCaseClassUtils`**: Utility class for detecting Scala case classes and retrieving default values from companion object methods. 2. **`MetaSharedSerializer`**: Modified to cache default value field information and apply default values to missing fields just before returning the deserialized object. 3. **`ScalaDefaultValueField`**: Internal field info class that caches field accessors and default values for efficient application during deserialization. 4. **Cached Processing**: During serializer initialization, default values are discovered and cached. During deserialization, cached default values are applied to any null fields that have corresponding default parameters. This feature enhances Fury's compatibility with Scala case classes and provides better support for evolving schemas with default parameters. ## Related issues Closes #1683 ## Does this PR introduce any user-facing change? <!-- If any user-facing interface changes, please [open an issue](https://github.com/apache/fory/issues/new/choose) describing the need to do so and update the document if necessary. --> - [ ] Does this PR introduce any public API change? - [ ] Does this PR introduce any binary protocol compatibility change? ## Benchmark <!-- When the PR has an impact on performance (if you don't know whether the PR will have an impact on performance, you can submit the PR first, and if it will have impact on performance, the code reviewer will explain it), be sure to attach a benchmark data here. --> --- docs/guide/scala_guide.md | 149 ++++++++ .../src/main/java/org/apache/fory/Fory.java | 12 +- .../fory/builder/MetaSharedCodecBuilder.java | 57 +++ .../org/apache/fory/collection/Collections.java | 23 ++ .../fory/serializer/MetaSharedSerializer.java | 30 +- .../main/java/org/apache/fory/type/ScalaTypes.java | 12 + .../apache/fory/util/ScalaDefaultValueUtils.java | 385 +++++++++++++++++++++ .../serializer/scala/ScalaDefaultValueTest.scala | 199 +++++++++++ .../fory/util/ScalaDefaultValueUtilsTest.scala | 383 ++++++++++++++++++++ 9 files changed, 1248 insertions(+), 2 deletions(-) diff --git a/docs/guide/scala_guide.md b/docs/guide/scala_guide.md index 83b0d4d7c..c0ca64963 100644 --- a/docs/guide/scala_guide.md +++ b/docs/guide/scala_guide.md @@ -162,3 +162,152 @@ println(fory.deserialize(fory.serialize(opt))) val opt1: Option[Long] = None println(fory.deserialize(fory.serialize(opt1))) ``` + +## Scala Class Default Values Support + +Fory supports Scala class default values during deserialization when using compatible mode. This feature enables forward/backward compatibility when case classes or regular Scala classes have default parameters. + +### Overview + +When a Scala class has default parameters, the Scala compiler generates methods in the companion object (for case classes) or in the class itself (for regular Scala classes) like `apply$default$1`, `apply$default$2`, etc. that return the default values. Fory can detect these methods and use them when deserializing objects where certain fields are missing from the serialized data. + +### Supported Class Types + +Fory supports default values for: + +- **Case classes** with default parameters +- **Regular Scala classes** with default parameters in their primary constructor +- **Nested case classes** with default parameters +- **Deeply nested case classes** with default parameters + +### How It Works + +1. **Detection**: Fory detects if a class is a Scala class by checking for the presence of default value methods (`apply$default$N` or `$default$N`). + +2. **Default Value Discovery**: + - For case classes: Fory scans the companion object for methods named `apply$default$1`, `apply$default$2`, etc. + - For regular Scala classes: Fory scans the class itself for methods named `$default$1`, `$default$2`, etc. + +3. **Field Mapping**: During deserialization, Fory identifies fields that exist in the target class but are missing from the serialized data. + +4. **Value Application**: After reading all available fields from the serialized data, Fory applies default values to any missing fields using direct field access for optimal performance. + +### Usage + +This feature is automatically enabled when: + +- Compatible mode is enabled (`withCompatibleMode(CompatibleMode.COMPATIBLE)`) +- The target class is detected as a Scala class with default values +- A field is missing from the serialized data but exists in the target class + +No additional configuration is required. + +### Examples + +#### Case Class with Default Values + +```scala +// Class WITHOUT default values (for serialization) +case class PersonNoDefaults(name: String) + +// Class WITH default values (for deserialization) +case class PersonWithDefaults(name: String, age: Int = 25, city: String = "Unknown") + +val fory = Fory.builder() + .withCompatibleMode(CompatibleMode.COMPATIBLE) + .withScalaOptimizationEnabled(true) + .build() + +// Serialize using class without default values +val original = PersonNoDefaults("John") +val serialized = fory.serialize(original) + +// Deserialize into class with default values - missing fields will use defaults +val deserialized = fory.deserialize(serialized, classOf[PersonWithDefaults]) +// deserialized.name will be "John" +// deserialized.age will be 25 (default) +// deserialized.city will be "Unknown" (default) +``` + +#### Regular Scala Class with Default Values + +```scala +// Class WITHOUT default values (for serialization) +class EmployeeNoDefaults(val name: String) + +// Class WITH default values (for deserialization) +class EmployeeWithDefaults(val name: String, val age: Int = 30, val department: String = "Engineering") + +val fory = Fory.builder() + .withCompatibleMode(CompatibleMode.COMPATIBLE) + .withScalaOptimizationEnabled(true) + .build() + +// Serialize using class without default values +val original = new EmployeeNoDefaults("Jane") +val serialized = fory.serialize(original) + +// Deserialize into class with default values - missing fields will use defaults +val deserialized = fory.deserialize(serialized, classOf[EmployeeWithDefaults]) +// deserialized.name will be "Jane" +// deserialized.age will be 30 (default) +// deserialized.department will be "Engineering" (default) +``` + +#### Complex Default Values + +```scala +// Class WITHOUT default values (for serialization) +case class ConfigurationNoDefaults(name: String) + +// Class WITH default values (for deserialization) +case class ConfigurationWithDefaults( + name: String, + settings: Map[String, String] = Map("default" -> "value"), + tags: List[String] = List("default"), + enabled: Boolean = true +) + +val fory = Fory.builder() + .withCompatibleMode(CompatibleMode.COMPATIBLE) + .withScalaOptimizationEnabled(true) + .build() + +// Serialize using class without default values +val original = ConfigurationNoDefaults("myConfig") +val serialized = fory.serialize(original) + +// Deserialize into class with default values - missing fields will use defaults +val deserialized = fory.deserialize(serialized, classOf[ConfigurationWithDefaults]) +// deserialized.name will be "myConfig" +// deserialized.settings will be Map("default" -> "value") +// deserialized.tags will be List("default") +// deserialized.enabled will be true +``` + +#### Nested Case Classes + +```scala +object NestedClasses { + // Class WITHOUT default values (for serialization) + case class SimplePerson(name: String) + + // Class WITH default values (for deserialization) + case class Address(street: String, city: String = "DefaultCity") + case class PersonWithDefaults(name: String, address: Address = Address("DefaultStreet")) +} + +val fory = Fory.builder() + .withCompatibleMode(CompatibleMode.COMPATIBLE) + .withScalaOptimizationEnabled(true) + .build() + +// Serialize using class without default values +val original = NestedClasses.SimplePerson("Alice") +val serialized = fory.serialize(original) + +// Deserialize into class with default values - missing address field will use default +val deserialized = fory.deserialize(serialized, classOf[NestedClasses.PersonWithDefaults]) +// deserialized.name will be "Alice" +// deserialized.address will be Address("DefaultStreet", "DefaultCity") +``` diff --git a/java/fory-core/src/main/java/org/apache/fory/Fory.java b/java/fory-core/src/main/java/org/apache/fory/Fory.java index a2840ef5d..99eed4bbf 100644 --- a/java/fory-core/src/main/java/org/apache/fory/Fory.java +++ b/java/fory-core/src/main/java/org/apache/fory/Fory.java @@ -796,9 +796,19 @@ public final class Fory implements BaseFory { @SuppressWarnings("unchecked") @Override public <T> T deserialize(byte[] bytes, Class<T> type) { + MemoryBuffer buffer = MemoryUtils.wrap(bytes); + if (!crossLanguage && shareMeta) { + byte bitmap = buffer.readByte(); + if ((bitmap & isNilFlag) == isNilFlag) { + return null; + } + boolean peerOutOfBandEnabled = (bitmap & isOutOfBandFlag) == isOutOfBandFlag; + assert !peerOutOfBandEnabled : "Out of band buffers not passed in when deserializing"; + return deserializeJavaObject(buffer, type); + } generics.pushGenericType(classResolver.buildGenericType(type)); try { - return (T) deserialize(MemoryUtils.wrap(bytes), null); + return (T) deserialize(buffer, null); } finally { generics.popGenericType(); } diff --git a/java/fory-core/src/main/java/org/apache/fory/builder/MetaSharedCodecBuilder.java b/java/fory-core/src/main/java/org/apache/fory/builder/MetaSharedCodecBuilder.java index 099b0081d..649a0fc15 100644 --- a/java/fory-core/src/main/java/org/apache/fory/builder/MetaSharedCodecBuilder.java +++ b/java/fory-core/src/main/java/org/apache/fory/builder/MetaSharedCodecBuilder.java @@ -20,7 +20,10 @@ package org.apache.fory.builder; import static org.apache.fory.builder.Generated.GeneratedMetaSharedSerializer.SERIALIZER_FIELD_NAME; +import static org.apache.fory.type.TypeUtils.OBJECT_TYPE; +import static org.apache.fory.type.TypeUtils.STRING_TYPE; +import java.lang.reflect.Member; import java.util.Collection; import java.util.Map; import java.util.SortedMap; @@ -30,6 +33,7 @@ import org.apache.fory.builder.Generated.GeneratedMetaSharedSerializer; import org.apache.fory.codegen.CodeGenerator; import org.apache.fory.codegen.Expression; import org.apache.fory.codegen.Expression.Literal; +import org.apache.fory.codegen.Expression.StaticInvoke; import org.apache.fory.config.CompatibleMode; import org.apache.fory.config.ForyBuilder; import org.apache.fory.memory.MemoryBuffer; @@ -45,6 +49,7 @@ import org.apache.fory.type.DescriptorGrouper; import org.apache.fory.util.ExceptionUtils; import org.apache.fory.util.GraalvmSupport; import org.apache.fory.util.Preconditions; +import org.apache.fory.util.ScalaDefaultValueUtils; import org.apache.fory.util.StringUtils; import org.apache.fory.util.record.RecordComponent; import org.apache.fory.util.record.RecordUtils; @@ -67,6 +72,7 @@ import org.apache.fory.util.record.RecordUtils; */ public class MetaSharedCodecBuilder extends ObjectCodecBuilder { private final ClassDef classDef; + private final ScalaDefaultValueUtils.ScalaDefaultValueField[] scalaDefaultValueFields; public MetaSharedCodecBuilder(TypeRef<?> beanType, Fory fory, ClassDef classDef) { super(beanType, fory, GeneratedMetaSharedSerializer.class); @@ -84,6 +90,15 @@ public class MetaSharedCodecBuilder extends ObjectCodecBuilder { DescriptorGrouper grouper = fory.getClassResolver().createDescriptorGrouper(descriptors, false); objectCodecOptimizer = new ObjectCodecOptimizer(beanClass, grouper, !fory.isBasicTypesRefIgnored(), ctx); + + if (fory.getConfig().isScalaOptimizationEnabled()) { + // Check if this is a Scala case class and build default value fields + this.scalaDefaultValueFields = + ScalaDefaultValueUtils.buildScalaDefaultValueFields( + fory, beanClass, grouper.getSortedDescriptors()); + } else { + this.scalaDefaultValueFields = new ScalaDefaultValueUtils.ScalaDefaultValueField[0]; + } } // Must be static to be shared across the whole process life. @@ -140,6 +155,7 @@ public class MetaSharedCodecBuilder extends ObjectCodecBuilder { protected void addCommonImports() { super.addCommonImports(); ctx.addImport(GeneratedMetaSharedSerializer.class); + ctx.addImport(ScalaDefaultValueUtils.class); } // Invoked by JIT. @@ -196,4 +212,45 @@ public class MetaSharedCodecBuilder extends ObjectCodecBuilder { } return super.setFieldValue(bean, descriptor, value); } + + @Override + protected Expression newBean() { + Expression bean = super.newBean(); + if (scalaDefaultValueFields.length == 0) { + return bean; + } + + Expression.ListExpression setDefaultsExpr = new Expression.ListExpression(); + setDefaultsExpr.add(bean); + Map<Member, Descriptor> descriptors = Descriptor.getAllDescriptorsMap(beanClass); + for (ScalaDefaultValueUtils.ScalaDefaultValueField defaultField : scalaDefaultValueFields) { + Object defaultValue = defaultField.getDefaultValue(); + Member member = defaultField.getFieldAccessor().getField(); + Descriptor descriptor = descriptors.get(member); + TypeRef<?> typeRef = descriptor.getTypeRef(); + Expression defaultValueExpr; + if (typeRef.unwrap().isPrimitive() || typeRef.equals(STRING_TYPE)) { + defaultValueExpr = new Literal(defaultValue, typeRef); + } else { + defaultValueExpr = + getOrCreateField( + true, + typeRef.getRawType(), + member.getName(), + () -> { + Expression expr = + new StaticInvoke( + ScalaDefaultValueUtils.class, + "getDefaultValue", + OBJECT_TYPE, + staticBeanClassExpr(), + Literal.ofString(member.getName())); + return new Expression.Cast(expr, typeRef); + }); + } + setDefaultsExpr.add(super.setFieldValue(bean, descriptor, defaultValueExpr)); + } + setDefaultsExpr.add(bean); + return setDefaultsExpr; + } } diff --git a/java/fory-core/src/main/java/org/apache/fory/collection/Collections.java b/java/fory-core/src/main/java/org/apache/fory/collection/Collections.java index 6a8475d85..347af20a7 100644 --- a/java/fory-core/src/main/java/org/apache/fory/collection/Collections.java +++ b/java/fory-core/src/main/java/org/apache/fory/collection/Collections.java @@ -19,6 +19,8 @@ package org.apache.fory.collection; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; import com.google.common.collect.MapMaker; import java.util.ArrayList; import java.util.Collection; @@ -197,4 +199,25 @@ public class Collections { return new MapMaker().weakKeys().makeMap(); } } + + /** + * Create a cache with weak keys and soft values. + * + * <p>when in graalvm, the cache is a concurrent hash map. when in jvm, the cache is a weak hash + * map. + * + * @param concurrencyLevel the concurrency level + * @return the cache + */ + public static <T> Cache<Class<?>, T> newClassKeySoftCache(int concurrencyLevel) { + if (GraalvmSupport.isGraalBuildtime()) { + return CacheBuilder.newBuilder().concurrencyLevel(concurrencyLevel).build(); + } else { + return CacheBuilder.newBuilder() + .weakKeys() + .softValues() + .concurrencyLevel(concurrencyLevel) + .build(); + } + } } diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/MetaSharedSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/MetaSharedSerializer.java index 4d49e2578..245ef530f 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/MetaSharedSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/MetaSharedSerializer.java @@ -41,6 +41,7 @@ import org.apache.fory.type.Descriptor; import org.apache.fory.type.DescriptorGrouper; import org.apache.fory.type.Generics; import org.apache.fory.util.Preconditions; +import org.apache.fory.util.ScalaDefaultValueUtils; import org.apache.fory.util.record.RecordInfo; import org.apache.fory.util.record.RecordUtils; @@ -77,6 +78,8 @@ public class MetaSharedSerializer<T> extends AbstractObjectSerializer<T> { private Serializer<T> serializer; private final ClassInfoHolder classInfoHolder; private final SerializationBinding binding; + private final boolean hasScalaDefaultValues; + private final ScalaDefaultValueUtils.ScalaDefaultValueField[] scalaDefaultValueFields; public MetaSharedSerializer(Fory fory, Class<T> type, ClassDef classDef) { super(fory, type); @@ -111,6 +114,15 @@ public class MetaSharedSerializer<T> extends AbstractObjectSerializer<T> { recordInfo = null; } binding = SerializationBinding.createBinding(fory); + if (fory.getConfig().isScalaOptimizationEnabled()) { + hasScalaDefaultValues = ScalaDefaultValueUtils.hasScalaDefaultValues(type); + scalaDefaultValueFields = + ScalaDefaultValueUtils.buildScalaDefaultValueFields( + fory, type, descriptorGrouper.getSortedDescriptors()); + } else { + hasScalaDefaultValues = false; + scalaDefaultValueFields = new ScalaDefaultValueUtils.ScalaDefaultValueField[0]; + } } @Override @@ -134,6 +146,7 @@ public class MetaSharedSerializer<T> extends AbstractObjectSerializer<T> { new Object[finalFields.length + otherFields.length + containerFields.length]; readFields(buffer, fieldValues); fieldValues = RecordUtils.remapping(recordInfo, fieldValues); + try { T t = (T) constructor.invokeWithArguments(fieldValues); Arrays.fill(recordInfo.getRecordComponents(), null); @@ -142,7 +155,7 @@ public class MetaSharedSerializer<T> extends AbstractObjectSerializer<T> { Platform.throwException(e); } } - T obj = newBean(); + T obj = newInstance(); Fory fory = this.fory; RefResolver refResolver = this.refResolver; ClassResolver classResolver = this.classResolver; @@ -171,6 +184,7 @@ public class MetaSharedSerializer<T> extends AbstractObjectSerializer<T> { fieldAccessor.putObject(obj, fieldValue); } } else { + // Skip the field value from buffer since it doesn't exist in current class if (skipPrimitiveFieldValueFailed(fory, fieldInfo.classId, buffer)) { if (fieldInfo.classInfo == null) { // TODO(chaokunyang) support registered serializer in peer with ref tracking disabled. @@ -198,9 +212,22 @@ public class MetaSharedSerializer<T> extends AbstractObjectSerializer<T> { fieldAccessor.putObject(obj, fieldValue); } } + + // Set default values for missing fields in Scala case classes + if (hasScalaDefaultValues) { + ScalaDefaultValueUtils.setScalaDefaultValues(obj, scalaDefaultValueFields); + } + return obj; } + private T newInstance() { + if (!hasScalaDefaultValues) { + return newBean(); + } + return Platform.newInstance(type); + } + @Override public T xread(MemoryBuffer buffer) { return read(buffer); @@ -231,6 +258,7 @@ public class MetaSharedSerializer<T> extends AbstractObjectSerializer<T> { fields[counter++] = fieldValue; } } else { + // Skip the field value from buffer since it doesn't exist in current class if (skipPrimitiveFieldValueFailed(fory, fieldInfo.classId, buffer)) { if (fieldInfo.classInfo == null) { // TODO(chaokunyang) support registered serializer in peer with ref tracking disabled. diff --git a/java/fory-core/src/main/java/org/apache/fory/type/ScalaTypes.java b/java/fory-core/src/main/java/org/apache/fory/type/ScalaTypes.java index 84dff043b..4c32c1b93 100644 --- a/java/fory-core/src/main/java/org/apache/fory/type/ScalaTypes.java +++ b/java/fory-core/src/main/java/org/apache/fory/type/ScalaTypes.java @@ -34,6 +34,7 @@ public class ScalaTypes { private static volatile Class<?> SCALA_ITERABLE_TYPE; private static volatile java.lang.reflect.Type SCALA_ITERATOR_RETURN_TYPE; private static volatile java.lang.reflect.Type SCALA_NEXT_RETURN_TYPE; + private static volatile Class<?> SCALA_PRODUCT_TYPE; public static Class<?> getScalaMapType() { if (SCALA_MAP_TYPE == null) { @@ -103,4 +104,15 @@ public class ScalaTypes { Type[] types = type.getActualTypeArguments(); return Tuple2.of(TypeRef.of(types[0]), TypeRef.of(types[1])); } + + public static Class<?> getScalaProductType() { + if (SCALA_PRODUCT_TYPE == null) { + SCALA_PRODUCT_TYPE = ReflectionUtils.loadClass("scala.Product"); + } + return SCALA_PRODUCT_TYPE; + } + + public static boolean isScalaProductType(Class<?> cls) { + return getScalaProductType().isAssignableFrom(cls); + } } diff --git a/java/fory-core/src/main/java/org/apache/fory/util/ScalaDefaultValueUtils.java b/java/fory-core/src/main/java/org/apache/fory/util/ScalaDefaultValueUtils.java new file mode 100644 index 000000000..bff06ab97 --- /dev/null +++ b/java/fory-core/src/main/java/org/apache/fory/util/ScalaDefaultValueUtils.java @@ -0,0 +1,385 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.util; + +import com.google.common.cache.Cache; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fory.Fory; +import org.apache.fory.annotation.Internal; +import org.apache.fory.collection.Collections; +import org.apache.fory.logging.Logger; +import org.apache.fory.logging.LoggerFactory; +import org.apache.fory.memory.Platform; +import org.apache.fory.reflect.FieldAccessor; +import org.apache.fory.resolver.ClassResolver; +import org.apache.fory.type.ScalaTypes; +import org.apache.fory.type.TypeUtils; +import org.apache.fory.util.unsafe._JDKAccess; + +/** + * Utility class for detecting Scala classes with default values and their default value methods. + * + * <p>Scala classes (including case classes) with default parameters generate companion objects with + * methods like `apply$default$1`, `apply$default$2`, etc. that return the default values. + */ +@Internal +public class ScalaDefaultValueUtils { + private static final Logger LOG = LoggerFactory.getLogger(ScalaDefaultValueUtils.class); + + private static final Cache<Class<?>, Map<Integer, Object>> cachedCtrDefaultValues = + Collections.newClassKeySoftCache(32); + private static final Cache<Class<?>, ScalaDefaultValueField[]> defaultValueFieldsCache = + Collections.newClassKeySoftCache(32); + private static final Cache<Class<?>, Map<String, Object>> allDefaultValuesCache = + Collections.newClassKeySoftCache(32); + + /** Field info for Scala case class fields with default values. */ + public static final class ScalaDefaultValueField { + private final Object defaultValue; + private final String fieldName; + private final FieldAccessor fieldAccessor; + private final short classId; + + private ScalaDefaultValueField( + String fieldName, Object defaultValue, FieldAccessor fieldAccessor, short classId) { + this.fieldName = fieldName; + this.defaultValue = defaultValue; + this.fieldAccessor = fieldAccessor; + this.classId = classId; + } + + public Object getDefaultValue() { + return defaultValue; + } + + public String getFieldName() { + return fieldName; + } + + public FieldAccessor getFieldAccessor() { + return fieldAccessor; + } + + public short getClassId() { + return classId; + } + } + + public static boolean hasScalaDefaultValues(Class<?> cls) { + return getAllDefaultValues(cls).size() > 0; + } + + /** + * Builds Scala default value fields for the given class. Only includes fields that are not + * present in the serialized data. + * + * @param fory the Fory instance + * @param type the class type + * @param descriptors list of descriptors that are present in the serialized data + * @return array of ScalaDefaultValueField objects + */ + public static ScalaDefaultValueField[] buildScalaDefaultValueFields( + Fory fory, Class<?> type, java.util.List<org.apache.fory.type.Descriptor> descriptors) { + ScalaDefaultValueField[] defaultFieldsArray = defaultValueFieldsCache.getIfPresent(type); + if (defaultFieldsArray != null) { + return defaultFieldsArray; + } + try { + // Extract field names from descriptors + java.util.Set<String> serializedFieldNames = new java.util.HashSet<>(); + for (org.apache.fory.type.Descriptor descriptor : descriptors) { + java.lang.reflect.Field field = descriptor.getField(); + if (field != null) { + serializedFieldNames.add(field.getName()); + } + } + java.lang.reflect.Field[] allFields = type.getDeclaredFields(); + List<ScalaDefaultValueField> defaultFields = new ArrayList<>(); + Map<String, Object> allDefaults = getAllDefaultValues(type); + for (java.lang.reflect.Field field : allFields) { + // Only include fields that are not in the serialized data + if (!serializedFieldNames.contains(field.getName())) { + String fieldName = field.getName(); + Object defaultValue = allDefaults.get(fieldName); + + if (defaultValue != null + && TypeUtils.wrap(field.getType()).isAssignableFrom(defaultValue.getClass())) { + FieldAccessor fieldAccessor = FieldAccessor.createAccessor(field); + Short classId = fory.getClassResolver().getRegisteredClassId(field.getType()); + defaultFields.add( + new ScalaDefaultValueField( + fieldName, + defaultValue, + fieldAccessor, + classId != null ? classId : ClassResolver.NO_CLASS_ID)); + } + } + } + defaultFieldsArray = defaultFields.toArray(new ScalaDefaultValueField[0]); + defaultValueFieldsCache.put(type, defaultFieldsArray); + } catch (Exception e) { + LOG.warn( + "Error {} building Scala default value fields for {}, default values support is disabled when deserializing object of type {}", + e.getMessage(), + type.getName(), + type.getName()); + // Ignore exceptions and return empty array + defaultValueFieldsCache.put(type, new ScalaDefaultValueField[0]); + } + return defaultFieldsArray; + } + + /** + * Sets default values for missing fields in a Scala case class. + * + * @param obj the object to set default values on + * @param scalaDefaultValueFields the cached default value fields + */ + public static void setScalaDefaultValues( + Object obj, ScalaDefaultValueField[] scalaDefaultValueFields) { + for (ScalaDefaultValueField defaultField : scalaDefaultValueFields) { + FieldAccessor fieldAccessor = defaultField.getFieldAccessor(); + if (fieldAccessor != null) { + Object defaultValue = defaultField.getDefaultValue(); + short classId = defaultField.getClassId(); + long fieldOffset = fieldAccessor.getFieldOffset(); + switch (classId) { + case ClassResolver.PRIMITIVE_BOOLEAN_CLASS_ID: + case ClassResolver.BOOLEAN_CLASS_ID: + Platform.putBoolean(obj, fieldOffset, (Boolean) defaultValue); + break; + case ClassResolver.PRIMITIVE_BYTE_CLASS_ID: + case ClassResolver.BYTE_CLASS_ID: + Platform.putByte(obj, fieldOffset, (Byte) defaultValue); + break; + case ClassResolver.PRIMITIVE_CHAR_CLASS_ID: + case ClassResolver.CHAR_CLASS_ID: + Platform.putChar(obj, fieldOffset, (Character) defaultValue); + break; + case ClassResolver.PRIMITIVE_SHORT_CLASS_ID: + case ClassResolver.SHORT_CLASS_ID: + Platform.putShort(obj, fieldOffset, (Short) defaultValue); + break; + case ClassResolver.PRIMITIVE_INT_CLASS_ID: + case ClassResolver.INTEGER_CLASS_ID: + Platform.putInt(obj, fieldOffset, (Integer) defaultValue); + break; + case ClassResolver.PRIMITIVE_LONG_CLASS_ID: + case ClassResolver.LONG_CLASS_ID: + Platform.putLong(obj, fieldOffset, (Long) defaultValue); + break; + case ClassResolver.PRIMITIVE_FLOAT_CLASS_ID: + case ClassResolver.FLOAT_CLASS_ID: + Platform.putFloat(obj, fieldOffset, (Float) defaultValue); + break; + case ClassResolver.PRIMITIVE_DOUBLE_CLASS_ID: + case ClassResolver.DOUBLE_CLASS_ID: + Platform.putDouble(obj, fieldOffset, (Double) defaultValue); + break; + default: + // Object type + fieldAccessor.putObject(obj, defaultValue); + } + } + } + } + + public static Object getDefaultValue(Class<?> cls, String fieldName) { + Map<String, Object> allDefaults = getAllDefaultValues(cls); + return allDefaults.get(fieldName); + } + + /** + * Gets all default values for a Scala class. This method caches all default values at the class + * level for better performance. + * + * @param cls the Scala class + * @return a map from parameter index to default value (null if no default) + */ + public static Map<String, Object> getAllDefaultValues(Class<?> cls) { + Preconditions.checkNotNull(cls, "Class must not be null"); + // Check cache first + Map<String, Object> allDefaults = allDefaultValuesCache.getIfPresent(cls); + if (allDefaults != null) { + return allDefaults; + } + allDefaults = new HashMap<>(); + // Get all constructors + Constructor<?>[] constructors = cls.getDeclaredConstructors(); + // Find the constructor with the most parameters (assuming it's the primary constructor) + Constructor<?> primaryConstructor = null; + for (Constructor<?> constructor : constructors) { + if (primaryConstructor == null + || constructor.getParameterCount() > primaryConstructor.getParameterCount()) { + primaryConstructor = constructor; + } + } + Preconditions.checkNotNull( + primaryConstructor, "Primary constructor not found for class " + cls.getName()); + Map<Integer, Object> defaultValues = getDefaultValuesForClass(cls); + int paramCount = primaryConstructor.getParameterCount(); + for (int i = 0; i < paramCount; i++) { + String paramName = primaryConstructor.getParameters()[i].getName(); + Object defaultValue = defaultValues.get(i + 1); // +1 because default values are 1-indexed + if (defaultValue != null) { + allDefaults.put(paramName, defaultValue); + } + } + allDefaultValuesCache.put(cls, allDefaults); + return allDefaults; + } + + /** + * Finds all default value methods for a Scala class. + * + * @param cls the Scala class + * @return a map from parameter index to method handle + */ + private static Map<Integer, Object> getDefaultValuesForClass(Class<?> cls) { + if (cachedCtrDefaultValues.getIfPresent(cls) != null) { + return cachedCtrDefaultValues.getIfPresent(cls); + } + Map<Integer, Object> defaultValueMethods; + if (ScalaTypes.isScalaProductType(cls)) { + defaultValueMethods = getDefaultValuesForCaseClass(cls); + } else { + defaultValueMethods = getDefaultValuesForRegularScalaClass(cls); + } + cachedCtrDefaultValues.put(cls, defaultValueMethods); + return defaultValueMethods; + } + + private static Map<Integer, Object> getDefaultValuesForCaseClass(Class<?> cls) { + Map<Integer, Object> values = new HashMap<>(); + String companionClassName = cls.getName() + "$"; + Class<?> companionClass = null; + Object companionInstance = null; + try { + companionClass = Class.forName(companionClassName, false, cls.getClassLoader()); + companionInstance = companionClass.getField("MODULE$").get(null); + } catch (Exception e) { + // For nested case classes, try to find the companion object in the enclosing class + Class<?> enclosingClass = cls.getEnclosingClass(); + if (enclosingClass != null) { + // Look for a companion object field in the enclosing class + for (java.lang.reflect.Field field : enclosingClass.getDeclaredFields()) { + if (field.getType().getName().equals(companionClassName)) { + field.setAccessible(true); + try { + companionInstance = field.get(null); + } catch (Exception e1) { + LOG.warn( + "Error {} accessing companion object for {}, default values support is disabled when deserializing object of type {}", + e1.getMessage(), + cls.getName(), + cls.getName()); + return values; + } + if (companionInstance != null) { + companionClass = companionInstance.getClass(); + break; + } + } + } + } + } + if (companionClass == null) { + LOG.warn( + "Companion class not found for {}, default values support is disabled when deserializing object of type {}", + cls.getName(), + cls.getName()); + return values; + } + MethodHandles.Lookup lookup = _JDKAccess._trustedLookup(companionClass); + + // Look for methods named `apply$default$1`, `apply$default$2`, etc. + Method[] companionMethods = companionClass.getDeclaredMethods(); + for (Method method : companionMethods) { + String methodName = method.getName(); + if (methodName.contains("$default$")) { + try { + // Extract the parameter index from the method name + String indexStr = + methodName.substring(methodName.lastIndexOf("$default$") + "$default$".length()); + int paramIndex = Integer.parseInt(indexStr); + // Create method handle for the default value method + MethodHandle methodHandle = lookup.unreflect(method); + Object defaultValue = methodHandle.invoke(companionInstance); + values.put(paramIndex, defaultValue); + } catch (Throwable e) { + LOG.warn( + "Error: {} finding default value methods for {}, default values support is disabled when deserializing object of type {}", + e.getMessage(), + cls.getName(), + cls.getName()); + return values; + } + } + } + return values; + } + + private static Map<Integer, Object> getDefaultValuesForRegularScalaClass(Class<?> cls) { + Map<Integer, Object> values = new HashMap<>(); + try { + MethodHandles.Lookup lookup = _JDKAccess._trustedLookup(cls); + Method[] classMethods = cls.getDeclaredMethods(); + for (Method method : classMethods) { + String methodName = method.getName(); + if (methodName.contains("$default$")) { + try { + // Extract the parameter index from the method name + String indexStr = + methodName.substring(methodName.lastIndexOf("$default$") + "$default$".length()); + int paramIndex = Integer.parseInt(indexStr); + // Create method handle for the default value method + MethodHandle methodHandle = lookup.unreflect(method); + // For regular Scala classes, we need to create an instance to call instance methods + // Since these are default value methods, we can try to call them as static methods + Object defaultValue = methodHandle.invoke(); + values.put(paramIndex, defaultValue); + } catch (Throwable e) { + LOG.warn( + "Error {} finding default value for {}, default values support is disabled when deserializing object of type {}", + e.getMessage(), + cls.getName(), + cls.getName()); + return values; + } + } + } + } catch (Exception e) { + LOG.warn( + "Error {} finding default value for {}, default values support is disabled when deserializing object of type {}", + e.getMessage(), + cls.getName(), + cls.getName()); + return values; + } + return values; + } +} diff --git a/scala/src/test/scala/org/apache/fory/serializer/scala/ScalaDefaultValueTest.scala b/scala/src/test/scala/org/apache/fory/serializer/scala/ScalaDefaultValueTest.scala new file mode 100644 index 000000000..5467ac440 --- /dev/null +++ b/scala/src/test/scala/org/apache/fory/serializer/scala/ScalaDefaultValueTest.scala @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.serializer.scala + +import org.apache.fory.Fory +import org.apache.fory.config.Language +import org.apache.fory.config.CompatibleMode +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +object NestedCases { + // Classes WITHOUT default values for serialization + case class NestedCaseClassNoDefaults(a: String, b: Int, c: Option[String]) + + // Classes WITH default values for deserialization + case class NestedCaseClassWithDefaults(a: String, b: Int = 99, c: Option[String] = Some("nested")) + + // Simple case class with only one field for testing missing fields + case class SimpleNestedCase(a: String) +} + +// Regular Scala classes WITHOUT default values for serialization +class RegularScalaClassNoDefaults(val name: String) { + override def equals(obj: Any): Boolean = obj match { + case that: RegularScalaClassNoDefaults => + this.name == that.name + case _ => false + } + + override def hashCode(): Int = { + val prime = 31 + var result = 1 + result = prime * result + (if (name == null) 0 else name.hashCode) + result + } + + override def toString: String = s"RegularScalaClassNoDefaults($name)" +} + +// Regular Scala classes WITH default values for deserialization +class RegularScalaClassWithDefaults(val name: String, val age: Int = 25, val city: String = "Unknown") { + override def equals(obj: Any): Boolean = obj match { + case that: RegularScalaClassWithDefaults => + this.name == that.name && this.age == that.age && this.city == that.city + case _ => false + } + + override def hashCode(): Int = { + val prime = 31 + var result = 1 + result = prime * result + (if (name == null) 0 else name.hashCode) + result = prime * result + age + result = prime * result + (if (city == null) 0 else city.hashCode) + result + } + + override def toString: String = s"RegularScalaClassWithDefaults($name, $age, $city)" +} + +class ScalaDefaultValueTest extends AnyWordSpec with Matchers { + + // Test both runtime mode (MetaSharedSerializer) and codegen mode (MetaSharedCodecBuilder) + val testModes = Seq( + ("Runtime Mode", false), + ("Codegen Mode", true) + ) + + def createFory(codegen: Boolean): Fory = Fory.builder() + .withLanguage(Language.JAVA) + .withRefTracking(true) + .withScalaOptimizationEnabled(true) + .requireClassRegistration(false) + .suppressClassRegistrationWarnings(false) + .withCodegen(codegen) + .withCompatibleMode(CompatibleMode.COMPATIBLE) + .build() + + "Fory Scala default value support" should { + testModes.foreach { case (modeName, codegen) => + s"handle missing fields with default values in case class in $modeName" in { + val fory = createFory(codegen) + // Serialize object WITHOUT the x field (it doesn't exist in source class) + val original = CaseClassNoDefaults("test") + val serialized = fory.serialize(original) + + // Deserialize into class WITH default values for x + val deserialized = fory.deserialize(serialized, classOf[CaseClassWithDefaults]) + deserialized.v shouldEqual "test" + deserialized.x shouldEqual 1 // Should use default value + } + + s"handle multiple missing fields with default values in case class in $modeName" in { + val fory = createFory(codegen) + // Serialize object WITHOUT x and y fields (they don't exist in source class) + val original = CaseClassMultipleDefaultsNoDefaults("test") + val serialized = fory.serialize(original) + + // Deserialize into class WITH default values for x and y + val deserialized = fory.deserialize(serialized, classOf[CaseClassMultipleDefaultsWithDefaults]) + deserialized.v shouldEqual "test" + deserialized.x shouldEqual 1 // Should use default value + deserialized.y shouldEqual 2.0 // Should use default value + } + + s"work with complex default values in case class in $modeName" in { + val fory = createFory(codegen) + // Serialize object WITHOUT the list field (it doesn't exist in source class) + val original = CaseClassComplexDefaultsNoDefaults("test") + val serialized = fory.serialize(original) + + // Deserialize into class WITH default values for list + val deserialized = fory.deserialize(serialized, classOf[CaseClassComplexDefaultsWithDefaults]) + deserialized.v shouldEqual "test" + deserialized.list shouldEqual List(1, 2, 3) // Should use default value + } + + s"handle partial missing fields with default values in case class in $modeName" in { + val fory = createFory(codegen) + // This test case needs to be updated since we can't have partial fields + // For now, we'll test with a different approach - serialize with one field missing + val original = CaseClassNoDefaults("test") // Only has v field + val serialized = fory.serialize(original) + + // Deserialize into class WITH default values for x + val deserialized = fory.deserialize(serialized, classOf[CaseClassWithDefaults]) + deserialized.v shouldEqual "test" + deserialized.x shouldEqual 1 // Should use default value + } + + s"handle missing fields with default values in nested case class in $modeName" in { + val fory = createFory(codegen) + import NestedCases._ + // Create a case class with only the first field, simulating missing b and c fields + // We'll use a different approach - serialize a simpler object and deserialize into a more complex one + val original = SimpleNestedCase("nestedTest") + val serialized = fory.serialize(original) + + // Deserialize into class WITH default values for b and c + val deserialized = fory.deserialize(serialized, classOf[NestedCaseClassWithDefaults]) + deserialized.a shouldEqual "nestedTest" + deserialized.b shouldEqual 99 // Should use default value + deserialized.c shouldEqual Some("nested") // Should use default value + } + + s"handle missing fields with default values in regular Scala class in $modeName" in { + val fory = createFory(codegen) + // Serialize object WITHOUT default values (missing age and city fields) + val original = new RegularScalaClassNoDefaults("Jane") + val serialized = fory.serialize(original) + + // Deserialize into class WITH default values + val deserialized = fory.deserialize(serialized, classOf[RegularScalaClassWithDefaults]) + deserialized.name shouldEqual "Jane" + deserialized.age shouldEqual 25 // Should use default value + deserialized.city shouldEqual "Unknown" // Should use default value + } + + s"handle partial missing fields with default values in regular Scala class in $modeName" in { + val fory = createFory(codegen) + // Serialize object with only name field, missing age and city + val original = new RegularScalaClassNoDefaults("Bob") + val serialized = fory.serialize(original) + + // Deserialize into class WITH default values + val deserialized = fory.deserialize(serialized, classOf[RegularScalaClassWithDefaults]) + deserialized.name shouldEqual "Bob" + deserialized.age shouldEqual 25 // Should use default value + deserialized.city shouldEqual "Unknown" // Should use default value + } + } + } +} + +// Test case classes WITHOUT default values (for serialization) +case class CaseClassNoDefaults(v: String) // No x field at all +case class CaseClassMultipleDefaultsNoDefaults(v: String) // No x and y fields at all +case class CaseClassComplexDefaultsNoDefaults(v: String) // No list field at all + +// Test case classes WITH default values (for deserialization) +case class CaseClassWithDefaults(v: String, x: Int = 1) +case class CaseClassMultipleDefaultsWithDefaults(v: String, x: Int = 1, y: Double = 2.0) +case class CaseClassComplexDefaultsWithDefaults(v: String, list: List[Int] = List(1, 2, 3)) \ No newline at end of file diff --git a/scala/src/test/scala/org/apache/fory/util/ScalaDefaultValueUtilsTest.scala b/scala/src/test/scala/org/apache/fory/util/ScalaDefaultValueUtilsTest.scala new file mode 100644 index 000000000..a487faf76 --- /dev/null +++ b/scala/src/test/scala/org/apache/fory/util/ScalaDefaultValueUtilsTest.scala @@ -0,0 +1,383 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.util + +import org.apache.fory.Fory +import org.apache.fory.config.Language +import org.apache.fory.`type`.Descriptor +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.Succeeded +import org.scalatest.matchers.should.Matchers._ +import java.util.{List => JavaList, ArrayList} +import scala.jdk.CollectionConverters._ + +// Test case classes WITH default values for testing ScalaDefaultValueUtils +case class TestCaseClassWithDefaults( + name: String, + age: Int = 25, + city: String = "Unknown", + active: Boolean = true, + score: Double = 0.0, + tags: List[String] = List("default") +) + +case class TestCaseClassMultipleDefaults( + id: Int, + name: String = "default", + description: String = "no description", + count: Int = 0, + enabled: Boolean = false +) + +case class TestCaseClassComplexDefaults( + title: String, + metadata: Map[String, String] = Map("type" -> "default"), + numbers: List[Int] = List(1, 2, 3), + optional: Option[String] = Some("default") +) + +// Test case classes WITHOUT default values (for comparison) +case class TestCaseClassNoDefaults(name: String, age: Int, city: String) + +// Regular Scala classes WITH default values +class TestRegularScalaClassWithDefaults( + val name: String, + val age: Int = 30, + val city: String = "DefaultCity", + val active: Boolean = false +) { + override def equals(obj: Any): Boolean = obj match { + case that: TestRegularScalaClassWithDefaults => + this.name == that.name && this.age == that.age && + this.city == that.city && this.active == that.active + case _ => false + } + + override def hashCode(): Int = { + val prime = 31 + var result = 1 + result = prime * result + (if (name == null) 0 else name.hashCode) + result = prime * result + age + result = prime * result + (if (city == null) 0 else city.hashCode) + result = prime * result + (if (active) 1 else 0) + result + } + + override def toString: String = s"TestRegularScalaClassWithDefaults($name, $age, $city, $active)" +} + +// Regular Scala classes WITHOUT default values +class TestRegularScalaClassNoDefaults(val name: String, val age: Int) { + override def equals(obj: Any): Boolean = obj match { + case that: TestRegularScalaClassNoDefaults => + this.name == that.name && this.age == that.age + case _ => false + } + + override def hashCode(): Int = { + val prime = 31 + var result = 1 + result = prime * result + (if (name == null) 0 else name.hashCode) + result = prime * result + age + result + } + + override def toString: String = s"TestRegularScalaClassNoDefaults($name, $age)" +} + +// Java-like class for testing non-Scala classes +class TestJavaClass(val name: String, val age: Int) { + override def equals(obj: Any): Boolean = obj match { + case that: TestJavaClass => + this.name == that.name && this.age == that.age + case _ => false + } + + override def hashCode(): Int = { + val prime = 31 + var result = 1 + result = prime * result + (if (name == null) 0 else name.hashCode) + result = prime * result + age + result + } + + override def toString: String = s"TestJavaClass($name, $age)" +} + +// Object to contain truly nested case classes +object NestedClasses { + case class NestedCaseClass( + outer: String, + inner: TestCaseClassWithDefaults = TestCaseClassWithDefaults("nested") + ) + + case class DeeplyNestedCaseClass( + level1: String, + level2: NestedCaseClass = NestedCaseClass("level2", TestCaseClassWithDefaults("deep")), + level3: TestCaseClassMultipleDefaults = TestCaseClassMultipleDefaults(999, "deep3") + ) + + case class NestedCaseClassNoDefaults( + outer: String, + inner: TestCaseClassNoDefaults + ) +} + +class ScalaDefaultValueUtilsTest extends AnyWordSpec with Matchers { + + def createFory(): Fory = Fory.builder() + .withLanguage(Language.JAVA) + .withRefTracking(true) + .withScalaOptimizationEnabled(true) + .requireClassRegistration(false) + .suppressClassRegistrationWarnings(false) + .build() + + /** + * Helper function to get default values as a Scala Map for easier testing + */ + def getDefaultValuesAsScalaMap[T](cls: Class[T]): Map[String, Object] = { + ScalaDefaultValueUtils.getAllDefaultValues(cls).asScala.toMap + } + + /** + * Helper function to build Scala default value fields for easier testing + */ + def buildDefaultValueFields[T](cls: Class[T]): Array[ScalaDefaultValueUtils.ScalaDefaultValueField] = { + val fory = createFory() + val descriptors = new ArrayList[Descriptor]() + ScalaDefaultValueUtils.buildScalaDefaultValueFields(fory, cls, descriptors) + } + + "ScalaDefaultValueUtils" should { + + "detect Scala classes with default values correctly" in { + // Test case classes with default values + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[TestCaseClassWithDefaults]) shouldEqual true + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[TestCaseClassMultipleDefaults]) shouldEqual true + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[TestCaseClassComplexDefaults]) shouldEqual true + + // Test regular Scala classes with default values + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[TestRegularScalaClassWithDefaults]) shouldEqual true + + // Test classes without default values + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[TestCaseClassNoDefaults]) shouldEqual false + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[TestRegularScalaClassNoDefaults]) shouldEqual false + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[TestJavaClass]) shouldEqual false + + // Test built-in types + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[String]) shouldEqual false + // Skip primitive types as they don't have constructors + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[List[_]]) shouldEqual false + } + + "get all default values for Scala classes" in { + // Test case class with defaults + val caseClassDefaults = getDefaultValuesAsScalaMap(classOf[TestCaseClassWithDefaults]) + caseClassDefaults should not be empty + caseClassDefaults("age") shouldEqual 25 + caseClassDefaults("city") shouldEqual "Unknown" + caseClassDefaults("active") shouldEqual true + caseClassDefaults("score") shouldEqual 0.0 + caseClassDefaults("tags") shouldEqual List("default") + + // Test case class with multiple defaults + val multipleDefaults = getDefaultValuesAsScalaMap(classOf[TestCaseClassMultipleDefaults]) + multipleDefaults should not be empty + multipleDefaults("name") shouldEqual "default" + multipleDefaults("description") shouldEqual "no description" + multipleDefaults("count") shouldEqual 0 + multipleDefaults("enabled") shouldEqual false + + // Test case class with complex defaults + val complexDefaults = getDefaultValuesAsScalaMap(classOf[TestCaseClassComplexDefaults]) + complexDefaults should not be empty + // Handle the metadata map - it might be a Scala Map or Java Map + val metadataValue = complexDefaults("metadata") + metadataValue should not be null + // Check if it's a Scala Map or Java Map and handle accordingly + metadataValue match { + case javaMap: java.util.Map[String, String] => + javaMap.asScala.toMap shouldEqual Map("type" -> "default") + case scalaMap: Map[String, String] => + scalaMap shouldEqual Map("type" -> "default") + case _ => + fail(s"Unexpected metadata type: ${metadataValue.getClass}") + } + complexDefaults("numbers") shouldEqual List(1, 2, 3) + complexDefaults("optional") shouldEqual Some("default") + + // Test regular Scala class with defaults + val regularDefaults = getDefaultValuesAsScalaMap(classOf[TestRegularScalaClassWithDefaults]) + regularDefaults should not be empty + regularDefaults("age") shouldEqual 30 + regularDefaults("city") shouldEqual "DefaultCity" + regularDefaults("active") shouldEqual false + + // Test classes without defaults + getDefaultValuesAsScalaMap(classOf[TestCaseClassNoDefaults]) shouldBe empty + getDefaultValuesAsScalaMap(classOf[TestRegularScalaClassNoDefaults]) shouldBe empty + getDefaultValuesAsScalaMap(classOf[TestJavaClass]) shouldBe empty + } + + "build Scala default value fields correctly" in { + // Test with case class that has defaults + val fields = buildDefaultValueFields(classOf[TestCaseClassWithDefaults]) + + fields should not be empty + fields.foreach { field => + field.getFieldName should not be null + field.getDefaultValue should not be null + field.getFieldAccessor should not be null + } + + // Test with case class without defaults + val noDefaultFields = buildDefaultValueFields(classOf[TestCaseClassNoDefaults]) + noDefaultFields shouldBe empty + + // Test with Java class + val javaFields = buildDefaultValueFields(classOf[TestJavaClass]) + javaFields shouldBe empty + } + + "set Scala default values on objects correctly" in { + val fory = createFory() + + // Create an object with missing fields + val obj = new TestCaseClassWithDefaults("test", 0, null, false, 0.0, null) + + // Build default value fields + val descriptors = new ArrayList[Descriptor]() + val fields = ScalaDefaultValueUtils.buildScalaDefaultValueFields( + fory, classOf[TestCaseClassWithDefaults], descriptors) + + // Set default values + ScalaDefaultValueUtils.setScalaDefaultValues(obj, fields) + + // Verify default values were set + obj.age shouldEqual 25 + obj.city shouldEqual "Unknown" + obj.active shouldEqual true + obj.score shouldEqual 0.0 + obj.tags shouldEqual List("default") + } + + "cache results for better performance" in { + // Test that getAllDefaultValues caches results + val firstCall = ScalaDefaultValueUtils.getAllDefaultValues(classOf[TestCaseClassWithDefaults]) + val secondCall = ScalaDefaultValueUtils.getAllDefaultValues(classOf[TestCaseClassWithDefaults]) + + // Should return the same cached result + firstCall should be theSameInstanceAs secondCall + + // Test that buildScalaDefaultValueFields caches results + val fory = createFory() + val descriptors = new ArrayList[Descriptor]() + + val firstFields = ScalaDefaultValueUtils.buildScalaDefaultValueFields( + fory, classOf[TestCaseClassWithDefaults], descriptors) + val secondFields = ScalaDefaultValueUtils.buildScalaDefaultValueFields( + fory, classOf[TestCaseClassWithDefaults], descriptors) + + // Should return the same cached result + firstFields should be theSameInstanceAs secondFields + } + + "handle different field types correctly" in { + // Test with different field types + val fields = buildDefaultValueFields(classOf[TestCaseClassWithDefaults]) + + fields.foreach { field => + field.getDefaultValue should not be null + field.getFieldName should not be null + field.getFieldAccessor should not be null + field.getClassId.toInt should be >= 0 + } + } + + "work with nested case classes" in { + import NestedClasses._ + // Nested case classes with default values should be detected + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[NestedCaseClass]) shouldEqual true + + val nestedDefaults = getDefaultValuesAsScalaMap(classOf[NestedCaseClass]) + nestedDefaults should not be empty + nestedDefaults("inner") shouldEqual TestCaseClassWithDefaults("nested") + } + + "work with deeply nested case classes" in { + import NestedClasses._ + // Deeply nested case classes with default values should be detected + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[DeeplyNestedCaseClass]) shouldEqual true + + val deepDefaults = getDefaultValuesAsScalaMap(classOf[DeeplyNestedCaseClass]) + deepDefaults should not be empty + deepDefaults("level2") shouldEqual NestedCaseClass("level2", TestCaseClassWithDefaults("deep")) + deepDefaults("level3") shouldEqual TestCaseClassMultipleDefaults(999, "deep3") + } + + "work with nested case classes without defaults" in { + import NestedClasses._ + // Nested case classes without default values should not be detected + ScalaDefaultValueUtils.hasScalaDefaultValues(classOf[NestedCaseClassNoDefaults]) shouldEqual false + + val nestedDefaults = getDefaultValuesAsScalaMap(classOf[NestedCaseClassNoDefaults]) + nestedDefaults shouldBe empty + } + + "handle error cases gracefully" in { + val fory = createFory() + + // Test with classes that might cause reflection issues + val descriptors = new ArrayList[Descriptor]() + + // These should not throw exceptions + noException should be thrownBy { + ScalaDefaultValueUtils.buildScalaDefaultValueFields(fory, classOf[Object], descriptors) + } + + noException should be thrownBy { + ScalaDefaultValueUtils.buildScalaDefaultValueFields(fory, classOf[String], descriptors) + } + + noException should be thrownBy { + ScalaDefaultValueUtils.buildScalaDefaultValueFields(fory, classOf[List[_]], descriptors) + } + } + + "test ScalaDefaultValueField inner class" in { + // Test the getter methods of ScalaDefaultValueField + // We can't create instances directly due to private constructor, + // but we can test the getters if we get an instance from the utility methods + + // Try to get fields from a class with defaults + val fields = buildDefaultValueFields(classOf[TestCaseClassWithDefaults]) + + if (fields.nonEmpty) { + val field = fields(0) + field.getFieldName should not be null + field.getDefaultValue should not be null + field.getFieldAccessor should not be null + field.getClassId.toInt should be >= 0 + } + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@fory.apache.org For additional commands, e-mail: commits-h...@fory.apache.org