From 115d231e107ad2c662839b7b52735e40660a4eea Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Thu, 31 Oct 2024 22:41:50 +0100 Subject: [PATCH] Add subclass validation: ifInstanceOf and requireInstanceOf (#152) --- README.md | 32 +++++++ api/konform.api | 16 ++++ .../konform/validation/ValidationBuilder.kt | 42 ++++++--- .../string/ValidationBuilderString.kt | 6 ++ .../validations/IsClassValidation.kt | 37 ++++++++ .../validationbuilder/InstanceOfTest.kt | 90 +++++++++++++++++++ 6 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 src/commonMain/kotlin/io/konform/validation/string/ValidationBuilderString.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/validations/IsClassValidation.kt create mode 100644 src/commonTest/kotlin/io/konform/validation/validationbuilder/InstanceOfTest.kt diff --git a/README.md b/README.md index 3a18130..0abbfc0 100644 --- a/README.md +++ b/README.md @@ -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::name { + notBlank() + } + // Only run this validation if the current Animal is a Cat and not null + ifInstanceOf { + Cat::favoritePrey { + notBlank() + } + } +} +val requireCat = Validation { + // This will return an invalid result is the current Animal is not a Cat or null + requireInstanceOf { + Cat::favoritePrey { + // ... + } + } +} +``` + ### Other validation libraries written in Kotlin - Valikator: https://github.com/valiktor/valiktor diff --git a/api/konform.api b/api/konform.api index a7ed2d6..c9cbc21 100644 --- a/api/konform.api +++ b/api/konform.api @@ -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 ()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; @@ -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 @@ -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 (Lkotlin/reflect/KClass;ZLio/konform/validation/Validation;)V + public synthetic fun (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; +} + diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index ab4f3ab..12a1ac0 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -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 @@ -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 @@ -167,32 +169,45 @@ public class ValidationBuilder { get() = toPropKey(name, NonNull).getOrCreateBuilder() public val KFunction1.has: ValidationBuilder get() = toPropKey(name, NonNull).getOrCreateBuilder() + + public inline fun ifInstanceOf(init: ValidationBuilder.() -> Unit): Unit = + run(IsClassValidation(SubT::class, required = false, buildWithNew(init))) + + public inline fun requireInstanceOf(init: ValidationBuilder.() -> Unit): Unit = + run(IsClassValidation(SubT::class, required = true, buildWithNew(init))) + + public companion object { + public inline fun buildWithNew(block: ValidationBuilder.() -> Unit): Validation { + val builder = ValidationBuilder() + 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 ValidationBuilder.ifPresent(init: ValidationBuilder.() -> Unit) { - val builder = ValidationBuilder() - init(builder) - run(OptionalValidation(builder.build())) -} +public fun ValidationBuilder.ifPresent(init: ValidationBuilder.() -> Unit): Unit = + run(OptionalValidation(buildWithNew(init))) /** * Run a validation on a nullable property, giving an error on nulls. */ -public fun ValidationBuilder.required(init: ValidationBuilder.() -> Unit) { - val builder = ValidationBuilder() - init(builder) - run(RequiredValidation(builder.build())) -} +public fun ValidationBuilder.required(init: ValidationBuilder.() -> Unit): Unit = + run(RequiredValidation(buildWithNew(init))) @JvmName("onEachIterable") public fun > ValidationBuilder.onEach(init: ValidationBuilder.() -> Unit) { val builder = ValidationBuilder() init(builder) - @Suppress("UNCHECKED_CAST") - run(IterableValidation(builder.build()) as Validation) + run(IterableValidation(builder.build())) } @JvmName("onEachArray") @@ -206,6 +221,5 @@ public fun ValidationBuilder>.onEach(init: ValidationBuilder.() public fun > ValidationBuilder.onEach(init: ValidationBuilder>.() -> Unit) { val builder = ValidationBuilder>() init(builder) - @Suppress("UNCHECKED_CAST") - run(MapValidation(builder.build()) as Validation) + run(MapValidation(builder.build())) } diff --git a/src/commonMain/kotlin/io/konform/validation/string/ValidationBuilderString.kt b/src/commonMain/kotlin/io/konform/validation/string/ValidationBuilderString.kt new file mode 100644 index 0000000..a4566dc --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/string/ValidationBuilderString.kt @@ -0,0 +1,6 @@ +package io.konform.validation.string + +import io.konform.validation.Constraint +import io.konform.validation.ValidationBuilder + +public fun ValidationBuilder.notBlank(): Constraint = addConstraint("must not be blank") { it.isNotBlank() } diff --git a/src/commonMain/kotlin/io/konform/validation/validations/IsClassValidation.kt b/src/commonMain/kotlin/io/konform/validation/validations/IsClassValidation.kt new file mode 100644 index 0000000..8bda540 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/validations/IsClassValidation.kt @@ -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( + private val clazz: KClass, + private val required: Boolean = false, + private val validation: Validation, +) : Validation { + override fun validate(value: ParentT): ValidationResult { + 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) + } + } +} diff --git a/src/commonTest/kotlin/io/konform/validation/validationbuilder/InstanceOfTest.kt b/src/commonTest/kotlin/io/konform/validation/validationbuilder/InstanceOfTest.kt new file mode 100644 index 0000000..47a08fb --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/validationbuilder/InstanceOfTest.kt @@ -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::favoritePrey { + notBlank() + } + } + + private val ifCatValidation = + Validation { + ifInstanceOf { + run(catValidation) + } + } + + private val requireCatValidation = + Validation { + requireInstanceOf { + 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