diff --git a/docs/supported-types.md b/docs/supported-types.md index 88555a7a..485c2729 100644 --- a/docs/supported-types.md +++ b/docs/supported-types.md @@ -25,6 +25,7 @@ For types not listed here, register a [custom factory](custom-factories.md). | **Standard Library** | | | | `kotlin.uuid.Uuid` | `some()` | Requires `@OptIn(ExperimentalUuidApi::class)` | | `java.util.UUID` | `some()` | | +| `java.util.Optional` | `some>()` | See [NullableStrategy](configuration/nullable-strategy.md) | | `kotlin.time.Duration` | `some()` | | | `java.time.Duration` | `some()` | | | `kotlin.time.Instant` | `some()` | | diff --git a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt index f8fe267c..73ae3280 100644 --- a/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt +++ b/src/main/kotlin/dev/appoutlet/some/config/SomeConfig.kt @@ -30,6 +30,7 @@ import dev.appoutlet.some.resolver.MapResolver import dev.appoutlet.some.resolver.NullableResolver import dev.appoutlet.some.resolver.NumberResolver import dev.appoutlet.some.resolver.ObjectResolver +import dev.appoutlet.some.resolver.OptionalResolver import dev.appoutlet.some.resolver.SealedClassResolver import dev.appoutlet.some.resolver.SetResolver import dev.appoutlet.some.resolver.ShortResolver @@ -118,6 +119,7 @@ data class SomeConfig( JavaInstantResolver(random), JavaDurationResolver(random), JavaZonedDateTimeResolver(random), + OptionalResolver(this[NullableStrategy::class], random), BigDecimalResolver(random), BigIntegerResolver(random), LocalDateResolver(random), diff --git a/src/main/kotlin/dev/appoutlet/some/resolver/OptionalResolver.kt b/src/main/kotlin/dev/appoutlet/some/resolver/OptionalResolver.kt new file mode 100644 index 00000000..317a13b8 --- /dev/null +++ b/src/main/kotlin/dev/appoutlet/some/resolver/OptionalResolver.kt @@ -0,0 +1,85 @@ +package dev.appoutlet.some.resolver + +import dev.appoutlet.some.config.NullableStrategy +import dev.appoutlet.some.core.ResolverChain +import dev.appoutlet.some.core.TypeResolver +import dev.appoutlet.some.exception.SomeCircularReferenceException +import java.util.Optional +import kotlin.random.Random +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.full.isSubclassOf + +/** + * Resolves [Optional] types according to the configured [NullableStrategy]. + * + * - **AlwaysNull** – always returns [Optional.empty]. + * - **NeverNull** – always resolves a present [Optional]. + * - **Random** – returns [Optional.empty] based on the strategy's probability. + * - **NullOnCircularReference** – returns [Optional.empty] if a circular reference is detected for the optional field. + * + * @param nullableStrategy Strategy for resolving optional types. Defaults to [NullableStrategy.default] when null. + * @param random Random source used by [NullableStrategy.Random]. + */ +class OptionalResolver( + nullableStrategy: NullableStrategy?, + private val random: Random +) : TypeResolver { + /** + * Resolved nullable strategy used to decide whether the resulting [Optional] is empty or present. + * + * Initialized from the constructor parameter, falling back to [NullableStrategy.default] when null. + */ + private val nullableStrategy = nullableStrategy ?: NullableStrategy.default + + /** + * Returns `true` when [type] is a subclass of [Optional]. + * + * @param type The type to inspect. + * @return `true` if the type's classifier is a [KClass] that extends [Optional], `false` otherwise. + */ + override fun canResolve(type: KType): Boolean { + val kClass = type.classifier as? KClass<*> ?: return false + return kClass.isSubclassOf(Optional::class) + } + + /** + * Resolves an [Optional] of the wrapped type described by [type]. + * + * The behavior depends on the configured [nullableStrategy]: + * - [NullableStrategy.AlwaysNull] – always returns [Optional.empty]. + * - [NullableStrategy.NeverNull] – always returns a present [Optional] containing the resolved value. + * - [NullableStrategy.Random] – returns [Optional.empty] with probability defined by the strategy. + * - [NullableStrategy.NullOnCircularReference] – resolves the wrapped type and returns it inside a + * present [Optional]. If the chain throws [SomeCircularReferenceException], returns [Optional.empty]. + * + * @param type The [Optional] type to resolve. Its first type argument is used as the wrapped value type. + * @param chain Resolver chain used to generate the wrapped value. + * @return An [Optional] that may be empty or present depending on the active strategy. + * @throws IllegalArgumentException If the [Optional] type uses a star projection. + */ + override fun resolve(type: KType, chain: ResolverChain): Any { + val valueType = requireNotNull(type.arguments.firstOrNull()?.type) { + "Star projection not supported in Optional" + } + + return when (nullableStrategy) { + is NullableStrategy.AlwaysNull -> Optional.empty() + is NullableStrategy.NeverNull -> Optional.ofNullable(chain.resolve(valueType)) + is NullableStrategy.Random -> { + if (random.nextDouble() < nullableStrategy.probability) { + Optional.empty() + } else { + Optional.ofNullable(chain.resolve(valueType)) + } + } + is NullableStrategy.NullOnCircularReference -> { + try { + Optional.ofNullable(chain.resolve(valueType)) + } catch (_: SomeCircularReferenceException) { + Optional.empty() + } + } + } + } +} diff --git a/src/test/kotlin/dev/appoutlet/some/resolver/OptionalResolverTest.kt b/src/test/kotlin/dev/appoutlet/some/resolver/OptionalResolverTest.kt new file mode 100644 index 00000000..88852228 --- /dev/null +++ b/src/test/kotlin/dev/appoutlet/some/resolver/OptionalResolverTest.kt @@ -0,0 +1,77 @@ +package dev.appoutlet.some.resolver + +import dev.appoutlet.some.config.NullableStrategy +import dev.appoutlet.some.some +import java.util.Optional +import kotlin.reflect.typeOf +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OptionalResolverTest { + @Test + fun `JavaOptionalResolver generates Optional values`() { + val result: Optional = some() + assertNotNull(result) + assertTrue(result.isPresent) + assertIs(result.get()) + } + + @Test + fun `JavaOptionalResolver with AlwaysNull strategy returns empty Optional`() { + val result: Optional = some { + strategy(NullableStrategy.AlwaysNull) + } + assertNotNull(result) + assertFalse(result.isPresent) + } + + @Test + fun `JavaOptionalResolver with NeverNull strategy always returns present Optional`() { + repeat(100) { + val result: Optional = some { + strategy(NullableStrategy.NeverNull) + } + assertNotNull(result) + assertTrue(result.isPresent) + } + } + + @Test + fun `JavaOptionalResolver with Random strategy can return empty or present Optional`() { + val results = (1..100).map { + some> { + strategy(NullableStrategy.Random(probability = 0.5)) + } + } + assertTrue(results.any { !it.isPresent }) + assertTrue(results.any { it.isPresent }) + } + + data class OptionalCircular(val optional: Optional) + + @Test + fun `JavaOptionalResolver handles circular references with empty Optional`() { + val result: OptionalCircular = some { + strategy(NullableStrategy.NullOnCircularReference) + } + assertNotNull(result) + assertFalse(result.optional.isPresent, "Circular reference should result in empty optional") + } + + @Test + fun `JavaOptionalResolver canResolve detects Optional types`() { + val resolver = OptionalResolver(NullableStrategy.default, kotlin.random.Random.Default) + assertTrue(resolver.canResolve(typeOf>())) + assertTrue(resolver.canResolve(typeOf>())) + } + + @Test + fun `JavaOptionalResolver rejects non-Optional types`() { + val resolver = OptionalResolver(NullableStrategy.default, kotlin.random.Random.Default) + assertFalse(resolver.canResolve(typeOf())) + assertFalse(resolver.canResolve(typeOf>())) + } +}