Skip to content

Commit

Permalink
Allow customizing hint in required (#172)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhoepelman authored Nov 17, 2024
1 parent e151f6c commit ae36b18
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 53 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ 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 {
// Optionally set a hint, default hint is "is required"
hint = "Email address must be given"
pattern("[email protected]") hint "Organizers must have a BigCorp email address"
}
}
Expand Down
26 changes: 24 additions & 2 deletions api/konform.api
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public class io/konform/validation/ValidationBuilder {
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 applyConstraint (Lio/konform/validation/Constraint;)Lio/konform/validation/Constraint;
public final fun build ()Lio/konform/validation/Validation;
public fun build ()Lio/konform/validation/Validation;
public final fun constrain (Ljava/lang/String;Lio/konform/validation/path/ValidationPath;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Lio/konform/validation/Constraint;
public static synthetic fun constrain$default (Lio/konform/validation/ValidationBuilder;Ljava/lang/String;Lio/konform/validation/path/ValidationPath;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/konform/validation/Constraint;
public final fun dynamic (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
Expand Down Expand Up @@ -158,7 +158,8 @@ public final class io/konform/validation/ValidationErrorKt {
public final class io/konform/validation/ValidationKt {
public static final fun flatten (Ljava/util/List;)Lio/konform/validation/Validation;
public static final fun ifPresent (Lio/konform/validation/Validation;)Lio/konform/validation/Validation;
public static final fun required (Lio/konform/validation/Validation;)Lio/konform/validation/Validation;
public static final fun required (Lio/konform/validation/Validation;Ljava/lang/String;)Lio/konform/validation/Validation;
public static synthetic fun required$default (Lio/konform/validation/Validation;Ljava/lang/String;ILjava/lang/Object;)Lio/konform/validation/Validation;
}

public abstract class io/konform/validation/ValidationResult {
Expand All @@ -179,6 +180,19 @@ public final class io/konform/validation/ValidationResultKt {
public static final fun flattenOrValidInvalidList (Ljava/util/List;Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
}

public final class io/konform/validation/builders/RequiredValidationBuilder : io/konform/validation/ValidationBuilder {
public static final field Companion Lio/konform/validation/builders/RequiredValidationBuilder$Companion;
public fun <init> ()V
public synthetic fun build ()Lio/konform/validation/Validation;
public fun build ()Lio/konform/validation/types/RequireNotNullValidation;
public final fun getHint ()Ljava/lang/String;
public final fun setHint (Ljava/lang/String;)V
}

public final class io/konform/validation/builders/RequiredValidationBuilder$Companion {
public final fun buildWithNew (Lkotlin/jvm/functions/Function1;)Lio/konform/validation/types/RequireNotNullValidation;
}

public final class io/konform/validation/constraints/EnumConstraintsKt {
public static final fun const (Lio/konform/validation/ValidationBuilder;Ljava/lang/Object;)Lio/konform/validation/Constraint;
public static final fun enum (Lio/konform/validation/ValidationBuilder;[Ljava/lang/Object;)Lio/konform/validation/Constraint;
Expand Down Expand Up @@ -415,6 +429,14 @@ public final class io/konform/validation/types/PrependPathValidation : io/konfor
public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
}

public final class io/konform/validation/types/RequireNotNullValidation : io/konform/validation/Validation {
public fun <init> (Ljava/lang/String;Lio/konform/validation/Validation;)V
public fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult;
public fun prependPath (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/Validation;
public fun prependPath (Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/Validation;
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;
Expand Down
8 changes: 5 additions & 3 deletions src/commonMain/kotlin/io/konform/validation/Validation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package io.konform.validation
import io.konform.validation.path.PathSegment
import io.konform.validation.path.ValidationPath
import io.konform.validation.types.EmptyValidation
import io.konform.validation.types.NullableValidation
import io.konform.validation.types.IfNotNullValidation
import io.konform.validation.types.PrependPathValidation
import io.konform.validation.types.RequireNotNullValidation
import io.konform.validation.types.RequireNotNullValidation.Companion.DEFAULT_REQUIRED_HINT
import io.konform.validation.types.ValidateAll

public interface Validation<in T> {
Expand All @@ -30,7 +32,7 @@ public fun <T> List<Validation<T>>.flatten(): Validation<T> =
}

/** Run a validation only if the actual value is not-null. */
public fun <T : Any> Validation<T>.ifPresent(): Validation<T?> = NullableValidation(required = false, validation = this)
public fun <T : Any> Validation<T>.ifPresent(): Validation<T?> = IfNotNullValidation(this)

/** Require a nullable value to actually be present. */
public fun <T : Any> Validation<T>.required(): Validation<T?> = NullableValidation(required = true, validation = this)
public fun <T : Any> Validation<T>.required(hint: String = DEFAULT_REQUIRED_HINT): Validation<T?> = RequireNotNullValidation(hint, this)
17 changes: 9 additions & 8 deletions src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.konform.validation

import io.konform.validation.ValidationBuilder.Companion.buildWithNew
import io.konform.validation.builders.RequiredValidationBuilder
import io.konform.validation.helpers.prepend
import io.konform.validation.path.FuncRef
import io.konform.validation.path.PathSegment
Expand All @@ -27,7 +28,7 @@ public open class ValidationBuilder<T> {
protected val constraints: MutableList<Constraint<T>> = mutableListOf()
protected val subValidations: MutableList<Validation<T>> = mutableListOf()

public fun build(): Validation<T> =
public open fun build(): Validation<T> =
subValidations
.let {
if (constraints.isNotEmpty()) {
Expand Down Expand Up @@ -118,13 +119,13 @@ public open class ValidationBuilder<T> {

public operator fun <R> KFunction1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit): Unit = validate(this, this, init)

public infix fun <R> KProperty1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit): Unit = ifPresent(this, this, init)
public infix fun <R : Any> KProperty1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit): Unit = ifPresent(this, this, init)

public infix fun <R> KFunction1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit): Unit = ifPresent(this, this, init)
public infix fun <R : Any> KFunction1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit): Unit = ifPresent(this, this, init)

public infix fun <R> KProperty1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit): Unit = required(this, this, init)
public infix fun <R : Any> KProperty1<T, R?>.required(init: RequiredValidationBuilder<R>.() -> Unit): Unit = required(this, this, init)

public infix fun <R> KFunction1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit): Unit = required(this, this, init)
public infix fun <R : Any> KFunction1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit): Unit = required(this, this, init)

public infix fun <R> KProperty1<T, R>.dynamic(init: ValidationBuilder<R>.(T) -> Unit): Unit = dynamic(this, this, init)

Expand Down Expand Up @@ -167,11 +168,11 @@ public open class ValidationBuilder<T> {
* @param path The [PathSegment] or [ValidationPath] of the validation.
* is [Any] for backwards compatibility and ease of use, see [ValidationPath.of].
*/
public fun <R> required(
public fun <R : Any> required(
path: Any,
f: (T) -> R?,
init: ValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, buildWithNew(init).required()))
init: RequiredValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, RequiredValidationBuilder.buildWithNew(init)))

public fun run(validation: Validation<T>) {
subValidations.add(validation)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.konform.validation.builders

import io.konform.validation.ValidationBuilder
import io.konform.validation.types.RequireNotNullValidation
import io.konform.validation.types.RequireNotNullValidation.Companion.DEFAULT_REQUIRED_HINT

/**
* A [ValidationBuilder] for [RequireNotNullValidation].
* Allows setting the hint for the validation when the value is null with the following syntax:
* ```
* User::name required {
* hint = "Please fill in your name"
* // other validations on name as normal
* }
* ```
*/
public class RequiredValidationBuilder<T : Any> : ValidationBuilder<T>() {
public var hint: String = DEFAULT_REQUIRED_HINT

override fun build(): RequireNotNullValidation<T> {
val subValidation = super.build()
return RequireNotNullValidation(hint, subValidation)
}

public companion object {
public inline fun <T : Any> buildWithNew(block: RequiredValidationBuilder<T>.() -> Unit): RequireNotNullValidation<T> {
val builder = RequiredValidationBuilder<T>()
block(builder)
return builder.build()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,33 @@ import io.konform.validation.Invalid
import io.konform.validation.Valid
import io.konform.validation.Validation
import io.konform.validation.ValidationResult
import io.konform.validation.path.PathSegment
import io.konform.validation.path.ValidationPath

internal class NullableValidation<T : Any>(
private val pathSegment: PathSegment? = null,
private val required: Boolean,
internal class IfNotNullValidation<T : Any>(
private val validation: Validation<T>,
) : Validation<T?> {
override fun validate(value: T?): ValidationResult<T?> =
if (value == null) {
if (required) {
val path = ValidationPath(listOfNotNull(pathSegment))
Invalid.of(path, "is required")
} else {
Valid(value)
}
Valid(value)
} else {
// Don't prepend path here since we expect the validation to contain the complete path
validation(value)
}
}

public class RequireNotNullValidation<T : Any>(
private val hint: String,
private val validation: Validation<T>,
) : Validation<T?> {
override fun validate(value: T?): ValidationResult<T?> =
if (value == null) {
Invalid.of(ValidationPath.EMPTY, hint)
} else {
// Don't prepend path here since we expect the validation to contain the complete path
validation(value)
}

internal companion object {
internal const val DEFAULT_REQUIRED_HINT = "is required"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,21 +111,6 @@ class ValidationBuilderTest {
Register(referredBy = "poweruser@").let { assertEquals(1, countErrors(nullableFieldValidation(it), Register::referredBy)) }
}

@Test
fun validatingRequiredFields() {
val nullableFieldValidation =
Validation<Register> {
Register::referredBy required {
pattern(".+@.+".toRegex()).hint("must have correct format")
}
}

Register(referredBy = "[email protected]").let { assertEquals(Valid(it), nullableFieldValidation(it)) }

Register(referredBy = null).let { assertEquals(1, countErrors(nullableFieldValidation(it), Register::referredBy)) }
Register(referredBy = "poweruser@").let { assertEquals(1, countErrors(nullableFieldValidation(it), Register::referredBy)) }
}

@Test
fun validatingNestedTypesDirectly() {
val nestedTypeValidation =
Expand Down Expand Up @@ -155,21 +140,6 @@ class ValidationBuilderTest {
"poweruser@".let { assertEquals(1, countErrors(nullableTypeValidation(it))) }
}

@Test
fun validatingRequiredNullableValues() {
val nullableRequiredValidation =
Validation<String?> {
required {
pattern(".+@.+".toRegex()).hint("must have correct format")
}
}

"[email protected]".let { assertEquals(Valid(it), nullableRequiredValidation(it)) }

null.let { assertEquals(1, countErrors(nullableRequiredValidation(it))) }
"poweruser@".let { assertEquals(1, countErrors(nullableRequiredValidation(it))) }
}

@Test
fun functionAccessorSyntax() {
val splitDoubleValidation =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package io.konform.validation.validationbuilder

import io.konform.validation.Valid
import io.konform.validation.Validation
import io.konform.validation.ValidationError
import io.konform.validation.constraints.minLength
import io.konform.validation.constraints.pattern
import io.konform.validation.countErrors
import io.konform.validation.required
import io.kotest.assertions.konform.shouldBeInvalid
import io.kotest.assertions.konform.shouldBeValid
import io.kotest.assertions.konform.shouldContainOnlyError
import kotlin.test.Test
import kotlin.test.assertEquals

class RequiredTest {
@Test
fun validatingRequiredFields() {
val nullableFieldValidation =
Validation<Register> {
Register::referredBy required {
pattern(".+@.+".toRegex()).hint("must have correct format")
}
}

Register("[email protected]").let { assertEquals(Valid(it), nullableFieldValidation(it)) }

Register(null).let { assertEquals(1, countErrors(nullableFieldValidation(it), Register::referredBy)) }
Register("poweruser@").let { assertEquals(1, countErrors(nullableFieldValidation(it), Register::referredBy)) }
}

@Test
fun setRequiredHint() {
val validation =
Validation<Register> {
Register::referredBy required {
hint = "a referral is required"
}
}

(validation shouldBeInvalid Register(null)) shouldContainOnlyError
ValidationError.of(Register::referredBy, "a referral is required")
}

@Test
fun validatingRequiredNullableValues() {
val nullableRequiredValidation =
Validation<String?> {
required {
pattern(".+@.+".toRegex()).hint("must have correct format")
}
}

"[email protected]".let { assertEquals(Valid(it), nullableRequiredValidation(it)) }

null.let { assertEquals(1, countErrors(nullableRequiredValidation(it))) }
"poweruser@".let { assertEquals(1, countErrors(nullableRequiredValidation(it))) }
}

@Test
fun requiredFunction() {
val validation =
Validation<String?> {
required("trimmed", { it?.trim() }) {
hint = "string must be present"
minLength(2)
}
}

validation shouldBeValid "abc"
(validation shouldBeInvalid null) shouldContainOnlyError
ValidationError.of("trimmed", "string must be present")
(validation shouldBeInvalid " a") shouldContainOnlyError
ValidationError.of("trimmed", "must have at least 2 characters")
}

private data class Register(
val referredBy: String? = null,
)
}

0 comments on commit ae36b18

Please sign in to comment.