diff --git a/api/konform.api b/api/konform.api index 2cb340c..37f8437 100644 --- a/api/konform.api +++ b/api/konform.api @@ -91,6 +91,7 @@ public class io/konform/validation/ValidationBuilder { public final fun dynamic (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function2;)V public final fun dynamic (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function2;)V protected final fun getConstraints ()Ljava/util/List; + public final fun getPath ()Lio/konform/validation/path/ValidationPath; protected final fun getSubValidations ()Ljava/util/List; public final fun hint (Lio/konform/validation/Constraint;Ljava/lang/String;)Lio/konform/validation/Constraint; public final fun ifPresent (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V @@ -115,6 +116,7 @@ public class io/konform/validation/ValidationBuilder { public final fun requiredOnNotNullProperty (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function1;)V public final fun run (Lio/konform/validation/Validation;)V public final fun runDynamic (Lkotlin/jvm/functions/Function1;)V + public final fun setPath (Lio/konform/validation/path/ValidationPath;)V public final fun userContext (Lio/konform/validation/Constraint;Ljava/lang/Object;)Lio/konform/validation/Constraint; public final fun validate (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V } diff --git a/api/konform.klib.api b/api/konform.klib.api index de3a549..60d3df6 100644 --- a/api/konform.klib.api +++ b/api/konform.klib.api @@ -311,6 +311,10 @@ open class <#A: kotlin/Any?> io.konform.validation/ValidationBuilder { // io.kon final val subValidations // io.konform.validation/ValidationBuilder.subValidations|{}subValidations[0] final fun (): kotlin.collections/MutableList> // io.konform.validation/ValidationBuilder.subValidations.|(){}[0] + final var path // io.konform.validation/ValidationBuilder.path|{}path[0] + final fun (): io.konform.validation.path/ValidationPath? // io.konform.validation/ValidationBuilder.path.|(){}[0] + final fun (io.konform.validation.path/ValidationPath?) // io.konform.validation/ValidationBuilder.path.|(io.konform.validation.path.ValidationPath?){}[0] + final fun (io.konform.validation/Constraint<#A>).hint(kotlin/String): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.hint|hint@io.konform.validation.Constraint<1:0>(kotlin.String){}[0] final fun (io.konform.validation/Constraint<#A>).path(io.konform.validation.path/ValidationPath): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.path|path@io.konform.validation.Constraint<1:0>(io.konform.validation.path.ValidationPath){}[0] final fun (io.konform.validation/Constraint<#A>).replace(kotlin/String = ..., io.konform.validation.path/ValidationPath = ..., kotlin/Any? = ...): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.replace|replace@io.konform.validation.Constraint<1:0>(kotlin.String;io.konform.validation.path.ValidationPath;kotlin.Any?){}[0] diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index 86ef6d6..e8cb5ba 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -29,6 +29,13 @@ public open class ValidationBuilder { protected val constraints: MutableList> = mutableListOf() protected val subValidations: MutableList> = mutableListOf() + /** + * Override the path for this validation. + * When set, this path replaces the automatic path segment that would normally be added. + * Use [ValidationPath.EMPTY] to suppress the path segment entirely. + */ + public var path: ValidationPath? = null + public open fun build(): Validation = subValidations .let { @@ -99,19 +106,31 @@ public open class ValidationBuilder { pathSegment: PathSegment, prop: (T) -> Iterable, init: ValidationBuilder.() -> Unit, - ) = run(CallableValidation(pathSegment, prop, IterableValidation(buildWithNew(init)))) + ) { + val (pathOverride, validation) = buildWithNewAndPath(init) + val effectivePath = pathOverride ?: pathSegment + run(CallableValidation(effectivePath, prop, IterableValidation(validation))) + } private fun onEachArray( pathSegment: PathSegment, prop: (T) -> Array, init: ValidationBuilder.() -> Unit, - ) = run(CallableValidation(pathSegment, prop, ArrayValidation(buildWithNew(init)))) + ) { + val (pathOverride, validation) = buildWithNewAndPath(init) + val effectivePath = pathOverride ?: pathSegment + run(CallableValidation(effectivePath, prop, ArrayValidation(validation))) + } private fun onEachMap( pathSegment: PathSegment, prop: (T) -> Map, init: ValidationBuilder>.() -> Unit, - ) = run(CallableValidation(pathSegment, prop, MapValidation(buildWithNew(init)))) + ) { + val (pathOverride, validation) = buildWithNewAndPath(init) + val effectivePath = pathOverride ?: pathSegment + run(CallableValidation(effectivePath, prop, MapValidation(validation))) + } @JvmName("onEachIterable") public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = @@ -190,7 +209,11 @@ public open class ValidationBuilder { path: Any, f: (T) -> R, init: ValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(path, f, buildWithNew(init))) + ) { + val (pathOverride, validation) = buildWithNewAndPath(init) + val effectivePath = pathOverride ?: path + run(CallableValidation(effectivePath, f, validation)) + } /** * Build a new validation based on a transformed value of the input and run it. @@ -216,7 +239,11 @@ public open class ValidationBuilder { path: Any, f: (T) -> R?, init: ValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(path, f, buildWithNew(init).ifPresent())) + ) { + val (pathOverride, validation) = buildWithNewAndPath(init) + val effectivePath = pathOverride ?: path + run(CallableValidation(effectivePath, f, validation.ifPresent())) + } /** * Calculate a value from the input and run a validation on it, and give an error if the result is null. @@ -227,7 +254,12 @@ public open class ValidationBuilder { path: Any, f: (T) -> R?, init: RequiredValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(path, f, RequiredValidationBuilder.buildWithNew(init))) + ) { + val builder = RequiredValidationBuilder() + init(builder) + val effectivePath = builder.path ?: path + run(CallableValidation(effectivePath, f, builder.build())) + } // endregion @@ -256,6 +288,13 @@ public open class ValidationBuilder { block(builder) return builder.build() } + + /** Build with new and return the builder's path override if set. */ + internal inline fun buildWithNewAndPath(block: ValidationBuilder.() -> Unit): Pair> { + val builder = ValidationBuilder() + block(builder) + return builder.path to builder.build() + } } } diff --git a/src/commonTest/kotlin/io/konform/validation/validationbuilder/PathOverrideTest.kt b/src/commonTest/kotlin/io/konform/validation/validationbuilder/PathOverrideTest.kt new file mode 100644 index 0000000..1bde542 --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/validationbuilder/PathOverrideTest.kt @@ -0,0 +1,144 @@ +package io.konform.validation.validationbuilder + +import io.konform.validation.Validation +import io.konform.validation.ValidationError +import io.konform.validation.constraints.minimum +import io.konform.validation.path.ValidationPath +import io.kotest.assertions.konform.shouldBeInvalid +import io.kotest.assertions.konform.shouldBeValid +import io.kotest.assertions.konform.shouldContainOnlyError +import kotlin.jvm.JvmInline +import kotlin.test.Test + +class PathOverrideTest { + @JvmInline + value class ValueClass( + val integer: Int, + ) + + data class WrapperClass( + val valueClass: ValueClass, + ) + + @Test + fun pathOverrideWithValidationPathEmpty() { + val validation = + Validation { + WrapperClass::valueClass { + ValueClass::integer { + path = ValidationPath.EMPTY + minimum(1) + } + } + } + + validation shouldBeValid WrapperClass(ValueClass(1)) + validation shouldBeValid WrapperClass(ValueClass(10)) + + // Error path should be .valueClass (not .valueClass.integer) + (validation shouldBeInvalid WrapperClass(ValueClass(0))) shouldContainOnlyError + ValidationError.of(WrapperClass::valueClass, "must be at least '1'") + } + + @Test + fun pathOverrideDoesNotAffectOtherValidations() { + data class MultiField( + val field1: ValueClass, + val field2: ValueClass, + ) + + val validation = + Validation { + MultiField::field1 { + ValueClass::integer { + path = ValidationPath.EMPTY + minimum(1) + } + } + MultiField::field2 { + ValueClass::integer { + minimum(1) + } + } + } + + validation shouldBeValid MultiField(ValueClass(1), ValueClass(1)) + + // field1 should have path .field1 (integer suppressed) + (validation shouldBeInvalid MultiField(ValueClass(0), ValueClass(1))) shouldContainOnlyError + ValidationError.of(MultiField::field1, "must be at least '1'") + + // field2 should have path .field2.integer (normal behavior) + (validation shouldBeInvalid MultiField(ValueClass(1), ValueClass(0))) shouldContainOnlyError + ValidationError(ValidationPath.of(MultiField::field2, ValueClass::integer), "must be at least '1'") + } + + @Test + fun pathOverrideWithCustomPath() { + val validation = + Validation { + WrapperClass::valueClass { + ValueClass::integer { + path = ValidationPath.of("customPath") + minimum(1) + } + } + } + + // Error path should be .valueClass.customPath (not .valueClass.integer) + (validation shouldBeInvalid WrapperClass(ValueClass(0))) shouldContainOnlyError + ValidationError(ValidationPath.of(WrapperClass::valueClass, "customPath"), "must be at least '1'") + } + + @Test + fun pathOverrideOnNestedProperty() { + data class Level2( + val value: Int, + ) + + data class Level1( + val level2: Level2, + ) + + val validation = + Validation { + Level1::level2 { + path = ValidationPath.EMPTY + Level2::value { + minimum(1) + } + } + } + + // Error path should be .value (level2 suppressed) + (validation shouldBeInvalid Level1(Level2(0))) shouldContainOnlyError + ValidationError(ValidationPath.of(Level2::value), "must be at least '1'") + } + + @Test + fun pathOverrideWorksWithRequired() { + data class Profile( + val name: String?, + ) + + data class User( + val profile: Profile?, + ) + + val validation = + Validation { + User::profile required { + Profile::name required { + path = ValidationPath.EMPTY + hint = "Name is required" + } + } + } + + validation shouldBeValid User(Profile("John")) + + // Error path should be .profile (name suppressed) + (validation shouldBeInvalid User(Profile(null))) shouldContainOnlyError + ValidationError.of(User::profile, "Name is required") + } +}