diff --git a/src/commonMain/kotlin/io/konform/validation/Validation.kt b/src/commonMain/kotlin/io/konform/validation/Validation.kt index d76e5e1..2067217 100644 --- a/src/commonMain/kotlin/io/konform/validation/Validation.kt +++ b/src/commonMain/kotlin/io/konform/validation/Validation.kt @@ -1,14 +1,22 @@ package io.konform.validation +import io.konform.validation.types.EmptyValidation +import io.konform.validation.types.ValidateAll + public interface Validation { public companion object { - public operator fun invoke(init: ValidationBuilder.() -> Unit): Validation { - val builder = ValidationBuilder() - return builder.apply(init).build() - } + public operator fun invoke(init: ValidationBuilder.() -> Unit): Validation = ValidationBuilder.buildWithNew(init) } public fun validate(value: T): ValidationResult<@UnsafeVariance T> public operator fun invoke(value: T): ValidationResult<@UnsafeVariance T> = validate(value) } + +/** Combine a [List] of [Validation]s into a single one that returns all validation errors. */ +public fun List>.flatten(): Validation = + when (size) { + 0 -> EmptyValidation + 1 -> first() + else -> ValidateAll(this) + } diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationError.kt b/src/commonMain/kotlin/io/konform/validation/ValidationError.kt new file mode 100644 index 0000000..4bcd866 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/ValidationError.kt @@ -0,0 +1,13 @@ +package io.konform.validation + +public interface ValidationError { + public val dataPath: String + public val message: String + + public companion object { + internal operator fun invoke( + dataPath: String, + message: String, + ): ValidationError = PropertyValidationError(dataPath, message) + } +} diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt index eafe1c8..7c3ea47 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt @@ -1,11 +1,7 @@ package io.konform.validation import io.konform.validation.kotlin.Path - -public interface ValidationError { - public val dataPath: String - public val message: String -} +import kotlin.jvm.JvmName internal data class PropertyValidationError( override val dataPath: String, @@ -68,3 +64,35 @@ public data class Valid( override val errors: List get() = emptyList() } + +internal fun List>.flattenNonEmpty(): ValidationResult { + require(isNotEmpty()) { "List is not allowed to be empty in flattenNonEmpty" } + val invalids = filterIsInstance() + return if (invalids.isEmpty()) { + first() as Valid + } else { + invalids.flattenNotEmpty() + } +} + +internal fun List.flattenNotEmpty(): Invalid { + require(isNotEmpty()) { "List is not allowed to be empty in flattenNonEmpty" } + val merged = mutableMapOf>() + for (invalid in this) { + val added = + invalid.internalErrors.mapValues { + merged.getOrElse(it.key, ::emptyList) + it.value + } + merged += added + } + return Invalid(merged) +} + +internal fun List>.flattenOrValid(value: T): ValidationResult = + takeIf { isNotEmpty() } + ?.flattenNonEmpty() + ?.takeIf { it is Invalid } + ?: Valid(value) + +@JvmName("flattenOrValidInvalidList") +internal fun List.flattenOrValid(value: T): ValidationResult = if (isNotEmpty()) flattenNonEmpty() else Valid(value) diff --git a/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt b/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt new file mode 100644 index 0000000..1efb0f3 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt @@ -0,0 +1,13 @@ +package io.konform.validation.path + +import io.konform.validation.kotlin.Path + +/** Represents a path to a validation. */ +public data class ValidationPath( + // val segments: List, + val dataPaths: List, +) { + public companion object { + public fun fromAny(vararg validationPath: Any): ValidationPath = ValidationPath(validationPath.map { Path.toPath(*validationPath) }) + } +} diff --git a/src/commonMain/kotlin/io/konform/validation/types/EmptyValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/EmptyValidation.kt new file mode 100644 index 0000000..e25bb5d --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/types/EmptyValidation.kt @@ -0,0 +1,16 @@ +package io.konform.validation.types + +import io.konform.validation.Valid +import io.konform.validation.Validation +import io.konform.validation.ValidationResult + +/** Validation that always returns [Valid]. `unit` in monadic terms. */ +public object EmptyValidation : Validation { + override fun validate(value: Any?): ValidationResult = Valid(value) + + override fun toString(): String = "EmptyValidation" + + override fun equals(other: Any?): Boolean = other === this + + override fun hashCode(): Int = 912378 +} diff --git a/src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt b/src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt new file mode 100644 index 0000000..235c144 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt @@ -0,0 +1,22 @@ +package io.konform.validation.types + +import io.konform.validation.Invalid +import io.konform.validation.Validation +import io.konform.validation.ValidationResult +import io.konform.validation.flattenOrValid + +/** Validation that runs multiple validations in sequence. */ +public class ValidateAll( + private val validations: List>, +) : Validation { + override fun validate(value: T): ValidationResult { + val errors = mutableListOf() + for (validation in validations) { + val result = validation.validate(value) + if (result is Invalid) errors += result + } + return errors.flattenOrValid(value) + } + + override fun toString(): String = "ValidateAll(validation=$validations)" +} diff --git a/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt b/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt new file mode 100644 index 0000000..2235aec --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt @@ -0,0 +1,55 @@ +package io.konform.validation + +import io.konform.validation.jsonschema.minimum +import io.konform.validation.types.EmptyValidation +import io.konform.validation.types.ValidateAll +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.types.shouldBeInstanceOf +import io.kotest.matchers.types.shouldBeSameInstanceAs +import kotlin.test.Test + +class ListValidationTest { + private val validation1 = + Validation { + minimum(0) + } + private val validation2 = + Validation { + minimum(10) + } + + @Test + fun flattenNone() { + val result = listOf>().flatten() + + result shouldBeSameInstanceAs EmptyValidation + result shouldBeValid 0 + result shouldBeValid -100 + } + + @Test + fun flattenOne() { + val result = listOf(validation1).flatten() + + result shouldBeSameInstanceAs validation1 + result shouldBeValid 0 + result shouldBeInvalid -100 + } + + @Test + fun flattenMore() { + val result = listOf(validation1, validation2).flatten() + + result.shouldBeInstanceOf>() + + result shouldBeValid 10 + (result shouldBeInvalid 5) shouldContainOnlyError ValidationError("", "must be at least '10'") + (result shouldBeInvalid -1).shouldContainExactlyErrors( + ValidationError("", "must be at least '0'"), + ValidationError("", "must be at least '10'"), + ) + } +} diff --git a/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt b/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt index a7373e7..7cb9442 100644 --- a/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt +++ b/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt @@ -18,6 +18,7 @@ import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.should +import io.kotest.matchers.shouldBe infix fun Validation.shouldBeValid(value: T) = this should beValid(value) @@ -102,3 +103,7 @@ fun Invalid.shouldContainExactlyErrors(vararg errors: Pair) = this.errors shouldContainExactlyInAnyOrder errors.map { PropertyValidationError(it.first, it.second) } infix fun Invalid.shouldContainExactlyErrors(errors: List) = this.errors shouldContainExactlyInAnyOrder errors + +infix fun Invalid.shouldContainOnlyError(error: ValidationError) { + this.errors shouldBe listOf(error) +}