Skip to content

Commit

Permalink
Add List<Validation<T>>.flatten() and List<ValidationResult<T>>.flatt…
Browse files Browse the repository at this point in the history
…en* (#162)
  • Loading branch information
dhoepelman authored Nov 11, 2024
1 parent d268325 commit ece1222
Show file tree
Hide file tree
Showing 9 changed files with 209 additions and 9 deletions.
40 changes: 40 additions & 0 deletions api/konform.api
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,21 @@ public final class io/konform/validation/ValidationBuilderKt {
}

public abstract interface class io/konform/validation/ValidationError {
public static final field Companion Lio/konform/validation/ValidationError$Companion;
public abstract fun getDataPath ()Ljava/lang/String;
public abstract fun getMessage ()Ljava/lang/String;
}

public final class io/konform/validation/ValidationError$Companion {
}

public abstract interface class io/konform/validation/ValidationErrors : java/util/List, kotlin/jvm/internal/markers/KMappedMarker {
}

public final class io/konform/validation/ValidationKt {
public static final fun flatten (Ljava/util/List;)Lio/konform/validation/Validation;
}

public abstract class io/konform/validation/ValidationResult {
public abstract fun get ([Ljava/lang/Object;)Ljava/util/List;
public abstract fun getErrors ()Ljava/util/List;
Expand All @@ -116,13 +124,45 @@ public final class io/konform/validation/jsonschema/JsonSchemaKt {
public static final fun uuid (Lio/konform/validation/ValidationBuilder;)Lio/konform/validation/Constraint;
}

public final class io/konform/validation/path/ValidationPath {
public static final field Companion Lio/konform/validation/path/ValidationPath$Companion;
public fun <init> (Ljava/util/List;)V
public final fun component1 ()Ljava/util/List;
public final fun copy (Ljava/util/List;)Lio/konform/validation/path/ValidationPath;
public static synthetic fun copy$default (Lio/konform/validation/path/ValidationPath;Ljava/util/List;ILjava/lang/Object;)Lio/konform/validation/path/ValidationPath;
public fun equals (Ljava/lang/Object;)Z
public final fun getDataPaths ()Ljava/util/List;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class io/konform/validation/path/ValidationPath$Companion {
public final fun fromAny ([Ljava/lang/Object;)Lio/konform/validation/path/ValidationPath;
}

public final class io/konform/validation/string/ValidationBuilderStringKt {
public static final fun notBlank (Lio/konform/validation/ValidationBuilder;)Lio/konform/validation/Constraint;
}

public final class io/konform/validation/types/EmptyValidation : io/konform/validation/Validation {
public static final field INSTANCE Lio/konform/validation/types/EmptyValidation;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
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;
public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
}

public final class io/konform/validation/types/ValidateAll : io/konform/validation/Validation {
public fun <init> (Ljava/util/List;)V
public fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
public fun toString ()Ljava/lang/String;
public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
}

16 changes: 12 additions & 4 deletions src/commonMain/kotlin/io/konform/validation/Validation.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
package io.konform.validation

import io.konform.validation.types.EmptyValidation
import io.konform.validation.types.ValidateAll

public interface Validation<in T> {
public companion object {
public operator fun <T> invoke(init: ValidationBuilder<T>.() -> Unit): Validation<T> {
val builder = ValidationBuilder<T>()
return builder.apply(init).build()
}
public operator fun <T> invoke(init: ValidationBuilder<T>.() -> Unit): Validation<T> = 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 <T> List<Validation<T>>.flatten(): Validation<T> =
when (size) {
0 -> EmptyValidation
1 -> first()
else -> ValidateAll(this)
}
13 changes: 13 additions & 0 deletions src/commonMain/kotlin/io/konform/validation/ValidationError.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 33 additions & 5 deletions src/commonMain/kotlin/io/konform/validation/ValidationResult.kt
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -68,3 +64,35 @@ public data class Valid<T>(
override val errors: List<ValidationError>
get() = emptyList()
}

internal fun <T> List<ValidationResult<T>>.flattenNonEmpty(): ValidationResult<T> {
require(isNotEmpty()) { "List<ValidationResult> is not allowed to be empty in flattenNonEmpty" }
val invalids = filterIsInstance<Invalid>()
return if (invalids.isEmpty()) {
first() as Valid
} else {
invalids.flattenNotEmpty()
}
}

internal fun List<Invalid>.flattenNotEmpty(): Invalid {
require(isNotEmpty()) { "List<Invalid> is not allowed to be empty in flattenNonEmpty" }
val merged = mutableMapOf<String, List<String>>()
for (invalid in this) {
val added =
invalid.internalErrors.mapValues {
merged.getOrElse(it.key, ::emptyList) + it.value
}
merged += added
}
return Invalid(merged)
}

internal fun <T> List<ValidationResult<T>>.flattenOrValid(value: T): ValidationResult<T> =
takeIf { isNotEmpty() }
?.flattenNonEmpty()
?.takeIf { it is Invalid }
?: Valid(value)

@JvmName("flattenOrValidInvalidList")
internal fun <T> List<Invalid>.flattenOrValid(value: T): ValidationResult<T> = if (isNotEmpty()) flattenNonEmpty() else Valid(value)
13 changes: 13 additions & 0 deletions src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt
Original file line number Diff line number Diff line change
@@ -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<PathSegment>,
val dataPaths: List<String>,
) {
public companion object {
public fun fromAny(vararg validationPath: Any): ValidationPath = ValidationPath(validationPath.map { Path.toPath(*validationPath) })
}
}
Original file line number Diff line number Diff line change
@@ -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<Any?> {
override fun validate(value: Any?): ValidationResult<Any?> = Valid(value)

override fun toString(): String = "EmptyValidation"

override fun equals(other: Any?): Boolean = other === this

override fun hashCode(): Int = 912378
}
22 changes: 22 additions & 0 deletions src/commonMain/kotlin/io/konform/validation/types/ValidateAll.kt
Original file line number Diff line number Diff line change
@@ -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<T>(
private val validations: List<Validation<T>>,
) : Validation<T> {
override fun validate(value: T): ValidationResult<T> {
val errors = mutableListOf<Invalid>()
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)"
}
55 changes: 55 additions & 0 deletions src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt
Original file line number Diff line number Diff line change
@@ -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<Int> {
minimum(0)
}
private val validation2 =
Validation<Int> {
minimum(10)
}

@Test
fun flattenNone() {
val result = listOf<Validation<Int>>().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<ValidateAll<Int>>()

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'"),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> Validation<T>.shouldBeValid(value: T) = this should beValid(value)

Expand Down Expand Up @@ -102,3 +103,7 @@ fun Invalid.shouldContainExactlyErrors(vararg errors: Pair<String, String>) =
this.errors shouldContainExactlyInAnyOrder errors.map { PropertyValidationError(it.first, it.second) }

infix fun Invalid.shouldContainExactlyErrors(errors: List<ValidationError>) = this.errors shouldContainExactlyInAnyOrder errors

infix fun Invalid.shouldContainOnlyError(error: ValidationError) {
this.errors shouldBe listOf(error)
}

0 comments on commit ece1222

Please sign in to comment.