Skip to content
Merged
7 changes: 4 additions & 3 deletions src/main/kotlin/dev/appoutlet/some/Some.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Some(
) {
@Suppress("MemberNameEqualsClassName")
inline fun <reified T> some(): T {
val session = ResolverChain(resolvers)
val session = ResolverChain(resolvers, config.nullableStrategy)
return session.resolve(typeOf<T>()) as T
}

Expand All @@ -31,10 +31,11 @@ fun someSetup(config: SomeConfig.() -> Unit = {}): Some {
return Some(someConfig.buildResolvers(random), random, someConfig)
}

val defaultResolvers: List<TypeResolver> by lazy { SomeConfig().buildResolvers() }
val defaultConfig: SomeConfig by lazy { SomeConfig() }
val defaultResolvers: List<TypeResolver> by lazy { defaultConfig.buildResolvers() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Note that defaultResolvers is lazily initialized using the initial state of defaultConfig. Since SomeConfig properties (like nullableStrategy) are mutable, any changes made to defaultConfig after defaultResolvers has been accessed will not be reflected in the existing resolvers. This creates a mismatch where the ResolverChain uses the updated strategy but the resolvers continue using the old one. Consider making SomeConfig immutable or refactoring resolvers to access strategies dynamically from the session context.


inline fun <reified T> some(): T {
val session = ResolverChain(defaultResolvers)
val session = ResolverChain(defaultResolvers, defaultConfig.nullableStrategy)
return session.resolve(typeOf<T>()) as T
}

Expand Down
13 changes: 13 additions & 0 deletions src/main/kotlin/dev/appoutlet/some/config/NullableStrategy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ package dev.appoutlet.some.config
*
* ## Available Strategies
*
* - [NullOnCircularReference] – (Default) Returns `null` when a circular reference is detected for a nullable type
* - [AlwaysNull] – Always produces `null` for nullable types
* - [NeverNull] – Always produces non-null values for nullable types
* - [Random] – Produces `null` based on a configurable probability
*
* ## Example Usage
*
* ```kotlin
* // Default strategy
* some { nullableStrategy = NullableStrategy.NullOnCircularReference }
*
* // Always generate null values
* some { nullableStrategy = NullableStrategy.AlwaysNull }
*
Expand All @@ -31,6 +35,15 @@ package dev.appoutlet.some.config
* ```
*/
sealed interface NullableStrategy {
/**
* Returns `null` when a circular reference is detected for a nullable type.
*
* This is the default strategy. It allows generating recursive data structures
* by resolving the recursive field to `null` when it's nullable.
* Non-nullable circular references still throw an exception.
*/
object NullOnCircularReference : NullableStrategy

/**
* Always returns `null` for nullable types.
*
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import kotlin.random.Random
import kotlin.reflect.KClass

data class SomeConfig(
var nullableStrategy: NullableStrategy = NullableStrategy.Random(),
var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
var stringStrategy: StringStrategy = StringStrategy.Random(),
var collectionStrategy: CollectionStrategy = CollectionStrategy(),
var seed: Long? = null,
Expand Down
19 changes: 18 additions & 1 deletion src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package dev.appoutlet.some.core

import dev.appoutlet.some.config.NullableStrategy
import dev.appoutlet.some.exception.SomeCircularReferenceException
import dev.appoutlet.some.exception.SomeUnresolvableTypeException
import kotlin.reflect.KType
import kotlin.reflect.full.createType

/**
* Resolution session that manages the type resolution chain and tracks circular dependencies.
Expand All @@ -11,7 +13,8 @@ import kotlin.reflect.KType
* Each call to `some()` creates a new instance of this session to ensure thread safety.
*/
class ResolverChain(
val resolvers: List<TypeResolver>
val resolvers: List<TypeResolver>,
private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference
Comment thread
MessiasLima marked this conversation as resolved.
Outdated
) {
private val resolutionStack = mutableListOf<KType>()

Expand All @@ -27,6 +30,12 @@ class ResolverChain(
throw SomeCircularReferenceException(type, resolutionStack.toList())
}

if (nullableStrategy is NullableStrategy.NullOnCircularReference && type.isMarkedNullable) {
Comment thread
MessiasLima marked this conversation as resolved.
Outdated
if (isCircularIgnoringNullability(type)) {
return null
}
}

resolutionStack.add(type)

try {
Expand All @@ -41,4 +50,12 @@ class ResolverChain(
resolutionStack.removeAt(resolutionStack.lastIndex)
}
}

private fun isCircularIgnoringNullability(type: KType): Boolean {
val nonNullableType = type.classifier?.createType(type.arguments, false) ?: type
return resolutionStack.any {
val otherNonNullable = it.classifier?.createType(it.arguments, false) ?: it
otherNonNullable == nonNullableType
}
}
Comment thread
MessiasLima marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,20 @@ import kotlin.reflect.full.createType
/**
* Resolves nullable Kotlin types according to the configured [NullableStrategy].
*
* - **NullOnCircularReference** – delegates to the chain to resolve normally (might be null if a cycle is detected).
* - **AlwaysNull** – always returns `null`.
* - **NeverNull** – always resolves a non‑null value.
* - **Random** – returns `null` based on the strategy's probability.
*/
class NullableResolver(
private val nullableStrategy: NullableStrategy = NullableStrategy.Random(),
private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

There is now a redundancy between the nullableStrategy stored in this resolver and the one passed to ResolverChain. If the global defaultConfig is mutated after defaultResolvers has been initialized, these two values will diverge, leading to inconsistent behavior. To ensure consistency, this resolver should ideally retrieve the strategy from the chain parameter during the resolve call.

private val random: Random
) : TypeResolver {
override fun canResolve(type: KType): Boolean = type.isMarkedNullable

override fun resolve(type: KType, chain: ResolverChain): Any? {
return when (nullableStrategy) {
is NullableStrategy.NullOnCircularReference -> createNonNullInstance(type, chain)
is NullableStrategy.AlwaysNull -> null
is NullableStrategy.NeverNull -> createNonNullInstance(type, chain)
is NullableStrategy.Random -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package dev.appoutlet.some.integration

import dev.appoutlet.some.some
import dev.appoutlet.some.config.NullableStrategy
import dev.appoutlet.some.exception.SomeCircularReferenceException
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertFailsWith

class CircularReferenceIntegrationTest {
data class Node(val next: Node?)
data class StrictNode(val next: StrictNode)
data class IndirectA(val b: IndirectB?)
data class IndirectB(val a: IndirectA?)

@Test
fun `circular nullable field returns null under NullOnCircularReference by default`() {
val node: Node = some<Node>()
assertNull(node.next)
}

@Test
fun `circular non-nullable field still throws SomeCircularReferenceException`() {
assertFailsWith<SomeCircularReferenceException> {
some<StrictNode>()
}
}

@Test
fun `indirect circular reference returns null for nullable field`() {
val a: IndirectA = some<IndirectA>()
assertIs<IndirectB>(a.b)
assertNull(a.b.a)
}

@Test
fun `NeverNull strategy still throws on circular reference even if nullable`() {
assertFailsWith<SomeCircularReferenceException> {
some<Node> {
nullableStrategy = NullableStrategy.NeverNull
}
}
}

@Test
fun `AlwaysNull strategy handles circular reference by returning null`() {
// In this case NullableResolver will return null even before ResolverChain detects a cycle
val node: Node = some<Node> {
nullableStrategy = NullableStrategy.AlwaysNull
}
assertNull(node.next)
}

@Test
fun `Random strategy still throws on circular reference when it decides to resolve non-null`() {
// With probability 0, it's like NeverNull
assertFailsWith<SomeCircularReferenceException> {
some<Node> {
nullableStrategy = NullableStrategy.Random(probability = 0.0)
}
}
}
}
Loading