Skip to content

Commit c478326

Browse files
committed
docs: add kdocs
1 parent 957b781 commit c478326

7 files changed

Lines changed: 202 additions & 14 deletions

File tree

docs/configuration/index.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ All configuration is done through `SomeConfig`. Every option has sensible defaul
99

1010
```kotlin
1111
data class SomeConfig(
12-
var nullableStrategy: NullableStrategy = NullableStrategy.Random(),
12+
var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
1313
var stringStrategy: StringStrategy = StringStrategy.Random(),
1414
var collectionStrategy: CollectionStrategy = CollectionStrategy(),
1515
var seed: Long? = null,
@@ -68,7 +68,7 @@ val stillNeverNull: Person = baseSome()
6868

6969
### Copying configurations
7070

71-
`SomeConfig.copy()` creates a deep copy, including a fresh copy of the `factories` map. This prevents shared mutable state between config instances:
71+
`SomeConfig.copy()` creates a new configuration with a fresh copy of the `factories` map. This prevents shared mutable factory registrations between config instances:
7272

7373
```kotlin
7474
val base = SomeConfig().apply { stringStrategy = StringStrategy.Uuid }
@@ -79,7 +79,7 @@ val custom = base.copy(seed = 999L)
7979

8080
| Property | Default | Description | Docs |
8181
|----------|---------|-------------|----------------------------------------------|
82-
| `nullableStrategy` | `NullableStrategy.Random()` | 50% chance of `null` for nullable types | [NullableStrategy](nullable-strategy.md) |
82+
| `nullableStrategy` | `NullableStrategy.NullOnCircularReference` | `null` for nullable circular references | [NullableStrategy](nullable-strategy.md) |
8383
| `stringStrategy` | `StringStrategy.Random()` | Random lowercase alphabetic, 8 characters | [StringStrategy](string-strategy.md) |
8484
| `collectionStrategy` | `CollectionStrategy()` | Collections of 1 to 5 elements | [CollectionStrategy](collection-strategy.md) |
8585
| `seed` | `null` | Uses non-deterministic `Random.Default` ||

docs/configuration/nullable-strategy.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,38 @@
22

33
Controls whether nullable types produce `null` or a concrete value during fixture generation.
44

5+
The default is `NullableStrategy.NullOnCircularReference`, which prefers concrete values and only returns `null`
6+
when a nullable type would otherwise create a circular reference.
7+
58
## Variants
69

10+
### `NullOnCircularReference`
11+
12+
Produces concrete values for nullable types unless resolving the value would create a circular reference.
13+
14+
```kotlin
15+
some { nullableStrategy = NullableStrategy.NullOnCircularReference }
16+
```
17+
18+
This is the default strategy.
19+
20+
```kotlin
21+
data class Node(val next: Node?)
22+
23+
val node = some<Node>()
24+
25+
node.next // null
26+
```
27+
28+
The nullable circular field is resolved as `null`, so generation stops instead of recursing forever.
29+
Non-nullable circular references still throw `SomeCircularReferenceException` because no finite value can satisfy them:
30+
31+
```kotlin
32+
data class StrictNode(val next: StrictNode)
33+
34+
some<StrictNode>() // throws SomeCircularReferenceException
35+
```
36+
737
### `AlwaysNull`
838

939
Always produces `null` for nullable types.
@@ -38,10 +68,10 @@ some { nullableStrategy = NullableStrategy.Random(probability = 0.2) }
3868
// 80% chance of null (mostly null values)
3969
some { nullableStrategy = NullableStrategy.Random(probability = 0.8) }
4070

41-
// Never generate null (0% chance) equivalent to NeverNull
71+
// Never generate null (0% chance) - equivalent to NeverNull
4272
some { nullableStrategy = NullableStrategy.Random(probability = 0.0) }
4373

44-
// Always generate null (100% chance) equivalent to AlwaysNull
74+
// Always generate null (100% chance) - equivalent to AlwaysNull
4575
some { nullableStrategy = NullableStrategy.Random(probability = 1.0) }
4676
```
4777

@@ -53,13 +83,14 @@ some { nullableStrategy = NullableStrategy.Random(probability = 1.0) }
5383
| `0.5` | 50% chance (default) |
5484
| `1.0` | Always produces `null` (equivalent to `AlwaysNull`) |
5585

56-
The default probability is `0.5`, giving an equal chance of null or non-null values — useful for testing a mix of scenarios.
86+
The default probability is `0.5`, giving an equal chance of null or non-null values.
5787

5888
## Summary table
5989

6090
| Strategy | Behavior |
6191
|----------|----------|
92+
| `NullOnCircularReference` | Produces concrete nullable values unless a circular reference requires `null` (default) |
6293
| `AlwaysNull` | Always produces `null` for nullable types |
6394
| `NeverNull` | Always produces a non-null value |
64-
| `Random()` | 50% chance of `null` (default) |
65-
| `Random(probability)` | Custom probability of `null` (0.01.0) |
95+
| `Random()` | 50% chance of `null` |
96+
| `Random(probability)` | Custom probability of `null` (0.0-1.0) |

src/main/kotlin/dev/appoutlet/some/Some.kt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,99 @@ import dev.appoutlet.some.core.TypeResolver
66
import kotlin.random.Random
77
import kotlin.reflect.typeOf
88

9+
/**
10+
* Fixture generator configured with a resolver chain and shared random source.
11+
*
12+
* Instances are created by [someSetup] and can be reused to generate multiple values with the same configuration.
13+
*
14+
* @param resolvers Ordered resolver list used to generate values.
15+
* @param random Random source shared by resolvers created for this instance.
16+
* @param config Configuration used by this generator.
17+
*/
918
class Some(
1019
val resolvers: List<TypeResolver>,
1120
val random: Random,
1221
val config: SomeConfig
1322
) {
23+
/**
24+
* Generates a fixture value of type [T] using this instance's configuration.
25+
*
26+
* @param T Type to generate.
27+
* @return Generated value of type [T].
28+
*/
1429
@Suppress("MemberNameEqualsClassName")
1530
inline fun <reified T> some(): T {
1631
val session = ResolverChain(resolvers, config.nullableStrategy)
1732
return session.resolve(typeOf<T>()) as T
1833
}
1934

35+
/**
36+
* Generates a fixture value of type [T].
37+
*
38+
* Enables concise usage such as `some<User>()` when `some` is a [Some] instance.
39+
*
40+
* @param T Type to generate.
41+
* @return Generated value of type [T].
42+
*/
2043
inline operator fun <reified T> invoke(): T = some()
2144

45+
/**
46+
* Generates a fixture value of type [T] with per-call configuration overrides.
47+
*
48+
* Overrides are applied to a copy of this instance's configuration and do not mutate the original [Some].
49+
*
50+
* @param T Type to generate.
51+
* @param config Configuration overrides for this call.
52+
* @return Generated value of type [T].
53+
*/
2254
inline operator fun <reified T> invoke(noinline config: SomeConfig.() -> Unit = {}): T {
2355
val aggregatedConfig = this.config.copy().apply(config)
2456
return Some(aggregatedConfig.buildResolvers(random), random, aggregatedConfig).some()
2557
}
2658
}
2759

60+
/**
61+
* Creates a reusable [Some] generator.
62+
*
63+
* Use this when multiple fixtures should share the same configuration and random source.
64+
*
65+
* @param config Configuration applied to the created generator.
66+
* @return A configured [Some] instance.
67+
*/
2868
fun someSetup(config: SomeConfig.() -> Unit = {}): Some {
2969
val someConfig = SomeConfig().apply(config)
3070
val random = someConfig.buildRandom()
3171
return Some(someConfig.buildResolvers(random), random, someConfig)
3272
}
3373

74+
/**
75+
* Lazily-created default configuration used by top-level [some].
76+
*/
3477
val defaultConfig: SomeConfig by lazy { SomeConfig() }
78+
79+
/**
80+
* Lazily-created default resolver chain used by top-level [some].
81+
*/
3582
val defaultResolvers: List<TypeResolver> by lazy { defaultConfig.buildResolvers() }
3683

84+
/**
85+
* Generates a fixture value using the default configuration.
86+
*
87+
* @param T Type to generate.
88+
* @return Generated value of type [T].
89+
*/
3790
inline fun <reified T> some(): T {
3891
val session = ResolverChain(defaultResolvers, defaultConfig.nullableStrategy)
3992
return session.resolve(typeOf<T>()) as T
4093
}
4194

95+
/**
96+
* Generates a fixture value using one-off configuration overrides.
97+
*
98+
* @param T Type to generate.
99+
* @param config Configuration applied only to this generation call.
100+
* @return Generated value of type [T].
101+
*/
42102
inline fun <reified T> some(noinline config: SomeConfig.() -> Unit = {}): T {
43103
val someSetup = someSetup(config)
44104
return someSetup.some<T>()

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ import kotlin.reflect.KClass
4141
* Controls strategies for nullable types, strings, collections, random seeding,
4242
* and custom factory registrations.
4343
*
44-
* @param nullableStrategy Strategy for handling nullable type resolution. Defaults to [NullableStrategy.NullOnCircularReference].
44+
* @param nullableStrategy Strategy for handling nullable type resolution.
45+
* Defaults to [NullableStrategy.NullOnCircularReference].
4546
* @param stringStrategy Strategy for generating string values. Defaults to [StringStrategy.Random].
4647
* @param collectionStrategy Strategy for generating collection sizes. Defaults to [CollectionStrategy].
4748
* @param seed Seed for reproducible random generation. If null, uses [Random.Default].

src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@ import dev.appoutlet.some.config.NullableStrategy
44
import dev.appoutlet.some.exception.SomeCircularReferenceException
55
import dev.appoutlet.some.exception.SomeUnresolvableTypeException
66
import kotlin.reflect.KType
7-
import kotlin.reflect.full.createType
87

98
/**
109
* Resolution session that manages the type resolution chain and tracks circular dependencies.
1110
*
1211
* This class maintains a mutable stack of types currently being resolved to detect circular references.
1312
* Each call to `some()` creates a new instance of this session to ensure thread safety.
13+
*
14+
* @param resolvers Ordered resolver list. The first resolver that supports a type is used.
15+
* @param nullableStrategy Strategy used when a circular reference is detected for a nullable type.
1416
*/
1517
class ResolverChain(
1618
val resolvers: List<TypeResolver>,
17-
private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference
19+
private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
1820
) {
1921
private val resolutionStack = mutableListOf<KType>()
2022

@@ -25,6 +27,18 @@ class ResolverChain(
2527
val stack: List<KType>
2628
get() = resolutionStack.toList()
2729

30+
/**
31+
* Resolves a value for [type] using the first matching resolver.
32+
*
33+
* The type is added to the resolution stack while it is being resolved and removed even if resolution fails.
34+
* If the type would create a circular reference, [handleCircularReference] decides whether to return `null`
35+
* or throw based on the configured [NullableStrategy].
36+
*
37+
* @param type Type to resolve.
38+
* @return A generated value for [type], or `null` when nullable circular references are allowed.
39+
* @throws SomeCircularReferenceException when a circular reference cannot be represented as `null`.
40+
* @throws SomeUnresolvableTypeException when no resolver supports [type].
41+
*/
2842
fun resolve(type: KType): Any? {
2943
if (detectCircularReference(type)) {
3044
return handleCircularReference(type)
@@ -45,6 +59,14 @@ class ResolverChain(
4559
}
4660
}
4761

62+
/**
63+
* Returns whether [type] would repeat a classifier already on the resolution stack.
64+
*
65+
* Classifier comparison treats `T` and `T?` as the same logical type, which is required to detect recursive
66+
* fields such as `data class Node(val next: Node?)`. The non-nullable case immediately after a nullable stack
67+
* entry is not circular: that is the expected path where [dev.appoutlet.some.resolver.NullableResolver]
68+
* unwraps `T?` into `T` before resolving the concrete value.
69+
*/
4870
private fun detectCircularReference(type: KType): Boolean {
4971
val sameClassifierDetected = resolutionStack.any { it.classifier == type.classifier }
5072

@@ -56,9 +78,19 @@ class ResolverChain(
5678
}
5779
}
5880

81+
/**
82+
* Handles a circular reference that was detected for [type].
83+
*
84+
* Nullable circular references can be represented as `null` when the configured strategy allows it. Non-nullable
85+
* circular references always throw because there is no finite value that satisfies the type.
86+
*
87+
* @param type Type that would create a circular reference.
88+
* @return `null` when [type] is nullable and the nullable strategy allows null for circular references.
89+
* @throws SomeCircularReferenceException when the circular reference cannot be resolved as `null`.
90+
*/
5991
private fun handleCircularReference(type: KType): Nothing? {
60-
val strategyAllowsNull = nullableStrategy is NullableStrategy.AlwaysNull ||
61-
nullableStrategy is NullableStrategy.NullOnCircularReference
92+
val strategyAllowsNull = nullableStrategy is NullableStrategy.AlwaysNull ||
93+
nullableStrategy is NullableStrategy.NullOnCircularReference
6294

6395
if (type.isMarkedNullable && strategyAllowsNull) {
6496
return null

src/main/kotlin/dev/appoutlet/some/resolver/NullableResolver.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,27 @@ import kotlin.reflect.full.createType
1212
*
1313
* - **NullOnCircularReference** – delegates to the chain to resolve normally (might be null if a cycle is detected).
1414
* - **AlwaysNull** – always returns `null`.
15-
* - **NeverNull** – always resolves a nonnull value.
15+
* - **NeverNull** – always resolves a non-null value.
1616
* - **Random** – returns `null` based on the strategy's probability.
17+
*
18+
* @param nullableStrategy Strategy used to decide whether nullable types should resolve to `null`.
19+
* @param random Random source used by [NullableStrategy.Random].
1720
*/
1821
class NullableResolver(
1922
private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
2023
private val random: Random
2124
) : TypeResolver {
2225
override fun canResolve(type: KType): Boolean = type.isMarkedNullable
2326

27+
/**
28+
* Resolves [type] according to [nullableStrategy].
29+
*
30+
* Strategies that choose a concrete value resolve the non-null version of [type] through [chain].
31+
*
32+
* @param type Nullable type to resolve.
33+
* @param chain Resolver chain used to create non-null values when needed.
34+
* @return `null` or a generated non-null value for [type].
35+
*/
2436
override fun resolve(type: KType, chain: ResolverChain): Any? {
2537
return when (nullableStrategy) {
2638
is NullableStrategy.NullOnCircularReference -> createNonNullInstance(type, chain)
@@ -37,6 +49,12 @@ class NullableResolver(
3749
}
3850
}
3951

52+
/**
53+
* Resolves the non-null version of [type] through [chain].
54+
*
55+
* Circular references are still detected by [ResolverChain], which decides whether the current strategy allows
56+
* the circular value to be represented as `null`.
57+
*/
4058
private fun createNonNullInstance(
4159
type: KType,
4260
chain: ResolverChain
@@ -45,6 +63,9 @@ class NullableResolver(
4563
return chain.resolve(nonNullType)
4664
}
4765

66+
/**
67+
* Creates a copy of [type] with the nullable marker removed.
68+
*/
4869
private fun createNonNullType(type: KType): KType {
4970
return type.classifier?.createType(type.arguments, false) ?: type
5071
}

src/test/kotlin/dev/appoutlet/some/integration/CircularReferenceIntegrationTest.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,49 @@ class CircularReferenceIntegrationTest {
2020
assertNull(node.next)
2121
}
2222

23+
@Test
24+
fun `nullable top-level circular type returns instance with null circular field by default`() {
25+
val node: Node? = some<Node?>()
26+
27+
val result = assertIs<Node>(node)
28+
assertNull(result.next)
29+
}
30+
31+
@Test
32+
fun `nullable top-level circular type returns instance with null circular field under NullOnCircularReference`() {
33+
val node: Node? = some<Node?> {
34+
nullableStrategy = NullableStrategy.NullOnCircularReference
35+
}
36+
37+
val result = assertIs<Node>(node)
38+
assertNull(result.next)
39+
}
40+
41+
@Test
42+
fun `nullable top-level circular type throws under NeverNull strategy`() {
43+
assertFailsWith<SomeCircularReferenceException> {
44+
some<Node?> {
45+
nullableStrategy = NullableStrategy.NeverNull
46+
}
47+
}
48+
}
49+
50+
@Test
51+
fun `nullable top-level indirect circular type returns instance with null circular field by default`() {
52+
val a: IndirectA? = some<IndirectA?>()
53+
54+
val result = assertIs<IndirectA>(a)
55+
val b = assertIs<IndirectB>(result.b)
56+
assertNull(b.a)
57+
}
58+
59+
@Test
60+
fun `nullable top-level strict circular type still throws by default`() {
61+
assertFailsWith<SomeCircularReferenceException> {
62+
some<StrictNode?>()
63+
}
64+
}
65+
2366
@Test
2467
fun `circular non-nullable field still throws SomeCircularReferenceException`() {
2568
assertFailsWith<SomeCircularReferenceException> {

0 commit comments

Comments
 (0)