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,
+)