Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compose validation builders #63

Closed
wants to merge 13 commits into from
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ You can define validations for nested classes and use them for new validations

```Kotlin
val ageCheck = Validation<UserProfile> {
UserProfile::age required {
UserProfile::age required with {
minimum(18)
}
}
Expand Down Expand Up @@ -128,8 +128,8 @@ data class Event(
val validateEvent = Validation<Event> {
Event::organizer {
// even though the email is nullable you can force it to be set in the validation
Person::email required {
pattern(".+@bigcorp.com") hint "Organizers must have a BigCorp email address"
Person::email required with {
pattern("\\w+@bigcorp.com") hint staticHint("Organizers must have a BigCorp email address")
}
}

Expand All @@ -144,22 +144,22 @@ val validateEvent = Validation<Event> {
minLength(2)
}
Person::age {
minimum(18) hint "Attendees must be 18 years or older"
minimum(18) hint stringHint("Attendees must be 18 years or older")
}
// Email is optional but if it is set it must be valid
Person::email ifPresent {
pattern(".+@.+\..+") hint "Please provide a valid email address (optional)"
pattern("\\w+@\\w+\\.\\w+") hint stringHint("Please provide a valid email address (optional)")
}
}

// validation on the ticketPrices Map as a whole
Event::ticketPrices {
minItems(1) hint "Provide at least one ticket price"
minItems(1) hint stringHint("Provide at least one ticket price")
}

// validations for the individual entries
Event::ticketPrices onEach {
// Tickets may be free in which case they are null
// Tickets may be free
Entry<String, Double?>::value ifPresent {
minimum(0.01)
}
Expand Down
28 changes: 20 additions & 8 deletions src/commonMain/kotlin/io/konform/validation/Validation.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
package io.konform.validation

import io.konform.validation.internal.ValidationBuilderImpl
import io.konform.validation.internal.ValidationNodeBuilder
import kotlin.jvm.JvmName

interface Validation<T> {
interface Validation<C, T, E> {

companion object {
operator fun <T> invoke(init: ValidationBuilder<T>.() -> Unit): Validation<T> {
val builder = ValidationBuilderImpl<T>()
operator fun <C, T, E> invoke(init: ValidationBuilder<C, T, E>.() -> Unit): Validation<C, T, E> {
val builder = ValidationNodeBuilder<C, T, E>()
return builder.apply(init).build()
}

@JvmName("contextInvoke")
operator fun <C, T> invoke(init: ValidationBuilder<C, T, String>.() -> Unit): Validation<C, T, String> {
val builder = ValidationNodeBuilder<C, T, String>()
return builder.apply(init).build()
}

@JvmName("simpleInvoke")
operator fun <T> invoke(init: ValidationBuilder<Unit, T, String>.() -> Unit): Validation<Unit, T, String> {
val builder = ValidationNodeBuilder<Unit, T, String>()
return builder.apply(init).build()
}
}

fun validate(value: T): ValidationResult<T>
operator fun invoke(value: T) = validate(value)
fun validate(context: C, value: T): ValidationResult<E, T>
operator fun invoke(context: C, value: T) = validate(context, value)
}


class Constraint<R> internal constructor(val hint: String, val templateValues: List<String>, val test: (R) -> Boolean)
operator fun <T, E> Validation<Unit, T, E>.invoke(value: T) = validate(Unit, value)
125 changes: 83 additions & 42 deletions src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt
Original file line number Diff line number Diff line change
@@ -1,70 +1,111 @@
package io.konform.validation

import io.konform.validation.internal.ArrayValidation
import io.konform.validation.internal.IterableValidation
import io.konform.validation.internal.MapValidation
import io.konform.validation.internal.OptionalValidation
import io.konform.validation.internal.RequiredValidation
import io.konform.validation.internal.ValidationBuilderImpl
import io.konform.validation.internal.*
import kotlin.jvm.JvmName
import kotlin.reflect.KFunction1
import kotlin.reflect.KProperty1

@DslMarker
private annotation class ValidationScope

@ValidationScope
abstract class ValidationBuilder<T> {
abstract fun build(): Validation<T>
abstract fun addConstraint(errorMessage: String, vararg templateValues: String, test: (T) -> Boolean): Constraint<T>
abstract infix fun Constraint<T>.hint(hint: String): Constraint<T>
abstract operator fun <R> KProperty1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit)
internal abstract fun <R> onEachIterable(prop: KProperty1<T, Iterable<R>>, init: ValidationBuilder<R>.() -> Unit)
abstract class ValidationBuilder<C, T, E> : ComposableBuilder<C, T, E> {
abstract override fun build(): Validation<C, T, E>

abstract fun addConstraint(hint: HintBuilder<C, T, E>, vararg values: Any, test: C.(T) -> Boolean): ConstraintBuilder<C, T, E>

abstract operator fun <R> KProperty1<T, R>.invoke(init: ValidationBuilder<C, R, E>.() -> Unit)
abstract operator fun <R> KFunction1<T, R>.invoke(init: ValidationBuilder<C, R, E>.() -> Unit)

internal abstract fun <R> onEachIterable(name: String, mapFn: (T) -> Iterable<R>, init: ValidationBuilder<C, R, E>.() -> Unit)
@JvmName("onEachIterable")
infix fun <R> KProperty1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit) = onEachIterable(this, init)
internal abstract fun <R> onEachArray(prop: KProperty1<T, Array<R>>, init: ValidationBuilder<R>.() -> Unit)
infix fun <R> KProperty1<T, Iterable<R>>.onEach(init: ValidationBuilder<C, R, E>.() -> Unit) = onEachIterable(this.name, this, init)
@JvmName("onEachIterable")
infix fun <R> KFunction1<T, Iterable<R>>.onEach(init: ValidationBuilder<C, R, E>.() -> Unit) = onEachIterable(this.name, this, init)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the other extension functions on KFunction1 are not tested as far as I can see.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right. I added some tests in a jvmTest module.


internal abstract fun <R> onEachArray(name: String, mapFn: (T) -> Array<R>, init: ValidationBuilder<C, R, E>.() -> Unit)
@JvmName("onEachArray")
infix fun <R> KProperty1<T, Array<R>>.onEach(init: ValidationBuilder<C, R, E>.() -> Unit) = onEachArray(this.name, this, init)
@JvmName("onEachArray")
infix fun <R> KProperty1<T, Array<R>>.onEach(init: ValidationBuilder<R>.() -> Unit) = onEachArray(this, init)
internal abstract fun <K, V> onEachMap(prop: KProperty1<T, Map<K, V>>, init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit)
infix fun <R> KFunction1<T, Array<R>>.onEach(init: ValidationBuilder<C, R, E>.() -> Unit) = onEachArray(this.name, this, init)

internal abstract fun <K, V> onEachMap(name: String, mapFn: (T) -> Map<K, V>, init: ValidationBuilder<C, Map.Entry<K, V>, E>.() -> Unit)
@JvmName("onEachMap")
infix fun <K, V> KProperty1<T, Map<K, V>>.onEach(init: ValidationBuilder<C, Map.Entry<K, V>, E>.() -> Unit) = onEachMap(this.name, this, init)
@JvmName("onEachMap")
infix fun <K, V> KProperty1<T, Map<K, V>>.onEach(init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit) = onEachMap(this, init)
abstract infix fun <R> KProperty1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit)
abstract infix fun <R> KProperty1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit)
abstract fun run(validation: Validation<T>)
abstract val <R> KProperty1<T, R>.has: ValidationBuilder<R>
infix fun <K, V> KFunction1<T, Map<K, V>>.onEach(init: ValidationBuilder<C, Map.Entry<K, V>, E>.() -> Unit) = onEachMap(this.name, this, init)

internal abstract fun <R : Any> ifPresent(name: String, mapFn: (T) -> R?, init: ValidationBuilder<C, R, E>.() -> Unit)
infix fun <R : Any> KProperty1<T, R?>.ifPresent(init: ValidationBuilder<C, R, E>.() -> Unit) = ifPresent(this.name, this, init)
infix fun <R : Any> KFunction1<T, R?>.ifPresent(init: ValidationBuilder<C, R, E>.() -> Unit) = ifPresent(this.name, this, init)

internal abstract fun <R : Any> required(name: String, hint: HintBuilder<C, R?, E>, mapFn: (T) -> R?, init: ValidationBuilder<C, R, E>.() -> Unit): ConstraintBuilder<C, R?, E>
infix fun <R : Any> KProperty1<T, R?>.required(hintedBuilder: HintedRequiredBuilder<C, R, E>): ConstraintBuilder<C, R?, E> =
required(this.name, hintedBuilder.hint, this, hintedBuilder.init)
infix fun <R : Any> KFunction1<T, R?>.required(hintedBuilder: HintedRequiredBuilder<C, R, E>): ConstraintBuilder<C, R?, E> =
required(this.name, hintedBuilder.hint, this, hintedBuilder.init)

abstract fun <C, R, E> with(hint: HintBuilder<C, R?, E>, init: ValidationBuilder<C, R, E>.() -> Unit): HintedRequiredBuilder<C, R, E>
abstract fun <C, R> with(init: ValidationBuilder<C, R, String>.() -> Unit): HintedRequiredBuilder<C, R, String>

abstract fun <S> run(validation: Validation<S, T, E>, map: (C) -> S)
fun run(validation: Validation<C, T, E>) = run(validation, ::identity)

internal abstract fun add(builder: ComposableBuilder<C, T, E>)

abstract val <R> KProperty1<T, R>.has: ValidationBuilder<C, R, E>
}

interface ConstraintBuilder<C, T, E> {
infix fun hint(hint: HintBuilder<C, T, E>) : ConstraintBuilder<C, T, E>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be possible to define an extension function to allow the old hint "error message" in addition to hint stringHint("error message". Maybe stringHint could be internal.

infix fun <C,T> ConstraintBuilder<C,T,String>.hint(hint: String) = hint(stringHint(hint))

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one!

I added this extension function and one on ValidationBuilder:
fun <C, T> ValidationBuilder<C, T, String>.addConstraint(hint: String, vararg values: Any, test: C.(T) -> Boolean): ConstraintBuilder<C, T, String>

I would keep stringHint public. It provides the "templated" approach that was previously in the NodeValidation. I can imagine that it is very useful when creating your own constraint extensions.

}

interface HintedRequiredBuilder<C, T, E> {
val hint: HintBuilder<C, T?, E>
val init: ValidationBuilder<C, T, E>.() -> Unit
}

fun <T : Any> ValidationBuilder<T?>.ifPresent(init: ValidationBuilder<T>.() -> Unit) {
val builder = ValidationBuilderImpl<T>()
init(builder)
run(OptionalValidation(builder.build()))
fun <C, T : Any, E> ValidationBuilder<C, T?, E>.ifPresent(init: ValidationBuilder<C, T, E>.() -> Unit) {
val builder = ValidationNodeBuilder<C, T, E>().also(init)
add(OptionalValidationBuilder(builder))
}

fun <T : Any> ValidationBuilder<T?>.required(init: ValidationBuilder<T>.() -> Unit) {
val builder = ValidationBuilderImpl<T>()
init(builder)
run(RequiredValidation(builder.build()))
fun <C, T : Any, E> ValidationBuilder<C, T?, E>.required(hint: HintBuilder<C, T?, E>, init: ValidationBuilder<C, T, E>.() -> Unit): ConstraintBuilder<C, T?, E> {
val builder = ValidationNodeBuilder<C, T, E>().also(init)
val requiredValidationBuilder = RequiredValidationBuilder(hint, builder)
add(requiredValidationBuilder)
return requiredValidationBuilder.requiredConstraintBuilder
}

@JvmName("onEachIterable")
fun <S, T : Iterable<S>> ValidationBuilder<T>.onEach(init: ValidationBuilder<S>.() -> Unit) {
val builder = ValidationBuilderImpl<S>()
init(builder)
fun <C, S, T : Iterable<S>, E> ValidationBuilder<C, T, E>.onEach(init: ValidationBuilder<C, S, E>.() -> Unit) {
val builder = ValidationNodeBuilder<C, S, E>().also(init)
@Suppress("UNCHECKED_CAST")
run(IterableValidation(builder.build()) as Validation<T>)
add(IterableValidationBuilder(builder) as ComposableBuilder<C, T, E>)
}

@JvmName("onEachArray")
fun <T> ValidationBuilder<Array<T>>.onEach(init: ValidationBuilder<T>.() -> Unit) {
val builder = ValidationBuilderImpl<T>()
init(builder)
@Suppress("UNCHECKED_CAST")
run(ArrayValidation(builder.build()) as Validation<Array<T>>)
fun <C, T, E> ValidationBuilder<C, Array<T>, E>.onEach(init: ValidationBuilder<C, T, E>.() -> Unit) {
val builder = ValidationNodeBuilder<C, T, E>().also(init)
add(ArrayValidationBuilder(builder))
}

@JvmName("onEachMap")
fun <K, V, T : Map<K, V>> ValidationBuilder<T>.onEach(init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit) {
val builder = ValidationBuilderImpl<Map.Entry<K, V>>()
init(builder)
fun <C, K, V, T : Map<K, V>, E> ValidationBuilder<C, T, E>.onEach(init: ValidationBuilder<C, Map.Entry<K, V>, E>.() -> Unit) {
val builder = ValidationNodeBuilder<C, Map.Entry<K, V>, E>().also(init)
@Suppress("UNCHECKED_CAST")
run(MapValidation(builder.build()) as Validation<T>)
add(MapValidationBuilder(builder) as ComposableBuilder<C, T, E>)
}

typealias HintArguments = List<Any>
typealias HintBuilder<C, T, E> = C.(T, HintArguments) -> E

fun <C, T> stringHint(template: String): HintBuilder<C, T, String> = { value, args ->
args
.map(Any::toString)
.foldIndexed(template.replace("{value}", value.toString())) { index, acc, arg ->
acc.replace("{$index}", arg)
}
}

fun <C, T, E> staticHint(e: E): HintBuilder<C, T, E> = { _, _ -> e }
50 changes: 29 additions & 21 deletions src/commonMain/kotlin/io/konform/validation/ValidationResult.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,44 @@ package io.konform.validation

import kotlin.reflect.KProperty1

interface ValidationError {
interface ValidationError<out E> {
val dataPath: String
val message: String
val message: E
}

internal data class PropertyValidationError(
internal data class PropertyValidationError<E>(
override val dataPath: String,
override val message: String
) : ValidationError {
override val message: E,
) : ValidationError<E> {
override fun toString(): String {
return "ValidationError(dataPath=$dataPath, message=$message)"
}
}

interface ValidationErrors : List<ValidationError>
interface ValidationErrors<out E> : List<ValidationError<E>>

internal object NoValidationErrors : ValidationErrors, List<ValidationError> by emptyList()
internal class DefaultValidationErrors(private val errors: List<ValidationError>) : ValidationErrors, List<ValidationError> by errors {
internal object NoValidationErrors : ValidationErrors<Nothing>, List<ValidationError<Nothing>> by emptyList()
internal class DefaultValidationErrors<E>(private val errors: List<ValidationError<E>>) : ValidationErrors<E>, List<ValidationError<E>> by errors {
override fun toString(): String {
return errors.toString()
}
}

sealed class ValidationResult<out T> {
abstract operator fun get(vararg propertyPath: Any): List<String>?
abstract fun <R> map(transform: (T) -> R): ValidationResult<R>
abstract val errors: ValidationErrors
sealed class ValidationResult<out E, out T> {
abstract val errors: ValidationErrors<E>

abstract operator fun get(vararg propertyPath: Any): List<E>?

fun <R> map(transform: (T) -> R): ValidationResult<E, R> =
flatMap { flatMap { Valid(transform(it)) } }
}

data class Invalid<T>(
internal val internalErrors: Map<String, List<String>>) : ValidationResult<T>() {
data class Invalid<out E>(
internal val internalErrors: Map<String, List<E>>,
) : ValidationResult<E, Nothing>() {

override fun get(vararg propertyPath: Any): List<String>? =
override fun get(vararg propertyPath: Any): List<E>? =
internalErrors[propertyPath.joinToString("", transform = ::toPathSegment)]
override fun <R> map(transform: (T) -> R): ValidationResult<R> = Invalid(this.internalErrors)

private fun toPathSegment(it: Any): String {
return when (it) {
Expand All @@ -46,7 +49,7 @@ data class Invalid<T>(
}
}

override val errors: ValidationErrors by lazy {
override val errors: ValidationErrors<E> by lazy {
DefaultValidationErrors(
internalErrors.flatMap { (path, errors ) ->
errors.map { PropertyValidationError(path, it) }
Expand All @@ -59,9 +62,14 @@ data class Invalid<T>(
}
}

data class Valid<T>(val value: T) : ValidationResult<T>() {
override fun get(vararg propertyPath: Any): List<String>? = null
override fun <R> map(transform: (T) -> R): ValidationResult<R> = Valid(transform(this.value))
override val errors: ValidationErrors
data class Valid<out E, out T>(val value: T) : ValidationResult<E, T>() {
override fun get(vararg propertyPath: Any): List<E>? = null
override val errors: ValidationErrors<E>
get() = DefaultValidationErrors(emptyList())
}

inline fun <A, B, C> ValidationResult<A, B>.flatMap(f: (B) -> ValidationResult<A, C>): ValidationResult<A, C> =
when (this) {
is Invalid -> this
is Valid -> f(this.value)
}
Loading