Skip to content

Commit

Permalink
Add fail-fast validation and andThen (#182)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhoepelman authored Nov 25, 2024
1 parent 8d9a3e8 commit 3f3de2a
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 18 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,21 @@ val validation = Validation<Node> {
private val validationRef get(): Validation<Node> = 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<String> { /* ... */ }
val slowValidation = Validation<String> { /* ... */ }

val runSlowOnlyIfFastValidationSucceeds = Validation<String> {
run(fastValidation andThen slowValidation)
}
```

### Other validation libraries for Kotlin

- Akkurate: https://akkurate.dev/docs/overview.html
Expand Down
13 changes: 12 additions & 1 deletion api/konform.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <init> (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 <init> (Lkotlin/reflect/KClass;ZLio/konform/validation/Validation;)V
public fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
Expand Down
13 changes: 10 additions & 3 deletions src/commonMain/kotlin/io/konform/validation/Validation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,16 +24,22 @@ public interface Validation<in T> {
public fun prependPath(pathSegment: PathSegment): Validation<T> = prependPath(ValidationPath.of(pathSegment))
}

/** Combine a [List] of [Validation]s into a single one that returns all validation errors. */
public fun <T> List<Validation<T>>.flatten(): Validation<T> =
/**
* 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 <T> List<Validation<T>>.flatten(failFast: Boolean = false): Validation<T> =
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. */
public fun <T : Any> Validation<T>.ifPresent(): Validation<T?> = IfNotNullValidation(this)

/** Require a nullable value to actually be present. */
public fun <T : Any> Validation<T>.required(hint: String = DEFAULT_REQUIRED_HINT): Validation<T?> = RequireNotNullValidation(hint, this)

/** First validate using this validation, and if the value passes validation run [nextValidation]. */
public infix fun <T> Validation<T>.andThen(nextValidation: Validation<T>): Validation<T> = FailFastValidation(listOf(this, nextValidation))
17 changes: 16 additions & 1 deletion src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt
Original file line number Diff line number Diff line change
@@ -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<T>(
private val validations: List<Validation<T>>,
) : Validation<T> {
Expand All @@ -20,3 +21,17 @@ public class ValidateAll<T>(

override fun toString(): String = "ValidateAll(validation=$validations)"
}

public class FailFastValidation<T>(
private val validations: List<Validation<T>>,
) : Validation<T> {
override fun validate(value: T): ValidationResult<T> {
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)"
}
12 changes: 12 additions & 0 deletions src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,16 @@ class ListValidationTest {
ValidationError(ValidationPath.EMPTY, "must be at least '10'"),
)
}

@Test
fun flattenFailFast() {
val validationException =
object : Validation<Int> {
override fun validate(value: Int): ValidationResult<Int> =
throw IllegalStateException("This validation should never be called")
}
val result = listOf(validation1, validationException).flatten(failFast = true)

result shouldBeInvalid -1
}
}
66 changes: 53 additions & 13 deletions src/commonTest/kotlin/io/konform/validation/ValidationTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,22 +20,31 @@ class ValidationTest {
val favoritePrey: String,
) : Animal

@Test
fun validationsShouldBeUsableOnTypes() {
val animalValidation: Validation<Animal> =
Validation {
Animal::name {
minLength(1)
}
data class Dog(
override val name: String,
) : Animal

private val animalValidation: Validation<Animal> =
Validation {
Animal::name {
minLength(1)
}
val catValidation: Validation<Cat> =
Validation {
run(animalValidation)
Cat::favoritePrey {
minLength(1)
}
}
private val catValidation: Validation<Cat> =
Validation {
run(animalValidation)
Cat::favoritePrey {
minLength(1)
}
}

private val dogValidation: Validation<Dog> =
Validation {
run(animalValidation)
}

@Test
fun validationsShouldBeUsableOnTypes() {
// This is allowed and should compile, as every cat is an animal
@Suppress("UNUSED_VARIABLE")
val animalAsCatValidation: Validation<Cat> = animalValidation
Expand All @@ -53,4 +65,32 @@ class ValidationTest {
ValidationError.of(Cat::favoritePrey, "must have at least 1 characters"),
)
}

@Test
fun andThen() {
val catFirst: Validation<Cat> = catValidation andThen animalValidation
val animalFirst: Validation<Cat> = 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<Animal> {
override fun validate(value: Animal): ValidationResult<Animal> = throw IllegalStateException("should never be called")
}
)

chain shouldBeInvalid Dog("")

shouldThrow<IllegalStateException> {
chain.validate(Dog("abc"))
}
}
}

0 comments on commit 3f3de2a

Please sign in to comment.