From 3f3de2a9badd19fbad5fb492010ec1b14caa1b31 Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Mon, 25 Nov 2024 23:11:39 +0100 Subject: [PATCH] Add fail-fast validation and andThen (#182) --- README.md | 15 +++++ api/konform.api | 13 +++- .../io/konform/validation/Validation.kt | 13 +++- .../konform/validation/types/ValidateAll.kt | 17 ++++- .../konform/validation/ListValidationTest.kt | 12 ++++ .../io/konform/validation/ValidationTest.kt | 66 +++++++++++++++---- 6 files changed, 118 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 06966cf..c037447 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,21 @@ val validation = Validation { private val validationRef get(): Validation = validation ``` +#### Fail-fast validations + +Konform is primarily intended to validate the complete data and return all validation errors. +However, if you want to "fail fast" and not run later validations, you can do this with `andThen` +on `Validation` or `flatten` on a list of validations. + +```kotlin +val fastValidation = Validation { /* ... */ } +val slowValidation = Validation { /* ... */ } + +val runSlowOnlyIfFastValidationSucceeds = Validation { + run(fastValidation andThen slowValidation) +} +``` + ### Other validation libraries for Kotlin - Akkurate: https://akkurate.dev/docs/overview.html diff --git a/api/konform.api b/api/konform.api index 3d8d089..30362f2 100644 --- a/api/konform.api +++ b/api/konform.api @@ -158,7 +158,9 @@ public final class io/konform/validation/ValidationErrorKt { } public final class io/konform/validation/ValidationKt { - public static final fun flatten (Ljava/util/List;)Lio/konform/validation/Validation; + public static final fun andThen (Lio/konform/validation/Validation;Lio/konform/validation/Validation;)Lio/konform/validation/Validation; + public static final fun flatten (Ljava/util/List;Z)Lio/konform/validation/Validation; + public static synthetic fun flatten$default (Ljava/util/List;ZILjava/lang/Object;)Lio/konform/validation/Validation; public static final fun ifPresent (Lio/konform/validation/Validation;)Lio/konform/validation/Validation; public static final fun required (Lio/konform/validation/Validation;Ljava/lang/String;)Lio/konform/validation/Validation; public static synthetic fun required$default (Lio/konform/validation/Validation;Ljava/lang/String;ILjava/lang/Object;)Lio/konform/validation/Validation; @@ -425,6 +427,15 @@ public final class io/konform/validation/types/EmptyValidation : io/konform/vali public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; } +public final class io/konform/validation/types/FailFastValidation : io/konform/validation/Validation { + public fun (Ljava/util/List;)V + public fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; + public fun prependPath (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/Validation; + public fun prependPath (Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/Validation; + public fun toString ()Ljava/lang/String; + public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; +} + public final class io/konform/validation/types/IsClassValidation : io/konform/validation/Validation { public fun (Lkotlin/reflect/KClass;ZLio/konform/validation/Validation;)V public fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; diff --git a/src/commonMain/kotlin/io/konform/validation/Validation.kt b/src/commonMain/kotlin/io/konform/validation/Validation.kt index 192c1dc..e74c6a7 100644 --- a/src/commonMain/kotlin/io/konform/validation/Validation.kt +++ b/src/commonMain/kotlin/io/konform/validation/Validation.kt @@ -3,6 +3,7 @@ package io.konform.validation import io.konform.validation.path.PathSegment import io.konform.validation.path.ValidationPath import io.konform.validation.types.EmptyValidation +import io.konform.validation.types.FailFastValidation import io.konform.validation.types.IfNotNullValidation import io.konform.validation.types.PrependPathValidation import io.konform.validation.types.RequireNotNullValidation @@ -23,12 +24,15 @@ public interface Validation { public fun prependPath(pathSegment: PathSegment): Validation = prependPath(ValidationPath.of(pathSegment)) } -/** Combine a [List] of [Validation]s into a single one that returns all validation errors. */ -public fun List>.flatten(): Validation = +/** + * Combine a [List] of [Validation]s into a single one that returns all validation errors and runs them in sequence. + * @param failFast if true, stop after the first validation error + * */ +public fun List>.flatten(failFast: Boolean = false): Validation = when (size) { 0 -> EmptyValidation 1 -> first() - else -> ValidateAll(this) + else -> if (failFast) FailFastValidation(this) else ValidateAll(this) } /** Run a validation only if the actual value is not-null. */ @@ -36,3 +40,6 @@ public fun Validation.ifPresent(): Validation = IfNotNullValida /** Require a nullable value to actually be present. */ public fun Validation.required(hint: String = DEFAULT_REQUIRED_HINT): Validation = RequireNotNullValidation(hint, this) + +/** First validate using this validation, and if the value passes validation run [nextValidation]. */ +public infix fun Validation.andThen(nextValidation: Validation): Validation = FailFastValidation(listOf(this, nextValidation)) diff --git a/src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt b/src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt index 235c144..95b77b9 100644 --- a/src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt +++ b/src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt @@ -1,11 +1,12 @@ package io.konform.validation.types import io.konform.validation.Invalid +import io.konform.validation.Valid import io.konform.validation.Validation import io.konform.validation.ValidationResult import io.konform.validation.flattenOrValid -/** Validation that runs multiple validations in sequence. */ +/** Validation that runs multiple validations in sequence and returns all validation errors. */ public class ValidateAll( private val validations: List>, ) : Validation { @@ -20,3 +21,17 @@ public class ValidateAll( override fun toString(): String = "ValidateAll(validation=$validations)" } + +public class FailFastValidation( + private val validations: List>, +) : Validation { + override fun validate(value: T): ValidationResult { + for (validation in validations) { + val result = validation.validate(value) + if (result is Invalid) return result + } + return Valid(value) + } + + override fun toString(): String = "FailFastValidation(validation=$validations)" +} diff --git a/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt b/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt index 42e7981..bb5196b 100644 --- a/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt @@ -53,4 +53,16 @@ class ListValidationTest { ValidationError(ValidationPath.EMPTY, "must be at least '10'"), ) } + + @Test + fun flattenFailFast() { + val validationException = + object : Validation { + override fun validate(value: Int): ValidationResult = + throw IllegalStateException("This validation should never be called") + } + val result = listOf(validation1, validationException).flatten(failFast = true) + + result shouldBeInvalid -1 + } } diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationTest.kt index 0b9f4c2..0140a0a 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationTest.kt @@ -1,8 +1,11 @@ package io.konform.validation import io.konform.validation.constraints.minLength +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.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import kotlin.test.Test @@ -17,22 +20,31 @@ class ValidationTest { val favoritePrey: String, ) : Animal - @Test - fun validationsShouldBeUsableOnTypes() { - val animalValidation: Validation = - Validation { - Animal::name { - minLength(1) - } + data class Dog( + override val name: String, + ) : Animal + + private val animalValidation: Validation = + Validation { + Animal::name { + minLength(1) } - val catValidation: Validation = - Validation { - run(animalValidation) - Cat::favoritePrey { - minLength(1) - } + } + private val catValidation: Validation = + Validation { + run(animalValidation) + Cat::favoritePrey { + minLength(1) } + } + private val dogValidation: Validation = + Validation { + run(animalValidation) + } + + @Test + fun validationsShouldBeUsableOnTypes() { // This is allowed and should compile, as every cat is an animal @Suppress("UNUSED_VARIABLE") val animalAsCatValidation: Validation = animalValidation @@ -53,4 +65,32 @@ class ValidationTest { ValidationError.of(Cat::favoritePrey, "must have at least 1 characters"), ) } + + @Test + fun andThen() { + val catFirst: Validation = catValidation andThen animalValidation + val animalFirst: Validation = animalValidation andThen catValidation + + val validCat = Cat("abc", "mouse") + val invalidCat = Cat("abc", "") + animalFirst shouldBeValid validCat + catFirst shouldBeValid validCat + (animalFirst shouldBeInvalid invalidCat) shouldContainOnlyError + ValidationError.of(Cat::favoritePrey, "must have at least 1 characters") + (catFirst shouldBeInvalid invalidCat) shouldContainOnlyError + ValidationError.of(Cat::favoritePrey, "must have at least 1 characters") + + val chain = + animalValidation andThen ( + object : Validation { + override fun validate(value: Animal): ValidationResult = throw IllegalStateException("should never be called") + } + ) + + chain shouldBeInvalid Dog("") + + shouldThrow { + chain.validate(Dog("abc")) + } + } }