Skip to content
Closed
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
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,24 @@ class MyTypeResolverTest {
- Use custom exceptions in `dev.appoutlet.some.exception` package
- Exception classes extend `Exception` with descriptive messages

### Strategy and Factory Registration
Strategies and type factories are registered through [SomeConfigBuilder]:

```kotlin
someSetup {
// Register a custom strategy
strategy(MyCustomStrategy())

// Register a custom type factory (formerly register())
factory(MyType::class) {
MyType(name = "Custom")
}
}
```

### Resolver Registration Order (in SomeConfig)
Order matters - first match wins:
1. CustomTypeFactoryResolver (user overrides)
1. CustomTypeFactoryResolver (user overrides via factory())
2. NullableResolver
3. ObjectResolver, EnumResolver, SealedClassResolver, ValueClassResolver
4. Primitive resolvers (String, Int, Long, etc.)
Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/dev/appoutlet/some/Some.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class Some(
*/
@Suppress("MemberNameEqualsClassName")
inline fun <reified T> some(): T {
val session = ResolverChain(resolvers, config.nullableStrategy)
val session = ResolverChain(resolvers, config)
return session.resolve(typeOf<T>()) as T
}

Expand Down Expand Up @@ -89,7 +89,7 @@ val defaultResolvers: List<TypeResolver> by lazy { defaultConfig.buildResolvers(
* @return Generated value of type [T].
*/
inline fun <reified T> some(): T {
val session = ResolverChain(defaultResolvers, defaultConfig.nullableStrategy)
val session = ResolverChain(defaultResolvers, defaultConfig)
return session.resolve(typeOf<T>()) as T
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ private val defaultSizeRange = DEFAULT_SIZE_RANGE_START..DEFAULT_SIZE_RANGE_END

data class CollectionStrategy(
val sizeRange: IntRange = defaultSizeRange
) {
) : Strategy {
init {
require(sizeRange.first > -1) { "sizeRange.start must be positive" }
require(sizeRange.last > sizeRange.first) { "sizeRange.end must be greater than or equal to sizeRange.start" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ package dev.appoutlet.some.config
* some { nullableStrategy = NullableStrategy.Random(probability = 0.0) }
* ```
*/
sealed interface NullableStrategy {
sealed interface NullableStrategy : Strategy {
/**
* Returns `null` when a circular reference is detected for a nullable type.
*
Expand Down
52 changes: 27 additions & 25 deletions src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.appoutlet.some.config

import dev.appoutlet.some.core.FixtureContext
import dev.appoutlet.some.core.StrategyProvider
import dev.appoutlet.some.core.TypeResolver
import dev.appoutlet.some.resolver.ArrayResolver
import dev.appoutlet.some.resolver.BigDecimalResolver
Expand Down Expand Up @@ -38,17 +39,15 @@ import kotlin.reflect.KClass
/**
* Immutable configuration for customizing the behavior of [some][dev.appoutlet.some.some] fixture generation.
*
* Controls nullable handling, string generation, collection sizing, random seeding, type factories, and property
* factories. Each generation call uses a [SomeConfig] to build an ordered resolver list, and that resolver order
* defines which customization wins when multiple options could apply.
* Controls generation strategies, random seeding, type factories, and property factories. Each generation call uses
* a [SomeConfig] to build an ordered resolver list, and that resolver order defines which customization wins when
* multiple options could apply.
*
* Prefer [SomeConfigBuilder] through `some { ... }` or `someSetup { ... }` for user-facing configuration. Direct
* construction is useful for tests or library integrations that need to assemble configuration values explicitly.
* Use [toBuilder] to derive a mutable copy without mutating the original configuration.
*
* @param nullableStrategy Strategy for handling nullable type resolution.
* @param stringStrategy Strategy for generating values handled by [StringResolver].
* @param collectionStrategy Strategy for collection sizes handled by collection resolvers.
* @param strategies Map of strategies keyed by their [Strategy] implementation class.
* @param seed Seed for reproducible random generation. When `null`, [Random.Default] is used.
* @param typeFactories Custom type factories keyed by the exact class they override. These are resolved before all
* built-in resolvers.
Expand All @@ -57,14 +56,23 @@ import kotlin.reflect.KClass
* applied by [ClassResolver] while constructing model objects.
*/
data class SomeConfig(
val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
val stringStrategy: StringStrategy = StringStrategy.Random(),
val collectionStrategy: CollectionStrategy = CollectionStrategy(),
private val strategies: Map<KClass<out Strategy>, Strategy> = mapOf(
NullableStrategy::class to NullableStrategy.NullOnCircularReference,
StringStrategy::class to StringStrategy.Random(),
CollectionStrategy::class to CollectionStrategy(),
),
val defaultValueStrategy: DefaultValueStrategy = DefaultValueStrategy.UseDefault,
val seed: Long? = null,
val typeFactories: Map<KClass<*>, FixtureContext.() -> Any?> = emptyMap(),
val propertyFactories: Map<Pair<KClass<*>, String>, FixtureContext.() -> Any?> = emptyMap(),
) {
) : StrategyProvider {

@Suppress("UNCHECKED_CAST")
override fun <T : Strategy> get(key: KClass<T>): T {
return strategies[key] as? T
?: throw IllegalArgumentException("Strategy ${key.simpleName} not registered")
}
Comment on lines +70 to +74

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

Using as? T and throwing a "not registered" exception can be misleading if the strategy is registered but the cast fails due to a type mismatch. Performing an explicit null check first allows us to throw the correct IllegalArgumentException for missing strategies, while letting the cast throw a ClassCastException if there is an unexpected type mismatch.

Suggested change
@Suppress("UNCHECKED_CAST")
override fun <T : Strategy> get(key: KClass<T>): T {
return strategies[key] as? T
?: throw IllegalArgumentException("Strategy ${key.simpleName} not registered")
}
@Suppress("UNCHECKED_CAST")
override fun <T : Strategy> get(key: KClass<T>): T {
val strategy = strategies[key] ?: throw IllegalArgumentException("Strategy ${key.simpleName} not registered")
return strategy as T
}


/**
* Creates a [SomeConfigBuilder] pre-populated with this configuration's values.
*
Expand All @@ -74,9 +82,7 @@ data class SomeConfig(
* @return A [SomeConfigBuilder] containing this configuration's current state.
*/
fun toBuilder(): SomeConfigBuilder = SomeConfigBuilder().apply {
nullableStrategy = this@SomeConfig.nullableStrategy
stringStrategy = this@SomeConfig.stringStrategy
collectionStrategy = this@SomeConfig.collectionStrategy
populateStrategies(this@SomeConfig.strategies)
defaultValueStrategy = this@SomeConfig.defaultValueStrategy
seed = this@SomeConfig.seed
populateTypeFactories(this@SomeConfig.typeFactories)
Expand All @@ -98,17 +104,15 @@ data class SomeConfig(
CustomTypeFactoryResolver(
typeFactories = typeFactories,
random = random,
nullableStrategy = nullableStrategy,
stringStrategy = stringStrategy,
collectionStrategy = collectionStrategy,
strategyProvider = this,
defaultValueStrategy = defaultValueStrategy,
),
NullableResolver(nullableStrategy, random),
NullableResolver(this, random),
ObjectResolver(),
EnumResolver(random),
SealedClassResolver(random),
ValueClassResolver(),
StringResolver(stringStrategy, random),
StringResolver(this, random),
IntResolver(random),
LongResolver(random),
DoubleResolver(random),
Expand All @@ -127,16 +131,14 @@ data class SomeConfig(
BigIntegerResolver(random),
LocalDateResolver(random),
LocalDateTimeResolver(random),
ListResolver(collectionStrategy, random),
SetResolver(collectionStrategy, random),
MapResolver(collectionStrategy, random),
ArrayResolver(collectionStrategy, random),
ListResolver(this, random),
SetResolver(this, random),
MapResolver(this, random),
ArrayResolver(this, random),
ClassResolver(
propertyFactories = propertyFactories,
random = random,
nullableStrategy = nullableStrategy,
stringStrategy = stringStrategy,
collectionStrategy = collectionStrategy,
strategyProvider = this,
defaultValueStrategy = defaultValueStrategy,
)
)
Expand Down
53 changes: 46 additions & 7 deletions src/main/kotlin/dev/appoutlet/some/config/SomeConfigBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,25 @@ class SomeConfigBuilder {
* Strategy for handling nullable type resolution.
* Defaults to [NullableStrategy.NullOnCircularReference].
*/
var nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference
var nullableStrategy: NullableStrategy
get() = strategiesMap[NullableStrategy::class] as NullableStrategy
set(value) { strategiesMap[NullableStrategy::class] = value }

/**
* Strategy for generating string values.
* Defaults to [StringStrategy.Random].
*/
var stringStrategy: StringStrategy = StringStrategy.Random()
var stringStrategy: StringStrategy
get() = strategiesMap[StringStrategy::class] as StringStrategy
set(value) { strategiesMap[StringStrategy::class] = value }

/**
* Strategy for generating collection sizes.
* Defaults to [CollectionStrategy] with a range of 1..5.
*/
var collectionStrategy: CollectionStrategy = CollectionStrategy()
var collectionStrategy: CollectionStrategy
get() = strategiesMap[CollectionStrategy::class] as CollectionStrategy
set(value) { strategiesMap[CollectionStrategy::class] = value }

/**
* Strategy for handling data class constructor defaults.
Expand All @@ -43,9 +49,25 @@ class SomeConfigBuilder {
*/
var seed: Long? = null

@PublishedApi
internal val strategiesMap: MutableMap<KClass<out Strategy>, Strategy> = mutableMapOf(
NullableStrategy::class to NullableStrategy.NullOnCircularReference,
StringStrategy::class to StringStrategy.Random(),
CollectionStrategy::class to CollectionStrategy(),
)
private val _typeFactories: MutableMap<KClass<*>, FixtureContext.() -> Any?> = mutableMapOf()
private val _propertyFactories: MutableMap<Pair<KClass<*>, String>, FixtureContext.() -> Any?> = mutableMapOf()

/**
* Registers a strategy instance.
*
* @param T The strategy type.
* @param strategy The strategy instance to register.
*/
inline fun <reified T : Strategy> strategy(strategy: T) {
strategiesMap[T::class] = strategy
}
Comment on lines +67 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When registering a strategy using strategy(MyStrategy()) or strategy(NullableStrategy.AlwaysNull), the reified type T is inferred as the specific subclass/implementation type (e.g., NullableStrategy.AlwaysNull). This registers the strategy in _strategies under the key NullableStrategy.AlwaysNull::class.

However, the built-in resolvers (like NullableResolver, StringResolver, etc.) query the StrategyProvider using the base strategy interface class (e.g., NullableStrategy::class). Since the base interface key in the map is not updated, the custom strategy is completely ignored by the resolvers, and they continue to use the default strategy.

To fix this, we should also update any keys in _strategies that are supertypes of the registered strategy (i.e., where key.isInstance(strategy) is true).

    inline fun <reified T : Strategy> strategy(strategy: T) {
        _strategies[T::class] = strategy
        _strategies.keys.toList().forEach { key ->
            if (key.isInstance(strategy)) {
                _strategies[key] = strategy
            }
        }
    }


/**
* Registers a custom type factory function for type [T].
*
Expand All @@ -55,10 +77,18 @@ class SomeConfigBuilder {
* @param kClass The [KClass] of the type to override.
* @param typeFactory Lambda receiving a [FixtureContext] and returning a value of type [T].
*/
fun <T : Any> register(kClass: KClass<T>, typeFactory: FixtureContext.() -> T) {
fun <T : Any> factory(kClass: KClass<T>, typeFactory: FixtureContext.() -> T) {
_typeFactories[kClass] = typeFactory
}

/**
* Deprecated: Use [factory] instead.
*/
@Deprecated("Use factory() instead", ReplaceWith("factory(kClass, typeFactory)"))
fun <T : Any> register(kClass: KClass<T>, typeFactory: FixtureContext.() -> T) {
factory(kClass, typeFactory)
}

/**
* Registers a custom property factory function for a specific property of class [T].
*
Expand All @@ -73,6 +103,17 @@ class SomeConfigBuilder {
_propertyFactories[kClass to property.name] = factory
}

/**
* Populates the builder's strategy map with entries from an existing map.
*
* Used internally by [SomeConfig.toBuilder] to transfer strategy registrations.
*
* @param strategies Map of strategy registrations to copy into this builder.
*/
internal fun populateStrategies(strategies: Map<KClass<out Strategy>, Strategy>) {
strategiesMap.putAll(strategies)
}

/**
* Populates the builder's type factory map with entries from an existing map.
*
Expand Down Expand Up @@ -103,9 +144,7 @@ class SomeConfigBuilder {
* @return A new [SomeConfig] instance with the configured values.
*/
fun build(): SomeConfig = SomeConfig(
nullableStrategy = nullableStrategy,
stringStrategy = stringStrategy,
collectionStrategy = collectionStrategy,
strategies = strategiesMap.toMap(),
defaultValueStrategy = defaultValueStrategy,
seed = seed,
typeFactories = _typeFactories.toMap(),
Expand Down
6 changes: 6 additions & 0 deletions src/main/kotlin/dev/appoutlet/some/config/Strategy.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dev.appoutlet.some.config

/**
* Marker interface for all generation strategies.
*/
interface Strategy
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ package dev.appoutlet.some.config
* val some = someSetup { stringStrategy = StringStrategy.Uuid }
* ```
*/
sealed interface StringStrategy {
sealed interface StringStrategy : Strategy {
/**
* Generates random lowercase alphabetic strings.
* @param length The length of the generated string (default 8)
Expand Down
15 changes: 4 additions & 11 deletions src/main/kotlin/dev/appoutlet/some/core/FixtureContext.kt
Original file line number Diff line number Diff line change
@@ -1,34 +1,27 @@
package dev.appoutlet.some.core

import dev.appoutlet.some.config.CollectionStrategy
import dev.appoutlet.some.config.DefaultValueStrategy
import dev.appoutlet.some.config.NullableStrategy
import dev.appoutlet.some.config.StringStrategy
import kotlin.random.Random
import kotlin.reflect.KType

/**
* Runtime context provided as the receiver for custom factory functions.
*
* Both type factories registered with `register` and property factories registered with `property` receive this
* context. It exposes the same random source and generation strategies used by the resolver chain so custom values
* Both type factories registered with `factory` and property factories registered with `property` receive this
* context. It exposes the same random source and strategy provider used by the resolver chain so custom values
* can stay consistent with the active [dev.appoutlet.some.config.SomeConfig].
*
* The context is a snapshot for the current factory invocation. In particular, [resolutionStack] is immutable from the
* factory's perspective and should be used only for inspection or debugging, not for controlling resolver state.
*
* @property random Random source for factory-generated values. This respects the configured seed when one is set.
* @property resolutionStack Types currently being resolved, ordered from the outermost request to the current type.
* @property nullableStrategy Strategy currently used for nullable type handling.
* @property stringStrategy Strategy currently used for generated string values.
* @property collectionStrategy Strategy currently used for generated collection sizes.
* @property strategyProvider Provider for accessing configured generation strategies.
* @property defaultValueStrategy Strategy currently used for handling constructor defaults.
*/
data class FixtureContext(
val random: Random,
val resolutionStack: List<KType>,
val nullableStrategy: NullableStrategy,
val stringStrategy: StringStrategy,
val collectionStrategy: CollectionStrategy,
val strategyProvider: StrategyProvider,
val defaultValueStrategy: DefaultValueStrategy,
)
7 changes: 4 additions & 3 deletions src/main/kotlin/dev/appoutlet/some/core/ResolverChain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import kotlin.reflect.KType
* Each call to `some()` creates a new instance of this session to ensure thread safety.
*
* @param resolvers Ordered resolver list. The first resolver that supports a type is used.
* @param nullableStrategy Strategy used when a circular reference is detected for a nullable type.
* @param strategyProvider Provider used to retrieve the [NullableStrategy] when a circular reference is detected.
*/
class ResolverChain(
val resolvers: List<TypeResolver>,
private val nullableStrategy: NullableStrategy = NullableStrategy.NullOnCircularReference,
private val strategyProvider: StrategyProvider,
) {
private val resolutionStack = mutableListOf<KType>()

Expand Down Expand Up @@ -73,7 +73,7 @@ class ResolverChain(
return when {
sameClassifierDetected.not() -> false
type.isMarkedNullable -> true
resolutionStack.last().isMarkedNullable -> false
resolutionStack.lastOrNull()?.isMarkedNullable == true -> false
else -> true
}
}
Expand All @@ -89,6 +89,7 @@ class ResolverChain(
* @throws SomeCircularReferenceException when the circular reference cannot be resolved as `null`.
*/
private fun handleCircularReference(type: KType): Nothing? {
val nullableStrategy = strategyProvider[NullableStrategy::class]
val strategyAllowsNull = nullableStrategy is NullableStrategy.AlwaysNull ||
nullableStrategy is NullableStrategy.NullOnCircularReference

Expand Down
16 changes: 16 additions & 0 deletions src/main/kotlin/dev/appoutlet/some/core/StrategyProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package dev.appoutlet.some.core

import dev.appoutlet.some.config.Strategy
import kotlin.reflect.KClass

/**
* Provides access to configured [Strategy] instances.
*/
interface StrategyProvider {
/**
* Retrieves the [Strategy] of type [key].
*
* @throws IllegalArgumentException if the strategy is not registered.
*/
operator fun <T : Strategy> get(key: KClass<T>): T
}
Loading