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
1 change: 1 addition & 0 deletions .ignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
docs/reference
45 changes: 26 additions & 19 deletions docs/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,33 @@ icon: lucide/settings
---
# Configuration

All configuration is done through `SomeConfig`. Every option has sensible defaults so you can start using `some()` immediately without any setup.
All configuration is done through `SomeConfigBuilder`. Every option has sensible defaults so you can start using `some()` immediately without any setup.

## The SomeConfig class
## The SomeConfigBuilder class

Configuration lambdas (`someSetup {}`, `some {}`) receive a `SomeConfigBuilder` receiver with mutable properties. Calling `build()` produces an immutable `SomeConfig`.

```kotlin
class SomeConfigBuilder {
var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference
var stringStrategy: StringStrategy = StringStrategy.Random()
var collectionStrategy: CollectionStrategy = CollectionStrategy()
var seed: Long? = null

fun <T : Any> register(kClass: KClass<T>, factory: FixtureContext.() -> T)
fun build(): SomeConfig
}
```

The resulting `SomeConfig` is immutable:

```kotlin
data class SomeConfig(
var nullableStrategy: NullableStrategy = NullableStrategy.Random(),
var stringStrategy: StringStrategy = StringStrategy.Random(),
var collectionStrategy: CollectionStrategy = CollectionStrategy(),
var seed: Long? = null,
val factories: MutableMap<KClass<*>, FixtureContext.() -> Any?> = mutableMapOf(),
val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
val stringStrategy: StringStrategy = StringStrategy.Random(),
val collectionStrategy: CollectionStrategy = CollectionStrategy(),
val seed: Long? = null,
val factories: Map<KClass<*>, FixtureContext.() -> Any?> = emptyMap(),
)
```

Expand Down Expand Up @@ -45,7 +61,7 @@ val user = some<User> {
}
```

The inline form creates a copy of the default configuration, applies your overrides, and generates a single instance — without affecting the global defaults.
The inline form creates a new builder from the default configuration, applies your overrides, and generates a single instance — without affecting the global defaults.

### Aggregated configuration

Expand All @@ -66,20 +82,11 @@ val result: Person = baseSome {
val stillNeverNull: Person = baseSome()
```

### Copying configurations

`SomeConfig.copy()` creates a deep copy, including a fresh copy of the `factories` map. This prevents shared mutable state between config instances:

```kotlin
val base = SomeConfig().apply { stringStrategy = StringStrategy.Uuid }
val custom = base.copy(seed = 999L)
```

## Default configuration

| Property | Default | Description | Docs |
|----------|---------|-------------|----------------------------------------------|
| `nullableStrategy` | `NullableStrategy.Random()` | 50% chance of `null` for nullable types | [NullableStrategy](nullable-strategy.md) |
| `nullableStrategy` | `NullableStrategy.NullOnCircularReference` | `null` for nullable circular references | [NullableStrategy](nullable-strategy.md) |
| `stringStrategy` | `StringStrategy.Random()` | Random lowercase alphabetic, 8 characters | [StringStrategy](string-strategy.md) |
| `collectionStrategy` | `CollectionStrategy()` | Collections of 1 to 5 elements | [CollectionStrategy](collection-strategy.md) |
| `seed` | `null` | Uses non-deterministic `Random.Default` | — |
Expand All @@ -101,7 +108,7 @@ Internally, `SomeConfig.buildRandom()` creates a seeded `kotlin.random.Random` i

## Custom factories

`SomeConfig` holds a `factories` map where you can register custom factory functions for specific types:
`SomeConfigBuilder` provides a `register` function to add custom factory functions for specific types:

```kotlin
val some = someSetup {
Expand Down
41 changes: 36 additions & 5 deletions docs/configuration/nullable-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,38 @@

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

The default is `NullableStrategy.NullOnCircularReference`, which prefers concrete values and only returns `null`
when a nullable type would otherwise create a circular reference.

## Variants

### `NullOnCircularReference`

Produces concrete values for nullable types unless resolving the value would create a circular reference.

```kotlin
some { nullableStrategy = NullableStrategy.NullOnCircularReference }
```

This is the default strategy.

```kotlin
data class Node(val next: Node?)

val node = some<Node>()

node.next // null
```

The nullable circular field is resolved as `null`, so generation stops instead of recursing forever.
Non-nullable circular references still throw `SomeCircularReferenceException` because no finite value can satisfy them:

```kotlin
data class StrictNode(val next: StrictNode)

some<StrictNode>() // throws SomeCircularReferenceException
```

### `AlwaysNull`

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

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

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

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

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

## Summary table

| Strategy | Behavior |
|----------|----------|
| `NullOnCircularReference` | Produces concrete nullable values unless a circular reference requires `null` (default) |
| `AlwaysNull` | Always produces `null` for nullable types |
| `NeverNull` | Always produces a non-null value |
| `Random()` | 50% chance of `null` (default) |
| `Random(probability)` | Custom probability of `null` (0.01.0) |
| `Random()` | 50% chance of `null` |
| `Random(probability)` | Custom probability of `null` (0.0-1.0) |
78 changes: 70 additions & 8 deletions src/main/kotlin/dev/appoutlet/some/Some.kt
Original file line number Diff line number Diff line change
@@ -1,44 +1,106 @@
package dev.appoutlet.some

import dev.appoutlet.some.config.SomeConfig
import dev.appoutlet.some.config.SomeConfigBuilder
import dev.appoutlet.some.core.ResolverChain
import dev.appoutlet.some.core.TypeResolver
import kotlin.random.Random
import kotlin.reflect.typeOf

/**
* Fixture generator configured with a resolver chain and shared random source.
*
* Instances are created by [someSetup] and can be reused to generate multiple values with the same configuration.
*
* @param resolvers Ordered resolver list used to generate values.
* @param random Random source shared by resolvers created for this instance.
* @param config Configuration used by this generator.
*/
class Some(
val resolvers: List<TypeResolver>,
val random: Random,
val config: SomeConfig
) {
/**
* Generates a fixture value of type [T] using this instance's configuration.
*
* @param T Type to generate.
* @return Generated value of type [T].
*/
@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
}

/**
* Generates a fixture value of type [T].
*
* Enables concise usage such as `some<User>()` when `some` is a [Some] instance.
*
* @param T Type to generate.
* @return Generated value of type [T].
*/
inline operator fun <reified T> invoke(): T = some()

inline operator fun <reified T> invoke(noinline config: SomeConfig.() -> Unit = {}): T {
val aggregatedConfig = this.config.copy().apply(config)
/**
* Generates a fixture value of type [T] with per-call configuration overrides.
*
* Overrides are applied to a copy of this instance's configuration and do not mutate the original [Some].
*
* @param T Type to generate.
* @param config Configuration overrides for this call.
* @return Generated value of type [T].
*/
inline operator fun <reified T> invoke(noinline config: SomeConfigBuilder.() -> Unit = {}): T {
val aggregatedConfig = this.config.toBuilder().apply(config).build()
return Some(aggregatedConfig.buildResolvers(random), random, aggregatedConfig).some()
}
}

fun someSetup(config: SomeConfig.() -> Unit = {}): Some {
val someConfig = SomeConfig().apply(config)
/**
* Creates a reusable [Some] generator.
*
* Use this when multiple fixtures should share the same configuration and random source.
*
* @param config Configuration applied to the created generator.
* @return A configured [Some] instance.
*/
fun someSetup(config: SomeConfigBuilder.() -> Unit = {}): Some {
val someConfig = SomeConfigBuilder().apply(config).build()
val random = someConfig.buildRandom()
return Some(someConfig.buildResolvers(random), random, someConfig)
}

val defaultResolvers: List<TypeResolver> by lazy { SomeConfig().buildResolvers() }
/**
* Lazily-created default configuration used by top-level [some].
*/
val defaultConfig: SomeConfig by lazy { SomeConfig() }

/**
* Lazily-created default resolver chain used by top-level [some].
*/
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.


/**
* Generates a fixture value using the default configuration.
*
* @param T Type to generate.
* @return Generated value of type [T].
*/
inline fun <reified T> some(): T {
val session = ResolverChain(defaultResolvers)
val session = ResolverChain(defaultResolvers, defaultConfig.nullableStrategy)
return session.resolve(typeOf<T>()) as T
}

inline fun <reified T> some(noinline config: SomeConfig.() -> Unit = {}): T {
/**
* Generates a fixture value using one-off configuration overrides.
*
* @param T Type to generate.
* @param config Configuration applied only to this generation call.
* @return Generated value of type [T].
*/
inline fun <reified T> some(noinline config: SomeConfigBuilder.() -> Unit = {}): T {
val someSetup = someSetup(config)
return someSetup.some<T>()
}
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
68 changes: 43 additions & 25 deletions src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,38 +35,51 @@ import dev.appoutlet.some.resolver.ValueClassResolver
import kotlin.random.Random
import kotlin.reflect.KClass

/**
* Immutable configuration for customizing the behavior of [some][dev.appoutlet.some.some] fixture generation.
*
* Controls strategies for nullable types, strings, collections, random seeding,
* and custom factory registrations.
*
* Use [SomeConfigBuilder] to create instances via the DSL-style configuration lambdas,
* or construct directly. Use [toBuilder] to derive a new configuration from an existing one.
*
* @param nullableStrategy Strategy for handling nullable type resolution.
* Defaults to [NullableStrategy.NullOnCircularReference].
* @param stringStrategy Strategy for generating string values. Defaults to [StringStrategy.Random].
* @param collectionStrategy Strategy for generating collection sizes. Defaults to [CollectionStrategy].
* @param seed Seed for reproducible random generation. If null, uses [Random.Default].
* @param factories Map of custom factory functions registered for specific types.
*/
data class SomeConfig(
var nullableStrategy: NullableStrategy = NullableStrategy.Random(),
var stringStrategy: StringStrategy = StringStrategy.Random(),
var collectionStrategy: CollectionStrategy = CollectionStrategy(),
var seed: Long? = null,
val factories: MutableMap<KClass<*>, FixtureContext.() -> Any?> = mutableMapOf(),
val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
val stringStrategy: StringStrategy = StringStrategy.Random(),
val collectionStrategy: CollectionStrategy = CollectionStrategy(),
val seed: Long? = null,
val factories: Map<KClass<*>, FixtureContext.() -> Any?> = emptyMap(),
) {
fun <T : Any> register(kClass: KClass<T>, factory: FixtureContext.() -> T) {
factories[kClass] = factory
}

/**
* Creates a deep copy of this configuration, including a copy of the factories map
* to prevent shared mutable state between config instances.
* Creates a [SomeConfigBuilder] pre-populated with this configuration's values.
*
* Useful for deriving new configurations from an existing one without mutation.
*
* @return A [SomeConfigBuilder] containing this configuration's current state.
*/
fun copy(
nullableStrategy: NullableStrategy = this.nullableStrategy,
stringStrategy: StringStrategy = this.stringStrategy,
collectionStrategy: CollectionStrategy = this.collectionStrategy,
seed: Long? = this.seed,
): SomeConfig {
return SomeConfig(
nullableStrategy = nullableStrategy,
stringStrategy = stringStrategy,
collectionStrategy = collectionStrategy,
seed = seed,
factories = this.factories.toMutableMap()
)
fun toBuilder(): SomeConfigBuilder = SomeConfigBuilder().apply {
nullableStrategy = this@SomeConfig.nullableStrategy
stringStrategy = this@SomeConfig.stringStrategy
collectionStrategy = this@SomeConfig.collectionStrategy
seed = this@SomeConfig.seed
populateFactories(this@SomeConfig.factories)
}

/**
* Builds the list of resolvers configured with this config's strategies and random instance.
* Creates the resolver chain used to generate fixture values.
*
* Resolver order defines precedence: the first resolver that supports a type is used.
*
* @param random Random source shared by resolvers that generate randomized values.
* @return The ordered [TypeResolver] list for this configuration.
*/
fun buildResolvers(random: Random = buildRandom()): List<TypeResolver> {
return listOf(
Expand Down Expand Up @@ -103,5 +116,10 @@ data class SomeConfig(
)
}

/**
* Creates a [Random] instance for this configuration.
*
* @return [Random] seeded with [seed] if set, or [Random.Default] otherwise.
*/
internal fun buildRandom(): Random = seed?.let { Random(it) } ?: Random.Default
}
Loading