From 8ef4325108d9bab30cfa8701e6f35f1348761f12 Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Wed, 13 Nov 2024 12:04:15 +0100 Subject: [PATCH] Add model for validation path (#155) --- README.md | 8 +- api/konform.api | 192 ++++++++++++++++-- .../io/konform/validation/Validation.kt | 7 + .../konform/validation/ValidationBuilder.kt | 146 +++++-------- .../io/konform/validation/ValidationError.kt | 47 ++++- .../io/konform/validation/ValidationResult.kt | 91 ++++----- .../io/konform/validation/builder/PropKey.kt | 57 ------ .../validation/builder/PropModifier.kt | 31 --- .../io/konform/validation/helpers/Helpers.kt | 9 + .../konform/validation/internal/Validation.kt | 135 ------------ .../io/konform/validation/kotlin/Grammar.kt | 24 --- .../io/konform/validation/kotlin/Path.kt | 25 --- .../io/konform/validation/path/FuncRef.kt | 27 +++ .../io/konform/validation/path/PathClass.kt | 14 ++ .../io/konform/validation/path/PathIndex.kt | 10 + .../io/konform/validation/path/PathKey.kt | 28 +++ .../io/konform/validation/path/PathSegment.kt | 45 ++++ .../io/konform/validation/path/PathValue.kt | 23 +++ .../io/konform/validation/path/PropRef.kt | 40 ++++ .../konform/validation/path/ValidationPath.kt | 31 ++- .../validation/platform/CallableEquals.kt | 9 + .../validation/types/CallableValidation.kt | 28 +++ .../validation/types/ConstraintsValidation.kt | 28 +++ .../validation/types/IsClassValidation.kt | 4 +- .../validation/types/IterableValidation.kt | 37 ++++ .../konform/validation/types/MapValidation.kt | 48 +++++ .../validation/types/NullableValidation.kt | 7 +- .../validation/types/PrependPathValidation.kt | 14 ++ .../konform/validation/ListValidationTest.kt | 7 +- .../konform/validation/ReadmeExampleTest.kt | 23 ++- .../io/konform/validation/TestHelpers.kt | 4 +- .../validation/ValidationBuilderTest.kt | 144 +++++++------ .../validation/ValidationResultTest.kt | 6 +- .../io/konform/validation/ValidationTest.kt | 11 +- .../validation/constraints/ConstraintsTest.kt | 47 ++--- .../validation/path/PathSegmentTest.kt | 50 +++++ .../validation/path/ValidationPathTest.kt | 24 +++ .../shaded/kotest/konform/Matchers.kt | 37 ++-- .../validationbuilder/InstanceOfTest.kt | 30 +-- .../validation/platform/CallableEquals.kt | 9 + .../validation/platform/CallableEquals.kt | 9 + .../validation/platform/CallableEquals.kt | 9 + .../validation/platform/CallableEquals.kt | 9 + .../validation/platform/CallableEquals.kt | 9 + 44 files changed, 1010 insertions(+), 583 deletions(-) delete mode 100644 src/commonMain/kotlin/io/konform/validation/builder/PropKey.kt delete mode 100644 src/commonMain/kotlin/io/konform/validation/builder/PropModifier.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/helpers/Helpers.kt delete mode 100644 src/commonMain/kotlin/io/konform/validation/internal/Validation.kt delete mode 100644 src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt delete mode 100644 src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/path/FuncRef.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/path/PathClass.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/path/PathIndex.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/path/PathKey.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/path/PathSegment.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/path/PathValue.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/path/PropRef.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/platform/CallableEquals.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/types/CallableValidation.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/types/ConstraintsValidation.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/types/IterableValidation.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/types/MapValidation.kt create mode 100644 src/commonMain/kotlin/io/konform/validation/types/PrependPathValidation.kt create mode 100644 src/commonTest/kotlin/io/konform/validation/path/PathSegmentTest.kt create mode 100644 src/commonTest/kotlin/io/konform/validation/path/ValidationPathTest.kt create mode 100644 src/jsMain/kotlin/io/konform/validation/platform/CallableEquals.kt create mode 100644 src/jvmMain/kotlin/io/konform/validation/platform/CallableEquals.kt create mode 100644 src/nativeMain/kotlin/io/konform/validation/platform/CallableEquals.kt create mode 100644 src/wasmJsMain/kotlin/io/konform/validation/platform/CallableEquals.kt create mode 100644 src/wasmWasiMain/kotlin/io/konform/validation/platform/CallableEquals.kt diff --git a/README.md b/README.md index c0e5d5a..c0ea8a8 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,10 @@ 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 -validationResult[UserProfile::fullName] +validationResult.errors.messagesAtPath(UserProfile::fullName) // yields listOf("must have at least 2 characters") -validationResult[UserProfile::age] +validationResult.errors.messagesAtPath(UserProfile::age) // yields listOf("must be at least '0'") ``` @@ -80,8 +80,8 @@ or you can get all validation errors with details as a list: ```kotlin validationResult.errors // yields listOf( -// ValidationError(dataPath=.fullName, message=must have at least 2 characters), -// ValidationError(dataPath=.age, message=must be at least '0' +// ValidationError(path=ValidationPath(Prop(fullName)), message=must have at least 2 characters), +// ValidationError(path=ValidationPath(Prop(age)), message=must be at least '0') // ) ``` diff --git a/api/konform.api b/api/konform.api index 9e4fc24..53aca5d 100644 --- a/api/konform.api +++ b/api/konform.api @@ -11,32 +11,42 @@ public final class io/konform/validation/Constraint$Companion { } public final class io/konform/validation/Invalid : io/konform/validation/ValidationResult { - public fun (Ljava/util/Map;)V - public final fun copy (Ljava/util/Map;)Lio/konform/validation/Invalid; - public static synthetic fun copy$default (Lio/konform/validation/Invalid;Ljava/util/Map;ILjava/lang/Object;)Lio/konform/validation/Invalid; + public static final field Companion Lio/konform/validation/Invalid$Companion; + public fun (Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Lio/konform/validation/Invalid; + public static synthetic fun copy$default (Lio/konform/validation/Invalid;Ljava/util/List;ILjava/lang/Object;)Lio/konform/validation/Invalid; public fun equals (Ljava/lang/Object;)Z - public fun get ([Ljava/lang/Object;)Ljava/util/List; public fun getErrors ()Ljava/util/List; public fun hashCode ()I + public fun isValid ()Z + public synthetic fun prependPath$konform (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/ValidationResult; + public synthetic fun prependPath$konform (Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/ValidationResult; public fun toString ()Ljava/lang/String; } +public final class io/konform/validation/Invalid$Companion { + public final fun of (Lio/konform/validation/path/ValidationPath;Ljava/lang/String;)Lio/konform/validation/Invalid; +} + public final class io/konform/validation/Valid : io/konform/validation/ValidationResult { public fun (Ljava/lang/Object;)V public final fun component1 ()Ljava/lang/Object; public final fun copy (Ljava/lang/Object;)Lio/konform/validation/Valid; public static synthetic fun copy$default (Lio/konform/validation/Valid;Ljava/lang/Object;ILjava/lang/Object;)Lio/konform/validation/Valid; public fun equals (Ljava/lang/Object;)Z - public fun get ([Ljava/lang/Object;)Ljava/util/List; public fun getErrors ()Ljava/util/List; public final fun getValue ()Ljava/lang/Object; public fun hashCode ()I + public fun isValid ()Z public fun toString ()Ljava/lang/String; } public abstract interface class io/konform/validation/Validation { public static final field Companion Lio/konform/validation/Validation$Companion; public abstract fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; + public abstract fun prependPath (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/Validation; + public abstract fun prependPath (Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/Validation; public abstract fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; } @@ -46,6 +56,8 @@ public final class io/konform/validation/Validation$Companion { public final class io/konform/validation/Validation$DefaultImpls { public static fun invoke (Lio/konform/validation/Validation;Ljava/lang/Object;)Lio/konform/validation/ValidationResult; + public static fun prependPath (Lio/konform/validation/Validation;Lio/konform/validation/path/PathSegment;)Lio/konform/validation/Validation; + public static fun prependPath (Lio/konform/validation/Validation;Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/Validation; } public final class io/konform/validation/ValidationBuilder { @@ -54,7 +66,7 @@ public final class io/konform/validation/ValidationBuilder { 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; public final fun hint (Lio/konform/validation/Constraint;Ljava/lang/String;)Lio/konform/validation/Constraint; - public final fun ifPresent (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public final fun ifPresent (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public final fun ifPresent (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function1;)V public final fun ifPresent (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function1;)V public final fun invoke (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function1;)V @@ -65,11 +77,11 @@ public final class io/konform/validation/ValidationBuilder { public final fun onEachIterable (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function1;)V public final fun onEachMap (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function1;)V public final fun onEachMap (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function1;)V - public final fun required (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public final fun required (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public final fun required (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function1;)V public final fun required (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function1;)V public final fun run (Lio/konform/validation/Validation;)V - public final fun validate (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public final fun validate (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V } public final class io/konform/validation/ValidationBuilder$Companion { @@ -84,16 +96,26 @@ public final class io/konform/validation/ValidationBuilderKt { public static final fun required (Lio/konform/validation/ValidationBuilder;Lkotlin/jvm/functions/Function1;)V } -public abstract interface class io/konform/validation/ValidationError { - public static final field Companion Lio/konform/validation/ValidationError$Companion; - public abstract fun getDataPath ()Ljava/lang/String; - public abstract fun getMessage ()Ljava/lang/String; -} - -public final class io/konform/validation/ValidationError$Companion { +public final class io/konform/validation/ValidationError { + public fun (Lio/konform/validation/path/ValidationPath;Ljava/lang/String;)V + public final fun component1 ()Lio/konform/validation/path/ValidationPath; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lio/konform/validation/path/ValidationPath;Ljava/lang/String;)Lio/konform/validation/ValidationError; + public static synthetic fun copy$default (Lio/konform/validation/ValidationError;Lio/konform/validation/path/ValidationPath;Ljava/lang/String;ILjava/lang/Object;)Lio/konform/validation/ValidationError; + public fun equals (Ljava/lang/Object;)Z + public final fun getDataPath ()Ljava/lang/String; + public final fun getMessage ()Ljava/lang/String; + public final fun getPath ()Lio/konform/validation/path/ValidationPath; + public fun hashCode ()I + public final fun mapPath (Lkotlin/jvm/functions/Function1;)Lio/konform/validation/ValidationError; + public fun toString ()Ljava/lang/String; } -public abstract interface class io/konform/validation/ValidationErrors : java/util/List, kotlin/jvm/internal/markers/KMappedMarker { +public final class io/konform/validation/ValidationErrorKt { + public static final fun filterDataPath (Ljava/util/List;[Ljava/lang/Object;)Ljava/util/List; + public static final fun filterPath (Ljava/util/List;[Ljava/lang/Object;)Ljava/util/List; + public static final fun messagesAtDataPath (Ljava/util/List;[Ljava/lang/Object;)Ljava/util/List; + public static final fun messagesAtPath (Ljava/util/List;[Ljava/lang/Object;)Ljava/util/List; } public final class io/konform/validation/ValidationKt { @@ -103,10 +125,18 @@ public final class io/konform/validation/ValidationKt { } public abstract class io/konform/validation/ValidationResult { - public abstract fun get ([Ljava/lang/Object;)Ljava/util/List; + public final fun get ([Ljava/lang/Object;)Ljava/util/List; public abstract fun getErrors ()Ljava/util/List; - public final fun isValid ()Z + public abstract fun isValid ()Z public final fun map (Lkotlin/jvm/functions/Function1;)Lio/konform/validation/ValidationResult; + public final fun plus (Lio/konform/validation/ValidationResult;)Lio/konform/validation/ValidationResult; +} + +public final class io/konform/validation/ValidationResultKt { + public static final fun flattenNonEmpty (Ljava/util/List;)Lio/konform/validation/ValidationResult; + public static final fun flattenNotEmpty (Ljava/util/List;)Lio/konform/validation/Invalid; + public static final fun flattenOrValid (Ljava/util/List;Ljava/lang/Object;)Lio/konform/validation/ValidationResult; + public static final fun flattenOrValidInvalidList (Ljava/util/List;Ljava/lang/Object;)Lio/konform/validation/ValidationResult; } public final class io/konform/validation/constraints/EnumConstraintsKt { @@ -170,6 +200,99 @@ 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/path/FuncRef : io/konform/validation/path/PathSegment { + public fun (Lkotlin/reflect/KFunction;)V + public final fun component1 ()Lkotlin/reflect/KFunction; + public final fun copy (Lkotlin/reflect/KFunction;)Lio/konform/validation/path/FuncRef; + public static synthetic fun copy$default (Lio/konform/validation/path/FuncRef;Lkotlin/reflect/KFunction;ILjava/lang/Object;)Lio/konform/validation/path/FuncRef; + public fun equals (Ljava/lang/Object;)Z + public final fun getFunction ()Lkotlin/reflect/KFunction; + public fun getPathString ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/konform/validation/path/FuncRefKt { + public static final fun toPathSegment (Lkotlin/reflect/KFunction;)Lio/konform/validation/path/FuncRef; +} + +public final class io/konform/validation/path/PathClass : io/konform/validation/path/PathSegment { + public fun (Lkotlin/reflect/KClass;)V + public final fun component1 ()Lkotlin/reflect/KClass; + public final fun copy (Lkotlin/reflect/KClass;)Lio/konform/validation/path/PathClass; + public static synthetic fun copy$default (Lio/konform/validation/path/PathClass;Lkotlin/reflect/KClass;ILjava/lang/Object;)Lio/konform/validation/path/PathClass; + public fun equals (Ljava/lang/Object;)Z + public final fun getKcls ()Lkotlin/reflect/KClass; + public fun getPathString ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/konform/validation/path/PathIndex : io/konform/validation/path/PathSegment { + public fun (I)V + public final fun component1 ()I + public final fun copy (I)Lio/konform/validation/path/PathIndex; + public static synthetic fun copy$default (Lio/konform/validation/path/PathIndex;IILjava/lang/Object;)Lio/konform/validation/path/PathIndex; + public fun equals (Ljava/lang/Object;)Z + public final fun getIndex ()I + public fun getPathString ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/konform/validation/path/PathKey : io/konform/validation/path/PathSegment { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lio/konform/validation/path/PathKey; + public static synthetic fun copy$default (Lio/konform/validation/path/PathKey;Ljava/lang/Object;ILjava/lang/Object;)Lio/konform/validation/path/PathKey; + public fun equals (Ljava/lang/Object;)Z + public final fun getKey ()Ljava/lang/Object; + public fun getPathString ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/konform/validation/path/PathKeyKt { + public static final fun toPathSegment (Ljava/util/Map$Entry;)Lio/konform/validation/path/PathKey; +} + +public abstract interface class io/konform/validation/path/PathSegment { + public static final field Companion Lio/konform/validation/path/PathSegment$Companion; + public abstract fun getPathString ()Ljava/lang/String; +} + +public final class io/konform/validation/path/PathSegment$Companion { + public final fun toPathSegment (Ljava/lang/Object;)Lio/konform/validation/path/PathSegment; +} + +public final class io/konform/validation/path/PathValue : io/konform/validation/path/PathSegment { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lio/konform/validation/path/PathValue; + public static synthetic fun copy$default (Lio/konform/validation/path/PathValue;Ljava/lang/Object;ILjava/lang/Object;)Lio/konform/validation/path/PathValue; + public fun equals (Ljava/lang/Object;)Z + public fun getPathString ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/konform/validation/path/PropRef : io/konform/validation/path/PathSegment { + public fun (Lkotlin/reflect/KProperty1;)V + public final fun component1 ()Lkotlin/reflect/KProperty1; + public final fun copy (Lkotlin/reflect/KProperty1;)Lio/konform/validation/path/PropRef; + public static synthetic fun copy$default (Lio/konform/validation/path/PropRef;Lkotlin/reflect/KProperty1;ILjava/lang/Object;)Lio/konform/validation/path/PropRef; + public fun equals (Ljava/lang/Object;)Z + public fun getPathString ()Ljava/lang/String; + public final fun getProperty ()Lkotlin/reflect/KProperty1; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/konform/validation/path/PropRefKt { + public static final fun toPathSegment (Lkotlin/reflect/KProperty1;)Lio/konform/validation/path/PropRef; +} + public final class io/konform/validation/path/ValidationPath { public static final field Companion Lio/konform/validation/path/ValidationPath$Companion; public fun (Ljava/util/List;)V @@ -177,13 +300,29 @@ public final class io/konform/validation/path/ValidationPath { public final fun copy (Ljava/util/List;)Lio/konform/validation/path/ValidationPath; public static synthetic fun copy$default (Lio/konform/validation/path/ValidationPath;Ljava/util/List;ILjava/lang/Object;)Lio/konform/validation/path/ValidationPath; public fun equals (Ljava/lang/Object;)Z - public final fun getDataPaths ()Ljava/util/List; + public final fun getDataPath ()Ljava/lang/String; + public final fun getSegments ()Ljava/util/List; public fun hashCode ()I + public final fun plus (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/path/ValidationPath; + public final fun plus (Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/path/ValidationPath; + public final fun prepend (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/path/ValidationPath; + public final fun prepend (Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/path/ValidationPath; public fun toString ()Ljava/lang/String; } public final class io/konform/validation/path/ValidationPath$Companion { public final fun fromAny ([Ljava/lang/Object;)Lio/konform/validation/path/ValidationPath; + public final fun of (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/path/ValidationPath; +} + +public final class io/konform/validation/types/CallableValidation : io/konform/validation/Validation { + public fun (Lio/konform/validation/path/ValidationPath;Lkotlin/jvm/functions/Function1;Lio/konform/validation/Validation;)V + public synthetic fun (Lio/konform/validation/path/ValidationPath;Lkotlin/jvm/functions/Function1;Lio/konform/validation/Validation;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 toString ()Ljava/lang/String; + public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; } public final class io/konform/validation/types/EmptyValidation : io/konform/validation/Validation { @@ -191,6 +330,8 @@ public final class io/konform/validation/types/EmptyValidation : io/konform/vali public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I 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 toString ()Ljava/lang/String; public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; } @@ -198,12 +339,25 @@ public final class io/konform/validation/types/EmptyValidation : io/konform/vali public final class io/konform/validation/types/IsClassValidation : io/konform/validation/Validation { public fun (Lkotlin/reflect/KClass;ZLio/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/PrependPathValidation : io/konform/validation/Validation { + public fun (Lio/konform/validation/path/ValidationPath;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 toString ()Ljava/lang/String; public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; } public final class io/konform/validation/types/ValidateAll : io/konform/validation/Validation { public fun (Ljava/util/List;)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 toString ()Ljava/lang/String; public fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; } diff --git a/src/commonMain/kotlin/io/konform/validation/Validation.kt b/src/commonMain/kotlin/io/konform/validation/Validation.kt index fcc8fae..2324eea 100644 --- a/src/commonMain/kotlin/io/konform/validation/Validation.kt +++ b/src/commonMain/kotlin/io/konform/validation/Validation.kt @@ -1,7 +1,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.PrependPathValidation import io.konform.validation.types.ValidateAll public interface Validation { @@ -12,6 +15,10 @@ public interface Validation { public fun validate(value: T): ValidationResult<@UnsafeVariance T> public operator fun invoke(value: T): ValidationResult<@UnsafeVariance T> = validate(value) + + public fun prependPath(path: ValidationPath): Validation = PrependPathValidation(path, this) + + public fun prependPath(pathSegment: PathSegment): Validation = prependPath(ValidationPath.of(pathSegment)) } /** Combine a [List] of [Validation]s into a single one that returns all validation errors. */ diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index a2ebd5c..ed6c299 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -1,22 +1,18 @@ 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 -import io.konform.validation.builder.PropKey -import io.konform.validation.builder.PropModifier -import io.konform.validation.builder.PropModifier.NonNull -import io.konform.validation.builder.PropModifier.Optional -import io.konform.validation.builder.PropModifier.OptionalRequired -import io.konform.validation.builder.SingleValuePropKey -import io.konform.validation.internal.ArrayValidation -import io.konform.validation.internal.IterableValidation -import io.konform.validation.internal.MapValidation -import io.konform.validation.internal.ValidationNode -import io.konform.validation.kotlin.Grammar +import io.konform.validation.helpers.prepend +import io.konform.validation.path.FuncRef +import io.konform.validation.path.PathSegment +import io.konform.validation.path.PathSegment.Companion.toPathSegment +import io.konform.validation.path.PropRef +import io.konform.validation.path.ValidationPath +import io.konform.validation.types.ArrayValidation +import io.konform.validation.types.CallableValidation +import io.konform.validation.types.ConstraintsValidation import io.konform.validation.types.IsClassValidation -import io.konform.validation.types.NullableValidation +import io.konform.validation.types.IterableValidation +import io.konform.validation.types.MapValidation import kotlin.jvm.JvmName import kotlin.reflect.KFunction1 import kotlin.reflect.KProperty1 @@ -27,16 +23,17 @@ private annotation class ValidationScope @ValidationScope public class ValidationBuilder { private val constraints = mutableListOf>() - private val subValidations = mutableMapOf, ValidationBuilder<*>>() - private val prebuiltValidations = mutableListOf>() - - public fun build(): Validation { - val nestedValidations = - subValidations.map { (key, builder) -> - key.build(builder.build()) - } - return ValidationNode(constraints, nestedValidations + prebuiltValidations) - } + private val subValidations = mutableListOf>() + + public fun build(): Validation = + subValidations + .let { + if (constraints.isNotEmpty()) { + it.prepend(ConstraintsValidation(ValidationPath.EMPTY, constraints)) + } else { + it + } + }.flatten() public fun addConstraint( errorMessage: String, @@ -52,119 +49,93 @@ public class ValidationBuilder { } private fun onEachIterable( - name: String, + pathSegment: PathSegment, prop: (T) -> Iterable, init: ValidationBuilder.() -> Unit, - ) { - requireValidName(name) - val key = IterablePropKey(prop, name, NonNull) - init(key.getOrCreateBuilder()) - } + ) = run(CallableValidation(pathSegment, prop, IterableValidation(buildWithNew(init)))) private fun onEachArray( - name: String, + pathSegment: PathSegment, prop: (T) -> Array, init: ValidationBuilder.() -> Unit, - ) { - requireValidName(name) - val key = ArrayPropKey(prop, name, NonNull) - init(key.getOrCreateBuilder()) - } + ) = run(CallableValidation(pathSegment, prop, ArrayValidation(buildWithNew(init)))) private fun onEachMap( - name: String, + pathSegment: PathSegment, prop: (T) -> Map, init: ValidationBuilder>.() -> Unit, - ) { - requireValidName(name) - init(MapPropKey(prop, name, NonNull).getOrCreateBuilder()) - } + ) = run(CallableValidation(pathSegment, prop, MapValidation(buildWithNew(init)))) @JvmName("onEachIterable") - public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachIterable(name, this, init) + public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = + onEachIterable(PropRef(this), this, init) @JvmName("onEachIterable") public infix fun KFunction1>.onEach(init: ValidationBuilder.() -> Unit): Unit = - onEachIterable("$name()", this, init) + onEachIterable(FuncRef(this), this, init) @JvmName("onEachArray") - public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachArray(name, this, init) + public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = + onEachArray(PropRef(this), this, init) @JvmName("onEachArray") - public infix fun KFunction1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachArray("$name()", this, init) + public infix fun KFunction1>.onEach(init: ValidationBuilder.() -> Unit): Unit = + onEachArray(FuncRef(this), this, init) @JvmName("onEachMap") public infix fun KProperty1>.onEach(init: ValidationBuilder>.() -> Unit): Unit = - onEachMap(name, this, init) + onEachMap(PropRef(this), this, init) @JvmName("onEachMap") public infix fun KFunction1>.onEach(init: ValidationBuilder>.() -> Unit): Unit = - onEachMap("$name()", this, init) + onEachMap(FuncRef(this), this, init) - public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(name, this, init) + public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(PropRef(this), this, init) - public operator fun KFunction1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate("$name()", this, init) + public operator fun KFunction1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(this, this, init) - public infix fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit): Unit = ifPresent(name, this, init) + public infix fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit): Unit = ifPresent(this, this, init) - public infix fun KFunction1.ifPresent(init: ValidationBuilder.() -> Unit): Unit = ifPresent("$name()", this, init) + public infix fun KFunction1.ifPresent(init: ValidationBuilder.() -> Unit): Unit = ifPresent(this, this, init) - public infix fun KProperty1.required(init: ValidationBuilder.() -> Unit): Unit = required(name, this, init) + public infix fun KProperty1.required(init: ValidationBuilder.() -> Unit): Unit = required(this, this, init) - public infix fun KFunction1.required(init: ValidationBuilder.() -> Unit): Unit = required("$name()", this, init) + public infix fun KFunction1.required(init: ValidationBuilder.() -> Unit): Unit = required(this, 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 pathSegment The [PathSegment] of the validation. + * is [Any] for backwards compatibility and easy of use, see [toPathSegment] * @param f The function for which you want to validate the result of - * @see run */ public fun validate( - name: String, + pathSegment: Any, f: (T) -> R, init: ValidationBuilder.() -> Unit, - ): Unit = init(f.toPropKey(name, NonNull).getOrCreateBuilder()) + ): Unit = run(CallableValidation(pathSegment, f, buildWithNew(init))) /** * Calculate a value from the input and run a validation on it, but only if the value is not null. */ public fun ifPresent( - name: String, + pathSegment: Any, f: (T) -> R?, init: ValidationBuilder.() -> Unit, - ): Unit = init(f.toPropKey(name, Optional).getOrCreateBuilder()) + ): Unit = run(CallableValidation(pathSegment, f, buildWithNew(init).ifPresent())) /** * Calculate a value from the input and run a validation on it, and give an error if the result is null. */ public fun required( - name: String, + pathSegment: Any, f: (T) -> R?, init: ValidationBuilder.() -> Unit, - ): Unit = init(f.toPropKey(name, OptionalRequired).getOrCreateBuilder()) + ): Unit = run(CallableValidation(pathSegment, f, buildWithNew(init).required())) public fun run(validation: Validation) { - prebuiltValidations.add(validation) - } - - private fun ((T) -> R?).toPropKey( - name: String, - modifier: PropModifier, - ): PropKey { - requireValidName(name) - return SingleValuePropKey(this, name, modifier) + subValidations.add(validation) } - private fun PropKey.getOrCreateBuilder(): ValidationBuilder { - @Suppress("UNCHECKED_CAST") - return subValidations.getOrPut(this) { ValidationBuilder() } as ValidationBuilder - } - - private fun requireValidName(name: String) = - require(Grammar.Identifier.isValid(name) || Grammar.FunctionDeclaration.isUnary(name)) { - "'$name' is not a valid kotlin identifier or getter name." - } - public inline fun ifInstanceOf(init: ValidationBuilder.() -> Unit): Unit = run(IsClassValidation(SubT::class, required = false, buildWithNew(init))) @@ -183,21 +154,16 @@ public class ValidationBuilder { /** * Run a validation if the property is not-null, and allow nulls. */ -public fun ValidationBuilder.ifPresent(init: ValidationBuilder.() -> Unit): Unit = - run(NullableValidation(required = false, validation = buildWithNew(init))) +public fun ValidationBuilder.ifPresent(init: ValidationBuilder.() -> Unit): Unit = run(buildWithNew(init).ifPresent()) /** * Run a validation on a nullable property, giving an error on nulls. */ -public fun ValidationBuilder.required(init: ValidationBuilder.() -> Unit): Unit = - run(NullableValidation(required = true, validation = buildWithNew(init))) +public fun ValidationBuilder.required(init: ValidationBuilder.() -> Unit): Unit = run(buildWithNew(init).required()) @JvmName("onEachIterable") -public fun > ValidationBuilder.onEach(init: ValidationBuilder.() -> Unit) { - val builder = ValidationBuilder() - init(builder) - run(IterableValidation(builder.build())) -} +public fun > ValidationBuilder.onEach(init: ValidationBuilder.() -> Unit): Unit = + run(IterableValidation(buildWithNew(init))) @JvmName("onEachArray") public fun ValidationBuilder>.onEach(init: ValidationBuilder.() -> Unit) { diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationError.kt b/src/commonMain/kotlin/io/konform/validation/ValidationError.kt index 4bcd866..81d61e3 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationError.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationError.kt @@ -1,13 +1,46 @@ package io.konform.validation -public interface ValidationError { - public val dataPath: String - public val message: String +import io.konform.validation.helpers.prepend +import io.konform.validation.path.PathSegment +import io.konform.validation.path.ValidationPath - public companion object { - internal operator fun invoke( - dataPath: String, +/** Represents the path and error of a validation failure. */ +public data class ValidationError( + public val path: ValidationPath, + public val message: String, +) { + public val dataPath: String get() = path.dataPath + + public inline fun mapPath(f: (List) -> List): ValidationError = copy(path = ValidationPath(f(path.segments))) + + internal fun prependPath(path: ValidationPath) = copy(path = this.path.prepend(path)) + + internal fun prependPath(pathSegment: PathSegment) = mapPath { it.prepend(pathSegment) } + + internal companion object { + internal fun of( + pathSegment: PathSegment, + message: String, + ): ValidationError = ValidationError(ValidationPath.of(pathSegment), message) + + internal fun ofAny( + pathSegment: Any, message: String, - ): ValidationError = PropertyValidationError(dataPath, message) + ): ValidationError = of(PathSegment.toPathSegment(pathSegment), message) } } + +public fun List.filterPath(vararg validationPath: Any): List { + val path = ValidationPath.fromAny(*validationPath) + return filter { it.path == path } +} + +public fun List.filterDataPath(vararg validationPath: Any): List { + val dataPath = ValidationPath.fromAny(*validationPath).dataPath + return filter { it.dataPath == dataPath } +} + +public fun List.messagesAtPath(vararg validationPath: Any): List = filterPath(*validationPath).map { it.message } + +public fun List.messagesAtDataPath(vararg validationPath: Any): List = + filterDataPath(*validationPath).map { it.message } diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt index 7c3ea47..3e436c0 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt @@ -1,21 +1,16 @@ package io.konform.validation -import io.konform.validation.kotlin.Path +import io.konform.validation.path.PathSegment +import io.konform.validation.path.ValidationPath import kotlin.jvm.JvmName -internal data class PropertyValidationError( - override val dataPath: String, - override val message: String, -) : ValidationError { - override fun toString(): String = "ValidationError(dataPath=$dataPath, message=$message)" -} - -@Deprecated("Replace with directly using List", ReplaceWith("List")) -public interface ValidationErrors : List - public sealed class ValidationResult { - /** Get the validation errors at a specific path. Will return null for a valid result. */ - public abstract operator fun get(vararg propertyPath: Any): List? + /** Get the validation errors at a specific path. Will return empty list for [Valid]. */ + @Deprecated( + "Prefer using ValidationError and ValidationPath", + ReplaceWith("errors.messagesAtDataPath(*validationPath)", "io.konform.validation.messagesAtDataPath"), + ) + public operator fun get(vararg validationPath: Any): List = errors.messagesAtDataPath(*validationPath) /** If this is a valid result, returns the result of applying the given [transform] function to the value. Otherwise, return the original error. */ public inline fun map(transform: (T) -> R): ValidationResult = @@ -26,46 +21,58 @@ public sealed class ValidationResult { public abstract val errors: List - /** - * Returns true if the [ValidationResult] is [Valid]. - */ - public val isValid: Boolean = + /** Returns true if the [ValidationResult] is [Valid]. */ + public abstract val isValid: Boolean + + /** Merge two [ValidationResult], returning [Valid] if both are valid, and the error(s) otherwise. */ + public infix operator fun plus(other: ValidationResult<@UnsafeVariance T>): ValidationResult = when (this) { - is Invalid -> false - is Valid -> true + is Valid -> other + is Invalid -> + when (other) { + is Valid -> this + is Invalid -> Invalid(errors + other.errors) + } } + + internal abstract fun prependPath(pathSegment: PathSegment): ValidationResult + + internal abstract fun prependPath(path: ValidationPath): ValidationResult } public data class Invalid( - internal val internalErrors: Map>, + override val errors: List, ) : ValidationResult() { - override fun get(vararg propertyPath: Any): List? = internalErrors[Path.toPath(*propertyPath)] + override val isValid: Boolean get() = false - override val errors: List by lazy { - internalErrors.flatMap { (path, errors) -> - errors.map { PropertyValidationError(path, it) } - } - } + override fun prependPath(pathSegment: PathSegment): Invalid = Invalid(errors.map { it.prependPath(pathSegment) }) + + override fun prependPath(path: ValidationPath): Invalid = + if (path.segments.isEmpty()) this else Invalid(errors.map { it.prependPath(path) }) - override fun toString(): String = "Invalid(errors=$errors)" + public companion object { + public fun of( + path: ValidationPath, + message: String, + ): Invalid = Invalid(listOf(ValidationError(path, message))) + } } public data class Valid( val value: T, ) : ValidationResult() { - // This will not be removed as long as ValidationResult has it, but we still deprecate it to warn the user - // that it is nonsensical to do. - @Deprecated("It is not useful to index a valid result, it will always return null", ReplaceWith("null")) - override fun get(vararg propertyPath: Any): List? = null + override val isValid: Boolean get() = true - // This will not be removed as long as ValidationResult has it, but we still deprecate it to warn the user - // that it is nonsensical to do. @Deprecated("It is not useful to call errors on a valid result, it will always return an empty list.", ReplaceWith("emptyList()")) override val errors: List get() = emptyList() + + override fun prependPath(pathSegment: PathSegment): ValidationResult = this + + override fun prependPath(path: ValidationPath): ValidationResult = this } -internal fun List>.flattenNonEmpty(): ValidationResult { +public fun List>.flattenNonEmpty(): ValidationResult { require(isNotEmpty()) { "List is not allowed to be empty in flattenNonEmpty" } val invalids = filterIsInstance() return if (invalids.isEmpty()) { @@ -75,24 +82,16 @@ internal fun List>.flattenNonEmpty(): ValidationResult.flattenNotEmpty(): Invalid { +public fun List.flattenNotEmpty(): Invalid { require(isNotEmpty()) { "List is not allowed to be empty in flattenNonEmpty" } - val merged = mutableMapOf>() - for (invalid in this) { - val added = - invalid.internalErrors.mapValues { - merged.getOrElse(it.key, ::emptyList) + it.value - } - merged += added - } - return Invalid(merged) + return Invalid(map { it.errors }.flatten()) } -internal fun List>.flattenOrValid(value: T): ValidationResult = +public fun List>.flattenOrValid(value: T): ValidationResult = takeIf { isNotEmpty() } ?.flattenNonEmpty() ?.takeIf { it is Invalid } ?: Valid(value) @JvmName("flattenOrValidInvalidList") -internal fun List.flattenOrValid(value: T): ValidationResult = if (isNotEmpty()) flattenNonEmpty() else Valid(value) +public fun List.flattenOrValid(value: T): ValidationResult = if (isNotEmpty()) flattenNonEmpty() else Valid(value) diff --git a/src/commonMain/kotlin/io/konform/validation/builder/PropKey.kt b/src/commonMain/kotlin/io/konform/validation/builder/PropKey.kt deleted file mode 100644 index 6eea431..0000000 --- a/src/commonMain/kotlin/io/konform/validation/builder/PropKey.kt +++ /dev/null @@ -1,57 +0,0 @@ -package io.konform.validation.builder - -import io.konform.validation.Validation -import io.konform.validation.internal.ArrayValidation -import io.konform.validation.internal.IterableValidation -import io.konform.validation.internal.MapValidation - -/** - * The key of a property which we want to validate on - * @param T The type under validation - * */ -internal interface PropKey { - /** Combine the validation of the property into the validation of the outer type. */ - fun build(validation: Validation<*>): Validation -} - -/** - * A validation on a single property - */ -internal data class SingleValuePropKey( - val property: (T) -> R, - val name: String, - val modifier: PropModifier, -) : PropKey { - @Suppress("UNCHECKED_CAST") - override fun build(validation: Validation<*>): Validation = modifier.buildValidation(property, name, validation as Validation) -} - -internal data class IterablePropKey( - val property: (T) -> Iterable, - val name: String, - val modifier: PropModifier, -) : PropKey { - @Suppress("UNCHECKED_CAST") - override fun build(validation: Validation<*>): Validation = - modifier.buildValidation(property, name, IterableValidation(validation as Validation)) -} - -internal data class ArrayPropKey( - val property: (T) -> Array, - val name: String, - val modifier: PropModifier, -) : PropKey { - @Suppress("UNCHECKED_CAST") - override fun build(validation: Validation<*>): Validation = - modifier.buildValidation(property, name, ArrayValidation(validation as Validation)) -} - -internal data class MapPropKey( - val property: (T) -> Map, - val name: String, - val modifier: PropModifier, -) : PropKey { - @Suppress("UNCHECKED_CAST") - override fun build(validation: Validation<*>): Validation = - modifier.buildValidation(property, name, MapValidation(validation as Validation>)) -} diff --git a/src/commonMain/kotlin/io/konform/validation/builder/PropModifier.kt b/src/commonMain/kotlin/io/konform/validation/builder/PropModifier.kt deleted file mode 100644 index e166185..0000000 --- a/src/commonMain/kotlin/io/konform/validation/builder/PropModifier.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.konform.validation.builder - -import io.konform.validation.Validation -import io.konform.validation.internal.NonNullPropertyValidation -import io.konform.validation.internal.OptionalPropertyValidation -import io.konform.validation.internal.RequiredPropertyValidation - -/** A modifier on the validation of a property */ -internal enum class PropModifier { - /** Indicates that the property is required/not nullable. */ - NonNull, - - /** Indicates that the property is not required/nullable*/ - Optional, - - /** Indicates that even though the property is nullable, is it still required. */ - OptionalRequired, - - ; - - fun buildValidation( - property: (T) -> R, - propertyName: String, - validations: Validation, - ): Validation = - when (this) { - NonNull -> NonNullPropertyValidation(property, propertyName, validations) - Optional -> OptionalPropertyValidation(property, propertyName, validations) - OptionalRequired -> RequiredPropertyValidation(property, propertyName, validations) - } -} diff --git a/src/commonMain/kotlin/io/konform/validation/helpers/Helpers.kt b/src/commonMain/kotlin/io/konform/validation/helpers/Helpers.kt new file mode 100644 index 0000000..17df689 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/helpers/Helpers.kt @@ -0,0 +1,9 @@ +package io.konform.validation.helpers + +internal fun List.prepend(element: T): List { + if (isEmpty()) return listOf(element) + val result = ArrayList(size + 1) + result.add(element) + result.addAll(this) + return result +} diff --git a/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt b/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt deleted file mode 100644 index a2e0f4f..0000000 --- a/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt +++ /dev/null @@ -1,135 +0,0 @@ -package io.konform.validation.internal - -import io.konform.validation.Constraint -import io.konform.validation.Invalid -import io.konform.validation.Valid -import io.konform.validation.Validation -import io.konform.validation.ValidationResult - -/** A property that is required and not null. */ -internal class NonNullPropertyValidation( - val property: (T) -> R, - private val name: String, - private val validation: Validation, -) : Validation { - override fun validate(value: T): ValidationResult { - val propertyValue = property(value) - return validation(propertyValue).mapError { ".${name}$it" }.map { value } - } -} - -/** A property that is optional and nullable. */ -internal class OptionalPropertyValidation( - val property: (T) -> R?, - private val name: String, - private val validation: Validation, -) : Validation { - override fun validate(value: T): ValidationResult { - val propertyValue = property(value) ?: return Valid(value) - return validation(propertyValue).mapError { ".${name}$it" }.map { value } - } -} - -/** A property that is nullable, but still required. */ -internal class RequiredPropertyValidation( - val property: (T) -> R?, - private val name: String, - private val validation: Validation, -) : Validation { - override fun validate(value: T): ValidationResult { - val propertyValue = - property(value) - ?: return Invalid(mapOf(".$name" to listOf("is required"))) - return validation(propertyValue).mapError { ".${name}$it" }.map { value } - } -} - -internal class IterableValidation( - private val validation: Validation, -) : Validation> { - override fun validate(value: Iterable): ValidationResult> = - value.foldIndexed(Valid(value)) { index, result: ValidationResult>, propertyValue -> - val propertyValidation = validation(propertyValue).mapError { "[$index]$it" }.map { value } - result.combineWith(propertyValidation) - } -} - -internal class ArrayValidation( - private val validation: Validation, -) : Validation> { - override fun validate(value: Array): ValidationResult> = - value.foldIndexed(Valid(value)) { index, result: ValidationResult>, propertyValue -> - val propertyValidation = validation(propertyValue).mapError { "[$index]$it" }.map { value } - result.combineWith(propertyValidation) - } -} - -internal class MapValidation( - private val validation: Validation>, -) : Validation> { - override fun validate(value: Map): ValidationResult> = - value.asSequence().fold(Valid(value)) { result: ValidationResult>, entry -> - val propertyValidation = validation(entry).mapError { ".${entry.key}${it.removePrefix(".value")}" }.map { value } - result.combineWith(propertyValidation) - } -} - -internal class ValidationNode( - private val constraints: List>, - private val subValidations: List>, -) : Validation { - override fun validate(value: T): ValidationResult { - val subValidationResult = applySubValidations(value, keyTransform = { it }) - val localValidationResult = localValidation(value) - return localValidationResult.combineWith(subValidationResult) - } - - private fun localValidation(value: T): ValidationResult = - constraints - .filter { !it.test(value) } - .map { it.createHint(value) } - .let { errors -> - if (errors.isEmpty()) { - Valid(value) - } else { - Invalid(mapOf("" to errors)) - } - } - - private fun applySubValidations( - propertyValue: T, - keyTransform: (String) -> String, - ): ValidationResult = - subValidations.fold(Valid(propertyValue)) { existingValidation: ValidationResult, validation -> - val newValidation = validation.validate(propertyValue).mapError(keyTransform) - existingValidation.combineWith(newValidation) - } -} - -internal fun ValidationResult.mapError(keyTransform: (String) -> String): ValidationResult = - when (this) { - is Valid -> this - is Invalid -> - Invalid( - this.internalErrors.mapKeys { (key, _) -> - keyTransform(key) - }, - ) - } - -internal fun ValidationResult.combineWith(other: ValidationResult): ValidationResult { - return when (this) { - is Valid -> return other - is Invalid -> - when (other) { - is Valid -> this - is Invalid -> { - Invalid( - (this.internalErrors.toList() + other.internalErrors.toList()) - .groupBy({ it.first }, { it.second }) - .mapValues { (_, values) -> values.flatten() }, - ) - } - } - } -} diff --git a/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt b/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt deleted file mode 100644 index cb4bc0c..0000000 --- a/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt +++ /dev/null @@ -1,24 +0,0 @@ -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 UNICODE_DIGIT = "\\p{Nd}" // Unicode digits (Nd) - private const val QUOTED_SYMBOL = "[^`\r\n]" // Anything except backtick, CR, or LF inside backticks - - object Identifier { - 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) - } -} diff --git a/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt b/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt deleted file mode 100644 index 9491da0..0000000 --- a/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt +++ /dev/null @@ -1,25 +0,0 @@ -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" - } -} diff --git a/src/commonMain/kotlin/io/konform/validation/path/FuncRef.kt b/src/commonMain/kotlin/io/konform/validation/path/FuncRef.kt new file mode 100644 index 0000000..4f8e69a --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/path/FuncRef.kt @@ -0,0 +1,27 @@ +package io.konform.validation.path + +import io.konform.validation.platform.callableEquals +import kotlin.reflect.KFunction1 + +/** + * Represents a function in the path. + * Note: equality differs between platforms, on JS & WASM, only the function name is considered + */ +public data class FuncRef( + val function: KFunction1<*, *>, +) : PathSegment { + override val pathString: String get() = ".${function.name}" + + override fun toString(): String = "FuncRef(${function.name})" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as FuncRef + return callableEquals(function, other.function) + } + + override fun hashCode(): Int = function.name.hashCode() +} + +public fun KFunction1<*, *>.toPathSegment(): FuncRef = FuncRef(this) diff --git a/src/commonMain/kotlin/io/konform/validation/path/PathClass.kt b/src/commonMain/kotlin/io/konform/validation/path/PathClass.kt new file mode 100644 index 0000000..befcdc7 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/path/PathClass.kt @@ -0,0 +1,14 @@ +package io.konform.validation.path + +import kotlin.reflect.KClass + +/** A path for a class reference. */ +public data class PathClass( + val kcls: KClass<*>, +) : PathSegment { + private val name get() = kcls.simpleName ?: "Anonymous" + + override val pathString: String get() = name + + override fun toString(): String = "PathClass($name)" +} diff --git a/src/commonMain/kotlin/io/konform/validation/path/PathIndex.kt b/src/commonMain/kotlin/io/konform/validation/path/PathIndex.kt new file mode 100644 index 0000000..0ccabf8 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/path/PathIndex.kt @@ -0,0 +1,10 @@ +package io.konform.validation.path + +/** An index to an array, list, or other iterable. */ +public data class PathIndex( + val index: Int, +) : PathSegment { + override val pathString: String get() = "[$index]" + + override fun toString(): String = "PathIndex($index)" +} diff --git a/src/commonMain/kotlin/io/konform/validation/path/PathKey.kt b/src/commonMain/kotlin/io/konform/validation/path/PathKey.kt new file mode 100644 index 0000000..43dc2ff --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/path/PathKey.kt @@ -0,0 +1,28 @@ +package io.konform.validation.path + +/** + * The key of a map or object. + * + * Equality: will equal a [PathValue] of the same value + * */ +public data class PathKey( + val key: Any?, +) : PathSegment { + override val pathString: String get() = ".$key" + + override fun toString(): String = "PathKey($key)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + + return when (other) { + is PathKey -> other.key == key + is PathValue -> other.value == key + else -> false + } + } + + override fun hashCode(): Int = key?.hashCode() ?: 0 +} + +public fun Map.Entry<*, *>.toPathSegment(): PathKey = PathKey(key) diff --git a/src/commonMain/kotlin/io/konform/validation/path/PathSegment.kt b/src/commonMain/kotlin/io/konform/validation/path/PathSegment.kt new file mode 100644 index 0000000..384863a --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/path/PathSegment.kt @@ -0,0 +1,45 @@ +package io.konform.validation.path + +import kotlin.reflect.KClass +import kotlin.reflect.KFunction1 +import kotlin.reflect.KProperty1 + +/** + * Represents a path element in a validation. + * + * Example: + * ``` + * data class Person(val name: String) + * val validation = Validation { + * Person::name { + * notBlank() + * } + * } + * val result = validation.validate(Person("")) as Invalid + * result.errors[0].path == PathSegment.Property(Person::name) + * ``` + * */ +public sealed interface PathSegment { + /** A JSONPath-ish representation of the path segment. */ + public val pathString: String + + public companion object { + /** + * Converts [Any] value to its corresponding [PathSegment] + * If it is already a PathSegment it will be returned + * otherwise the most appropriate subtype of PathSegment will be returned, + * e.g. an [KFunction1] will become a [PathSegment.Func]. + * If no more appropriate subtype exists, [PathValue] will be returned. + */ + public fun toPathSegment(pathSegment: Any?): PathSegment = + when (pathSegment) { + is PathSegment -> pathSegment + is KProperty1<*, *> -> pathSegment.toPathSegment() + is KFunction1<*, *> -> pathSegment.toPathSegment() + is Int -> PathIndex(pathSegment) + is Map.Entry<*, *> -> pathSegment.toPathSegment() + is KClass<*> -> PathClass(pathSegment) + else -> PathValue(pathSegment) + } + } +} diff --git a/src/commonMain/kotlin/io/konform/validation/path/PathValue.kt b/src/commonMain/kotlin/io/konform/validation/path/PathValue.kt new file mode 100644 index 0000000..46f9ec8 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/path/PathValue.kt @@ -0,0 +1,23 @@ +package io.konform.validation.path + +/** Any value provided by the user, often a field name. */ +public data class PathValue( + val value: Any?, +) : PathSegment { + override val pathString: String get() = ".$value" + + override fun toString(): String = "ProvidedValue($value)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + + return when (other) { + is PathValue -> other.value == value + is PathKey -> other.key == value + + else -> false + } + } + + override fun hashCode(): Int = value?.hashCode() ?: 0 +} diff --git a/src/commonMain/kotlin/io/konform/validation/path/PropRef.kt b/src/commonMain/kotlin/io/konform/validation/path/PropRef.kt new file mode 100644 index 0000000..5fc91a2 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/path/PropRef.kt @@ -0,0 +1,40 @@ +package io.konform.validation.path + +import io.konform.validation.platform.callableEquals +import kotlin.reflect.KProperty1 + +/** + * Represents a path through a property. + * + * Example: + * ``` + * data class Person(val name: String) + * val validation = Validation { + * Person::name { + * notBlank() + * } + * } + * val result = validation.validate(Person("")) as Invalid + * result.errors[0] = ValidationError(ValidationPath(Prop(Person::name)), "must not be blank") + * ``` + * + * Note: equality differs between platforms, on JS & WASM, only the function name is considered + */ +public data class PropRef( + val property: KProperty1<*, *>, +) : PathSegment { + override val pathString: String get() = ".${property.name}" + + override fun toString(): String = "PropRef(${property.name})" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as PropRef + return callableEquals(property, other.property) + } + + override fun hashCode(): Int = property.name.hashCode() +} + +public fun KProperty1<*, *>.toPathSegment(): PropRef = PropRef(this) diff --git a/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt b/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt index 1efb0f3..9e20012 100644 --- a/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt +++ b/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt @@ -1,13 +1,36 @@ package io.konform.validation.path -import io.konform.validation.kotlin.Path +import io.konform.validation.helpers.prepend /** Represents a path to a validation. */ public data class ValidationPath( - // val segments: List, - val dataPaths: List, + val segments: List, ) { + /** A JSONPath-ish representation of the path. */ + public val dataPath: String + get() = segments.joinToString("") { it.pathString } + + public fun prepend(other: ValidationPath): ValidationPath = + when { + segments.isEmpty() -> other + other.segments.isEmpty() -> this + else -> ValidationPath(other.segments + segments) + } + + public fun prepend(pathSegment: PathSegment): ValidationPath = ValidationPath(segments.prepend(pathSegment)) + + public infix operator fun plus(segment: PathSegment): ValidationPath = ValidationPath(segments + segment) + + public infix operator fun plus(other: ValidationPath): ValidationPath = other.prepend(this) + + override fun toString(): String = "ValidationPath(${segments.joinToString(", ")})" + public companion object { - public fun fromAny(vararg validationPath: Any): ValidationPath = ValidationPath(validationPath.map { Path.toPath(*validationPath) }) + internal val EMPTY = ValidationPath(emptyList()) + + public fun of(pathSegment: PathSegment): ValidationPath = ValidationPath(listOf(pathSegment)) + + public fun fromAny(vararg validationPath: Any): ValidationPath = + ValidationPath(validationPath.map { PathSegment.toPathSegment(it) }) } } diff --git a/src/commonMain/kotlin/io/konform/validation/platform/CallableEquals.kt b/src/commonMain/kotlin/io/konform/validation/platform/CallableEquals.kt new file mode 100644 index 0000000..bd57312 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/platform/CallableEquals.kt @@ -0,0 +1,9 @@ +package io.konform.validation.platform + +import kotlin.reflect.KCallable + +// For rare cases where we need to behave different between platforms +internal expect fun callableEquals( + first: KCallable<*>, + second: KCallable<*>, +): Boolean diff --git a/src/commonMain/kotlin/io/konform/validation/types/CallableValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/CallableValidation.kt new file mode 100644 index 0000000..49fa58d --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/types/CallableValidation.kt @@ -0,0 +1,28 @@ +package io.konform.validation.types + +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 + +/** Validate the result of a property/function. */ +public class CallableValidation( + private val path: ValidationPath = ValidationPath.EMPTY, + private val callable: (T) -> R, + private val validation: Validation, +) : Validation { + internal constructor(pathSegment: Any, callable: (T) -> R, validation: Validation) : + this(ValidationPath.of(PathSegment.toPathSegment(pathSegment)), callable, validation) + + override fun validate(value: T): ValidationResult { + val toValidate = callable(value) + return when (val callableResult = validation(toValidate)) { + is Valid -> Valid(value) + is Invalid -> callableResult.prependPath(path) + } + } + + override fun toString(): String = "CallableValidation(path=$path, callable=$callable, validation=$validation)" +} diff --git a/src/commonMain/kotlin/io/konform/validation/types/ConstraintsValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/ConstraintsValidation.kt new file mode 100644 index 0000000..cff9ae9 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/types/ConstraintsValidation.kt @@ -0,0 +1,28 @@ +package io.konform.validation.types + +import io.konform.validation.Constraint +import io.konform.validation.Invalid +import io.konform.validation.Valid +import io.konform.validation.Validation +import io.konform.validation.ValidationError +import io.konform.validation.ValidationResult +import io.konform.validation.path.ValidationPath + +internal class ConstraintsValidation( + private val path: ValidationPath = ValidationPath.EMPTY, + private val constraints: List>, +) : Validation { + override fun validate(value: T): ValidationResult = + constraints + .filterNot { it.test(value) } + .map { ValidationError(path, it.createHint(value)) } + .let { errors -> + if (errors.isEmpty()) { + Valid(value) + } else { + Invalid(errors) + } + } + + override fun toString(): String = "ConstraintsValidation(path=$path,constraints=$constraints)" +} diff --git a/src/commonMain/kotlin/io/konform/validation/types/IsClassValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/IsClassValidation.kt index f7b008f..844107d 100644 --- a/src/commonMain/kotlin/io/konform/validation/types/IsClassValidation.kt +++ b/src/commonMain/kotlin/io/konform/validation/types/IsClassValidation.kt @@ -3,7 +3,9 @@ package io.konform.validation.types import io.konform.validation.Invalid import io.konform.validation.Valid import io.konform.validation.Validation +import io.konform.validation.ValidationError import io.konform.validation.ValidationResult +import io.konform.validation.path.ValidationPath import kotlin.reflect.KClass import kotlin.reflect.safeCast @@ -26,7 +28,7 @@ public class IsClassValidation( 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'"))) + Invalid(listOf(ValidationError(ValidationPath.EMPTY, "must be a '${clazz.simpleName}', was a '$actualType'"))) } else { Valid(value) } diff --git a/src/commonMain/kotlin/io/konform/validation/types/IterableValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/IterableValidation.kt new file mode 100644 index 0000000..1ebed9d --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/types/IterableValidation.kt @@ -0,0 +1,37 @@ +package io.konform.validation.types + +import io.konform.validation.Invalid +import io.konform.validation.Validation +import io.konform.validation.ValidationResult +import io.konform.validation.flattenOrValid +import io.konform.validation.path.PathIndex + +internal class IterableValidation( + private val validation: Validation, +) : Validation> { + override fun validate(value: Iterable): ValidationResult> { + val errors = mutableListOf() + value.forEachIndexed { i, element -> + val result = validation.validate(element) + if (result is Invalid) { + errors += result.prependPath(PathIndex(i)) + } + } + return errors.flattenOrValid(value) + } +} + +internal class ArrayValidation( + private val validation: Validation, +) : Validation> { + override fun validate(value: Array): ValidationResult> { + val errors = mutableListOf() + value.forEachIndexed { i, element -> + val result = validation.validate(element) + if (result is Invalid) { + errors += result.prependPath(PathIndex(i)) + } + } + return errors.flattenOrValid(value) + } +} diff --git a/src/commonMain/kotlin/io/konform/validation/types/MapValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/MapValidation.kt new file mode 100644 index 0000000..cd3684e --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/types/MapValidation.kt @@ -0,0 +1,48 @@ +package io.konform.validation.types + +import io.konform.validation.Invalid +import io.konform.validation.Validation +import io.konform.validation.ValidationError +import io.konform.validation.ValidationResult +import io.konform.validation.flattenOrValid +import io.konform.validation.path.PathKey +import io.konform.validation.path.PropRef + +internal class MapValidation( + private val validation: Validation>, +) : Validation> { + override fun validate(value: Map): ValidationResult> { + val errors = mutableListOf() + value.forEach { + val result = validation.validate(it) + println(result) + if (result is Invalid) { + errors += Invalid(result.errors.map { e -> setMapPath(it.key, e) }) + } + } + return errors.flattenOrValid(value) + } + + private fun setMapPath( + key: K, + error: ValidationError, + ): ValidationError { + val keySegment = PathKey(key) + return when (error.path.segments.firstOrNull()) { + // Remove ".key" or ".value" to the path as usually we want + // ".mapField.toStringKey.xxx" and not ".mapField.toStringKey.key.xxx" + // or ".mapField.toStringKey.value.xxx" + SEGMENT_MAP_KEY, SEGMENT_MAP_VALUE -> + error.mapPath { + it.toMutableList().also { path -> path[0] = keySegment } + } + + else -> error.prependPath(keySegment) + } + } + + private companion object { + private val SEGMENT_MAP_KEY = PropRef(Map.Entry<*, *>::key) + private val SEGMENT_MAP_VALUE = PropRef(Map.Entry<*, *>::value) + } +} diff --git a/src/commonMain/kotlin/io/konform/validation/types/NullableValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/NullableValidation.kt index 20de42e..5cc28b0 100644 --- a/src/commonMain/kotlin/io/konform/validation/types/NullableValidation.kt +++ b/src/commonMain/kotlin/io/konform/validation/types/NullableValidation.kt @@ -4,19 +4,24 @@ 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( + private val pathSegment: PathSegment? = null, private val required: Boolean, private val validation: Validation, ) : Validation { override fun validate(value: T?): ValidationResult = if (value == null) { if (required) { - Invalid(mapOf("" to listOf("is required"))) + val path = ValidationPath(listOfNotNull(pathSegment)) + Invalid.of(path, "is required") } else { Valid(value) } } else { + // Don't prepend path here since we expect the validation to contain the complete path validation(value) } } diff --git a/src/commonMain/kotlin/io/konform/validation/types/PrependPathValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/PrependPathValidation.kt new file mode 100644 index 0000000..8af3100 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/types/PrependPathValidation.kt @@ -0,0 +1,14 @@ +package io.konform.validation.types + +import io.konform.validation.Validation +import io.konform.validation.ValidationResult +import io.konform.validation.path.ValidationPath + +public class PrependPathValidation( + private val path: ValidationPath, + private val validation: Validation, +) : Validation { + override fun validate(value: T): ValidationResult = validation.validate(value).prependPath(path) + + override fun toString(): String = "PrependPathValidation(path=$path,validation=$validation)" +} diff --git a/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt b/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt index f20fe9e..42e7981 100644 --- a/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ListValidationTest.kt @@ -1,6 +1,7 @@ package io.konform.validation import io.konform.validation.constraints.minimum +import io.konform.validation.path.ValidationPath import io.konform.validation.types.EmptyValidation import io.konform.validation.types.ValidateAll import io.kotest.assertions.konform.shouldBeInvalid @@ -46,10 +47,10 @@ class ListValidationTest { result.shouldBeInstanceOf>() result shouldBeValid 10 - (result shouldBeInvalid 5) shouldContainOnlyError ValidationError("", "must be at least '10'") + (result shouldBeInvalid 5) shouldContainOnlyError ValidationError(ValidationPath.EMPTY, "must be at least '10'") (result shouldBeInvalid -1).shouldContainExactlyErrors( - ValidationError("", "must be at least '0'"), - ValidationError("", "must be at least '10'"), + ValidationError(ValidationPath.EMPTY, "must be at least '0'"), + ValidationError(ValidationPath.EMPTY, "must be at least '10'"), ) } } diff --git a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt index a6b6867..e43e921 100644 --- a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt @@ -7,6 +7,8 @@ import io.konform.validation.constraints.minItems import io.konform.validation.constraints.minLength import io.konform.validation.constraints.minimum import io.konform.validation.constraints.pattern +import io.konform.validation.path.PathValue +import io.konform.validation.path.PropRef import io.kotest.assertions.konform.shouldBeInvalid import io.kotest.assertions.konform.shouldBeValid import io.kotest.assertions.konform.shouldContainError @@ -133,7 +135,14 @@ class ReadmeExampleTest { ) assertEquals(3, countFieldsWithErrors(validateEvent(invalidEvent))) - assertEquals("Attendees must be 18 years or older", validateEvent(invalidEvent)[Event::attendees, 0, Person::age]!![0]) + assertEquals( + "Attendees must be 18 years or older", + validateEvent(invalidEvent).errors.messagesAtDataPath( + Event::attendees, + 0, + Person::age, + )[0], + ) } @Test @@ -147,7 +156,7 @@ class ReadmeExampleTest { validateUser1 shouldBeValid johnDoe validateUser1.shouldBeInvalid(UserProfile("John\tDoe", 30)) { - it.shouldContainError(".fullName", "Name cannot contain a tab") + it.shouldContainError(ValidationError.of(PropRef(UserProfile::fullName), "Name cannot contain a tab")) } val validateUser2 = @@ -159,7 +168,7 @@ class ReadmeExampleTest { validateUser2 shouldBeValid johnDoe validateUser2.shouldBeInvalid(UserProfile("J", 30)) { - it.shouldContainError(".trimmedName", "must have at least 5 characters") + it.shouldContainError(ValidationError.of(PathValue("trimmedName"), "must have at least 5 characters")) } } @@ -181,7 +190,7 @@ class ReadmeExampleTest { validateUser shouldBeValid johnDoe validateUser.shouldBeInvalid(UserProfile("John doe", 10)) { - it.shouldContainError(".age", "must be at least '21'") + it.shouldContainError(ValidationError.of(PropRef(UserProfile::age), "must be at least '21'")) } val transform = @@ -193,7 +202,7 @@ class ReadmeExampleTest { transform shouldBeValid UserProfile("X", 31) transform.shouldBeInvalid(johnDoe) { - it.shouldContainError(".ageMinus10", "must be at least '21'") + it.shouldContainError(ValidationError.of(PathValue("ageMinus10"), "must be at least '21'")) } val required = @@ -210,12 +219,12 @@ class ReadmeExampleTest { } val noAge = UserProfile("John Doe", null) required.shouldBeInvalid(noAge) { - it.shouldContainError(".age", "is required") + it.shouldContainError(ValidationError.of(PathValue("age"), "is required")) } optional.shouldBeValid(noAge) optional.shouldBeValid(johnDoe) optional.shouldBeInvalid(UserProfile("John Doe", 10)) { - it.shouldContainError(".age", "must be at least '21'") + it.shouldContainError(ValidationError.of(PathValue("age"), "must be at least '21'")) } } } diff --git a/src/commonTest/kotlin/io/konform/validation/TestHelpers.kt b/src/commonTest/kotlin/io/konform/validation/TestHelpers.kt index f2f6a78..98110bb 100644 --- a/src/commonTest/kotlin/io/konform/validation/TestHelpers.kt +++ b/src/commonTest/kotlin/io/konform/validation/TestHelpers.kt @@ -1,8 +1,8 @@ package io.konform.validation -fun countFieldsWithErrors(validationResult: ValidationResult) = (validationResult as Invalid).internalErrors.size +fun countFieldsWithErrors(validationResult: ValidationResult) = (validationResult as Invalid).errors.size fun countErrors( validationResult: ValidationResult<*>, vararg properties: Any, -) = validationResult.get(*properties)?.size ?: 0 +) = validationResult.errors.messagesAtDataPath(*properties).size diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt index 64da890..b25dbde 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt @@ -1,12 +1,20 @@ package io.konform.validation -import io.konform.validation.jsonschema.const -import io.konform.validation.jsonschema.enum -import io.konform.validation.jsonschema.minItems +import io.konform.validation.constraints.const +import io.konform.validation.constraints.containsPattern +import io.konform.validation.constraints.enum +import io.konform.validation.constraints.maxLength +import io.konform.validation.constraints.minItems +import io.konform.validation.constraints.minLength +import io.konform.validation.constraints.pattern +import io.konform.validation.path.FuncRef +import io.konform.validation.path.PathKey +import io.konform.validation.path.ValidationPath 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.shouldContainOnlyError import io.kotest.assertions.konform.shouldHaveErrorCount import io.kotest.assertions.konform.shouldNotContainErrorAt import kotlin.test.Test @@ -14,16 +22,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class ValidationBuilderTest { - // Some example constraints for Testing - private fun ValidationBuilder.minLength(minValue: Int) = - addConstraint("must have at least {0} characters", minValue.toString()) { it.length >= minValue } - - private fun ValidationBuilder.maxLength(minValue: Int) = - addConstraint("must have at most {0} characters", minValue.toString()) { it.length <= minValue } - - private fun ValidationBuilder.matches(regex: Regex) = addConstraint("must have correct format") { it.contains(regex) } - - private fun ValidationBuilder.containsANumber() = matches("[0-9]".toRegex()) hint "must have at least one number" + private fun ValidationBuilder.containsANumber() = containsPattern("[0-9]".toRegex()) hint "must have at least one number" @Test fun singleValidation() { @@ -81,17 +80,23 @@ class ValidationBuilderTest { } Register::email { - matches(".+@.+".toRegex()) + pattern(".+@.+".toRegex()).hint("must have correct format") } } - Register(email = "tester@test.com", password = "verysecure1").let { assertEquals(Valid(it), overlappingValidations(it)) } - Register(email = "tester@test.com").let { - assertEquals(1, countFieldsWithErrors(overlappingValidations(it))) - assertEquals(2, countErrors(overlappingValidations(it), Register::password)) - } - Register(password = "verysecure1").let { assertEquals(1, countErrors(overlappingValidations(it), Register::email)) } - Register().let { assertEquals(2, countFieldsWithErrors(overlappingValidations(it))) } + overlappingValidations shouldBeValid Register(email = "tester@test.com", password = "verysecure1") + (overlappingValidations shouldBeInvalid Register(email = "tester@test.com")).shouldContainExactlyErrors( + ValidationError.ofAny(Register::password, "must have at least 8 characters"), + ValidationError.ofAny(Register::password, "must have at least one number"), + ) + (overlappingValidations shouldBeInvalid Register(password = "verysecure1")) shouldContainOnlyError + ValidationError.ofAny(Register::email, "must have correct format") + + (overlappingValidations shouldBeInvalid Register()).shouldContainExactlyErrors( + ValidationError.ofAny(Register::password, "must have at least 8 characters"), + ValidationError.ofAny(Register::password, "must have at least one number"), + ValidationError.ofAny(Register::email, "must have correct format"), + ) } @Test @@ -99,7 +104,7 @@ class ValidationBuilderTest { val nullableFieldValidation = Validation { Register::referredBy ifPresent { - matches(".+@.+".toRegex()) + pattern(".+@.+".toRegex()).hint("must have correct format") } } @@ -113,7 +118,7 @@ class ValidationBuilderTest { val nullableFieldValidation = Validation { Register::referredBy required { - matches(".+@.+".toRegex()) + pattern(".+@.+".toRegex()).hint("must have correct format") } } @@ -143,7 +148,7 @@ class ValidationBuilderTest { val nullableTypeValidation = Validation { ifPresent { - matches(".+@.+".toRegex()) + pattern(".+@.+".toRegex()).hint("must have correct format") } } @@ -157,7 +162,7 @@ class ValidationBuilderTest { val nullableRequiredValidation = Validation { required { - matches(".+@.+".toRegex()) + pattern(".+@.+".toRegex()).hint("must have correct format") } } @@ -178,19 +183,27 @@ class ValidationBuilderTest { maxLength(10) } Register::getEmailFun { - matches(".+@.+".toRegex()) + pattern(".+@.+".toRegex()).hint("must have correct format") } } - 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), Register::getPasswordFun)) } - Register(email = "tester@test.com", password = "aaaaaaaaaaa").let { - assertEquals(1, countErrors(splitDoubleValidation(it), Register::getPasswordFun)) - } - Register(email = "tester@").let { assertEquals(2, countFieldsWithErrors(splitDoubleValidation(it))) } + splitDoubleValidation shouldBeValid Register(email = "tester@test.com", password = "a") + + ( + splitDoubleValidation shouldBeInvalid + Register( + email = "tester@test.com", + password = "", + ) + ) shouldContainOnlyError ValidationError.of(FuncRef(Register::getPasswordFun), "must have at least 1 characters") + + (splitDoubleValidation shouldBeInvalid Register(email = "tester@test.com", password = "aaaaaaaaaaa")) shouldContainOnlyError + ValidationError.of(FuncRef(Register::getPasswordFun), "must have at most 10 characters") + + (splitDoubleValidation shouldBeInvalid Register(email = "tester@")).shouldContainExactlyErrors( + ValidationError.of(FuncRef(Register::getPasswordFun), "must have at least 1 characters"), + ValidationError.of(FuncRef(Register::getEmailFun), "must have correct format"), + ) } @Test @@ -202,21 +215,21 @@ class ValidationBuilderTest { maxLength(10) } validate("getEmailLambda", { r: Register -> r.email }) { - matches(".+@.+".toRegex()) + pattern(".+@.+".toRegex()).hint("must have correct format") } } 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") + it.shouldContainOnlyError(ValidationError.ofAny("getPasswordLambda", "must have at least 1 characters")) } splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "aaaaaaaaaaa")) { - it.shouldContainExactlyErrors(".getPasswordLambda" to "must have at most 10 characters") + it.shouldContainOnlyError(ValidationError.ofAny("getPasswordLambda", "must have at most 10 characters")) } splitDoubleValidation.shouldBeInvalid(Register(email = "tester@", password = "")) { it.shouldContainExactlyErrors( - ".getPasswordLambda" to "must have at least 1 characters", - ".getEmailLambda" to "must have correct format", + ValidationError.ofAny("getPasswordLambda", "must have at least 1 characters"), + ValidationError.ofAny("getEmailLambda", "must have correct format"), ) } } @@ -385,6 +398,10 @@ class ValidationBuilderTest { } } + @Test + fun validateX() { + } + @Test fun validateHashMaps() { data class Data( @@ -413,11 +430,17 @@ class ValidationBuilderTest { ), ), ) { - it.shouldContainExactlyErrors( - ".registrations.user2.email" to "must have at least 2 characters", + it shouldContainOnlyError + ValidationError( + ValidationPath.fromAny(Data::registrations, PathKey("user2"), Register::email), + "must have at least 2 characters", + ) + + it.shouldContainError( + listOf(Data::registrations, PathKey("user2"), Register::email), + "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.shouldNotContainErrorAt(Data::registrations, PathKey("user1"), Register::email) it.shouldHaveErrorCount(1) } } @@ -428,7 +451,7 @@ class ValidationBuilderTest { val registrations: Map? = null, ) - val mapValidation = + val validation = Validation { Data::registrations ifPresent { onEach { @@ -441,18 +464,23 @@ class ValidationBuilderTest { } } - Data(null).let { assertEquals(Valid(it), mapValidation(it)) } - Data(emptyMap()).let { assertEquals(Valid(it), mapValidation(it)) } - 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)) - } + validation shouldBeValid Data(null) + validation shouldBeValid Data(emptyMap()) + + val invalidData = + Data( + registrations = + mapOf( + "user1" to Register(email = "valid"), + "user2" to Register(email = "a"), + ), + ) + + (validation shouldBeInvalid invalidData) shouldContainOnlyError + ValidationError( + ValidationPath.fromAny(Data::registrations, PathKey("user2"), Register::email), + "must have at least 2 characters", + ) } @Test @@ -482,7 +510,7 @@ class ValidationBuilderTest { minLength(8) } } - assertTrue(validation(Register(password = ""))[Register::password]!![0].contains("8")) + assertTrue(validation(Register(password = "")).errors.messagesAtDataPath(Register::password)[0].contains("8")) } private data class Register( diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationResultTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationResultTest.kt index 1f0b358..7c2c3d0 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationResultTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationResultTest.kt @@ -1,8 +1,8 @@ package io.konform.validation -import io.konform.validation.jsonschema.maxLength -import io.konform.validation.jsonschema.minLength -import io.konform.validation.jsonschema.pattern +import io.konform.validation.constraints.maxLength +import io.konform.validation.constraints.minLength +import io.konform.validation.constraints.pattern import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationTest.kt index c61a6da..e16d7d6 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationTest.kt @@ -1,7 +1,8 @@ package io.konform.validation -import io.konform.validation.jsonschema.minLength +import io.konform.validation.constraints.minLength import io.kotest.assertions.konform.shouldContainExactlyErrors +import io.kotest.assertions.konform.shouldContainOnlyError import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import kotlin.test.Test @@ -44,12 +45,12 @@ class ValidationTest { val invalidAnimal = animalValidation.validate(emptyCat) val invalidCat = catValidation.validate(emptyCat) - invalidAnimal.shouldBeInstanceOf().shouldContainExactlyErrors( - ".name" to "must have at least 1 characters", + invalidAnimal.shouldBeInstanceOf().shouldContainOnlyError( + ValidationError.ofAny(Animal::name, "must have at least 1 characters"), ) invalidCat.shouldBeInstanceOf().shouldContainExactlyErrors( - ".name" to "must have at least 1 characters", - ".favoritePrey" to "must have at least 1 characters", + ValidationError.ofAny(Animal::name, "must have at least 1 characters"), + ValidationError.ofAny(Cat::favoritePrey, "must have at least 1 characters"), ) } } diff --git a/src/commonTest/kotlin/io/konform/validation/constraints/ConstraintsTest.kt b/src/commonTest/kotlin/io/konform/validation/constraints/ConstraintsTest.kt index a2acae8..0543cc0 100644 --- a/src/commonTest/kotlin/io/konform/validation/constraints/ConstraintsTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/constraints/ConstraintsTest.kt @@ -27,6 +27,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +@Suppress("DEPRECATION") class ConstraintsTest { @Test fun typeConstraint() { @@ -45,8 +46,8 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(anyNumberValidation("String"))) assertEquals(1, countFieldsWithErrors(anyNumberValidation(true))) - assertEquals("must be of type 'String'", anyValidation(1).get()!![0]) - assertEquals("must be of type 'Int'", anyNumberValidation("String").get()!![0]) + assertEquals("must be of type 'String'", anyValidation(1).get()[0]) + assertEquals("must be of type 'Int'", anyNumberValidation("String").get()[0]) } @Test @@ -70,7 +71,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation("???"))) assertEquals(1, countFieldsWithErrors(validation(""))) - assertEquals("must be one of: 'OK', 'CANCEL'", validation("").get()!![0]) + assertEquals("must be one of: 'OK', 'CANCEL'", validation("").get()[0]) } enum class TCPPacket { @@ -92,8 +93,8 @@ class ConstraintsTest { assertEquals(Valid("SYNACK"), stringifiedEnumValidation("SYNACK")) assertEquals(1, countFieldsWithErrors(stringifiedEnumValidation("ASDF"))) - assertEquals("must be one of: 'SYN', 'ACK'", partialEnumValidation(SYNACK).get()!![0]) - assertEquals("must be one of: 'SYN', 'ACK', 'SYNACK'", stringifiedEnumValidation("").get()!![0]) + assertEquals("must be one of: 'SYN', 'ACK'", partialEnumValidation(SYNACK).get()[0]) + assertEquals("must be one of: 'SYN', 'ACK', 'SYNACK'", stringifiedEnumValidation("").get()[0]) } @Test @@ -112,9 +113,9 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(nullableConstValidation(null))) assertEquals(1, countFieldsWithErrors(nullableConstValidation("Konverse"))) - assertEquals("must be 'Konform'", validation("Konverse").get()!![0]) - assertEquals("must be 'null'", nullableConstNullValidation("Konform").get()!![0]) - assertEquals("must be 'Konform'", nullableConstValidation(null).get()!![0]) + assertEquals("must be 'Konform'", validation("Konverse").get()[0]) + assertEquals("must be 'null'", nullableConstNullValidation("Konform").get()[0]) + assertEquals("must be 'Konform'", nullableConstValidation(null).get()[0]) } @Test @@ -134,7 +135,7 @@ class ConstraintsTest { assertFailsWith(IllegalArgumentException::class) { Validation { multipleOf(0) } } assertFailsWith(IllegalArgumentException::class) { Validation { multipleOf(-1) } } - assertEquals("must be a multiple of '2.5'", validation(1).get()!![0]) + assertEquals("must be a multiple of '2.5'", validation(1).get()[0]) } @Test @@ -158,7 +159,7 @@ class ConstraintsTest { }(Double.POSITIVE_INFINITY), ) - assertEquals("must be at most '10'", validation(11).get()!![0]) + assertEquals("must be at most '10'", validation(11).get()[0]) } @Test @@ -177,7 +178,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation(Double.POSITIVE_INFINITY))) assertEquals(1, countFieldsWithErrors(Validation { exclusiveMaximum(Double.POSITIVE_INFINITY) }(Double.POSITIVE_INFINITY))) - assertEquals("must be less than '10'", validation(11).get()!![0]) + assertEquals("must be less than '10'", validation(11).get()[0]) } @Test @@ -201,7 +202,7 @@ class ConstraintsTest { }(Double.NEGATIVE_INFINITY), ) - assertEquals("must be at least '10'", validation(9).get()!![0]) + assertEquals("must be at least '10'", validation(9).get()[0]) } @Test @@ -220,7 +221,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation(Double.NEGATIVE_INFINITY))) assertEquals(1, countFieldsWithErrors(Validation { exclusiveMinimum(Double.NEGATIVE_INFINITY) }(Double.NEGATIVE_INFINITY))) - assertEquals("must be greater than '10'", validation(9).get()!![0]) + assertEquals("must be greater than '10'", validation(9).get()[0]) } @Test @@ -233,7 +234,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation("Hello"))) assertEquals(1, countFieldsWithErrors(validation(""))) - assertEquals("must have at least 10 characters", validation("").get()!![0]) + assertEquals("must have at least 10 characters", validation("").get()[0]) } @Test @@ -246,7 +247,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation("Hello World"))) - assertEquals("must have at most 10 characters", validation("Hello World").get()!![0]) + assertEquals("must have at most 10 characters", validation("Hello World").get()[0]) } @Test @@ -258,7 +259,7 @@ class ConstraintsTest { assertEquals(Valid(" a@a "), validation(" a@a ")) assertEquals(1, countFieldsWithErrors(validation("a"))) - assertEquals("must match the expected pattern", validation("").get()!![0]) + assertEquals("must match the expected pattern", validation("").get()[0]) val compiledRegexValidation = Validation { @@ -270,7 +271,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(compiledRegexValidation(" tester@example.com"))) assertEquals(1, countFieldsWithErrors(compiledRegexValidation("tester@example.com "))) - assertEquals("must match the expected pattern", compiledRegexValidation("").get()!![0]) + assertEquals("must match the expected pattern", compiledRegexValidation("").get()[0]) } @Test @@ -280,7 +281,7 @@ class ConstraintsTest { assertEquals(Valid("ae40fe0d-05cb-4796-be1f-a1798fec52cf"), validation("ae40fe0d-05cb-4796-be1f-a1798fec52cf")) assertEquals(1, countFieldsWithErrors(validation("a"))) - assertEquals("must be a valid UUID string", validation("").get()!![0]) + assertEquals("must be a valid UUID string", validation("").get()[0]) } @Test @@ -306,7 +307,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(mapValidation(emptyMap()))) - assertEquals("must have at least 1 items", validation(emptyList()).get()!![0]) + assertEquals("must have at least 1 items", validation(emptyList()).get()[0]) } @Test @@ -332,7 +333,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(mapValidation(mapOf("a" to 0, "b" to 1)))) - assertEquals("must have at most 1 items", mapValidation(mapOf("a" to 0, "b" to 1)).get()!![0]) + assertEquals("must have at most 1 items", mapValidation(mapOf("a" to 0, "b" to 1)).get()[0]) } @Test @@ -344,7 +345,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation(emptyMap()))) - assertEquals("must have at least 1 properties", validation(emptyMap()).get()!![0]) + assertEquals("must have at least 1 properties", validation(emptyMap()).get()[0]) } @Test @@ -356,7 +357,7 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(validation(mapOf("a" to 0, "b" to 1)))) - assertEquals("must have at most 1 properties", validation(mapOf("a" to 0, "b" to 1)).get()!![0]) + assertEquals("must have at most 1 properties", validation(mapOf("a" to 0, "b" to 1)).get()[0]) } @Test @@ -377,6 +378,6 @@ class ConstraintsTest { assertEquals(1, countFieldsWithErrors(arrayValidation(arrayOf("a", "a")))) - assertEquals("all items must be unique", validation(listOf("a", "a")).get()!![0]) + assertEquals("all items must be unique", validation(listOf("a", "a")).get()[0]) } } diff --git a/src/commonTest/kotlin/io/konform/validation/path/PathSegmentTest.kt b/src/commonTest/kotlin/io/konform/validation/path/PathSegmentTest.kt new file mode 100644 index 0000000..6ca34eb --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/path/PathSegmentTest.kt @@ -0,0 +1,50 @@ +package io.konform.validation.path + +import io.kotest.inspectors.forAll +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.test.assertEquals + +class PathSegmentTest { + @Test + fun toPathSegment() { + val mappings = + mapOf( + "fieldName" to PathValue("fieldName"), + 1 to PathIndex(1), + PathIndex(1) to PathIndex(1), + PathSegmentTest::class to PathClass(PathSegmentTest::class), + functionRef to FuncRef(functionRef), + mapKeyRef to PropRef(mapKeyRef), + null to PathValue(null), + ) + + mappings.forAll { (input, expected) -> + PathSegment.toPathSegment(input) shouldBe expected + } + } + + @Test + fun refFromDifferentContextShouldBeEqual() { + // We saw some differing behavior here between different Kotlin targets (mainly JS/WASM vs rest) + // when directly using callable references + + val ref2 = Map.Entry<*, *>::key + + assertEquals(PropRef(mapKeyRef), PropRef(ref2)) + assertEquals(PropRef(ref2), PropRef(mapKeyRef)) + } + + @Test + fun valueAndMapKeyShouldBeEqual() { + val pathValue = PathValue("abc") + val pathKey = PathKey("abc") + + assertEquals(pathKey, pathValue) + assertEquals(pathValue, pathKey) + assertEquals(pathKey.hashCode(), pathValue.hashCode()) + } +} + +private val mapKeyRef = Map.Entry<*, *>::key +private val functionRef = List<*>::isEmpty diff --git a/src/commonTest/kotlin/io/konform/validation/path/ValidationPathTest.kt b/src/commonTest/kotlin/io/konform/validation/path/ValidationPathTest.kt new file mode 100644 index 0000000..b2bcd45 --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/path/ValidationPathTest.kt @@ -0,0 +1,24 @@ +package io.konform.validation.path + +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class ValidationPathTest { + @Test + fun fromAny() { + ValidationPath.fromAny("abc", List<*>::isEmpty, 1) shouldBe + ValidationPath( + listOf(PathValue("abc"), FuncRef(List<*>::isEmpty), PathIndex(1)), + ) + } + + @Test + fun appendPrepend() { + val base = ValidationPath.fromAny("abc", "def") + + base + PathSegment.toPathSegment("ghj") shouldBe ValidationPath.fromAny("abc", "def", "ghj") + base + ValidationPath.fromAny(1, 2) shouldBe ValidationPath.fromAny("abc", "def", 1, 2) + base.prepend(PathSegment.toPathSegment(0)) shouldBe ValidationPath.fromAny(0, "abc", "def") + base.prepend(ValidationPath.fromAny(1, 2)) shouldBe ValidationPath.fromAny(1, 2, "abc", "def") + } +} diff --git a/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt b/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt index 7cb9442..b9922dd 100644 --- a/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt +++ b/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt @@ -4,18 +4,19 @@ package io.kotest.assertions.konform import io.konform.validation.Invalid -import io.konform.validation.PropertyValidationError import io.konform.validation.Valid import io.konform.validation.Validation import io.konform.validation.ValidationError -import io.konform.validation.kotlin.Path +import io.konform.validation.filterDataPath +import io.konform.validation.messagesAtDataPath +import io.konform.validation.path.ValidationPath import io.kotest.matchers.Matcher import io.kotest.matchers.MatcherResult +import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldNotContain -import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.should import io.kotest.matchers.shouldBe @@ -62,14 +63,19 @@ inline fun Validation.shouldBeInvalid( /** * Asserts that the validation result contains an error for the given field. - * @param field either a string with the full path or a property */ fun Invalid.shouldContainError( - field: Any, + path: ValidationPath, error: String, ) { - val path = Path.asPathOrToPath(field) - this.errors shouldContain PropertyValidationError(path, error) + this.errors shouldContain ValidationError(path, error) +} + +/** + * Asserts that the validation result contains an error for the given field. + */ +fun Invalid.shouldContainError(error: ValidationError) { + this.errors shouldContain error } /** @@ -81,29 +87,24 @@ fun Invalid.shouldContainError( error: String, ) { val array = propertyPaths.toTypedArray() - val path = Path.asPathOrToPath(*array) + val path = ValidationPath.fromAny(*array) // For a clearer error message this.shouldContainError(path, error) - val errors = this.get(*array) + val errors = this.errors.messagesAtDataPath(*array) errors.shouldNotBeNull() errors shouldContain error } fun Invalid.shouldNotContainErrorAt(vararg propertyPaths: Any) { - val path = Path.asPathOrToPath(*propertyPaths) + val path = ValidationPath.fromAny(*propertyPaths) this.errors.map { it.dataPath } shouldNotContain path - this[propertyPaths].shouldBeNull() + this.errors.filterDataPath(propertyPaths).shouldBeEmpty() } infix fun Invalid.shouldHaveErrorCount(count: Int) = this.errors shouldHaveSize count -fun Invalid.shouldContainExactlyErrors(vararg errors: ValidationError) = this.errors.shouldContainExactlyInAnyOrder(*errors) - -fun Invalid.shouldContainExactlyErrors(vararg errors: Pair) = - this.errors shouldContainExactlyInAnyOrder errors.map { PropertyValidationError(it.first, it.second) } - -infix fun Invalid.shouldContainExactlyErrors(errors: List) = this.errors shouldContainExactlyInAnyOrder errors - infix fun Invalid.shouldContainOnlyError(error: ValidationError) { this.errors shouldBe listOf(error) } + +fun Invalid.shouldContainExactlyErrors(vararg errors: ValidationError) = this.errors shouldContainExactlyInAnyOrder errors.toList() diff --git a/src/commonTest/kotlin/io/konform/validation/validationbuilder/InstanceOfTest.kt b/src/commonTest/kotlin/io/konform/validation/validationbuilder/InstanceOfTest.kt index 6ac3b8d..976ab33 100644 --- a/src/commonTest/kotlin/io/konform/validation/validationbuilder/InstanceOfTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/validationbuilder/InstanceOfTest.kt @@ -1,11 +1,13 @@ package io.konform.validation.validationbuilder -import io.konform.validation.PropertyValidationError import io.konform.validation.Validation +import io.konform.validation.ValidationError import io.konform.validation.constraints.notBlank +import io.konform.validation.path.PropRef +import io.konform.validation.path.ValidationPath import io.kotest.assertions.konform.shouldBeInvalid import io.kotest.assertions.konform.shouldBeValid -import io.kotest.assertions.konform.shouldContainExactlyErrors +import io.kotest.assertions.konform.shouldContainOnlyError import kotlin.test.Test class InstanceOfTest { @@ -43,10 +45,7 @@ class InstanceOfTest { ifCatValidation shouldBeValid null val invalid = ifCatValidation shouldBeInvalid invalidCat - invalid shouldContainExactlyErrors - listOf( - PropertyValidationError(".favoritePrey", "must not be blank"), - ) + invalid shouldContainOnlyError ValidationError.of(PropRef(Cat::favoritePrey), "must not be blank") } @Test @@ -54,25 +53,16 @@ class InstanceOfTest { requireCatValidation shouldBeValid validCat val invalidCatResult = requireCatValidation shouldBeInvalid invalidCat - invalidCatResult shouldContainExactlyErrors - listOf( - PropertyValidationError(".favoritePrey", "must not be blank"), - ) + invalidCatResult shouldContainOnlyError ValidationError.of(PropRef(Cat::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 expectedError = ValidationError(ValidationPath.EMPTY, "must be a 'Cat', was a 'Dog'") + validDogResult shouldContainOnlyError expectedError + invalidDogResult shouldContainOnlyError expectedError val nullResult = requireCatValidation shouldBeInvalid null - nullResult shouldContainExactlyErrors - listOf( - PropertyValidationError("", "must be a 'Cat', was a 'null'"), - ) + nullResult shouldContainOnlyError ValidationError(ValidationPath.EMPTY, "must be a 'Cat', was a 'null'") } } diff --git a/src/jsMain/kotlin/io/konform/validation/platform/CallableEquals.kt b/src/jsMain/kotlin/io/konform/validation/platform/CallableEquals.kt new file mode 100644 index 0000000..10e39c5 --- /dev/null +++ b/src/jsMain/kotlin/io/konform/validation/platform/CallableEquals.kt @@ -0,0 +1,9 @@ +package io.konform.validation.platform + +import kotlin.reflect.KCallable + +// For rare cases where we need to behave different between platforms +internal actual fun callableEquals( + first: KCallable<*>, + second: KCallable<*>, +): Boolean = first.name.filter { it.isLetterOrDigit() } == second.name.filter { it.isLetterOrDigit() } diff --git a/src/jvmMain/kotlin/io/konform/validation/platform/CallableEquals.kt b/src/jvmMain/kotlin/io/konform/validation/platform/CallableEquals.kt new file mode 100644 index 0000000..f173867 --- /dev/null +++ b/src/jvmMain/kotlin/io/konform/validation/platform/CallableEquals.kt @@ -0,0 +1,9 @@ +package io.konform.validation.platform + +import kotlin.reflect.KCallable + +// For rare cases where we need to behave different between platforms +internal actual fun callableEquals( + first: KCallable<*>, + second: KCallable<*>, +): Boolean = first == second diff --git a/src/nativeMain/kotlin/io/konform/validation/platform/CallableEquals.kt b/src/nativeMain/kotlin/io/konform/validation/platform/CallableEquals.kt new file mode 100644 index 0000000..f173867 --- /dev/null +++ b/src/nativeMain/kotlin/io/konform/validation/platform/CallableEquals.kt @@ -0,0 +1,9 @@ +package io.konform.validation.platform + +import kotlin.reflect.KCallable + +// For rare cases where we need to behave different between platforms +internal actual fun callableEquals( + first: KCallable<*>, + second: KCallable<*>, +): Boolean = first == second diff --git a/src/wasmJsMain/kotlin/io/konform/validation/platform/CallableEquals.kt b/src/wasmJsMain/kotlin/io/konform/validation/platform/CallableEquals.kt new file mode 100644 index 0000000..052fc2b --- /dev/null +++ b/src/wasmJsMain/kotlin/io/konform/validation/platform/CallableEquals.kt @@ -0,0 +1,9 @@ +package io.konform.validation.platform + +import kotlin.reflect.KCallable + +// For rare cases where we need to behave different between platforms +internal actual fun callableEquals( + first: KCallable<*>, + second: KCallable<*>, +): Boolean = first.name == second.name diff --git a/src/wasmWasiMain/kotlin/io/konform/validation/platform/CallableEquals.kt b/src/wasmWasiMain/kotlin/io/konform/validation/platform/CallableEquals.kt new file mode 100644 index 0000000..052fc2b --- /dev/null +++ b/src/wasmWasiMain/kotlin/io/konform/validation/platform/CallableEquals.kt @@ -0,0 +1,9 @@ +package io.konform.validation.platform + +import kotlin.reflect.KCallable + +// For rare cases where we need to behave different between platforms +internal actual fun callableEquals( + first: KCallable<*>, + second: KCallable<*>, +): Boolean = first.name == second.name