Skip to content

Commit a739b2e

Browse files
feat: ClassResolver
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: MessiasLima <MessiasLima@users.noreply.github.com> Co-authored-by: Messias Junior <messiaslima.03@gmail.com>
1 parent 88cbc06 commit a739b2e

5 files changed

Lines changed: 233 additions & 37 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,4 @@ Order matters - first match wins:
115115
5. **Kotlin native types FIRST** (KotlinUuidResolver, KotlinInstantResolver, KotlinDurationResolver)
116116
6. **Java types SECOND** (JavaUuidResolver, JavaInstantResolver, JavaDurationResolver)
117117
7. Collection resolvers (List, Set, Map, Array)
118-
8. DataClassResolver (fallback for data classes)
118+
8. ClassResolver (fallback for classes with constructors)

src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import dev.appoutlet.some.resolver.BigIntegerResolver
88
import dev.appoutlet.some.resolver.BooleanResolver
99
import dev.appoutlet.some.resolver.ByteResolver
1010
import dev.appoutlet.some.resolver.CharResolver
11+
import dev.appoutlet.some.resolver.ClassResolver
1112
import dev.appoutlet.some.resolver.CustomTypeFactoryResolver
12-
import dev.appoutlet.some.resolver.DataClassResolver
1313
import dev.appoutlet.some.resolver.DoubleResolver
1414
import dev.appoutlet.some.resolver.EnumResolver
1515
import dev.appoutlet.some.resolver.FloatResolver
@@ -54,7 +54,7 @@ import kotlin.reflect.KClass
5454
* built-in resolvers.
5555
* @param defaultValueStrategy Strategy for handling data class constructor defaults.
5656
* @param propertyFactories Custom property factories keyed by owning class and constructor parameter name. These are
57-
* applied by [DataClassResolver] while constructing model objects.
57+
* applied by [ClassResolver] while constructing model objects.
5858
*/
5959
data class SomeConfig(
6060
val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
@@ -87,7 +87,7 @@ data class SomeConfig(
8787
* Creates the resolver chain used to generate fixture values.
8888
*
8989
* Resolver order defines precedence: the first resolver that supports a type is used. Type factories are first so
90-
* explicit type-level user configuration overrides built-in behavior. [DataClassResolver] is last because it is the
90+
* explicit type-level user configuration overrides built-in behavior. [ClassResolver] is last because it is the
9191
* fallback for constructable model classes and is also where property factories are applied.
9292
*
9393
* @param random Random source shared by resolvers that generate randomized values.
@@ -131,7 +131,7 @@ data class SomeConfig(
131131
SetResolver(collectionStrategy, random),
132132
MapResolver(collectionStrategy, random),
133133
ArrayResolver(collectionStrategy, random),
134-
DataClassResolver(
134+
ClassResolver(
135135
propertyFactories = propertyFactories,
136136
random = random,
137137
nullableStrategy = nullableStrategy,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package dev.appoutlet.some.exception
2+
3+
import kotlin.reflect.KClass
4+
5+
class SomeInstantiationException(
6+
kClass: KClass<*>,
7+
failures: List<String>,
8+
) : Exception(
9+
"Failed to instantiate ${kClass.simpleName}. All constructors failed:\n${failures.joinToString("\n")}"
10+
)

src/main/kotlin/dev/appoutlet/some/resolver/DataClassResolver.kt renamed to src/main/kotlin/dev/appoutlet/some/resolver/ClassResolver.kt

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,30 @@ import dev.appoutlet.some.config.StringStrategy
77
import dev.appoutlet.some.core.FixtureContext
88
import dev.appoutlet.some.core.ResolverChain
99
import dev.appoutlet.some.core.TypeResolver
10+
import dev.appoutlet.some.exception.SomeCircularReferenceException
11+
import dev.appoutlet.some.exception.SomeInstantiationException
12+
import java.lang.reflect.Modifier
1013
import kotlin.random.Random
1114
import kotlin.reflect.KClass
15+
import kotlin.reflect.KFunction
16+
import kotlin.reflect.KParameter
1217
import kotlin.reflect.KType
1318
import kotlin.reflect.full.createType
1419
import kotlin.reflect.full.isSubclassOf
15-
import kotlin.reflect.full.primaryConstructor
20+
import kotlin.reflect.jvm.isAccessible
1621

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

6171
return when {
62-
kClass.primaryConstructor == null -> false
72+
kClass.constructors.isEmpty() -> false
73+
74+
Modifier.isAbstract(kClass.java.modifiers) -> false
6375

64-
kClass.isSubclassOf(List::class) || kClass.isSubclassOf(Set::class) || kClass.isSubclassOf(Map::class) -> {
76+
kClass.isSubclassOf(List::class) || kClass.isSubclassOf(Set::class) || kClass.isSubclassOf(
77+
Map::class
78+
) -> {
6579
false
6680
}
6781

@@ -78,25 +92,84 @@ class DataClassResolver(
7892
}
7993

8094
/**
81-
* Creates an instance of [type] by resolving values for its primary constructor parameters.
95+
* Creates an instance of [type] by trying each constructor in order.
8296
*
8397
* Property factories take precedence over generated values and receive a [FixtureContext] containing the current
8498
* random source, resolution stack, and configured generation strategies. Required parameters without custom
8599
* property factories are delegated back to [chain], while optional parameters are left out of the argument map so
86100
* their Kotlin default values are preserved.
87101
*
102+
* If a constructor succeeds, its result is returned immediately. If all constructors fail, a
103+
* [SomeInstantiationException] is thrown with details about each failure.
104+
*
105+
* [SomeCircularReferenceException] is re-thrown immediately and is never absorbed into the failure summary,
106+
* ensuring that circular-reference diagnostics propagate to the caller.
107+
*
88108
* @param type Class type to instantiate.
89109
* @param chain Resolver chain used to generate required constructor parameter values.
90110
* @return A new instance of [type].
91-
* @throws IllegalStateException when [type] does not expose a primary constructor.
111+
* @throws SomeInstantiationException when all constructors fail to instantiate [type].
92112
*/
113+
@Suppress("TooGenericExceptionCaught")
93114
override fun resolve(type: KType, chain: ResolverChain): Any {
94115
val kClass = type.classifier as KClass<*>
95-
val constructor = kClass.primaryConstructor
96-
?: error("No primary constructor found for ${kClass.simpleName}")
116+
val failures = mutableListOf<String>()
117+
118+
for (constructor in kClass.constructors) {
119+
try {
120+
constructor.isAccessible = true
121+
val result = tryConstructor(constructor, kClass, type, chain)
122+
return requireNotNull(result) { "Constructor returned null" }
123+
} catch (e: SomeCircularReferenceException) {
124+
throw e
125+
} catch (e: Exception) {
126+
failures.add(
127+
"Constructor ${constructor.parameters.map { it.name }}: ${e.message ?: e::class.simpleName}"
128+
)
129+
}
130+
}
131+
132+
throw SomeInstantiationException(kClass, failures)
133+
}
97134

135+
private fun tryConstructor(
136+
constructor: KFunction<*>,
137+
kClass: KClass<*>,
138+
type: KType,
139+
chain: ResolverChain,
140+
): Any? {
141+
val args = constructor.parameters.mapNotNull { param ->
142+
val propertyFactory = propertyFactories[kClass to param.name]
143+
val shouldGenerate = !param.isOptional ||
144+
defaultValueStrategy == DefaultValueStrategy.Generate
145+
146+
when {
147+
propertyFactory != null -> resolveByPropertyFactory(chain, param, propertyFactory)
148+
shouldGenerate -> resolveByResolvers(kClass, type, param, chain)
149+
else -> null
150+
}
151+
}.toMap()
152+
153+
return constructor.callBy(args)
154+
}
155+
156+
private fun resolveByResolvers(
157+
kClass: KClass<*>,
158+
type: KType,
159+
param: KParameter,
160+
chain: ResolverChain
161+
): Pair<KParameter, Any?> {
98162
val typeArgMap = buildTypeArgMap(kClass, type)
163+
val paramType = param.type
164+
val resolvedType = typeArgMap[paramType.toString()] ?: paramType
165+
return param to chain.resolve(resolvedType)
166+
}
99167

168+
private fun resolveByPropertyFactory(
169+
chain: ResolverChain,
170+
param: KParameter,
171+
propertyFactory: FixtureContext.() -> Any?
172+
): Pair<KParameter, Any?> {
100173
val context = FixtureContext(
101174
random = random,
102175
resolutionStack = chain.stack,
@@ -106,23 +179,7 @@ class DataClassResolver(
106179
defaultValueStrategy = defaultValueStrategy,
107180
)
108181

109-
val args = constructor.parameters
110-
.mapNotNull { param ->
111-
val propertyFactory = propertyFactories[kClass to param.name]
112-
val shouldGenerate = !param.isOptional || defaultValueStrategy == DefaultValueStrategy.Generate
113-
114-
if (propertyFactory != null) {
115-
param to propertyFactory(context)
116-
} else if (shouldGenerate) {
117-
val paramType = param.type
118-
val resolvedType = typeArgMap[paramType.toString()] ?: paramType
119-
param to chain.resolve(resolvedType)
120-
} else {
121-
null
122-
}
123-
}.toMap()
124-
125-
return constructor.callBy(args)
182+
return param to propertyFactory(context)
126183
}
127184

128185
/**
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package dev.appoutlet.some.resolver
2+
3+
import dev.appoutlet.some.exception.SomeInstantiationException
4+
import dev.appoutlet.some.some
5+
import dev.appoutlet.some.test.defaultTestChain
6+
import kotlin.random.Random
7+
import kotlin.reflect.typeOf
8+
import kotlin.test.Test
9+
import kotlin.test.assertFailsWith
10+
import kotlin.test.assertFalse
11+
import kotlin.test.assertNotNull
12+
import kotlin.test.assertTrue
13+
14+
abstract class AbstractClass
15+
16+
class ConcreteClass : AbstractClass()
17+
18+
data class SimpleDataClass(val name: String)
19+
20+
data class Wrapper<T>(val value: T)
21+
22+
data class SimpleDataClassWithGenerics(val names: Wrapper<String>)
23+
24+
class SecondaryOnlyClass private constructor(val value: String) {
25+
constructor(chars: List<Char>) : this(chars.joinToString(""))
26+
}
27+
28+
class FallbackClass private constructor(val name: String, val extra: AbstractClass) {
29+
constructor(name: String) : this(name, ConcreteClass())
30+
}
31+
32+
class EmptyConstructorClass {
33+
val greeting: String = "hello"
34+
}
35+
36+
class UnresolvableParamClass(val param: AbstractClass)
37+
38+
class PrivateConstructorClass private constructor(val secret: String)
39+
40+
sealed class TestSealed {
41+
data class Sub(val x: Int) : TestSealed()
42+
}
43+
44+
class ClassResolverTest {
45+
private val resolver = ClassResolver(random = Random.Default)
46+
47+
@Test
48+
fun `ClassResolver resolves data class with primary constructor`() {
49+
val result = resolver.resolve(typeOf<SimpleDataClass>(), defaultTestChain)
50+
assertTrue(result is SimpleDataClass)
51+
}
52+
53+
@Test
54+
fun `ClassResolver resolves data class with primary constructor with generics`() {
55+
val result = resolver.resolve(typeOf<SimpleDataClassWithGenerics>(), defaultTestChain)
56+
assertTrue(result is SimpleDataClassWithGenerics)
57+
}
58+
59+
@Test
60+
fun `ClassResolver resolves class with secondary constructor only`() {
61+
val result = resolver.resolve(typeOf<SecondaryOnlyClass>(), defaultTestChain)
62+
assertTrue(result is SecondaryOnlyClass)
63+
}
64+
65+
@Test
66+
fun `ClassResolver resolves external kotlin class`() {
67+
val result = resolver.resolve(typeOf<Pair<SimpleDataClass, FallbackClass>>(), defaultTestChain)
68+
val castedResult = result as Pair<SimpleDataClass, FallbackClass>
69+
assertTrue(castedResult.first is SimpleDataClass)
70+
assertTrue(castedResult.second is FallbackClass)
71+
}
72+
73+
@Test
74+
fun `ClassResolver resolves external java class`() {
75+
val date = resolver.resolve(typeOf<java.util.Date>(), defaultTestChain)
76+
assertNotNull(date)
77+
}
78+
79+
@Test
80+
fun `ClassResolver falls back to secondary constructor when primary fails`() {
81+
val result = resolver.resolve(typeOf<FallbackClass>(), defaultTestChain)
82+
assertTrue(result is FallbackClass)
83+
}
84+
85+
@Test
86+
fun `ClassResolver resolves class with empty constructor`() {
87+
val result = resolver.resolve(typeOf<EmptyConstructorClass>(), defaultTestChain)
88+
assertTrue(result is EmptyConstructorClass)
89+
}
90+
91+
@Test
92+
fun `ClassResolver throws SomeInstantiationException when all constructors fail`() {
93+
assertFailsWith<SomeInstantiationException> {
94+
resolver.resolve(typeOf<UnresolvableParamClass>(), defaultTestChain)
95+
}
96+
}
97+
98+
@Test
99+
fun `ClassResolver canResolve accepts class with constructor`() {
100+
assertTrue(resolver.canResolve(typeOf<SimpleDataClass>()))
101+
}
102+
103+
@Test
104+
fun `ClassResolver canResolve accepts class with secondary constructor only`() {
105+
assertTrue(resolver.canResolve(typeOf<SecondaryOnlyClass>()))
106+
}
107+
108+
@Test
109+
fun `ClassResolver canResolve accepts class with empty constructor`() {
110+
assertTrue(resolver.canResolve(typeOf<EmptyConstructorClass>()))
111+
}
112+
113+
@Test
114+
fun `ClassResolver canResolve rejects well known types`() {
115+
assertFalse(resolver.canResolve(typeOf<String>()))
116+
assertFalse(resolver.canResolve(typeOf<Int>()))
117+
assertFalse(resolver.canResolve(typeOf<List<String>>()))
118+
assertFalse(resolver.canResolve(typeOf<Set<String>>()))
119+
assertFalse(resolver.canResolve(typeOf<Map<String, Int>>()))
120+
assertFalse(resolver.canResolve(typeOf<AbstractClass>()))
121+
assertFalse(resolver.canResolve(typeOf<TestSealed>()))
122+
}
123+
124+
@Test
125+
fun `ClassResolver resolves class with private constructor via reflection`() {
126+
val result = resolver.resolve(typeOf<PrivateConstructorClass>(), defaultTestChain)
127+
assertTrue(result is PrivateConstructorClass)
128+
}
129+
}

0 commit comments

Comments
 (0)