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

Add function and lambda accessor syntax #65

Merged
merged 12 commits into from
Sep 10, 2024
67 changes: 44 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ dependencies {

Suppose you have a data class like this:

```Kotlin
```kotlin
data class UserProfile(
val fullName: String,
val age: Int?
Expand All @@ -44,7 +44,7 @@ data class UserProfile(

Using the Konform type-safe DSL you can quickly write up a validation

```Kotlin
```kotlin
val validateUser = Validation<UserProfile> {
UserProfile::fullName {
minLength(2)
Expand All @@ -60,14 +60,14 @@ val validateUser = Validation<UserProfile> {

and apply it to your data

```Kotlin
```kotlin
val invalidUser = UserProfile("A", -1)
val validationResult = validateUser(invalidUser)
```

since the validation fails the `validationResult` will be of type `Invalid` and you can get a list of validation errors by indexed access:

```Kotlin
```kotlin
validationResult[UserProfile::fullName]
// yields listOf("must have at least 2 characters")

Expand All @@ -77,7 +77,7 @@ validationResult[UserProfile::age]

or you can get all validation errors with details as a list:

```Kotlin
```kotlin
validationResult.errors
// yields listOf(
// ValidationError(dataPath=.fullName, message=must have at least 2 characters),
Expand All @@ -87,19 +87,19 @@ validationResult.errors

In case the validation went through successfully you get a result of type `Valid` with the validated value in the `value` field.

```Kotlin
```kotlin
val validUser = UserProfile("Alice", 25)
val validationResult = validateUser(validUser)
// yields Valid(UserProfile("Alice", 25))
```

### Advanced use
### Detailed usage

#### Hints

You can add custom hints to validations

```Kotlin
```kotlin
val validateUser = Validation<UserProfile> {
UserProfile::age ifPresent {
minimum(0) hint "Registering before birth is not supported"
Expand All @@ -109,7 +109,7 @@ val validateUser = Validation<UserProfile> {

You can use `{value}` to include the `.toString()`-ed data in the hint

```Kotlin
```kotlin
val validateUser = Validation<UserProfile> {
UserProfile::fullName {
minLength(2) hint "'{value}' is too short a name, must be at least 2 characters long."
Expand All @@ -119,40 +119,61 @@ val validateUser = Validation<UserProfile> {

#### Custom validations

You can add custom validations by using `addConstraint`
You can add custom validations on properties by using `addConstraint`

```Kotlin
```kotlin
val validateUser = Validation<UserProfile> {
UserProfile::fullName {
addConstraint("Name cannot contain a tab") { !it.contains("\t") }
}
}
```

#### Nested validations
You can transform data and then add a validation on the result

You can define validations for nested classes and use them for new validations
```kotlin
val validateUser = Validation<UserProfile> {
validate("trimmedName", { it.fullName.trim() }) {
minLength(5)
}
// This also required and ifPresent for nullable values
required("yourName", /* ...*/) {
// your validations, giving an error out if the result is null
}
ifPresent("yourName", /* ... */) {
// your validations, only running if the result is not null
}
}
```

#### Split validations

```Kotlin
val ageCheck = Validation<UserProfile> {
UserProfile::age required {
minimum(18)
You can define validations separately and run them from other validations

```kotlin
val ageCheck = Validation<Int?> {
required {
minimum(21)
}
}

val validateUser = Validation<UserProfile> {
UserProfile::fullName {
minLength(2)
maxLength(100)
UserProfile::age {
run(ageCheck)
}

run(ageCheck)
// You can also transform the data and then run a validation against the result
validate("ageMinus10", { it.age?.let { age -> age - 10 } }) {
run(ageCheck)
}
}
```

#### Collections

It is also possible to validate nested data classes and properties that are collections (List, Map, etc...)

```Kotlin
```kotlin
data class Person(val name: String, val email: String?, val age: Int)

data class Event(
Expand Down Expand Up @@ -206,7 +227,7 @@ val validateEvent = Validation<Event> {
Errors in the `ValidationResult` can also be accessed using the index access method. In case of `Iterables` and `Arrays` you use the
numerical index and in case of `Maps` you use the key as string.

```Kotlin
```kotlin
// get the error messages for the first attendees age if any
result[Event::attendees, 0, Person::age]

Expand Down
18 changes: 15 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ kotlin {
//endregion

sourceSets {
val kotestSupported =
listOf(
appleTest,
jsTest,
jvmTest,
nativeTest,
wasmJsTest,
)
// Shared dependencies
commonMain.dependencies {
api(kotlin("stdlib"))
Expand All @@ -104,9 +112,13 @@ kotlin {
implementation(kotlin("test"))
// implementation(kotlin("test-annotations-common"))
// implementation(kotlin("test-common"))
// implementation(libs.kotest.assertions.core)
// implementation(libs.kotest.framework.datatest)
// implementation(libs.kotest.framework.engine)
}
kotestSupported.forEach {
it.dependencies {
implementation(libs.kotest.assertions.core)
// implementation(libs.kotest.framework.datatest)
// implementation(libs.kotest.framework.engine)
}
}
jvmTest.dependencies {
// implementation(libs.kotest.runner.junit5)
Expand Down
90 changes: 74 additions & 16 deletions src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.konform.validation.internal.OptionalValidation
import io.konform.validation.internal.RequiredValidation
import io.konform.validation.internal.ValidationBuilderImpl
import kotlin.jvm.JvmName
import kotlin.reflect.KFunction1
import kotlin.reflect.KProperty1

@DslMarker
Expand All @@ -24,48 +25,106 @@ public abstract class ValidationBuilder<T> {

public abstract infix fun Constraint<T>.hint(hint: String): Constraint<T>

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

internal abstract fun <R> onEachIterable(
prop: KProperty1<T, Iterable<R>>,
name: String,
prop: (T) -> Iterable<R>,
init: ValidationBuilder<R>.() -> Unit,
)

@JvmName("onEachIterable")
public infix fun <R> KProperty1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit = onEachIterable(this, init)

internal abstract fun <R> onEachArray(
prop: KProperty1<T, Array<R>>,
name: String,
prop: (T) -> Array<R>,
init: ValidationBuilder<R>.() -> Unit,
)

@JvmName("onEachArray")
public infix fun <R> KProperty1<T, Array<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit = onEachArray(this, init)

internal abstract fun <K, V> onEachMap(
prop: KProperty1<T, Map<K, V>>,
name: String,
prop: (T) -> Map<K, V>,
init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit,
)

@JvmName("onEachIterable")
public infix fun <R> KProperty1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit = onEachIterable(name, this, init)

@JvmName("onEachIterable")
public infix fun <R> KFunction1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit =
onEachIterable("$name()", this, init)

@JvmName("onEachArray")
public infix fun <R> KProperty1<T, Array<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit = onEachArray(name, this, init)

@JvmName("onEachArray")
public infix fun <R> KFunction1<T, Array<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit = onEachArray("$name()", this, init)

@JvmName("onEachMap")
public infix fun <K, V> KProperty1<T, Map<K, V>>.onEach(init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit): Unit =
onEachMap(this, init)
onEachMap(name, this, init)

@JvmName("onEachMap")
public infix fun <K, V> KFunction1<T, Map<K, V>>.onEach(init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit): Unit =
onEachMap("$name()", this, init)

public abstract infix fun <R> KProperty1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit)
public operator fun <R> KProperty1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit): Unit = validate(name, this, init)

public abstract infix fun <R> KProperty1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit)
public operator fun <R> KFunction1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit): Unit = validate("$name()", this, init)

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

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

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

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

/**
* Calculate a value from the input and run a validation on it.
* @param name The name that should be reported in validation errors. Must be a valid kotlin name, optionally followed by ().
* @param f The function for which you want to validate the result of
* @see run
*/
public abstract fun <R> validate(
name: String,
f: (T) -> R,
init: ValidationBuilder<R>.() -> Unit,
)

/**
* Calculate a value from the input and run a validation on it, but only if the value is not null.
*/
public abstract fun <R> ifPresent(
name: String,
f: (T) -> R?,
init: ValidationBuilder<R>.() -> Unit,
)

/**
* Calculate a value from the input and run a validation on it, and give an error if the result is null.
*/
public abstract fun <R> required(
name: String,
f: (T) -> R?,
init: ValidationBuilder<R>.() -> Unit,
)

/** Run an arbitrary other validation. */
public abstract fun run(validation: Validation<T>)

public abstract val <R> KProperty1<T, R>.has: ValidationBuilder<R>
public abstract val <R> KFunction1<T, R>.has: ValidationBuilder<R>
}

/**
* 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 = ValidationBuilderImpl<T>()
init(builder)
run(OptionalValidation(builder.build()))
}

/**
* 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 = ValidationBuilderImpl<T>()
init(builder)
Expand All @@ -84,8 +143,7 @@ public fun <S, T : Iterable<S>> ValidationBuilder<T>.onEach(init: ValidationBuil
public 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>>)
run(ArrayValidation(builder.build()))
}

@JvmName("onEachMap")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package io.konform.validation

import kotlin.reflect.KProperty1
import io.konform.validation.kotlin.Path

public interface ValidationError {
public val dataPath: String
Expand Down Expand Up @@ -43,14 +43,7 @@ public sealed class ValidationResult<out T> {
public data class Invalid(
internal val internalErrors: Map<String, List<String>>,
) : ValidationResult<Nothing>() {
override fun get(vararg propertyPath: Any): List<String>? = internalErrors[propertyPath.joinToString("", transform = ::toPathSegment)]

private fun toPathSegment(it: Any): String =
when (it) {
is KProperty1<*, *> -> ".${it.name}"
is Int -> "[$it]"
else -> ".$it"
}
override fun get(vararg propertyPath: Any): List<String>? = internalErrors[Path.toPath(*propertyPath)]

override val errors: List<ValidationError> by lazy {
internalErrors.flatMap { (path, errors) ->
Expand Down
Loading
Loading