Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/konform.api
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public class io/konform/validation/ValidationBuilder {
public final fun dynamic (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function2;)V
public final fun dynamic (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function2;)V
protected final fun getConstraints ()Ljava/util/List;
public final fun getPath ()Lio/konform/validation/path/ValidationPath;
protected final fun getSubValidations ()Ljava/util/List;
public final fun hint (Lio/konform/validation/Constraint;Ljava/lang/String;)Lio/konform/validation/Constraint;
public final fun ifPresent (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
Expand All @@ -115,6 +116,7 @@ public class io/konform/validation/ValidationBuilder {
public final fun requiredOnNotNullProperty (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function1;)V
public final fun run (Lio/konform/validation/Validation;)V
public final fun runDynamic (Lkotlin/jvm/functions/Function1;)V
public final fun setPath (Lio/konform/validation/path/ValidationPath;)V
public final fun userContext (Lio/konform/validation/Constraint;Ljava/lang/Object;)Lio/konform/validation/Constraint;
public final fun validate (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
}
Expand Down
4 changes: 4 additions & 0 deletions api/konform.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ open class <#A: kotlin/Any?> io.konform.validation/ValidationBuilder { // io.kon
final val subValidations // io.konform.validation/ValidationBuilder.subValidations|{}subValidations[0]
final fun <get-subValidations>(): kotlin.collections/MutableList<io.konform.validation/Validation<#A>> // io.konform.validation/ValidationBuilder.subValidations.<get-subValidations>|<get-subValidations>(){}[0]

final var path // io.konform.validation/ValidationBuilder.path|{}path[0]
final fun <get-path>(): io.konform.validation.path/ValidationPath? // io.konform.validation/ValidationBuilder.path.<get-path>|<get-path>(){}[0]
final fun <set-path>(io.konform.validation.path/ValidationPath?) // io.konform.validation/ValidationBuilder.path.<set-path>|<set-path>(io.konform.validation.path.ValidationPath?){}[0]

final fun (io.konform.validation/Constraint<#A>).hint(kotlin/String): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.hint|hint@io.konform.validation.Constraint<1:0>(kotlin.String){}[0]
final fun (io.konform.validation/Constraint<#A>).path(io.konform.validation.path/ValidationPath): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.path|path@io.konform.validation.Constraint<1:0>(io.konform.validation.path.ValidationPath){}[0]
final fun (io.konform.validation/Constraint<#A>).replace(kotlin/String = ..., io.konform.validation.path/ValidationPath = ..., kotlin/Any? = ...): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.replace|replace@io.konform.validation.Constraint<1:0>(kotlin.String;io.konform.validation.path.ValidationPath;kotlin.Any?){}[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ public open class ValidationBuilder<T> {
protected val constraints: MutableList<Constraint<T>> = mutableListOf()
protected val subValidations: MutableList<Validation<T>> = mutableListOf()

/**
* Override the path for this validation.
* When set, this path replaces the automatic path segment that would normally be added.
* Use [ValidationPath.EMPTY] to suppress the path segment entirely.
*/
public var path: ValidationPath? = null

public open fun build(): Validation<T> =
subValidations
.let {
Expand Down Expand Up @@ -99,19 +106,31 @@ public open class ValidationBuilder<T> {
pathSegment: PathSegment,
prop: (T) -> Iterable<R>,
init: ValidationBuilder<R>.() -> Unit,
) = run(CallableValidation(pathSegment, prop, IterableValidation(buildWithNew(init))))
) {
val (pathOverride, validation) = buildWithNewAndPath(init)
val effectivePath = pathOverride ?: pathSegment
run(CallableValidation(effectivePath, prop, IterableValidation(validation)))
}

private fun <R> onEachArray(
pathSegment: PathSegment,
prop: (T) -> Array<R>,
init: ValidationBuilder<R>.() -> Unit,
) = run(CallableValidation(pathSegment, prop, ArrayValidation(buildWithNew(init))))
) {
val (pathOverride, validation) = buildWithNewAndPath(init)
val effectivePath = pathOverride ?: pathSegment
run(CallableValidation(effectivePath, prop, ArrayValidation(validation)))
}

private fun <K, V> onEachMap(
pathSegment: PathSegment,
prop: (T) -> Map<K, V>,
init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit,
) = run(CallableValidation(pathSegment, prop, MapValidation(buildWithNew(init))))
) {
val (pathOverride, validation) = buildWithNewAndPath(init)
val effectivePath = pathOverride ?: pathSegment
run(CallableValidation(effectivePath, prop, MapValidation(validation)))
}

@JvmName("onEachIterable")
public infix fun <R> KProperty1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit =
Expand Down Expand Up @@ -190,7 +209,11 @@ public open class ValidationBuilder<T> {
path: Any,
f: (T) -> R,
init: ValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, buildWithNew(init)))
) {
val (pathOverride, validation) = buildWithNewAndPath(init)
val effectivePath = pathOverride ?: path
run(CallableValidation(effectivePath, f, validation))
}

/**
* Build a new validation based on a transformed value of the input and run it.
Expand All @@ -216,7 +239,11 @@ public open class ValidationBuilder<T> {
path: Any,
f: (T) -> R?,
init: ValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, buildWithNew(init).ifPresent()))
) {
val (pathOverride, validation) = buildWithNewAndPath(init)
val effectivePath = pathOverride ?: path
run(CallableValidation(effectivePath, f, validation.ifPresent()))
}

/**
* Calculate a value from the input and run a validation on it, and give an error if the result is null.
Expand All @@ -227,7 +254,12 @@ public open class ValidationBuilder<T> {
path: Any,
f: (T) -> R?,
init: RequiredValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, RequiredValidationBuilder.buildWithNew(init)))
) {
val builder = RequiredValidationBuilder<R>()
init(builder)
val effectivePath = builder.path ?: path
run(CallableValidation(effectivePath, f, builder.build()))
}

// endregion

Expand Down Expand Up @@ -256,6 +288,13 @@ public open class ValidationBuilder<T> {
block(builder)
return builder.build()
}

/** Build with new and return the builder's path override if set. */
internal inline fun <T> buildWithNewAndPath(block: ValidationBuilder<T>.() -> Unit): Pair<ValidationPath?, Validation<T>> {
val builder = ValidationBuilder<T>()
block(builder)
return builder.path to builder.build()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package io.konform.validation.validationbuilder

import io.konform.validation.Validation
import io.konform.validation.ValidationError
import io.konform.validation.constraints.minimum
import io.konform.validation.path.ValidationPath
import io.kotest.assertions.konform.shouldBeInvalid
import io.kotest.assertions.konform.shouldBeValid
import io.kotest.assertions.konform.shouldContainOnlyError
import kotlin.jvm.JvmInline
import kotlin.test.Test

class PathOverrideTest {
@JvmInline
value class ValueClass(
val integer: Int,
)

data class WrapperClass(
val valueClass: ValueClass,
)

@Test
fun pathOverrideWithValidationPathEmpty() {
val validation =
Validation<WrapperClass> {
WrapperClass::valueClass {
ValueClass::integer {
path = ValidationPath.EMPTY
minimum(1)
}
}
}

validation shouldBeValid WrapperClass(ValueClass(1))
validation shouldBeValid WrapperClass(ValueClass(10))

// Error path should be .valueClass (not .valueClass.integer)
(validation shouldBeInvalid WrapperClass(ValueClass(0))) shouldContainOnlyError
ValidationError.of(WrapperClass::valueClass, "must be at least '1'")
}

@Test
fun pathOverrideDoesNotAffectOtherValidations() {
data class MultiField(
val field1: ValueClass,
val field2: ValueClass,
)

val validation =
Validation<MultiField> {
MultiField::field1 {
ValueClass::integer {
path = ValidationPath.EMPTY
minimum(1)
}
}
MultiField::field2 {
ValueClass::integer {
minimum(1)
}
}
}

validation shouldBeValid MultiField(ValueClass(1), ValueClass(1))

// field1 should have path .field1 (integer suppressed)
(validation shouldBeInvalid MultiField(ValueClass(0), ValueClass(1))) shouldContainOnlyError
ValidationError.of(MultiField::field1, "must be at least '1'")

// field2 should have path .field2.integer (normal behavior)
(validation shouldBeInvalid MultiField(ValueClass(1), ValueClass(0))) shouldContainOnlyError
ValidationError(ValidationPath.of(MultiField::field2, ValueClass::integer), "must be at least '1'")
}

@Test
fun pathOverrideWithCustomPath() {
val validation =
Validation<WrapperClass> {
WrapperClass::valueClass {
ValueClass::integer {
path = ValidationPath.of("customPath")
minimum(1)
}
}
}

// Error path should be .valueClass.customPath (not .valueClass.integer)
(validation shouldBeInvalid WrapperClass(ValueClass(0))) shouldContainOnlyError
ValidationError(ValidationPath.of(WrapperClass::valueClass, "customPath"), "must be at least '1'")
}

@Test
fun pathOverrideOnNestedProperty() {
data class Level2(
val value: Int,
)

data class Level1(
val level2: Level2,
)

val validation =
Validation<Level1> {
Level1::level2 {
path = ValidationPath.EMPTY
Level2::value {
minimum(1)
}
}
}

// Error path should be .value (level2 suppressed)
(validation shouldBeInvalid Level1(Level2(0))) shouldContainOnlyError
ValidationError(ValidationPath.of(Level2::value), "must be at least '1'")
}

@Test
fun pathOverrideWorksWithRequired() {
data class Profile(
val name: String?,
)

data class User(
val profile: Profile?,
)

val validation =
Validation<User> {
User::profile required {
Profile::name required {
path = ValidationPath.EMPTY
hint = "Name is required"
}
}
}

validation shouldBeValid User(Profile("John"))

// Error path should be .profile (name suppressed)
(validation shouldBeInvalid User(Profile(null))) shouldContainOnlyError
ValidationError.of(User::profile, "Name is required")
}
}
Loading