Skip to content

Commit

Permalink
Add dynamic validations (#171)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhoepelman authored Nov 16, 2024
1 parent 15b871b commit e151f6c
Show file tree
Hide file tree
Showing 10 changed files with 357 additions and 50 deletions.
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
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<Int>)

Validation<Numbers> {
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 {
Expand Down
11 changes: 11 additions & 0 deletions api/konform.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
24 changes: 22 additions & 2 deletions src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T> {
private val constraints = mutableListOf<Constraint<T>>()
private val subValidations = mutableListOf<Validation<T>>()
protected val constraints: MutableList<Constraint<T>> = mutableListOf()
protected val subValidations: MutableList<Validation<T>> = mutableListOf()

public fun build(): Validation<T> =
subValidations
Expand Down Expand Up @@ -124,6 +126,10 @@ public open class ValidationBuilder<T> {

public infix fun <R> KFunction1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit): Unit = required(this, this, init)

public infix fun <R> KProperty1<T, R>.dynamic(init: ValidationBuilder<R>.(T) -> Unit): Unit = dynamic(this, this, init)

public infix fun <R> KFunction1<T, R>.dynamic(init: ValidationBuilder<R>.(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.
Expand All @@ -136,6 +142,15 @@ public open class ValidationBuilder<T> {
init: ValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, buildWithNew(init)))

public fun <R> dynamic(
path: Any,
f: (T) -> R,
init: ValidationBuilder<R>.(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>.(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.
Expand All @@ -162,6 +177,11 @@ public open class ValidationBuilder<T> {
subValidations.add(validation)
}

/** Create a validation based on the current value being validated and run it. */
public fun runDynamic(creator: (T) -> Validation<T>) {
run(DynamicValidation(creator))
}

/** Add a [Constraint] and return it. */
public fun applyConstraint(constraint: Constraint<T>): Constraint<T> {
constraints.add(constraint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ public data class ValidationError(

public inline fun mapPath(f: (List<PathSegment>) -> List<PathSegment>): 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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ public sealed class ValidationResult<out T> {
public operator fun get(vararg validationPath: Any): List<String> = 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 <R> map(transform: (T) -> R): ValidationResult<R> =
public inline fun <R> map(transform: (T) -> R): ValidationResult<R> = flatMap { Valid(transform(it)) }

public inline fun <R> flatMap(transform: (T) -> ValidationResult<R>): ValidationResult<R> =
when (this) {
is Valid -> Valid(transform(this.value))
is Valid -> transform(this.value)
is Invalid -> this
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,33 @@ package io.konform.validation.constraints
import io.konform.validation.Constraint
import io.konform.validation.ValidationBuilder

public fun ValidationBuilder<String>.notBlank(): Constraint<String> = addConstraint("must not be blank") { it.isNotBlank() }

/**
* Checks that the string contains a match with the given [Regex].
* */
public fun ValidationBuilder<String>.containsPattern(pattern: Regex): Constraint<String> =
addConstraint("must include regex '$pattern'") {
it.contains(pattern)
}

public fun ValidationBuilder<String>.containsPattern(pattern: String): Constraint<String> = containsPattern(pattern.toRegex())
public fun ValidationBuilder<String>.notBlank(): Constraint<String> = constrain("must not be blank") { it.isNotBlank() }

public fun ValidationBuilder<String>.minLength(length: Int): Constraint<String> {
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<String>.maxLength(length: Int): Constraint<String> {
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<String>.pattern(pattern: String): Constraint<String> = pattern(pattern.toRegex())

/** Enforces the string must be UUID hex format. */
public fun ValidationBuilder<String>.uuid(): Constraint<String> =
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<String>.pattern(pattern: String): Constraint<String> = pattern(pattern.toRegex())

public fun ValidationBuilder<String>.pattern(pattern: Regex): Constraint<String> =
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<String>.containsPattern(pattern: Regex): Constraint<String> =
constrain("must include pattern '$pattern'") {
it.contains(pattern)
}

public fun ValidationBuilder<String>.containsPattern(pattern: String): Constraint<String> = containsPattern(pattern.toRegex())
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T>(
private val creator: (T) -> Validation<T>,
) : Validation<T> {
override fun validate(value: T): ValidationResult<T> {
val validation = creator(value)
return validation.validate(value)
}
}

internal class DynamicCallableValidation<T, R>(
private val path: ValidationPath,
private val callable: (T) -> R,
private val builder: ValidationBuilder<R>.(T) -> Unit,
) : Validation<T> {
override fun validate(value: T): ValidationResult<T> {
val validation =
ValidationBuilder<R>()
.also {
builder(it, value)
}.build()
val toValidate = callable(value)
return when (val callableResult = validation(toValidate)) {
is Valid -> Valid(value)
is Invalid -> callableResult.prependPath(path)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Number> { exclusiveMaximum(Double.POSITIVE_INFINITY) }(Double.POSITIVE_INFINITY)))
assertEquals(
1,
countFieldsWithErrors(Validation<Number> { exclusiveMaximum(Double.POSITIVE_INFINITY) }(Double.POSITIVE_INFINITY)),
)

assertEquals("must be less than '10'", validation(11).get()[0])
}
Expand Down Expand Up @@ -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<Number> { exclusiveMinimum(Double.NEGATIVE_INFINITY) }(Double.NEGATIVE_INFINITY)))
assertEquals(
1,
countFieldsWithErrors(Validation<Number> { exclusiveMinimum(Double.NEGATIVE_INFINITY) }(Double.NEGATIVE_INFINITY)),
)

assertEquals("must be greater than '10'", validation(9).get()[0])
}
Expand Down Expand Up @@ -254,24 +266,26 @@ class ConstraintsTest {
fun patternConstraint() {
val validation = Validation<String> { 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<String> {
pattern("^\\w+@\\w+\\.\\w+$".toRegex())
}

assertEquals(Valid("[email protected]"), compiledRegexValidation("[email protected]"))
assertEquals(1, countFieldsWithErrors(compiledRegexValidation("tester@example")))
assertEquals(1, countFieldsWithErrors(compiledRegexValidation(" [email protected]")))
assertEquals(1, countFieldsWithErrors(compiledRegexValidation("[email protected] ")))

assertEquals("must match the expected pattern", compiledRegexValidation("").get()[0])
compiledRegexValidation shouldBeValid "[email protected]"
val invalidComplex = (compiledRegexValidation shouldBeInvalid "tester@example")
invalidComplex.errors shouldHaveSize 1
invalidComplex.errors[0].message shouldContain "must match pattern '"
compiledRegexValidation shouldBeInvalid " [email protected]"
compiledRegexValidation shouldBeInvalid "[email protected] "
}

@Test
Expand Down
Loading

0 comments on commit e151f6c

Please sign in to comment.