diff --git a/README.md b/README.md index 42db762..142588e 100644 --- a/README.md +++ b/README.md @@ -259,15 +259,46 @@ numerical index and in case of `Maps` you use the key as string. ```kotlin // get the error messages for the first attendees age if any -result[Event::attendees, 0, Person::age] +result.errors.messagesAtPath(Event::attendees, 0, Person::age) // get the error messages for the free ticket if any -result[Event::ticketPrices, "free"] +result.errors.messagesAtPath(Event::ticketPrices, "free") +``` + +#### Dynamic Validations + +Sometimes you want to create validations that depend on the context of the actual value being validated, +or define validations for fields that depend on other fields. +Note that this will generally have worse performance than using static validations. + +```kotlin +Validation
{ + Address::postalCode dynamic { address -> + when (address.countryCode) { + "US" -> pattern("[0-9]{5}") + else -> pattern("[A-Z]+") + } + } +} +``` + +if you need to use a value further in, you can capture an earlier value with `dynamic`. + +```kotlin +data class Numbers(val minimum: Int, val numbers: List) + +Validation { + dynamic { numbers -> + Numbers::numbers onEach { + minimum(numbers.minimum) + } + } +} ``` #### Subtypes -You can run validations only if the valuen is of a specific subtype, or require it to be specific subtype. +You can run validations only if the value is of a specific subtype, or require it to be specific subtype. ```kotlin sealed interface Animal { diff --git a/api/konform.api b/api/konform.api index 32058f0..0898f51 100644 --- a/api/konform.api +++ b/api/konform.api @@ -85,6 +85,12 @@ public class io/konform/validation/ValidationBuilder { public final fun build ()Lio/konform/validation/Validation; public final fun constrain (Ljava/lang/String;Lio/konform/validation/path/ValidationPath;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Lio/konform/validation/Constraint; public static synthetic fun constrain$default (Lio/konform/validation/ValidationBuilder;Ljava/lang/String;Lio/konform/validation/path/ValidationPath;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/konform/validation/Constraint; + public final fun dynamic (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V + public final fun dynamic (Lkotlin/jvm/functions/Function2;)V + 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; + 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 public final fun ifPresent (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function1;)V @@ -105,6 +111,7 @@ public class io/konform/validation/ValidationBuilder { public final fun required (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function1;)V public final fun required (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 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 } @@ -136,6 +143,8 @@ public final class io/konform/validation/ValidationError { public final fun getUserContext ()Ljava/lang/Object; public fun hashCode ()I public final fun mapPath (Lkotlin/jvm/functions/Function1;)Lio/konform/validation/ValidationError; + public final fun prependPath (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/ValidationError; + public final fun prependPath (Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/ValidationError; public fun toString ()Ljava/lang/String; } @@ -153,6 +162,7 @@ public final class io/konform/validation/ValidationKt { } public abstract class io/konform/validation/ValidationResult { + public final fun flatMap (Lkotlin/jvm/functions/Function1;)Lio/konform/validation/ValidationResult; public final fun get ([Ljava/lang/Object;)Ljava/util/List; public abstract fun getErrors ()Ljava/util/List; public abstract fun isValid ()Z @@ -341,6 +351,7 @@ public final class io/konform/validation/path/ValidationPath { } public final class io/konform/validation/path/ValidationPath$Companion { + public final fun getEMPTY ()Lio/konform/validation/path/ValidationPath; public final fun of ([Ljava/lang/Object;)Lio/konform/validation/path/ValidationPath; } diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index 3ae43bc..c846808 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -9,6 +9,8 @@ import io.konform.validation.path.ValidationPath import io.konform.validation.types.ArrayValidation import io.konform.validation.types.CallableValidation import io.konform.validation.types.ConstraintsValidation +import io.konform.validation.types.DynamicCallableValidation +import io.konform.validation.types.DynamicValidation import io.konform.validation.types.IsClassValidation import io.konform.validation.types.IterableValidation import io.konform.validation.types.MapValidation @@ -22,8 +24,8 @@ private annotation class ValidationScope @ValidationScope // Class is open to users can define their extra local extension methods public open class ValidationBuilder { - private val constraints = mutableListOf>() - private val subValidations = mutableListOf>() + protected val constraints: MutableList> = mutableListOf() + protected val subValidations: MutableList> = mutableListOf() public fun build(): Validation = subValidations @@ -124,6 +126,10 @@ public open class ValidationBuilder { public infix fun KFunction1.required(init: ValidationBuilder.() -> Unit): Unit = required(this, this, init) + public infix fun KProperty1.dynamic(init: ValidationBuilder.(T) -> Unit): Unit = dynamic(this, this, init) + + public infix fun KFunction1.dynamic(init: ValidationBuilder.(T) -> Unit): Unit = dynamic(this, this, init) + /** * Calculate a value from the input and run a validation on it. * @param path The [PathSegment] or [ValidationPath] of the validation. @@ -136,6 +142,15 @@ public open class ValidationBuilder { init: ValidationBuilder.() -> Unit, ): Unit = run(CallableValidation(path, f, buildWithNew(init))) + public fun dynamic( + path: Any, + f: (T) -> R, + init: ValidationBuilder.(T) -> Unit, + ): Unit = run(DynamicCallableValidation(ValidationPath.of(path), f, init)) + + /** Build a new validation based on the current value being validated and run it. */ + public fun dynamic(init: ValidationBuilder.(T) -> Unit): Unit = dynamic(ValidationPath.EMPTY, { it }, init) + /** * Calculate a value from the input and run a validation on it, but only if the value is not null. * @param path The [PathSegment] or [ValidationPath] of the validation. @@ -162,6 +177,11 @@ public open class ValidationBuilder { subValidations.add(validation) } + /** Create a validation based on the current value being validated and run it. */ + public fun runDynamic(creator: (T) -> Validation) { + run(DynamicValidation(creator)) + } + /** Add a [Constraint] and return it. */ public fun applyConstraint(constraint: Constraint): Constraint { constraints.add(constraint) diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationError.kt b/src/commonMain/kotlin/io/konform/validation/ValidationError.kt index 7fd6b2a..2451d5f 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationError.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationError.kt @@ -14,17 +14,17 @@ public data class ValidationError( public inline fun mapPath(f: (List) -> List): ValidationError = copy(path = ValidationPath(f(path.segments))) - internal fun prependPath(path: ValidationPath) = copy(path = this.path.prepend(path)) + public fun prependPath(path: ValidationPath): ValidationError = copy(path = this.path.prepend(path)) - internal fun prependPath(pathSegment: PathSegment) = mapPath { it.prepend(pathSegment) } + public fun prependPath(pathSegment: PathSegment): ValidationError = mapPath { it.prepend(pathSegment) } internal companion object { - internal fun of( + public fun of( pathSegment: Any, message: String, ): ValidationError = ValidationError(ValidationPath.of(pathSegment), message) - internal fun ofEmptyPath(message: String): ValidationError = ValidationError(ValidationPath.EMPTY, message) + public fun ofEmptyPath(message: String): ValidationError = ValidationError(ValidationPath.EMPTY, message) } } diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt index 7114f28..cba1163 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt @@ -13,9 +13,11 @@ public sealed class ValidationResult { public operator fun get(vararg validationPath: Any): List = errors.messagesAtDataPath(*validationPath) /** If this is a valid result, returns the result of applying the given [transform] function to the value. Otherwise, return the original error. */ - public inline fun map(transform: (T) -> R): ValidationResult = + public inline fun map(transform: (T) -> R): ValidationResult = flatMap { Valid(transform(it)) } + + public inline fun flatMap(transform: (T) -> ValidationResult): ValidationResult = when (this) { - is Valid -> Valid(transform(this.value)) + is Valid -> transform(this.value) is Invalid -> this } diff --git a/src/commonMain/kotlin/io/konform/validation/constraints/StringConstraints.kt b/src/commonMain/kotlin/io/konform/validation/constraints/StringConstraints.kt index 15457f4..d6aa205 100644 --- a/src/commonMain/kotlin/io/konform/validation/constraints/StringConstraints.kt +++ b/src/commonMain/kotlin/io/konform/validation/constraints/StringConstraints.kt @@ -3,42 +3,33 @@ package io.konform.validation.constraints import io.konform.validation.Constraint import io.konform.validation.ValidationBuilder -public fun ValidationBuilder.notBlank(): Constraint = addConstraint("must not be blank") { it.isNotBlank() } - -/** - * Checks that the string contains a match with the given [Regex]. - * */ -public fun ValidationBuilder.containsPattern(pattern: Regex): Constraint = - addConstraint("must include regex '$pattern'") { - it.contains(pattern) - } - -public fun ValidationBuilder.containsPattern(pattern: String): Constraint = containsPattern(pattern.toRegex()) +public fun ValidationBuilder.notBlank(): Constraint = constrain("must not be blank") { it.isNotBlank() } public fun ValidationBuilder.minLength(length: Int): Constraint { require(length >= 0) { IllegalArgumentException("minLength requires the length to be >= 0") } - return addConstraint( - "must have at least {0} characters", - length.toString(), - ) { it.length >= length } + return constrain("must have at least $length characters") { it.length >= length } } public fun ValidationBuilder.maxLength(length: Int): Constraint { require(length >= 0) { IllegalArgumentException("maxLength requires the length to be >= 0") } - return addConstraint( - "must have at most {0} characters", - length.toString(), - ) { it.length <= length } + return constrain("must have at most $length characters") { it.length <= length } } -public fun ValidationBuilder.pattern(pattern: String): Constraint = pattern(pattern.toRegex()) - /** Enforces the string must be UUID hex format. */ public fun ValidationBuilder.uuid(): Constraint = pattern("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") hint "must be a valid UUID string" +public fun ValidationBuilder.pattern(pattern: String): Constraint = pattern(pattern.toRegex()) + public fun ValidationBuilder.pattern(pattern: Regex): Constraint = - addConstraint( - "must match the expected pattern", - pattern.toString(), - ) { it.matches(pattern) } + constrain("must match pattern '$pattern'") { it.matches(pattern) } + +/** + * Checks that the string contains a match with the given [Regex]. + * */ +public fun ValidationBuilder.containsPattern(pattern: Regex): Constraint = + constrain("must include pattern '$pattern'") { + it.contains(pattern) + } + +public fun ValidationBuilder.containsPattern(pattern: String): Constraint = containsPattern(pattern.toRegex()) diff --git a/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt b/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt index 0bd8b5f..a50a66f 100644 --- a/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt +++ b/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt @@ -26,7 +26,7 @@ public data class ValidationPath( override fun toString(): String = "ValidationPath(${segments.joinToString(", ")})" public companion object { - internal val EMPTY = ValidationPath(emptyList()) + public val EMPTY: ValidationPath = ValidationPath(emptyList()) /** * Convert the specified arguments into a [ValidationPath] diff --git a/src/commonMain/kotlin/io/konform/validation/types/DynamicValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/DynamicValidation.kt new file mode 100644 index 0000000..133db41 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/types/DynamicValidation.kt @@ -0,0 +1,36 @@ +package io.konform.validation.types + +import io.konform.validation.Invalid +import io.konform.validation.Valid +import io.konform.validation.Validation +import io.konform.validation.ValidationBuilder +import io.konform.validation.ValidationResult +import io.konform.validation.path.ValidationPath + +internal class DynamicValidation( + private val creator: (T) -> Validation, +) : Validation { + override fun validate(value: T): ValidationResult { + val validation = creator(value) + return validation.validate(value) + } +} + +internal class DynamicCallableValidation( + private val path: ValidationPath, + private val callable: (T) -> R, + private val builder: ValidationBuilder.(T) -> Unit, +) : Validation { + override fun validate(value: T): ValidationResult { + val validation = + ValidationBuilder() + .also { + builder(it, value) + }.build() + val toValidate = callable(value) + return when (val callableResult = validation(toValidate)) { + is Valid -> Valid(value) + is Invalid -> callableResult.prependPath(path) + } + } +} diff --git a/src/commonTest/kotlin/io/konform/validation/constraints/ConstraintsTest.kt b/src/commonTest/kotlin/io/konform/validation/constraints/ConstraintsTest.kt index 0543cc0..5727cda 100644 --- a/src/commonTest/kotlin/io/konform/validation/constraints/ConstraintsTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/constraints/ConstraintsTest.kt @@ -23,6 +23,12 @@ import io.konform.validation.constraints.type import io.konform.validation.constraints.uniqueItems import io.konform.validation.constraints.uuid import io.konform.validation.countFieldsWithErrors +import io.konform.validation.path.ValidationPath +import io.kotest.assertions.konform.shouldBeInvalid +import io.kotest.assertions.konform.shouldBeValid +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -176,7 +182,10 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation(10.00001))) assertEquals(1, countFieldsWithErrors(validation(11))) assertEquals(1, countFieldsWithErrors(validation(Double.POSITIVE_INFINITY))) - assertEquals(1, countFieldsWithErrors(Validation { exclusiveMaximum(Double.POSITIVE_INFINITY) }(Double.POSITIVE_INFINITY))) + assertEquals( + 1, + countFieldsWithErrors(Validation { exclusiveMaximum(Double.POSITIVE_INFINITY) }(Double.POSITIVE_INFINITY)), + ) assertEquals("must be less than '10'", validation(11).get()[0]) } @@ -219,7 +228,10 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation(9.99999999999))) assertEquals(1, countFieldsWithErrors(validation(8))) assertEquals(1, countFieldsWithErrors(validation(Double.NEGATIVE_INFINITY))) - assertEquals(1, countFieldsWithErrors(Validation { exclusiveMinimum(Double.NEGATIVE_INFINITY) }(Double.NEGATIVE_INFINITY))) + assertEquals( + 1, + countFieldsWithErrors(Validation { exclusiveMinimum(Double.NEGATIVE_INFINITY) }(Double.NEGATIVE_INFINITY)), + ) assertEquals("must be greater than '10'", validation(9).get()[0]) } @@ -254,24 +266,26 @@ class ConstraintsTest { fun patternConstraint() { val validation = Validation { pattern(".+@.+") } - assertEquals(Valid("a@a"), validation("a@a")) - assertEquals(Valid("a@a@a@a"), validation("a@a@a@a")) - assertEquals(Valid(" a@a "), validation(" a@a ")) + validation shouldBeValid "a@a" + validation shouldBeValid "a@a@a@a" + validation shouldBeValid " a@a " - assertEquals(1, countFieldsWithErrors(validation("a"))) - assertEquals("must match the expected pattern", validation("").get()[0]) + val invalid = validation shouldBeInvalid "a" + invalid.errors shouldHaveSize 1 + invalid.errors[0].path shouldBe ValidationPath.EMPTY + invalid.errors[0].message shouldContain "must match pattern '" val compiledRegexValidation = Validation { pattern("^\\w+@\\w+\\.\\w+$".toRegex()) } - assertEquals(Valid("tester@example.com"), compiledRegexValidation("tester@example.com")) - assertEquals(1, countFieldsWithErrors(compiledRegexValidation("tester@example"))) - assertEquals(1, countFieldsWithErrors(compiledRegexValidation(" tester@example.com"))) - assertEquals(1, countFieldsWithErrors(compiledRegexValidation("tester@example.com "))) - - assertEquals("must match the expected pattern", compiledRegexValidation("").get()[0]) + compiledRegexValidation shouldBeValid "tester@example.com" + val invalidComplex = (compiledRegexValidation shouldBeInvalid "tester@example") + invalidComplex.errors shouldHaveSize 1 + invalidComplex.errors[0].message shouldContain "must match pattern '" + compiledRegexValidation shouldBeInvalid " tester@example.com" + compiledRegexValidation shouldBeInvalid "tester@example.com " } @Test diff --git a/src/commonTest/kotlin/io/konform/validation/validationbuilder/DynamicValidationTest.kt b/src/commonTest/kotlin/io/konform/validation/validationbuilder/DynamicValidationTest.kt new file mode 100644 index 0000000..d8fc822 --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/validationbuilder/DynamicValidationTest.kt @@ -0,0 +1,202 @@ +package io.konform.validation.validationbuilder + +import io.konform.validation.Constraint +import io.konform.validation.Validation +import io.konform.validation.ValidationBuilder +import io.konform.validation.ValidationError +import io.konform.validation.constraints.minimum +import io.konform.validation.constraints.pattern +import io.konform.validation.path.ValidationPath +import io.konform.validation.types.AlwaysInvalidValidation +import io.konform.validation.types.EmptyValidation +import io.kotest.assertions.konform.shouldBeInvalid +import io.kotest.assertions.konform.shouldBeValid +import io.kotest.assertions.konform.shouldContainExactlyErrors +import io.kotest.assertions.konform.shouldContainOnlyError +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import kotlin.test.Test + +class DynamicValidationTest { + @Test + fun dynamicValidation1() { + val validation = + Validation
{ + dynamic { address -> + Address::postalCode { + when (address.countryCode) { + "US" -> pattern("[0-9]{5}") + else -> pattern("[A-Z]+") + } + } + } + } + + validation shouldBeValid Address("US", "12345") + validation shouldBeValid Address("DE", "ABC") + + val usResult = (validation shouldBeInvalid Address("US", "")) + usResult.errors shouldHaveSize 1 + usResult.errors[0].path shouldBe ValidationPath.of(Address::postalCode) + usResult.errors[0].message shouldContain """must match pattern '""" + val deResult = (validation shouldBeInvalid Address("DE", "123")) + deResult.errors shouldHaveSize 1 + deResult.errors[0].path shouldBe ValidationPath.of(Address::postalCode) + deResult.errors[0].message shouldContain """must match pattern '""" + } + + @Test + fun dynamicValidation2() { + val validation = + Validation
{ + Address::postalCode dynamic { address -> + when (address.countryCode) { + "US" -> pattern("[0-9]{5}") + else -> pattern("[A-Z]+") + } + } + } + + validation shouldBeValid Address("US", "12345") + validation shouldBeValid Address("DE", "ABC") + + val usResult = (validation shouldBeInvalid Address("US", "")) + usResult.errors shouldHaveSize 1 + usResult.errors[0].path shouldBe ValidationPath.of(Address::postalCode) + usResult.errors[0].message shouldContain """must match pattern '""" + val deResult = (validation shouldBeInvalid Address("DE", "123")) + deResult.errors shouldHaveSize 1 + deResult.errors[0].path shouldBe ValidationPath.of(Address::postalCode) + deResult.errors[0].message shouldContain """must match pattern '""" + } + + @Test + fun dynamicValidation3() { + val validation = + Validation { + dynamic { range -> + Range::to { + largerThan(range.from) + } + } + } + + validation shouldBeValid Range(0, 1) + (validation shouldBeInvalid Range(1, 0)) shouldContainOnlyError + ValidationError.of( + Range::to, + "must be larger than 1", + ) + } + + @Test + fun dynamicOnProperty() { + val validation = + Validation { + Range::to dynamic { range -> + largerThan(range.from) + } + } + + validation shouldBeValid Range(0, 1) + (validation shouldBeInvalid Range(1, 0)) shouldContainOnlyError + ValidationError.of( + Range::to, + "must be larger than 1", + ) + } + + @Test + fun dynamicWithLambda() { + val validation = + Validation { + dynamic(Range::to, { it.from to it.to }) { (from, to) -> + constrain("must be larger than from") { + to > from + } + } + } + + validation shouldBeValid Range(0, 1) + (validation shouldBeInvalid Range(1, 0)) shouldContainOnlyError + ValidationError.of( + Range::to, + "must be larger than from", + ) + } + + @Test + fun runDynamic() { + val validation = + Validation { + runDynamic { + if (it == "a") { + AlwaysInvalidValidation + } else { + EmptyValidation + } + } + } + + validation shouldBeValid "b" + (validation shouldBeInvalid "a") shouldContainOnlyError + ValidationError( + ValidationPath.EMPTY, + "always invalid", + ) + } + + @Test + fun outerDynamic() { + val validation = + Validation { + dynamic { nested1 -> + Nested1::nested2s onEach { + Nested2::value { + minimum(nested1.minimum) + } + } + } + } + + val invalid = + Nested1( + 20, + listOf( + Nested2(5), + Nested2(25), + Nested2(10), + ), + ) + + val valid = invalid.copy(minimum = 1) + + validation shouldBeValid valid + (validation shouldBeInvalid invalid).shouldContainExactlyErrors( + ValidationError(ValidationPath.of(Nested1::nested2s, 0, Nested2::value), "must be at least '20'"), + ValidationError(ValidationPath.of(Nested1::nested2s, 2, Nested2::value), "must be at least '20'"), + ) + } +} + +data class Address( + val countryCode: String, + val postalCode: String, +) + +data class Range( + val from: Int, + val to: Int, +) + +fun ValidationBuilder.largerThan(other: Int): Constraint = constrain("must be larger than $other") { it > other } + +data class Nested1( + val minimum: Int, + val nested2s: List, +) + +data class Nested2( + val value: Int, +)