Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -9,7 +9,7 @@ import dev.appoutlet.some.resolver.BooleanResolver
import dev.appoutlet.some.resolver.ByteResolver
import dev.appoutlet.some.resolver.CharResolver
import dev.appoutlet.some.resolver.CustomTypeFactoryResolver
import dev.appoutlet.some.resolver.DataClassResolver
import dev.appoutlet.some.resolver.ClassResolver
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,29 @@ 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 kotlin.random.Random
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.KType
import kotlin.reflect.full.createType
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.jvm.isAccessible
import java.lang.reflect.Modifier

/**
* 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 +46,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 +57,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,7 +68,9 @@ 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) -> {
false
Expand All @@ -78,23 +89,50 @@ 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].
*/
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)
?: throw IllegalArgumentException("Constructor returned null")
return result
} 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 typeArgMap = buildTypeArgMap(kClass, type)

val context = FixtureContext(
Expand Down Expand Up @@ -143,4 +181,4 @@ class DataClassResolver(
param.name to (projection.type ?: param.createType(emptyList(), false))
}
}
}
}
135 changes: 135 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,135 @@
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.assertIsNot
import kotlin.test.assertTrue

abstract class UnresolvableBase

class ConcreteUnresolvableBase : UnresolvableBase()

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

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

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

@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<SimpleData>()))
}

@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 String type`() {
assertFalse(resolver.canResolve(typeOf<String>()))
}

@Test
fun `ClassResolver canResolve rejects Int type`() {
assertFalse(resolver.canResolve(typeOf<Int>()))
}

@Test
fun `ClassResolver canResolve rejects List type`() {
assertFalse(resolver.canResolve(typeOf<List<String>>()))
}

@Test
fun `ClassResolver canResolve rejects Set type`() {
assertFalse(resolver.canResolve(typeOf<Set<String>>()))
}

@Test
fun `ClassResolver canResolve rejects Map type`() {
assertFalse(resolver.canResolve(typeOf<Map<String, Int>>()))
}

@Test
fun `ClassResolver canResolve rejects abstract class`() {
assertFalse(resolver.canResolve(typeOf<UnresolvableBase>()))
}

@Test
fun `ClassResolver canResolve rejects sealed class`() {
assertFalse(resolver.canResolve(typeOf<TestSealed>()))
}

@Test
fun `ClassResolver preserves primary constructor success path for data classes`() {
val result = some<SimpleData>()
assertTrue(result.name.isNotEmpty())
}

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

data class SimpleData(val name: String)

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

class FallbackClass private constructor(val name: String, val extra: UnresolvableBase) {
constructor(name: String) : this(name, ConcreteUnresolvableBase())
}

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

class UnresolvableParamClass(val param: UnresolvableBase)

class PrivateConstructorClass private constructor(val secret: String)

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