Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,4 @@ Order matters - first match wins:
5. **Kotlin native types FIRST** (KotlinUuidResolver, KotlinInstantResolver, KotlinDurationResolver)
6. **Java types SECOND** (JavaUuidResolver, JavaInstantResolver, JavaDurationResolver)
7. Collection resolvers (List, Set, Map, Array)
8. DataClassResolver (fallback for data classes)
8. ClassResolver (fallback for classes with constructors)
8 changes: 4 additions & 4 deletions src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import dev.appoutlet.some.resolver.BigIntegerResolver
import dev.appoutlet.some.resolver.BooleanResolver
import dev.appoutlet.some.resolver.ByteResolver
import dev.appoutlet.some.resolver.CharResolver
import dev.appoutlet.some.resolver.ClassResolver
import dev.appoutlet.some.resolver.CustomTypeFactoryResolver
import dev.appoutlet.some.resolver.DataClassResolver
import dev.appoutlet.some.resolver.DoubleResolver
import dev.appoutlet.some.resolver.EnumResolver
import dev.appoutlet.some.resolver.FloatResolver
Expand Down Expand Up @@ -54,7 +54,7 @@ import kotlin.reflect.KClass
* built-in resolvers.
* @param defaultValueStrategy Strategy for handling data class constructor defaults.
* @param propertyFactories Custom property factories keyed by owning class and constructor parameter name. These are
* applied by [DataClassResolver] while constructing model objects.
* applied by [ClassResolver] while constructing model objects.
*/
data class SomeConfig(
val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
Expand Down Expand Up @@ -87,7 +87,7 @@ data class SomeConfig(
* Creates the resolver chain used to generate fixture values.
*
* Resolver order defines precedence: the first resolver that supports a type is used. Type factories are first so
* explicit type-level user configuration overrides built-in behavior. [DataClassResolver] is last because it is the
* explicit type-level user configuration overrides built-in behavior. [ClassResolver] is last because it is the
* fallback for constructable model classes and is also where property factories are applied.
*
* @param random Random source shared by resolvers that generate randomized values.
Expand Down Expand Up @@ -131,7 +131,7 @@ data class SomeConfig(
SetResolver(collectionStrategy, random),
MapResolver(collectionStrategy, random),
ArrayResolver(collectionStrategy, random),
DataClassResolver(
ClassResolver(
propertyFactories = propertyFactories,
random = random,
nullableStrategy = nullableStrategy,
Expand Down

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/oc add a trailing line

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dev.appoutlet.some.exception

import kotlin.reflect.KClass

class SomeInstantiationException(
kClass: KClass<*>,
failures: List<String>,
) : Exception(
"Failed to instantiate ${kClass.simpleName}. All constructors failed:\n${failures.joinToString("\n")}"
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,30 @@ import dev.appoutlet.some.config.StringStrategy
import dev.appoutlet.some.core.FixtureContext
import dev.appoutlet.some.core.ResolverChain
import dev.appoutlet.some.core.TypeResolver
import dev.appoutlet.some.exception.SomeCircularReferenceException
import dev.appoutlet.some.exception.SomeInstantiationException
import java.lang.reflect.Modifier
import kotlin.random.Random
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.KParameter
import kotlin.reflect.KType
import kotlin.reflect.full.createType
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.jvm.isAccessible

/**
* Resolves constructable Kotlin classes by calling their primary constructor with generated arguments.
* Resolves constructable Kotlin classes by calling their constructors with generated arguments.
*
* This resolver is the fallback for user-defined model types. A type is supported when its classifier is a
* [KClass] with a primary constructor and is not one of the built-in scalar or collection types handled by earlier
* resolvers in the chain. Although the name refers to data classes, any class with a primary constructor can be
* resolved when it reaches this resolver.
* [KClass] with at least one constructor (primary or secondary), is not abstract, and is not one of the
* built-in scalar or collection types handled by earlier resolvers in the chain.
*
* Constructors are tried in declaration order. The first constructor whose arguments can be fully resolved
* wins. If all constructors fail, a [SomeInstantiationException] is thrown summarizing each failure.
*
* [SomeCircularReferenceException] is never caught by this resolver; it always propagates so callers receive
* accurate circular-reference diagnostics.
*
* Constructor parameters are resolved as follows:
* - If a property-specific factory exists for the parameter name, the factory value is used.
Expand All @@ -37,7 +47,7 @@ import kotlin.reflect.full.primaryConstructor
* @param collectionStrategy Collection sizing strategy exposed to property factories through [FixtureContext].
* @param defaultValueStrategy Strategy for handling constructor defaults.
*/
class DataClassResolver(
class ClassResolver(
private val propertyFactories: Map<Pair<KClass<*>, String>, FixtureContext.() -> Any?> = emptyMap(),
private val random: Random = Random.Default,
private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
Expand All @@ -48,9 +58,9 @@ class DataClassResolver(
/**
* Returns whether [type] can be instantiated by this resolver.
*
* The resolver requires a [KClass] classifier with a primary constructor. It deliberately rejects collection,
* string, numeric, boolean, and character types because those are handled by more specific resolvers with
* generation rules tailored to each type.
* The resolver requires a [KClass] classifier with at least one constructor that is not abstract.
* It deliberately rejects collection, string, numeric, boolean, and character types because those are
* handled by more specific resolvers with generation rules tailored to each type.
*
* @param type Type being checked by the resolver chain.
* @return `true` when [type] is a constructable class that should be handled as a model object.
Expand All @@ -59,9 +69,13 @@ class DataClassResolver(
val kClass = type.classifier as? KClass<*> ?: return false

return when {
kClass.primaryConstructor == null -> false
kClass.constructors.isEmpty() -> false

Modifier.isAbstract(kClass.java.modifiers) -> false

kClass.isSubclassOf(List::class) || kClass.isSubclassOf(Set::class) || kClass.isSubclassOf(Map::class) -> {
kClass.isSubclassOf(List::class) || kClass.isSubclassOf(Set::class) || kClass.isSubclassOf(
Map::class
) -> {
false
}

Expand All @@ -78,25 +92,84 @@ class DataClassResolver(
}

/**
* Creates an instance of [type] by resolving values for its primary constructor parameters.
* Creates an instance of [type] by trying each constructor in order.
*
* Property factories take precedence over generated values and receive a [FixtureContext] containing the current
* random source, resolution stack, and configured generation strategies. Required parameters without custom
* property factories are delegated back to [chain], while optional parameters are left out of the argument map so
* their Kotlin default values are preserved.
*
* If a constructor succeeds, its result is returned immediately. If all constructors fail, a
* [SomeInstantiationException] is thrown with details about each failure.
*
* [SomeCircularReferenceException] is re-thrown immediately and is never absorbed into the failure summary,
* ensuring that circular-reference diagnostics propagate to the caller.
*
* @param type Class type to instantiate.
* @param chain Resolver chain used to generate required constructor parameter values.
* @return A new instance of [type].
* @throws IllegalStateException when [type] does not expose a primary constructor.
* @throws SomeInstantiationException when all constructors fail to instantiate [type].
*/
@Suppress("TooGenericExceptionCaught")
override fun resolve(type: KType, chain: ResolverChain): Any {
Comment thread
MessiasLima marked this conversation as resolved.
val kClass = type.classifier as KClass<*>
val constructor = kClass.primaryConstructor
?: error("No primary constructor found for ${kClass.simpleName}")
val failures = mutableListOf<String>()

for (constructor in kClass.constructors) {
try {
constructor.isAccessible = true
val result = tryConstructor(constructor, kClass, type, chain)
return requireNotNull(result) { "Constructor returned null" }
} catch (e: SomeCircularReferenceException) {
throw e
} catch (e: Exception) {
failures.add(
"Constructor ${constructor.parameters.map { it.name }}: ${e.message ?: e::class.simpleName}"
)
}
}

throw SomeInstantiationException(kClass, failures)
}

private fun tryConstructor(
constructor: KFunction<*>,
kClass: KClass<*>,
type: KType,
chain: ResolverChain,
): Any? {
val args = constructor.parameters.mapNotNull { param ->
val propertyFactory = propertyFactories[kClass to param.name]
val shouldGenerate = !param.isOptional ||
defaultValueStrategy == DefaultValueStrategy.Generate

when {
propertyFactory != null -> resolveByPropertyFactory(chain, param, propertyFactory)
shouldGenerate -> resolveByResolvers(kClass, type, param, chain)
else -> null
}
}.toMap()

return constructor.callBy(args)
}

private fun resolveByResolvers(
kClass: KClass<*>,
type: KType,
param: KParameter,
chain: ResolverChain
): Pair<KParameter, Any?> {
val typeArgMap = buildTypeArgMap(kClass, type)
val paramType = param.type
val resolvedType = typeArgMap[paramType.toString()] ?: paramType
return param to chain.resolve(resolvedType)
}

private fun resolveByPropertyFactory(
chain: ResolverChain,
param: KParameter,
propertyFactory: FixtureContext.() -> Any?
): Pair<KParameter, Any?> {
val context = FixtureContext(
random = random,
resolutionStack = chain.stack,
Expand All @@ -106,23 +179,7 @@ class DataClassResolver(
defaultValueStrategy = defaultValueStrategy,
)

val args = constructor.parameters
.mapNotNull { param ->
val propertyFactory = propertyFactories[kClass to param.name]
val shouldGenerate = !param.isOptional || defaultValueStrategy == DefaultValueStrategy.Generate

if (propertyFactory != null) {
param to propertyFactory(context)
} else if (shouldGenerate) {
val paramType = param.type
val resolvedType = typeArgMap[paramType.toString()] ?: paramType
param to chain.resolve(resolvedType)
} else {
null
}
}.toMap()

return constructor.callBy(args)
return param to propertyFactory(context)
}

/**
Expand Down
129 changes: 129 additions & 0 deletions src/test/kotlin/dev/appoutlet/some/resolver/ClassResolverTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package dev.appoutlet.some.resolver

import dev.appoutlet.some.exception.SomeInstantiationException
import dev.appoutlet.some.some
import dev.appoutlet.some.test.defaultTestChain
import kotlin.random.Random
import kotlin.reflect.typeOf
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue

abstract class AbstractClass

class ConcreteClass : AbstractClass()

data class SimpleDataClass(val name: String)

data class Wrapper<T>(val value: T)

data class SimpleDataClassWithGenerics(val names: Wrapper<String>)

class SecondaryOnlyClass private constructor(val value: String) {
constructor(chars: List<Char>) : this(chars.joinToString(""))
}

class FallbackClass private constructor(val name: String, val extra: AbstractClass) {
constructor(name: String) : this(name, ConcreteClass())
}

class EmptyConstructorClass {
val greeting: String = "hello"
}

class UnresolvableParamClass(val param: AbstractClass)

class PrivateConstructorClass private constructor(val secret: String)

sealed class TestSealed {
data class Sub(val x: Int) : TestSealed()
}

class ClassResolverTest {
private val resolver = ClassResolver(random = Random.Default)

@Test
fun `ClassResolver resolves data class with primary constructor`() {
val result = resolver.resolve(typeOf<SimpleDataClass>(), defaultTestChain)
assertTrue(result is SimpleDataClass)
}

@Test
fun `ClassResolver resolves data class with primary constructor with generics`() {
val result = resolver.resolve(typeOf<SimpleDataClassWithGenerics>(), defaultTestChain)
assertTrue(result is SimpleDataClassWithGenerics)
}

@Test
fun `ClassResolver resolves class with secondary constructor only`() {
val result = resolver.resolve(typeOf<SecondaryOnlyClass>(), defaultTestChain)
assertTrue(result is SecondaryOnlyClass)
}

@Test
fun `ClassResolver resolves external kotlin class`() {
val result = resolver.resolve(typeOf<Pair<SimpleDataClass, FallbackClass>>(), defaultTestChain)
val castedResult = result as Pair<SimpleDataClass, FallbackClass>
assertTrue(castedResult.first is SimpleDataClass)
assertTrue(castedResult.second is FallbackClass)
}

@Test
fun `ClassResolver resolves external java class`() {
val date = resolver.resolve(typeOf<java.util.Date>(), defaultTestChain)
assertNotNull(date)
}

@Test
fun `ClassResolver falls back to secondary constructor when primary fails`() {
val result = resolver.resolve(typeOf<FallbackClass>(), defaultTestChain)
assertTrue(result is FallbackClass)
}

@Test
fun `ClassResolver resolves class with empty constructor`() {
val result = resolver.resolve(typeOf<EmptyConstructorClass>(), defaultTestChain)
assertTrue(result is EmptyConstructorClass)
}

@Test
fun `ClassResolver throws SomeInstantiationException when all constructors fail`() {
assertFailsWith<SomeInstantiationException> {
resolver.resolve(typeOf<UnresolvableParamClass>(), defaultTestChain)
}
}

@Test
fun `ClassResolver canResolve accepts class with constructor`() {
assertTrue(resolver.canResolve(typeOf<SimpleDataClass>()))
}

@Test
fun `ClassResolver canResolve accepts class with secondary constructor only`() {
assertTrue(resolver.canResolve(typeOf<SecondaryOnlyClass>()))
}

@Test
fun `ClassResolver canResolve accepts class with empty constructor`() {
assertTrue(resolver.canResolve(typeOf<EmptyConstructorClass>()))
}

@Test
fun `ClassResolver canResolve rejects well known types`() {
assertFalse(resolver.canResolve(typeOf<String>()))
assertFalse(resolver.canResolve(typeOf<Int>()))
assertFalse(resolver.canResolve(typeOf<List<String>>()))
assertFalse(resolver.canResolve(typeOf<Set<String>>()))
assertFalse(resolver.canResolve(typeOf<Map<String, Int>>()))
assertFalse(resolver.canResolve(typeOf<AbstractClass>()))
assertFalse(resolver.canResolve(typeOf<TestSealed>()))
}

@Test
fun `ClassResolver resolves class with private constructor via reflection`() {
val result = resolver.resolve(typeOf<PrivateConstructorClass>(), defaultTestChain)
assertTrue(result is PrivateConstructorClass)
}
}