Skip to content

Commit

Permalink
Fix API and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
dhoepelman committed Sep 10, 2024
1 parent 8d22773 commit a900bec
Show file tree
Hide file tree
Showing 10 changed files with 238 additions and 78 deletions.
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,11 @@ You can define validations separately and run them from other validations
```kotlin
val ageCheck = Validation<Int?> {
required {
minimum(18)
minimum(21)
}
}

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

UserProfile::age {
run(ageCheck)
}
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ kotlin {
implementation(kotlin("test"))
// implementation(kotlin("test-annotations-common"))
// implementation(kotlin("test-common"))
// implementation(libs.kotest.assertions.core)
implementation(libs.kotest.assertions.core)
// implementation(libs.kotest.framework.datatest)
// implementation(libs.kotest.framework.engine)
}
Expand Down
13 changes: 7 additions & 6 deletions src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public abstract class ValidationBuilder<T> {
init: ValidationBuilder<R>.() -> Unit,
)

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

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

Expand Down Expand Up @@ -86,20 +86,21 @@ public abstract class ValidationBuilder<T> {

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


/**
* Calculate a value from the input and run a validation on it.
* @param name The name that should be reported in validation errors
* @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)
public abstract fun <R> validate(
name: String,
f: (T) -> R,
init: ValidationBuilder<R>.() -> Unit,
)

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

public abstract fun <R> runOn(validation: Validation<T>, f: (T) -> R)

public abstract val <R> KProperty1<T, R>.has: ValidationBuilder<R>
public abstract val <R> KFunction1<T, R>.has: ValidationBuilder<R>
}
Expand Down
13 changes: 2 additions & 11 deletions src/commonMain/kotlin/io/konform/validation/ValidationResult.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package io.konform.validation

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

public interface ValidationError {
public val dataPath: String
Expand Down Expand Up @@ -44,15 +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 KFunction1<*, *> -> ".${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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.konform.validation.ValidationBuilder
import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifier.NonNull
import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifier.Optional
import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifier.OptionalRequired
import io.konform.validation.kotlin.Grammar
import kotlin.collections.Map.Entry
import kotlin.reflect.KFunction1
import kotlin.reflect.KProperty1
Expand Down Expand Up @@ -178,9 +179,15 @@ internal class ValidationBuilderImpl<T> : ValidationBuilder<T>() {
f: (T) -> R,
init: ValidationBuilder<R>.() -> Unit,
) {
requireValidName(name)
f.getOrCreateBuilder(name, NonNull).also(init)
}

private fun requireValidName(name: String) =
require(Grammar.Identifier.isValid(name) || Grammar.FunctionDeclaration.isUnary(name)) {
"'$name' is not a valid kotlin identifier or getter name."
}

override fun build(): Validation<T> {
val nestedValidations =
subValidations.map { (key, builder) ->
Expand Down
16 changes: 12 additions & 4 deletions src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ package io.konform.validation.kotlin
* Representation of parts of [the Kotlin grammar](https://kotlinlang.org/spec/syntax-and-grammar.html#lexical-grammar)
*/
internal object Grammar {
private const val letter = "\\p{L}\\p{Nl}" // Unicode letters (Lu, Ll, Lt, Lm, Lo)
private const val unicodeDigit = "\\p{Nd}" // Unicode digits (Nd)
private const val quotedSymbol = "[^`\r\n]" // Anything except backtick, CR, or LF inside backticks
private const val LETTER = "\\p{L}\\p{Nl}" // Unicode letters (Lu, Ll, Lt, Lm, Lo)
private const val UNICODE_DIGIT = "\\p{Nd}" // Unicode digits (Nd)
private const val QUOTED_SYMBOL = "[^`\r\n]" // Anything except backtick, CR, or LF inside backticks

object Identifier {
private const val STRING = "([${letter}_][${letter}_$unicodeDigit]*)|`$quotedSymbol+`"
internal const val STRING = "([${LETTER}_][${LETTER}_$UNICODE_DIGIT]*)|`$QUOTED_SYMBOL+`"
private val regex = "^$STRING$".toRegex()

fun isValid(s: String) = s.matches(regex)
}

object FunctionDeclaration {
private const val UNARY_STRING = """(${Identifier.STRING})\(\)"""
private val unaryRegex = "^$UNARY_STRING$".toRegex()

fun isUnary(s: String) = s.matches(unaryRegex)
}
}
22 changes: 22 additions & 0 deletions src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.konform.validation.kotlin

import kotlin.reflect.KFunction1
import kotlin.reflect.KProperty1

/** Represents a JSONPath-ish path to a property. */
internal object Path {
/** Get a path, but treat a single string as the full path */
fun asPathOrToPath(vararg segments: Any): String =
if (segments.size == 1 && segments[0] is String) segments[0] as String
else toPath(*segments)

fun toPath(vararg segments: Any): String = segments.joinToString("") { toPathSegment(it) }

fun toPathSegment(it: Any): String =
when (it) {
is KProperty1<*, *> -> ".${it.name}"
is KFunction1<*, *> -> ".${it.name}()"
is Int -> "[$it]"
else -> ".$it"
}
}
69 changes: 46 additions & 23 deletions src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import io.konform.validation.jsonschema.minItems
import io.konform.validation.jsonschema.minLength
import io.konform.validation.jsonschema.minimum
import io.konform.validation.jsonschema.pattern
import io.kotest.assertions.konform.shouldBeInvalid
import io.kotest.assertions.konform.shouldBeValid
import io.kotest.assertions.konform.shouldContainError
import kotlin.collections.Map.Entry
import kotlin.test.Test
import kotlin.test.assertEquals
Expand All @@ -17,6 +20,8 @@ class ReadmeExampleTest {
val age: Int?,
)

private val johnDoe = UserProfile("John Doe", 30)

@Test
fun simpleValidation() {
val validateUser =
Expand Down Expand Up @@ -133,44 +138,62 @@ class ReadmeExampleTest {

@Test
fun customValidations() {
val validateUser1 = Validation<UserProfile> {
UserProfile::fullName {
addConstraint("Name cannot contain a tab") { !it.contains("\t") }
val validateUser1 =
Validation<UserProfile> {
UserProfile::fullName {
addConstraint("Name cannot contain a tab") { !it.contains("\t") }
}
}
}

val validateUser2 = Validation<UserProfile> {
validate("trimmed name", { it.fullName.trim() }) {
minLength(5)
}
validateUser1 shouldBeValid johnDoe
validateUser1.shouldBeInvalid(UserProfile("John\tDoe", 30)) {
it.shouldContainError(".fullName", "Name cannot contain a tab")
}

val validateUser2 =
Validation<UserProfile> {
validate("trimmedName", { it.fullName.trim() }) {
minLength(5)
}
}

validateUser2 shouldBeValid johnDoe
validateUser2.shouldBeInvalid(UserProfile("J", 30)) {
it.shouldContainError(".trimmedName", "must have at least 5 characters")
}
}

@Test
fun splitValidations(){
val ageCheck = Validation<Int?> {
required {
minimum(18)
fun splitValidations() {
val ageCheck =
Validation<Int?> {
required {
minimum(21)
}
}
}

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

UserProfile::age {
run(ageCheck)
}
validateUser shouldBeValid johnDoe
validateUser.shouldBeInvalid(UserProfile("John doe", 10)) {
it.shouldContainError(".age", "must be at least '21'")
}

val transform = Validation<UserProfile> {
validate("ageMinus10", { it.age?.let { age -> age - 10 } }) {
run(ageCheck)
val transform =
Validation<UserProfile> {
validate("ageMinus10", { it.age?.let { age -> age - 10 } }) {
run(ageCheck)
}
}

transform shouldBeValid UserProfile("X", 31)
transform.shouldBeInvalid(johnDoe) {
it.shouldContainError(".ageMinus10", "must be at least '21'")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ package io.konform.validation
import io.konform.validation.jsonschema.const
import io.konform.validation.jsonschema.enum
import io.konform.validation.jsonschema.minItems
import io.kotest.assertions.konform.shouldBeInvalid
import io.kotest.assertions.konform.shouldBeValid
import io.kotest.assertions.konform.shouldContainError
import io.kotest.assertions.konform.shouldContainExactlyErrors
import io.kotest.assertions.konform.shouldHaveErrorCount
import io.kotest.assertions.konform.shouldNotContainErrorAt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
Expand All @@ -12,7 +18,7 @@ class ValidationBuilderTest {
private fun ValidationBuilder<String>.minLength(minValue: Int) =
addConstraint("must have at least {0} characters", minValue.toString()) { it.length >= minValue }

private fun ValidationBuilder<String>.maxLength(minValue: Int) =
private fun ValidationBuilder<String>.maxLength(minValue: Int) =
addConstraint("must have at most {0} characters", minValue.toString()) { it.length <= minValue }

private fun ValidationBuilder<String>.matches(regex: Regex) = addConstraint("must have correct format") { it.contains(regex) }
Expand Down Expand Up @@ -208,34 +214,30 @@ class ValidationBuilderTest {
}

@Test
fun lambdaAccessorSyntax() {
fun validateLambda() {
val splitDoubleValidation =
Validation<Register> {
val getPassword = { r: Register -> r.password }
val getEmail = { r: Register -> r.email }
getPassword("getPasswordLambda") {
validate("getPasswordLambda", { r: Register -> r.password }) {
minLength(1)
}
getPassword("getPasswordLambda") {
maxLength(10)
}
getEmail("getEmailLambda") {
validate("getEmailLambda", { r: Register -> r.email }) {
matches(".+@.+".toRegex())
}
}

Register(email = "tester@test.com", password = "a").let { assertEquals(Valid(it), splitDoubleValidation(it)) }
Register(
email = "tester@test.com",
password = "",
).let {
assertEquals(1, countErrors(splitDoubleValidation(it), "getPasswordLambda"))
splitDoubleValidation shouldBeValid Register(email = "tester@test.com", password = "a")
splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "")) {
it.shouldContainExactlyErrors(".getPasswordLambda" to "must have at least 1 characters")
}
Register(email = "tester@test.com", password = "aaaaaaaaaaa").let {
assertEquals(1, countErrors(splitDoubleValidation(it), "getPasswordLambda"))
splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "aaaaaaaaaaa")) {
it.shouldContainExactlyErrors(".getPasswordLambda" to "must have at most 10 characters")
}
Register(email = "tester@").let {
assertEquals(2, countFieldsWithErrors(splitDoubleValidation(it)))
splitDoubleValidation.shouldBeInvalid(Register(email = "tester@", password = "")) {
it.shouldContainExactlyErrors(
".getPasswordLambda" to "must have at least 1 characters",
".getEmailLambda" to "must have correct format",
)
}
}

Expand Down Expand Up @@ -421,17 +423,23 @@ class ValidationBuilderTest {
}
}

Data().let { assertEquals(Valid(it), mapValidation(it)) }
Data(
mapValidation shouldBeValid Data()

mapValidation.shouldBeInvalid(Data(
registrations =
mapOf(
"user1" to Register(email = "valid"),
"user2" to Register(email = "a"),
),
).let {
assertEquals(0, countErrors(mapValidation(it), Data::registrations, "user1", Register::email))
assertEquals(1, countErrors(mapValidation(it), Data::registrations, "user2", Register::email))
mapOf(
"user1" to Register(email = "valid"),
"user2" to Register(email = "a"),
),
)) {
it.shouldContainExactlyErrors(
".registrations.user2.email" to "must have at least 2 characters",
)
it.shouldContainError(listOf(Data::registrations, "user2", Register::email), "must have at least 2 characters")
it.shouldNotContainErrorAt(Data::registrations, "user1", Register::email)
it.shouldHaveErrorCount(1)
}

}

@Test
Expand Down
Loading

0 comments on commit a900bec

Please sign in to comment.