Skip to content

Commit

Permalink
Add subclass validation: ifInstanceOf and requireInstanceOf (#152)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhoepelman authored Oct 31, 2024
1 parent f5b0a7c commit 115d231
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 14 deletions.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,38 @@ result[Event::attendees, 0, Person::age]
result[Event::ticketPrices, "free"]
```

#### Subtypes

You can run validations only if the valuen is of a specific subtype, or require it to be specific subtype.

```kotlin
sealed interface Animal {
val name: String
}
data class Cat(override val name: String, val favoritePrey: String) : Animal
data class Dog(override val name: String) : Animal

val validateAnimal = Validation<Animal> {
Animal::name {
notBlank()
}
// Only run this validation if the current Animal is a Cat and not null
ifInstanceOf<Cat> {
Cat::favoritePrey {
notBlank()
}
}
}
val requireCat = Validation<Animal> {
// This will return an invalid result is the current Animal is not a Cat or null
requireInstanceOf<Cat> {
Cat::favoritePrey {
// ...
}
}
}
```

### Other validation libraries written in Kotlin

- Valikator: https://github.com/valiktor/valiktor
Expand Down
16 changes: 16 additions & 0 deletions api/konform.api
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public final class io/konform/validation/Validation$DefaultImpls {
}

public final class io/konform/validation/ValidationBuilder {
public static final field Companion Lio/konform/validation/ValidationBuilder$Companion;
public fun <init> ()V
public final fun addConstraint (Ljava/lang/String;[Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lio/konform/validation/Constraint;
public final fun build ()Lio/konform/validation/Validation;
Expand All @@ -67,6 +68,10 @@ public final class io/konform/validation/ValidationBuilder {
public final fun validate (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
}

public final class io/konform/validation/ValidationBuilder$Companion {
public final fun buildWithNew (Lkotlin/jvm/functions/Function1;)Lio/konform/validation/Validation;
}

public final class io/konform/validation/ValidationBuilderKt {
public static final fun ifPresent (Lio/konform/validation/ValidationBuilder;Lkotlin/jvm/functions/Function1;)V
public static final fun onEachArray (Lio/konform/validation/ValidationBuilder;Lkotlin/jvm/functions/Function1;)V
Expand Down Expand Up @@ -105,3 +110,14 @@ 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/string/ValidationBuilderStringKt {
public static final fun notBlank (Lio/konform/validation/ValidationBuilder;)Lio/konform/validation/Constraint;
}

public final class io/konform/validation/validations/IsClassValidation : io/konform/validation/Validation {
public fun <init> (Lkotlin/reflect/KClass;ZLio/konform/validation/Validation;)V
public synthetic fun <init> (Lkotlin/reflect/KClass;ZLio/konform/validation/Validation;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
}

42 changes: 28 additions & 14 deletions src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.konform.validation

import io.konform.validation.ValidationBuilder.Companion.buildWithNew
import io.konform.validation.builder.ArrayPropKey
import io.konform.validation.builder.IterablePropKey
import io.konform.validation.builder.MapPropKey
Expand All @@ -16,6 +17,7 @@ import io.konform.validation.internal.OptionalValidation
import io.konform.validation.internal.RequiredValidation
import io.konform.validation.internal.ValidationNode
import io.konform.validation.kotlin.Grammar
import io.konform.validation.validations.IsClassValidation
import kotlin.jvm.JvmName
import kotlin.reflect.KFunction1
import kotlin.reflect.KProperty1
Expand Down Expand Up @@ -167,32 +169,45 @@ public class ValidationBuilder<T> {
get() = toPropKey(name, NonNull).getOrCreateBuilder()
public val <R> KFunction1<T, R>.has: ValidationBuilder<R>
get() = toPropKey(name, NonNull).getOrCreateBuilder()

public inline fun <reified SubT : T & Any> ifInstanceOf(init: ValidationBuilder<SubT>.() -> Unit): Unit =
run(IsClassValidation<SubT, T>(SubT::class, required = false, buildWithNew(init)))

public inline fun <reified SubT : T & Any> requireInstanceOf(init: ValidationBuilder<SubT>.() -> Unit): Unit =
run(IsClassValidation<SubT, T>(SubT::class, required = true, buildWithNew(init)))

public companion object {
public inline fun <T> buildWithNew(block: ValidationBuilder<T>.() -> Unit): Validation<T> {
val builder = ValidationBuilder<T>()
block(builder)
return builder.build()
}
}
}

// TODO: ifPresent and required extension functions are hidden since the introduction of then on main validation builder
// but they do something different
// possible solutions:
// - Move main validation to extension function
// - Rename main validation extension function

/**
* Run a validation if the property is not-null, and allow nulls.
*/
public fun <T : Any> ValidationBuilder<T?>.ifPresent(init: ValidationBuilder<T>.() -> Unit) {
val builder = ValidationBuilder<T>()
init(builder)
run(OptionalValidation(builder.build()))
}
public fun <T : Any> ValidationBuilder<T?>.ifPresent(init: ValidationBuilder<T>.() -> Unit): Unit =
run(OptionalValidation(buildWithNew(init)))

/**
* Run a validation on a nullable property, giving an error on nulls.
*/
public fun <T : Any> ValidationBuilder<T?>.required(init: ValidationBuilder<T>.() -> Unit) {
val builder = ValidationBuilder<T>()
init(builder)
run(RequiredValidation(builder.build()))
}
public fun <T : Any> ValidationBuilder<T?>.required(init: ValidationBuilder<T>.() -> Unit): Unit =
run(RequiredValidation<T>(buildWithNew(init)))

@JvmName("onEachIterable")
public fun <S, T : Iterable<S>> ValidationBuilder<T>.onEach(init: ValidationBuilder<S>.() -> Unit) {
val builder = ValidationBuilder<S>()
init(builder)
@Suppress("UNCHECKED_CAST")
run(IterableValidation(builder.build()) as Validation<T>)
run(IterableValidation(builder.build()))
}

@JvmName("onEachArray")
Expand All @@ -206,6 +221,5 @@ public fun <T> ValidationBuilder<Array<T>>.onEach(init: ValidationBuilder<T>.()
public fun <K, V, T : Map<K, V>> ValidationBuilder<T>.onEach(init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit) {
val builder = ValidationBuilder<Map.Entry<K, V>>()
init(builder)
@Suppress("UNCHECKED_CAST")
run(MapValidation(builder.build()) as Validation<T>)
run(MapValidation(builder.build()))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.konform.validation.string

import io.konform.validation.Constraint
import io.konform.validation.ValidationBuilder

public fun ValidationBuilder<String>.notBlank(): Constraint<String> = addConstraint("must not be blank") { it.isNotBlank() }
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.konform.validation.validations

import io.konform.validation.Invalid
import io.konform.validation.Valid
import io.konform.validation.Validation
import io.konform.validation.ValidationResult
import kotlin.reflect.KClass
import kotlin.reflect.safeCast

/**
* Can run a validation if the input is of a specific type.
* @param clazz Class tag for the type
* @param required If true, will give an error if the actual input is not of the type.
* If false, will do nothing for a different type.
* @param validation The validation to run for the type
* @param T The type that the input must be of
* @param ParentT The best-defined parent type of T
*/
public class IsClassValidation<T : ParentT & Any, ParentT>(
private val clazz: KClass<T>,
private val required: Boolean = false,
private val validation: Validation<T>,
) : Validation<ParentT> {
override fun validate(value: ParentT): ValidationResult<ParentT> {
val castedValue = clazz.safeCast(value)
return if (castedValue == null) {
if (required) {
val actualType = value?.let { it::class.simpleName }
Invalid(mapOf("" to listOf("must be a '${clazz.simpleName}', was a '$actualType'")))
} else {
Valid(value)
}
} else {
validation.validate(castedValue)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.konform.validation.validationbuilder

import io.konform.validation.PropertyValidationError
import io.konform.validation.Validation
import io.konform.validation.string.notBlank
import io.kotest.assertions.konform.shouldBeInvalid
import io.kotest.assertions.konform.shouldBeValid
import io.kotest.assertions.konform.shouldContainExactlyErrors
import kotlin.test.Test

class InstanceOfTest {
private val catValidation =
Validation<Cat> {
Cat::favoritePrey {
notBlank()
}
}

private val ifCatValidation =
Validation<Animal?> {
ifInstanceOf<Cat> {
run(catValidation)
}
}

private val requireCatValidation =
Validation<Animal?> {
requireInstanceOf<Cat> {
run(catValidation)
}
}

val validCat = Cat("cat", "mouse")
val invalidCat = Cat("", "")
val validDog = Dog("dog")
val invalidDog = Dog("")

@Test
fun ifInstanceOfTest() {
ifCatValidation shouldBeValid validCat
ifCatValidation shouldBeValid validDog
ifCatValidation shouldBeValid invalidDog
ifCatValidation shouldBeValid null

val invalid = ifCatValidation shouldBeInvalid invalidCat
invalid shouldContainExactlyErrors
listOf(
PropertyValidationError(".favoritePrey", "must not be blank"),
)
}

@Test
fun requireInstanceOfTest() {
requireCatValidation shouldBeValid validCat

val invalidCatResult = requireCatValidation shouldBeInvalid invalidCat
invalidCatResult shouldContainExactlyErrors
listOf(
PropertyValidationError(".favoritePrey", "must not be blank"),
)

val validDogResult = requireCatValidation shouldBeInvalid validDog
val invalidDogResult = requireCatValidation shouldBeInvalid invalidDog
val expectedError =
listOf(
PropertyValidationError("", "must be a 'Cat', was a 'Dog'"),
)
validDogResult shouldContainExactlyErrors expectedError
invalidDogResult shouldContainExactlyErrors expectedError

val nullResult = requireCatValidation shouldBeInvalid null
nullResult shouldContainExactlyErrors
listOf(
PropertyValidationError("", "must be a 'Cat', was a 'null'"),
)
}
}

sealed interface Animal {
val name: String
}

data class Cat(
override val name: String,
val favoritePrey: String,
) : Animal

data class Dog(
override val name: String,
) : Animal

0 comments on commit 115d231

Please sign in to comment.