From 8465eb5deba8f5ed9bc421b515a3c1fcbdc568f6 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 10:43:02 +0200 Subject: [PATCH 01/19] MiMa added to build, derive macro modernized and improved --- build.sbt | 16 +- project/plugins.sbt | 6 + .../net/ghoula/valar/ValidationResult.scala | 23 +- .../scala/net/ghoula/valar/Validator.scala | 237 +++++++----------- .../ghoula/valar/internal/MacroHelper.scala | 25 ++ .../ghoula/valar/internal/MacroHelpers.scala | 110 -------- 6 files changed, 147 insertions(+), 270 deletions(-) create mode 100644 valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala delete mode 100644 valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelpers.scala diff --git a/build.sbt b/build.sbt index c77cad1..99de259 100644 --- a/build.sbt +++ b/build.sbt @@ -1,13 +1,9 @@ -// ===== Imports ===== import xerial.sbt.Sonatype.autoImport.* import xerial.sbt.Sonatype.{sonatypeCentralHost, sonatypeSettings} enablePlugins(SbtPgp) - import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} import scalanativecrossproject.ScalaNativeCrossPlugin.autoImport.* import scala.scalanative.build.* - -// mdoc documentation plugin import _root_.mdoc.MdocPlugin // ===== Build‑wide Settings ===== @@ -64,6 +60,10 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) name := "valar-core", usePgpKeyHex("9614A0CE1CE76975"), useGpgAgent := true, + // --- MiMa & TASTy-MiMa Configuration --- + mimaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8"), + tastyMiMaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8"), + // --- Library Dependencies --- libraryDependencies ++= Seq( "io.github.cquiroz" %%% "scala-java-time" % "2.6.0", "io.github.cquiroz" %%% "scala-java-time-tzdb" % "2.6.0", @@ -73,8 +73,12 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) .jvmSettings( mdocIn := file("docs-src"), mdocOut := file("."), + // --- Updated Check Command --- addCommandAlias("prepare", "scalafixAll; scalafmtAll; scalafmtSbt"), - addCommandAlias("check", "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck") + addCommandAlias( + "check", + "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck; mimaReportBinaryIssues; tastyMiMaReportIssues" + ) ) .jvmConfigure(_.enablePlugins(MdocPlugin)) .nativeSettings( @@ -93,6 +97,8 @@ lazy val valarMunit = crossProject(JVMPlatform, NativePlatform) name := "valar-munit", usePgpKeyHex("9614A0CE1CE76975"), useGpgAgent := true, + mimaPreviousArtifacts := Set.empty, + tastyMiMaPreviousArtifacts := Set.empty, libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" ) .nativeSettings( diff --git a/project/plugins.sbt b/project/plugins.sbt index de0938b..2b223de 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -16,3 +16,9 @@ addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") // Scala Native addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.8") + +// --- Compatibility Tools --- +// For binary compatibility checking +addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") +// For TASTy compatibility checking (for Scala 3 inlines/macros) +addSbtPlugin("ch.epfl.scala" % "sbt-tasty-mima" % "1.3.0") diff --git a/valar-core/src/main/scala/net/ghoula/valar/ValidationResult.scala b/valar-core/src/main/scala/net/ghoula/valar/ValidationResult.scala index 78587ca..6ae3b94 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/ValidationResult.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/ValidationResult.scala @@ -5,7 +5,6 @@ import scala.util.{Failure, Success, Try} import net.ghoula.valar import net.ghoula.valar.ValidationErrors.{ValidationError, ValidationException} -import net.ghoula.valar.internal.MacroHelpers /** Represents the outcome of a validation operation, containing either a successfully validated * value or validation errors. @@ -229,25 +228,21 @@ object ValidationResult { val resultB = validateType[B](value)(using vb, ctB) (resultA, resultB) match { - case (Valid(_), _) => MacroHelpers.upcastTo(resultA) - case (_, Valid(_)) => MacroHelpers.upcastTo(resultB) + case (Valid(_), _) => resultA + case (_, Valid(_)) => resultB case (Invalid(errsA), Invalid(errsB)) => val typeAName = ctA.runtimeClass.getSimpleName val typeBName = ctB.runtimeClass.getSimpleName val expectedTypes = s"$typeAName | $typeBName" val summaryMessage = s"Value failed validation for all expected types: $expectedTypes" - val allNestedErrors: Vector[ValidationError] = errsA ++ errsB - val combinedError: ValidationError = ValidationErrors.ValidationError( + val combinedError: ValidationError = ValidationError( message = summaryMessage, - fieldPath = Nil, - children = allNestedErrors, - code = None, - severity = None, + children = errsA ++ errsB, expected = Some(expectedTypes), actual = Some(value.toString) ) - ValidationResult.invalid(combinedError) + invalid(combinedError) } } @@ -443,11 +438,11 @@ object ValidationResult { * @return * The first successful result or combined errors */ - def or[B]( + def or[B >: A]( that: ValidationResult[B] - )(using acc: ErrorAccumulator[Vector[ValidationError]]): ValidationResult[A | B] = (vr, that) match { - case (Valid(_), _) => MacroHelpers.upcastTo(vr) - case (_, Valid(_)) => MacroHelpers.upcastTo(that) + )(using acc: ErrorAccumulator[Vector[ValidationError]]): ValidationResult[B] = (vr, that) match { + case (Valid(_), _) => vr + case (_, Valid(_)) => that case (Invalid(errsA), Invalid(errsB)) => Invalid(acc.combine(errsA, errsB)) } diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala index 778e1e3..89a1d2e 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala @@ -3,7 +3,6 @@ package net.ghoula.valar import java.time.{Duration, Instant, LocalDate, LocalDateTime, LocalTime, ZonedDateTime} import java.util.UUID import scala.collection.immutable.ArraySeq -import scala.compiletime.{constValueTuple, summonInline} import scala.deriving.Mirror import scala.language.reflectiveCalls import scala.quoted.{Expr, Quotes, Type} @@ -12,7 +11,7 @@ import scala.reflect.ClassTag import net.ghoula.valar.ValidationErrors.ValidationError import net.ghoula.valar.ValidationHelpers.* import net.ghoula.valar.ValidationResult.{validateUnion, given} -import net.ghoula.valar.internal.MacroHelpers +import net.ghoula.valar.internal.MacroHelper /** A typeclass for defining custom validation logic for type `A`. * @@ -307,114 +306,71 @@ object Validator { def validate(value: A | B): ValidationResult[A | B] = validateUnion[A, B](value)(using va, vb, ctA, ctB) } - /** Pass-through validator for Boolean. Always returns Valid. */ - given booleanValidator: Validator[Boolean] with { + /** This section provides "pass-through" `given` instances that always return `Valid`. They are + * marked as `inline` to allow the compiler to eliminate the validation overhead, making them + * zero-cost abstractions when used by the `deriveValidatorMacro`. + */ + inline given booleanValidator: Validator[Boolean] with { def validate(b: Boolean): ValidationResult[Boolean] = ValidationResult.Valid(b) } - /** Pass-through validator for Byte. Always returns Valid. */ - given byteValidator: Validator[Byte] with { + inline given byteValidator: Validator[Byte] with { def validate(b: Byte): ValidationResult[Byte] = ValidationResult.Valid(b) } - /** Pass-through validator for Short. Always returns Valid. */ - given shortValidator: Validator[Short] with { + inline given shortValidator: Validator[Short] with { def validate(s: Short): ValidationResult[Short] = ValidationResult.Valid(s) } - /** Pass-through validator for Long. Always returns Valid. */ - given longValidator: Validator[Long] with { + inline given longValidator: Validator[Long] with { def validate(l: Long): ValidationResult[Long] = ValidationResult.Valid(l) } - /** Pass-through validator for Char. Always returns Valid. */ - given charValidator: Validator[Char] with { + inline given charValidator: Validator[Char] with { def validate(c: Char): ValidationResult[Char] = ValidationResult.Valid(c) } - /** Pass-through validator for Unit. Always returns Valid. */ - given unitValidator: Validator[Unit] with { + inline given unitValidator: Validator[Unit] with { def validate(u: Unit): ValidationResult[Unit] = ValidationResult.Valid(u) } - /** Pass-through validator for BigInt. Always returns Valid. */ - given bigIntValidator: Validator[BigInt] with { + inline given bigIntValidator: Validator[BigInt] with { def validate(bi: BigInt): ValidationResult[BigInt] = ValidationResult.Valid(bi) } - /** Pass-through validator for BigDecimal. Always returns Valid. - * - * @note - * Scala's `BigDecimal` often relies on a `MathContext,` and representing constraints like - * finiteness or precision is complex for a default validator. Therefore, this default - * validator is pass-through. Users needing specific precision, range, or other checks for - * `BigDecimal` should define a custom `Validator[BigDecimal]` instance. - * @return - * A Validator[BigDecimal] that always returns Valid. - */ - given bigDecimalValidator: Validator[BigDecimal] with { + inline given bigDecimalValidator: Validator[BigDecimal] with { def validate(bd: BigDecimal): ValidationResult[BigDecimal] = ValidationResult.Valid(bd) } - /** Pass-through validator for Symbol. Always returns Valid. */ - given symbolValidator: Validator[Symbol] with { + inline given symbolValidator: Validator[Symbol] with { def validate(s: Symbol): ValidationResult[Symbol] = ValidationResult.Valid(s) } - /** ==Default Validators for Common Java Types== - * - * This section provides default, pass-through `Validator` instances for Java types that are - * frequently encountered in Scala data models, particularly within case classes used with - * `deriveValidatorMacro`. - * - * @note - * Rationale for Inclusion and Behavior: - * - **Ubiquity: ** These types (`java.util.UUID`, core `java.time.*`) are chosen because of - * their extremely common usage in Scala applications. - * - **Derivation Support: ** Providing these instances prevents compilation errors when - * deriving validators for case classes containing these types, reducing boilerplate for the - * user. - * - **Pass-Through Logic: ** These validators are simple "pass-through" validators (they - * always return `ValidationResult.Valid(value)`). They do not impose any validation rules - * beyond what the type system enforces. - * - **Extensibility: ** Users requiring specific validation logic for these types (e.g., - * checking the UUID version, ensuring an `Instant` is in the past) should define their own - * custom `given Validator[...]` instance, which will take precedence over these defaults - * according to implicit resolution rules. - */ - - /** Pass-through validator for java.util.UUID. Always returns Valid. */ - given uuidValidator: Validator[UUID] with { + inline given uuidValidator: Validator[UUID] with { def validate(v: UUID): ValidationResult[UUID] = ValidationResult.Valid(v) } - /** Pass-through validator for java.time.Instant. Always returns Valid. */ - given instantValidator: Validator[Instant] with { + inline given instantValidator: Validator[Instant] with { def validate(v: Instant): ValidationResult[Instant] = ValidationResult.Valid(v) } - /** Pass-through validator for java.time.LocalDate. Always returns Valid. */ - given localDateValidator: Validator[LocalDate] with { + inline given localDateValidator: Validator[LocalDate] with { def validate(v: LocalDate): ValidationResult[LocalDate] = ValidationResult.Valid(v) } - /** Pass-through validator for java.time.LocalTime. Always returns Valid. */ - given localTimeValidator: Validator[LocalTime] with { + inline given localTimeValidator: Validator[LocalTime] with { def validate(v: LocalTime): ValidationResult[LocalTime] = ValidationResult.Valid(v) } - /** Pass-through validator for java.time.LocalDateTime. Always returns Valid. */ - given localDateTimeValidator: Validator[LocalDateTime] with { + inline given localDateTimeValidator: Validator[LocalDateTime] with { def validate(v: LocalDateTime): ValidationResult[LocalDateTime] = ValidationResult.Valid(v) } - /** Pass-through validator for java.time.ZonedDateTime. Always returns Valid. */ - given zonedDateTimeValidator: Validator[ZonedDateTime] with { + inline given zonedDateTimeValidator: Validator[ZonedDateTime] with { def validate(v: ZonedDateTime): ValidationResult[ZonedDateTime] = ValidationResult.Valid(v) } - /** Pass-through validator for java.time.Duration. Always returns Valid. */ - given durationValidator: Validator[Duration] with { + inline given durationValidator: Validator[Duration] with { def validate(v: Duration): ValidationResult[Duration] = ValidationResult.Valid(v) } @@ -423,12 +379,6 @@ object Validator { * Derivation is recursive, validating each field using implicitly available validators. Errors * from nested fields are aggregated and annotated with clear field context. * - * Example usage: - * {{{ - * case class User(name: String, age: Int) - * given Validator[User] = deriveValidatorMacro - * }}} - * * @tparam T * case class type to derive validator for * @param m @@ -443,89 +393,94 @@ object Validator { m: Expr[Mirror.ProductOf[T]] )(using q: Quotes): Expr[Validator[T]] = { import q.reflect.* - if !(TypeRepr.of[Elems] <:< TypeRepr.of[Tuple]) then - report.errorAndAbort(s"deriveValidatorMacro: Expected Elems to be a Tuple type, but got ${Type.show[Elems]}") + + val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] + val fieldLabels: List[String] = getLabels[Labels] + val isOptionList: List[Boolean] = getIsOptionFlags[Elems] + + val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) + val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) + val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) '{ new Validator[T] { def validate(a: T): ValidationResult[T] = { - val productResult: Either[ValidationError, Product] = a match { - case product: Product => Right(product) - case other => Left(ValidationErrors.ValidationError(s"Expected Product type, got ${other.getClass}")) - } - - productResult match { - case Left(error) => ValidationResult.invalid(error) - case Right(product) => - val elems: Elems = MacroHelpers.upcastTo[Elems](Tuple.fromProduct(product)) - val labels: Labels = constValueTuple[Labels] - val validatedElemsResult: ValidationResult[Elems] = ${ - validateTupleWithLabelsMacro[Elems, Labels]('{ elems }, '{ labels }) + a match { + case product: Product => + val validators = ${ validatorsExpr } + val labels = ${ fieldLabelsExpr } + val isOptionFlags = ${ isOptionListExpr } + + val results = product.productIterator.zipWithIndex.map { case (fieldValue, i) => + val label = labels(i) + val isOption = isOptionFlags(i) + + if (Option(fieldValue).isEmpty && !isOption) { + ValidationResult.invalid( + ValidationError( + message = s"Field '$label' must not be null.", + fieldPath = List(label), + expected = Some("non-null value"), + actual = Some("null") + ) + ) + } else { + val validator = validators(i) + validator.validate(fieldValue) match { + case ValidationResult.Valid(v) => ValidationResult.Valid(v) + case ValidationResult.Invalid(errs) => + val fieldTypeName = Option(fieldValue).map(_.getClass.getSimpleName).getOrElse("null") + ValidationResult.Invalid(errs.map(_.annotateField(label, fieldTypeName))) + } + } + }.toList + + val allErrors = results.collect { case ValidationResult.Invalid(e) => e }.flatten.toVector + if (allErrors.isEmpty) { + val validValues = results.collect { case ValidationResult.Valid(v) => v } + ValidationResult.Valid($m.fromProduct(Tuple.fromArray(validValues.toArray))) + } else { + ValidationResult.Invalid(allErrors) } - validatedElemsResult.map { validatedElems => $m.fromProduct(validatedElems) } } } } } } - private def validateTupleWithLabelsMacro[Elems <: Tuple: Type, Labels <: Tuple: Type]( - values: Expr[Elems], - labels: Expr[Labels] - )(using q: Quotes): Expr[ValidationResult[Elems]] = { + private def summonValidators[Elems <: Tuple: Type](using q: Quotes): List[Expr[Validator[Any]]] = { import q.reflect.* - (TypeRepr.of[Elems].dealias, TypeRepr.of[Labels].dealias) match { - case (elemsType, _) if elemsType <:< TypeRepr.of[EmptyTuple] => - '{ ValidationResult.Valid(MacroHelpers.upcastTo[Elems](EmptyTuple)) } - case (elemsRepr, labelsRepr) => - elemsRepr.asType match { - case '[h *: tElems] => - labelsRepr.asType match { - case '[String *: tLabels] => - val headTypeRepr: TypeRepr = TypeRepr.of[h] - val typeSymbol: Symbol = headTypeRepr.typeSymbol - val fieldTypeNameString: String = typeSymbol.name - val fieldTypeNameExpr: Expr[String] = Expr(fieldTypeNameString) - val isOption = TypeRepr.of[h] <:< TypeRepr.of[Option[Any]] - val isOptionExpr: Expr[Boolean] = Expr(isOption) - '{ - val rawHead = $values.head - if (Option(MacroHelpers.upcastTo[Any](rawHead)).isEmpty && !${ isOptionExpr }) { - val headLabelNull: String = Option(MacroHelpers.upcastTo[String]($labels.head)).getOrElse("unknown") - val nullError = ValidationErrors.ValidationError( - message = s"Field '$headLabelNull' must not be null.", - fieldPath = List(headLabelNull), - expected = Some("non-null value"), - actual = Some("null") - ) - ValidationResult.invalid(nullError) - } else { - val head: h = MacroHelpers.castValue[h](rawHead) - val tail: tElems = MacroHelpers.upcastTo[tElems]($values.tail) - val headLabel: String = Option(MacroHelpers.upcastTo[String]($labels.head)).getOrElse("unknown") - val tailLabels: tLabels = Option(MacroHelpers.upcastTo[tLabels]($labels.tail)) - .getOrElse(MacroHelpers.upcastTo[tLabels](EmptyTuple)) - val fieldTypeNameValue: String = ${ fieldTypeNameExpr } - val headValidation: ValidationResult[h] = - summonInline[Validator[h]].validate(head) match { - case ValidationResult.Valid(v) => ValidationResult.Valid(v) - case ValidationResult.Invalid(errs) => - ValidationResult.Invalid( - errs.map(e => e.annotateField(headLabel, fieldTypeNameValue)) - ) - } - val tailValidation: ValidationResult[tElems] = ${ - validateTupleWithLabelsMacro[tElems, tLabels]('{ tail }, '{ tailLabels }) - } - headValidation.zip(tailValidation).map { case (hValidated, tValidated) => - MacroHelpers.upcastTo[Elems](hValidated *: tValidated) - } - } - } - case _ => report.errorAndAbort("Labels tuple...") - } - case _ => report.errorAndAbort("Unsupported elements tuple type...") + Type.of[Elems] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + val validatorExpr = Expr.summon[Validator[h]].getOrElse { + report.errorAndAbort(s"Could not find a given Validator for type ${Type.show[h]}") } + '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] + } + } + + private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { + import q.reflect.* + def loop(tpe: TypeRepr): List[String] = tpe.dealias match { + case AppliedType(_, List(head, tail)) => + head match { + case ConstantType(StringConstant(label)) => label :: loop(tail) + case _ => report.errorAndAbort(s"Macro error: Expected a literal string for a label, but got ${head.show}") + } + case t if t =:= TypeRepr.of[EmptyTuple] => Nil + case _ => report.errorAndAbort(s"Macro error: The labels tuple was not structured as expected: ${tpe.show}") + } + + loop(TypeRepr.of[Labels]) + } + + private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { + import q.reflect.* + Type.of[Elems] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] } } } diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala new file mode 100644 index 0000000..01c1b8f --- /dev/null +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala @@ -0,0 +1,25 @@ +package net.ghoula.valar.internal + +/** Internal utility for type casting operations required by Valar's macro system. + * + * This function exists to handle specific, unavoidable casting scenarios in macros while + * suppressing linter warnings for `asInstanceOf`. + * + * '''Safety Contract''': This function should only be used when macro logic guarantees type + * compatibility. Incorrect usage will result in a `ClassCastException` at runtime. + */ +object MacroHelper { + + /** Casts a value to a specific type `T` when the type is guaranteed by macro logic. + * + * @param x + * The value to cast. + * @tparam T + * The target type. + * @return + * The value `x` cast to type `T`. + */ + @SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) + inline def upcastTo[T](x: Any): T = x.asInstanceOf[T] + +} diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelpers.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelpers.scala deleted file mode 100644 index cc61b41..0000000 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelpers.scala +++ /dev/null @@ -1,110 +0,0 @@ -package net.ghoula.valar.internal - -import scala.annotation.unused -import scala.quoted.{Expr, Quotes, Type} - -import net.ghoula.valar.Validator - -/** Internal utilities for type casting operations required by Valar's macro system. - * - * These functions exist to handle three specific scenarios where unsafe casting is unavoidable: - * 1. **Macro compilation**: Bridging between compile-time types and runtime values - * 2. **Union type validation**: Working around type erasure when validating `A | B` types - * 3. **Generic tuple handling**: Converting between `Any` and specific tuple types in derived - * validators - * - * '''Safety Contract''': All functions in this object perform unchecked type casts using - * `asInstanceOf`. They should only be used when the macro logic or type system guarantees type - * compatibility. Incorrect usage will result in `ClassCastException` at runtime. - * - * '''Naming Convention''': Functions are prefixed with "upcast" to emphasize that they're casting - * from a more general type to a more specific one (e.g., `Any` to `T`). - */ -object MacroHelpers { - - /** Casts a value from `Any` to a specific type `T`. - * - * This is the primary casting function used throughout Valar's macro system. It's typically used - * when macro logic has determined the correct type at compile time, but the runtime value is - * typed as `Any` due to type erasure. - * - * @param x - * The value to cast (typically from macro-generated code) - * @tparam T - * The target type (must be correct or will throw at runtime) - * @return - * The value `x` cast to type `T` - * @throws ClassCastException - * if `x` is not of type `T` - */ - @SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) - def upcastTo[T](x: Any): T = x.asInstanceOf[T] - - /** Internal casting utilities for specific macro contexts. */ - private[internal] object Upcast { - - /** Generic casting between any two types. - * - * More dangerous than `upcastTo` as it doesn't require the source type to be `Any`. Only used - * internally where macro logic guarantees type relationships. - */ - @SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) - def apply[T, U](x: T): U = x.asInstanceOf[U] - - /** Casts a typed validator to accept `Any` input. - * - * Used in union type validation where we need to apply a `Validator[A]` to a value of an - * unknown type. The macro ensures the value is actually of type `A` before this cast. - */ - @SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) - def validator[A](v: Validator[A]): Validator[Any] = - v.asInstanceOf[Validator[Any]] - } - import Upcast.{apply as upcastApply, validator as upcastValidatorHelper} - - /** Casts a quoted expression within the macro context. - * - * Used when macro code needs to change the type of `quoted.Expr` from `Any` to a specific type - * `T`. The macro system guarantees type safety through compile-time analysis. - * - * @param expr - * The expression to cast - * @tparam T - * The target type (with implicit `Type` evidence) - * @return - * The expression cast to `Expr[T]` - */ - def castExpr[T: Type](expr: Expr[Any])(using @unused quotes: Quotes): Expr[T] = - upcastApply[Expr[Any], Expr[T]](expr) - - /** Alias for `upcastTo` with a more descriptive name for runtime value casting. - * - * @param value - * The runtime value to cast - * @tparam T - * The target type - * @return - * The value cast to type `T` - */ - def castValue[T](value: Any): T = upcastTo[T](value) - - /** Internal helper for validator casting in derived instances. */ - private inline def upcastValidatorInternal[A](v: Validator[A]): Validator[Any] = - upcastValidatorHelper(v) - - /** Casts a validator for use in union type validation. - * - * This is specifically used in `validateUnion` where we need to apply validators of different - * types to the same input value. The union validation logic ensures the cast is safe by checking - * type compatibility before applying the validator. - * - * @param v - * The validator to cast - * @tparam A - * The original validator type - * @return - * A validator that accepts `Any` input - */ - inline def upcastUnionValidator[A](v: Validator[A]): Validator[Any] = - upcastValidatorInternal(v) -} From 64eba45955ecd34d30955e4213faff2ab25ff978 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 10:58:12 +0200 Subject: [PATCH 02/19] Whats up scaladoc --- .../scala/net/ghoula/valar/Validator.scala | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala index 89a1d2e..9151da7 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala @@ -207,6 +207,24 @@ object Validator { } /** Helper method for validating iterable collections and building results. + * + * This method provides a generic implementation for validating any iterable collection of + * elements. It applies the provided validator to each element, accumulates any validation + * errors, and constructs a new collection of the same type containing only valid elements if all + * validations succeed. + * + * @tparam A + * the element type to be validated + * @tparam C + * the collection type constructor (e.g., Array, Vector) + * @param xs + * the iterable collection of elements to validate + * @param builder + * a function that constructs a collection of type C from a Vector of valid elements + * @param v + * the implicit validator for type A + * @return + * a ValidationResult containing either the valid collection or accumulated errors */ private def validateIterable[A, C[_]]( xs: Iterable[A], @@ -310,66 +328,147 @@ object Validator { * marked as `inline` to allow the compiler to eliminate the validation overhead, making them * zero-cost abstractions when used by the `deriveValidatorMacro`. */ + + /** Pass-through validator for Boolean values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given booleanValidator: Validator[Boolean] with { def validate(b: Boolean): ValidationResult[Boolean] = ValidationResult.Valid(b) } + /** Pass-through validator for Byte values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given byteValidator: Validator[Byte] with { def validate(b: Byte): ValidationResult[Byte] = ValidationResult.Valid(b) } + /** Pass-through validator for Short values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given shortValidator: Validator[Short] with { def validate(s: Short): ValidationResult[Short] = ValidationResult.Valid(s) } + /** Pass-through validator for Long values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given longValidator: Validator[Long] with { def validate(l: Long): ValidationResult[Long] = ValidationResult.Valid(l) } + /** Pass-through validator for Char values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given charValidator: Validator[Char] with { def validate(c: Char): ValidationResult[Char] = ValidationResult.Valid(c) } + /** Pass-through validator for Unit values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given unitValidator: Validator[Unit] with { def validate(u: Unit): ValidationResult[Unit] = ValidationResult.Valid(u) } + /** Pass-through validator for BigInt values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given bigIntValidator: Validator[BigInt] with { def validate(bi: BigInt): ValidationResult[BigInt] = ValidationResult.Valid(bi) } + /** Pass-through validator for BigDecimal values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given bigDecimalValidator: Validator[BigDecimal] with { def validate(bd: BigDecimal): ValidationResult[BigDecimal] = ValidationResult.Valid(bd) } + /** Pass-through validator for Symbol values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given symbolValidator: Validator[Symbol] with { def validate(s: Symbol): ValidationResult[Symbol] = ValidationResult.Valid(s) } + /** Pass-through validator for UUID values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given uuidValidator: Validator[UUID] with { def validate(v: UUID): ValidationResult[UUID] = ValidationResult.Valid(v) } + /** Pass-through validator for Instant values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given instantValidator: Validator[Instant] with { def validate(v: Instant): ValidationResult[Instant] = ValidationResult.Valid(v) } + /** Pass-through validator for LocalDate values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given localDateValidator: Validator[LocalDate] with { def validate(v: LocalDate): ValidationResult[LocalDate] = ValidationResult.Valid(v) } + /** Pass-through validator for LocalTime values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given localTimeValidator: Validator[LocalTime] with { def validate(v: LocalTime): ValidationResult[LocalTime] = ValidationResult.Valid(v) } + /** Pass-through validator for LocalDateTime values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given localDateTimeValidator: Validator[LocalDateTime] with { def validate(v: LocalDateTime): ValidationResult[LocalDateTime] = ValidationResult.Valid(v) } + /** Pass-through validator for ZonedDateTime values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given zonedDateTimeValidator: Validator[ZonedDateTime] with { def validate(v: ZonedDateTime): ValidationResult[ZonedDateTime] = ValidationResult.Valid(v) } + /** Pass-through validator for Duration values. + * + * Always returns a valid result without additional validation. Marked as `inline` for compiler + * optimization. + */ inline given durationValidator: Validator[Duration] with { def validate(v: Duration): ValidationResult[Duration] = ValidationResult.Valid(v) } @@ -389,6 +488,27 @@ object Validator { inline def deriveValidatorMacro[T](using m: Mirror.ProductOf[T]): Validator[T] = ${ deriveValidatorMacroImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } + /** Implementation of the `deriveValidatorMacro` method. + * + * This macro implementation generates a validator for a product type (case class) by: + * 1. Summoning validators for each field. + * 2. Extracting field names from the mirror. + * 3. Determining which fields are Options - for null-safety handling. + * 4. Generating code that validates each field and accumulates errors. + * + * @tparam T + * the product type (case class) for which to derive a validator + * @tparam Elems + * tuple type representing the types of all fields in T + * @tparam Labels + * tuple type representing the names of all fields in T + * @param m + * expression containing the product mirror for type T + * @param q + * the Quotes context for macro expansion + * @return + * an expression that constructs a Validator[T] + */ private def deriveValidatorMacroImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( m: Expr[Mirror.ProductOf[T]] )(using q: Quotes): Expr[Validator[T]] = { @@ -448,6 +568,19 @@ object Validator { } } + /** Summons validators for each element type in a tuple. + * + * This helper method is used by the macro implementation to get validator instances for each + * field in a product type. It recursively processes the tuple of element types, summoning a + * validator for each type and converting it to Validator[Any] for uniform handling. + * + * @tparam Elems + * tuple type representing the types for which to summon validators + * @param q + * the Quotes context for macro expansion + * @return + * a list of expressions, each constructing a Validator[Any] + */ private def summonValidators[Elems <: Tuple: Type](using q: Quotes): List[Expr[Validator[Any]]] = { import q.reflect.* Type.of[Elems] match { @@ -460,6 +593,19 @@ object Validator { } } + /** Extracts field names from a tuple type of string literals. + * + * This helper method is used by the macro implementation to obtain the names of fields in a + * product type. It recursively processes the tuple of string literal types, extracting each name + * as a String. + * + * @tparam Labels + * tuple type of string literals representing field names + * @param q + * the Quotes context for macro expansion + * @return + * a list of field names as strings + */ private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { import q.reflect.* def loop(tpe: TypeRepr): List[String] = tpe.dealias match { @@ -475,6 +621,18 @@ object Validator { loop(TypeRepr.of[Labels]) } + /** Determines which fields in a product type are Options. + * + * This helper method is used by the macro implementation to identify which fields in a product + * type are Option types. This information is used for null-safety handling during validation. + * + * @tparam Elems + * tuple type representing the types of all fields + * @param q + * the Quotes context for macro expansion + * @return + * a list of booleans indicating whether each field is an Option type + */ private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { import q.reflect.* Type.of[Elems] match { From 83056c25e882440f62172cace4a3c627be88a498 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 11:01:26 +0200 Subject: [PATCH 03/19] update CI --- .github/workflows/scala.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml index f3e77df..c259d3b 100644 --- a/.github/workflows/scala.yml +++ b/.github/workflows/scala.yml @@ -14,17 +14,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: '17' + java-version: '21' cache: 'sbt' - name: Set up sbt uses: sbt/setup-sbt@v1 - - name: Check formatting and code style + - name: Run all checks (style, formatting, API compatibility) run: sbt check - name: Run all tests on JVM @@ -51,11 +51,11 @@ jobs: with: fetch-depth: 0 # Fetch full history for dynver/release notes - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: '17' + java-version: '21' cache: 'sbt' - name: Set up sbt launcher From 24521dcbed7a50080dccfbf335bc2faa11f8396c Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 14:18:01 +0200 Subject: [PATCH 04/19] the observer --- build.sbt | 1 - .../net/ghoula/valar/ValidationObserver.scala | 90 +++++++++++++++++++ .../ghoula/valar/ValidationObserverSpec.scala | 61 +++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala create mode 100644 valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala diff --git a/build.sbt b/build.sbt index 99de259..e517fd9 100644 --- a/build.sbt +++ b/build.sbt @@ -73,7 +73,6 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) .jvmSettings( mdocIn := file("docs-src"), mdocOut := file("."), - // --- Updated Check Command --- addCommandAlias("prepare", "scalafixAll; scalafmtAll; scalafmtSbt"), addCommandAlias( "check", diff --git a/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala new file mode 100644 index 0000000..700d2e0 --- /dev/null +++ b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala @@ -0,0 +1,90 @@ +package net.ghoula.valar +import net.ghoula.valar.ValidationResult + +/** Defines a contract for observing the outcomes of validation processes. + * + * This typeclass provides a powerful mechanism to decouple validation logic from cross-cutting + * concerns such as logging, metrics collection, or auditing. By implementing this trait and + * providing it as a `given` instance, developers can seamlessly integrate Valar with external + * monitoring and diagnostic systems. + * + * @see + * [[ValidationObserver.noOpObserver]] for the default, zero-overhead implementation that is used + * when no custom observer is provided. + * + * @example + * {{{ import org.slf4j.LoggerFactory + * + * // An example implementation that logs validation results using SLF4J. given loggingObserver: + * ValidationObserver with { private val logger = LoggerFactory.getLogger("ValidationAnalytics") + * + * def onResult[A](result: ValidationResult[A]): Unit = result match { case + * ValidationResult.Valid(_) => logger.info("Validation succeeded.") case + * ValidationResult.Invalid(errors) => logger.warn(s"Validation failed with ${errors.size} errors: + * ${errors.map(_.message).mkString(", ")}") } } + * + * // Now, any call to .observe() will automatically use the loggingObserver. val result = + * someValidation().observe() }}} + */ +trait ValidationObserver { + + /** A callback executed for each `ValidationResult` passed to the `observe` method. + * + * Implementations of this method can inspect the result and trigger side-effects, such as + * writing to a log, incrementing a metrics counter, or sending an alert. This method should not + * throw exceptions. + * + * @tparam A + * The type of the value within the ValidationResult. + * @param result + * The `ValidationResult` to be observed. + */ + def onResult[A](result: ValidationResult[A]): Unit +} + +object ValidationObserver { + + /** The default, "no-op" `ValidationObserver` that performs no action. + * + * This instance is provided as an `inline given`. This is a critical optimization feature. When + * this default observer is in scope, the Scala compiler, in conjunction with the `inline` + * `observe()` extension method, will perform full dead-code elimination. + * + * This ensures that the observability feature is truly zero-cost and has no performance overhead + * unless a custom `ValidationObserver` is explicitly provided. + */ + inline given noOpObserver: ValidationObserver with { + def onResult[A](result: ValidationResult[A]): Unit = () // No operation + } +} + +extension [A](vr: ValidationResult[A]) { + + /** Applies the in-scope `ValidationObserver` to this `ValidationResult`. + * + * This extension method enables side-effecting operations on a validation result without + * altering the flow of validation logic. It returns the original result, allowing for seamless + * method chaining. + * + * This method is declared `inline` to facilitate powerful compile-time optimizations. If the + * default [[ValidationObserver.noOpObserver]] is in scope, the compiler will eliminate this + * entire method call from the generated bytecode. + * + * @param observer + * The `ValidationObserver` instance provided by the implicit context. + * @return + * The original, unmodified `ValidationResult`, to allow for method chaining. + * @example + * {{{ import net.ghoula.valar.Validator import net.ghoula.valar.ValidationResult + * + * def validateUsername(name: String): ValidationResult[String] = ??? + * + * // Assuming a `given ValidationObserver` is in scope val result = + * validateUsername("test-user") .observe() // The observer's onResult is called here + * .map(_.toUpperCase) }}} + */ + inline def observe()(using observer: ValidationObserver): ValidationResult[A] = { + observer.onResult(vr) + vr + } +} diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala new file mode 100644 index 0000000..2cdf1e1 --- /dev/null +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala @@ -0,0 +1,61 @@ +package net.ghoula.valar + +import munit.FunSuite + +import scala.collection.mutable.ListBuffer + +import net.ghoula.valar.ValidationErrors.ValidationError + +/** Verifies the behavior of the `ValidationObserver` typeclass and its `observe` extension method. + * + * This spec ensures that: + * - The default `noOpObserver` is a transparent, zero-cost operation when no custom observer is + * in scope. + * - A custom `given` `ValidationObserver` is correctly invoked for both `Valid` and `Invalid` + * results. + * - The `observe` method faithfully returns the original `ValidationResult` to preserve method + * chaining. + */ +class ValidationObserverSpec extends FunSuite { + + /** A mock observer that records any results passed to its `onResult` method. */ + private class TestObserver extends ValidationObserver { + val observedResults: ListBuffer[ValidationResult[?]] = ListBuffer() + override def onResult[A](result: ValidationResult[A]): Unit = { + observedResults += result + } + } + + test("observe should be transparent when using the default no-op observer") { + val validResult = ValidationResult.Valid(42) + assertEquals(validResult.observe(), validResult) + + val invalidResult = ValidationResult.invalid(ValidationError("An error")) + assertEquals(invalidResult.observe(), invalidResult) + } + + test("observe should invoke a custom observer for a Valid result") { + val testObserver = new TestObserver + given customObserver: ValidationObserver = testObserver + + val validResult = ValidationResult.Valid("success") + val returnedResult = validResult.observe() + + assertEquals(testObserver.observedResults.size, 1) + assertEquals(testObserver.observedResults.head, validResult) + assertEquals(returnedResult, validResult) + } + + test("observe should invoke a custom observer for an Invalid result") { + val testObserver = new TestObserver + given customObserver: ValidationObserver = testObserver + + val error = ValidationError("A critical failure") + val invalidResult = ValidationResult.invalid(error) + val returnedResult = invalidResult.observe() + + assertEquals(testObserver.observedResults.size, 1) + assertEquals(testObserver.observedResults.head, invalidResult) + assertEquals(returnedResult, invalidResult) + } +} From abe90281defa9c48a8ac61b0f64ce8a1d66a232d Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 17:16:22 +0200 Subject: [PATCH 05/19] d w i p --- build.sbt | 41 +++++++++- docs-src/munit/README.md | 0 docs-src/translator/README.md | 79 ++++++++++++++++++ .../ghoula/valar/translator/Translator.scala | 48 +++++++++++ .../valar/translator/TranslatorSpec.scala | 81 +++++++++++++++++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 docs-src/munit/README.md create mode 100644 docs-src/translator/README.md create mode 100644 valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala create mode 100644 valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala diff --git a/build.sbt b/build.sbt index e517fd9..de3a0b7 100644 --- a/build.sbt +++ b/build.sbt @@ -45,7 +45,9 @@ lazy val root = (project in file(".")) valarCoreJVM, valarCoreNative, valarMunitJVM, - valarMunitNative + valarMunitNative, + valarTranslatorJVM, + valarTranslatorNative ) .settings( name := "valar-root", @@ -100,6 +102,41 @@ lazy val valarMunit = crossProject(JVMPlatform, NativePlatform) tastyMiMaPreviousArtifacts := Set.empty, libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" ) + .jvmSettings( + mdocIn := file("docs-src/munit"), + mdocOut := file("valar-munit"), + mdocVariables := Map( + "VERSION" -> version.value, + "SCALA_VERSION" -> scalaVersion.value + ) + ) + .jvmConfigure(_.enablePlugins(MdocPlugin)) + .nativeSettings( + testFrameworks += new TestFramework("munit.Framework") + ) + +lazy val valarTranslator = crossProject(JVMPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("valar-translator")) + .dependsOn(valarCore) + .settings(sonatypeSettings *) + .settings( + name := "valar-translator", + usePgpKeyHex("9614A0CE1CE76975"), + useGpgAgent := true, + mimaPreviousArtifacts := Set.empty, + tastyMiMaPreviousArtifacts := Set.empty, + libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" % Test + ) + .jvmSettings( + mdocIn := file("docs-src/translator"), + mdocOut := file("valar-translator"), + mdocVariables := Map( + "VERSION" -> version.value, + "SCALA_VERSION" -> scalaVersion.value + ) + ) + .jvmConfigure(_.enablePlugins(MdocPlugin)) .nativeSettings( testFrameworks += new TestFramework("munit.Framework") ) @@ -109,3 +146,5 @@ lazy val valarCoreJVM = valarCore.jvm lazy val valarCoreNative = valarCore.native lazy val valarMunitJVM = valarMunit.jvm lazy val valarMunitNative = valarMunit.native +lazy val valarTranslatorJVM = valarTranslator.jvm +lazy val valarTranslatorNative = valarTranslator.native diff --git a/docs-src/munit/README.md b/docs-src/munit/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docs-src/translator/README.md b/docs-src/translator/README.md new file mode 100644 index 0000000..84bfb9a --- /dev/null +++ b/docs-src/translator/README.md @@ -0,0 +1,79 @@ +Thank you, that's a critical detail to get right in the documentation. You are correct; the `ValidationObserver` is a foundational pattern that lives in `valar-core`, making it available to all users. + +This makes the `valar-translator` module a perfect example of an optional, specialized extension that builds upon and composes with the core library's features. + +I'll update the `README.md` to reflect this relationship accurately. + +----- + +### Final README.md for `valar-translator` + +````markdown +# valar-translator + +The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. + +## Usage + +The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. + +### 1. Implement the `Translator` Trait + +Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. + +```scala +import net.ghoula.valar.translator.Translator +import net.ghoula.valar.ValidationErrors.ValidationError + +// Assuming you have an I18n library +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key and format with its arguments + I18n.lookup( + error.key.getOrElse("error.unknown"), + error.args + ) + } +} +```` + +### 2\. Call `translateErrors()` + +Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. + +```scala +val result = User.validate(someData) // An Invalid ValidationResult +val translatedResult = result.translateErrors() + +// translatedResult now contains errors with localized messages +``` + +----- + +## Composing with Core Features (like ValidationObserver) + +The `valar-translator` module is designed to compose cleanly with features from the core library. A prime example is the **`ValidationObserver` pattern**, a general-purpose tool for side-effects (like logging or metrics) that is **available directly in `valar-core`**. + +While the two patterns serve different purposes, they can be chained together for a powerful workflow: + +* **`ValidationObserver` (Side-Effect)**: Reacts to a result without changing it. +* **`Translator` (Data Transformation)**: Refines a result by localizing error messages. + +A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. + +```scala +// Given a defined `metricsObserver` from your application +// and a `myTranslator` from this module... + +val result = User.validate(invalidUser) + // 1. First, observe the raw result using the core ValidationObserver. + .observe() + // 2. Then, translate the errors for presentation using the Translator. + .translateErrors() + +// The final `result` contains user-friendly, translated messages, +// while the original, structured error was sent to your metrics system. +``` + +``` +``` \ No newline at end of file diff --git a/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala b/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala new file mode 100644 index 0000000..d470b19 --- /dev/null +++ b/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala @@ -0,0 +1,48 @@ +package net.ghoula.valar.translator + +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.ValidationResult + +/** A typeclass that defines how to translate a ValidationError into a human-readable string. + * Implement this to integrate with i18n libraries. + */ +trait Translator { + + /** Translates a single validation error. + * @param error + * The structured ValidationError containing the key, args, and default message. + * @return + * A localized string message. + */ + def translate(error: ValidationError): String +} + +extension [A](vr: ValidationResult[A]) { + + /** Translates all errors within an Invalid result using the in-scope Translator. If the result is + * Valid, it is returned unchanged. + * + * @param translator + * The given Translator instance. + * @return + * A new ValidationResult with translated error messages. + */ + def translateErrors()(using translator: Translator): ValidationResult[A] = { + vr match { + case ValidationResult.Valid(a) => ValidationResult.Valid(a) + case ValidationResult.Invalid(errors) => + val translatedErrors = errors.map { err => + ValidationError( + message = translator.translate(err), + fieldPath = err.fieldPath, + children = err.children, + code = err.code, + severity = err.severity, + expected = err.expected, + actual = err.actual + ) + } + ValidationResult.Invalid(translatedErrors) + } + } +} diff --git a/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala b/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala new file mode 100644 index 0000000..a6cd430 --- /dev/null +++ b/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala @@ -0,0 +1,81 @@ +package net.ghoula.valar.translator + +import munit.FunSuite +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.ValidationResult + +/** Provides a comprehensive test suite for the [[Translator]] typeclass and its associated + * `translateErrors` extension method. + * + * This specification validates the core functionalities of the translation mechanism. It ensures + * that `Valid` instances are returned without modification and that `Invalid` instances have their + * error messages properly translated by the in-scope `Translator`. + * + * The suite also confirms the integrity of `ValidationError` objects post-translation, verifying + * that all properties, such as `fieldPath`, `code`, and `severity`, are preserved. Finally, it + * guarantees that the translation is not applied recursively to nested child errors, maintaining + * the original state of the error hierarchy. + */ +class TranslatorSpec extends FunSuite { + + test("translateErrors on a Valid result should return the instance unchanged") { + given failingTranslator: Translator = (error: ValidationError) => { + fail(s"Translator should not be invoked for a Valid result, but was called for: ${error.message}") + "" // This line is unreachable but required for type correctness + } + + val validResult = ValidationResult.Valid("all good") + val result = validResult.translateErrors() + + assertEquals(result, validResult) + } + + test("translateErrors on an Invalid result should translate messages and preserve all other properties") { + given testTranslator: Translator = (error: ValidationError) => s"translated: ${error.message}" + + val originalError = ValidationError( + message = "A test error", + fieldPath = List("user", "email"), + children = Vector(ValidationError("A nested error")), + code = Some("E-101"), + severity = Some("Warning"), + expected = Some("a valid email"), + actual = Some("not-an-email") + ) + val invalidResult = ValidationResult.invalid(originalError) + + val translatedResult = invalidResult.translateErrors() + + translatedResult match { + case ValidationResult.Valid(_) => fail("Expected an Invalid result but got a Valid one.") + case ValidationResult.Invalid(Vector(translatedError)) => + assertEquals(translatedError.message, "translated: A test error") + assertEquals(translatedError.fieldPath, originalError.fieldPath) + assertEquals(translatedError.children, originalError.children) + assertEquals(translatedError.code, originalError.code) + assertEquals(translatedError.severity, originalError.severity) + assertEquals(translatedError.expected, originalError.expected) + assertEquals(translatedError.actual, originalError.actual) + case _ => fail("Expected an Invalid result with a single error.") + } + } + + test("translateErrors should not apply translation recursively to nested child errors") { + given testTranslator: Translator = (error: ValidationError) => s"translated: ${error.message}" + + val childError = ValidationError("This is a child error") + val parentError = ValidationError("This is a parent error", children = Vector(childError)) + val invalidResult = ValidationResult.invalid(parentError) + + val translatedResult = invalidResult.translateErrors() + + translatedResult match { + case ValidationResult.Valid(_) => fail("Expected an Invalid result but got a Valid one.") + case ValidationResult.Invalid(Vector(translatedParent)) => + assertEquals(translatedParent.message, "translated: This is a parent error") + assertEquals(translatedParent.children.headOption, Some(childError)) + assertEquals(translatedParent.children.head.message, "This is a child error") + case _ => fail("Expected an Invalid result with a single parent error.") + } + } +} From 43f6ecc1c5d8beaf87a4d2e65d68b3d4afdd13ee Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 19:18:52 +0200 Subject: [PATCH 06/19] d w i p --- MIGRATION.md | 95 ------- README.md | 251 ------------------ build.sbt | 3 +- docs-src/munit/README.md | 84 ++++++ docs-src/translator/README.md | 24 +- munit/README.md | 84 ++++++ translator/README.md | 63 +++++ .../net/ghoula/valar/munit/ValarSuite.scala | 77 +++--- .../valar/translator/TranslatorSpec.scala | 58 ++-- 9 files changed, 305 insertions(+), 434 deletions(-) delete mode 100644 MIGRATION.md delete mode 100644 README.md create mode 100644 munit/README.md create mode 100644 translator/README.md diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 80d3b0b..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,95 +0,0 @@ -# Migration Guide - -## Migrating from v0.3.0 to v0.4.8 - -The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular -architecture. - -### Update build.sbt: - -```scala -// Replace this: -libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" - -// With this (note the triple %%% for cross-platform support): -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" - -// Add optional testing utilities (if desired): -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test - -// Alternatively, use bundle versions with all dependencies included: -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test -``` - -### Available Artifacts - -The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). If you need to reference a specific artifact directly, here are all the available options: - -| Module | Platform | Artifact ID | Standard Version | Bundle Version | -|--------|----------|-------------------------|------------------------------------------------------|-------------------------------------------------------------| -| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8"` | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | -| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | -| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8"` | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | -| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | - -Your existing validation code will continue to work without any changes. - -## Note on Scala 3.7+ Givens Prioritization - -Scala 3.7 changes how the compiler resolves given instances when multiple candidates are available. Previously, the -compiler would select the most specific subtype, but now it chooses based on different prioritization rules. - -This may affect your code if: - -* You have multiple validator instances for the same type or related types (e.g., through type aliases or inheritance). -* You rely on implicit resolution to select the correct validator. - -### Potential Issues - -The most common issue is with type aliases, where you might have defined: - -```scala -type Email = String - -// General validator -given Validator[String] with { ... } - -// More specific validator -given Validator[Email] with { ... } - -// Which one gets used? In 3.6 vs. 3.7 it might be different! -val result = summon[Validator[Email]].validate(email) -``` - -### Solutions - -1. **Explicit imports**: Place validators in objects and import only the ones you need. - - ```scala - object validators { - given stringValidator: Validator[String] with { ... } - given emailValidator: Validator[Email] with { ... } - } - - // Be explicit about which one to use - import validators.emailValidator - ``` - -2. **Named instances**: Give your validators explicit names and use them directly. - - ```scala - given generalStringValidator: Validator[String] with { ... } - given specificEmailValidator: Validator[Email] with { ... } - - // Use the specific one explicitly - val result = specificEmailValidator.validate(email) - ``` - -3. **Extension methods**: Define your validation logic as extension methods to avoid ambiguity. - - ```scala - extension (email: Email) { - def validate: ValidationResult[Email] = { ... } - } - ``` diff --git a/README.md b/README.md deleted file mode 100644 index 5e8b14a..0000000 --- a/README.md +++ /dev/null @@ -1,251 +0,0 @@ -# **Valar – Type-Safe Validation for Scala 3** - -[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) -[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) - -Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and -metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, -detailed error messages useful for debugging or user feedback. - -## **✨ What's New in 0.4.8** - -* **🚀 Bundle Mode**: All modules now offer a `-bundle` version that includes all dependencies, making it easier to use - in projects. -* **🎯 Platform-Specific Artifacts**: Valar provides dedicated artifacts for both JVM (`valar-core_3`, `valar-munit_3`) - and Scala Native (`valar-core_native0.5_3`, `valar-munit_native0.5_3`) platforms. -* **📦 Modular Architecture**: The library is split into focused modules: `valar-core` for core validation functionality - and the optional `valar-munit` for enhanced testing utilities. - -## **Key Features** - -* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using - ValidationResult\[A\]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. -* **Flexible Error Handling:** Choose the strategy that fits your use case: - * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in - UIs or API responses). - * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. -* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation - rule specifics (like expected vs. actual values), and optional codes/severity. -* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward - compatibility. -* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. - -## **Available Artifacts** - -Valar provides artifacts for both JVM and Scala Native platforms: - -| Module | Platform | Artifact ID | Standard Version | Bundle Version | -|-----------|----------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | -| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | -| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | -| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | - -The **bundle versions** (with `-bundle` suffix) include all dependencies, making them easier to use in projects that -don't need fine-grained dependency control. - -> **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. - -## **Installation** - -Add the following to your build.sbt: - -```scala -// The core validation library (JVM & Scala Native) -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" - -// Optional: For enhanced testing with MUnit -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test - -// Alternatively, use bundle versions with all dependencies included -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test -``` - -## **Basic Usage Example** - -Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int (non-negative). - -```scala -import net.ghoula.valar.* -import net.ghoula.valar.ValidationErrors.ValidationError -import net.ghoula.valar.ValidationResult.{Invalid, Valid} -import net.ghoula.valar.ValidationHelpers.* - -case class User(name: String, age: Option[Int]) - -// Define a custom validator for String -given Validator[String] with { - def validate(value: String): ValidationResult[String] = - nonEmpty(value, _ => "Name must not be empty") -} - -// Define a custom validator for Int -given Validator[Int] with { - def validate(value: Int): ValidationResult[Int] = - nonNegativeInt(value, i => s"Age must be non-negative, got $i") -} - -// Automatically derive a Validator for the case class User using the givens above -given Validator[User] = Validator.deriveValidatorMacro - -val user = User("", Some(-10)) -val result: ValidationResult[User] = Validator[User].validate(user) - -result match { - case Valid(validUser) => println(s"Valid user: $validUser") - case Invalid(errors) => - println("Validation Failed:") - println(errors.map(_.prettyPrint(indent = 2)).mkString("\n")) -} -``` - -## **Testing with valar-munit** - -The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to -make your tests clean and expressive. - -```scala -import net.ghoula.valar.* -import net.ghoula.valar.munit.ValarSuite - -class UserValidationSuite extends ValarSuite { - - // A given Validator for User must be in scope - given Validator[User] = Validator.deriveValidatorMacro - - test("a valid user should pass validation") { - val result = Validator[User].validate(User("John", Some(25))) - val validUser = assertValid(result) // Fails test if Invalid, returns User if Valid - assertEquals(validUser.name, "John") - } - - test("a single validation error should be reported correctly") { - val result = Validator[User].validate(User("", Some(25))) - - // Use assertHasOneError for the common case of a single error - assertHasOneError(result) { error => - assertEquals(error.fieldPath, List("name")) - assert(error.message.contains("empty")) - } - } - - test("multiple validation errors should be accumulated") { - val result = Validator[User].validate(User("", Some(-10))) - - // Use assertInvalid for testing error accumulation - assertInvalid(result) { errors => - assertEquals(errors.size, 2) - assert(errors.exists(_.fieldPath.contains("name"))) - assert(errors.exists(_.fieldPath.contains("age"))) - } - } -} -``` - -## **Core Components** - -### **ValidationResult** - -Represents the outcome of validation as either Valid(value) or Invalid(errors): - -```scala -import net.ghoula.valar.ValidationErrors.ValidationError - -enum ValidationResult[+A] { - case Valid(value: A) - case Invalid(errors: Vector[ValidationError]) -} -``` - -### **ValidationError** - -Opaque type providing rich context for validation errors, including: - -* **message**: Human-readable description of the error. -* **fieldPath**: Path to the field causing the error (e.g., user.address.street). -* **code**: Optional application-specific error codes. -* **severity**: Optional severity indicator (Error, Warning). -* **expected/actual**: Information about expected and actual values. -* **children**: Nested errors for structured reporting. - -### **Validator[A]** - -A typeclass defining validation logic for a given type: - -```scala -import net.ghoula.valar.ValidationResult - -trait Validator[A] { - def validate(a: A): ValidationResult[A] -} -``` - -Validators can be automatically derived for case classes using deriveValidatorMacro. - -**Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances -to be available in scope for **all** field types within the case class. If a validator for any field type is missing, -**compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the -"Built-in Validators" section for types supported out-of-the-box. - -## **Built-in Validators** - -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This -includes: - -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, - Short, Char, Unit. -* **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, - java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their - element/key/value types). -* **Tuple Types:** Named tuples and regular tuples. -* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. - -Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are -**pass-through** validators. You should define custom validators if you need specific constraints for these types. - -## **Migration Guide from v0.3.0** - -The main breaking change since v0.4.0 is the **artifact name has changed** from valar to valar-core to support the new -modular architecture. - -1. **Update build.sbt**: - ```scala - // Replace this: - libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" - - // With this (note the triple %%% for cross-platform support): - libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" - ``` - -2. **Add optional testing utilities** (if desired): - ```scala - libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test - ``` - -3. **For simplified dependency management** (optional): - ```scala - // Use bundle versions with all dependencies included - libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test - ``` - -Your existing validation code will continue to work without any changes. - -## **Compatibility** - -* **Scala:** 3.7+ -* **Platforms:** JVM, Scala Native -* **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, - cross-platform support for the `java.time` API. - -## **License** - -Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) -file for details. diff --git a/build.sbt b/build.sbt index de3a0b7..7a096fc 100644 --- a/build.sbt +++ b/build.sbt @@ -118,7 +118,7 @@ lazy val valarMunit = crossProject(JVMPlatform, NativePlatform) lazy val valarTranslator = crossProject(JVMPlatform, NativePlatform) .crossType(CrossType.Pure) .in(file("valar-translator")) - .dependsOn(valarCore) + .dependsOn(valarCore, valarMunit % Test) .settings(sonatypeSettings *) .settings( name := "valar-translator", @@ -140,7 +140,6 @@ lazy val valarTranslator = crossProject(JVMPlatform, NativePlatform) .nativeSettings( testFrameworks += new TestFramework("munit.Framework") ) - // ===== Convenience Aliases ===== lazy val valarCoreJVM = valarCore.jvm lazy val valarCoreNative = valarCore.native diff --git a/docs-src/munit/README.md b/docs-src/munit/README.md index e69de29..864ef5c 100644 --- a/docs-src/munit/README.md +++ b/docs-src/munit/README.md @@ -0,0 +1,84 @@ +# valar-munit + +The `valar-munit` module provides testing utilities for Valar validation logic using the [MUnit](https://scalameta.org/munit/) testing framework. It introduces the `ValarSuite` trait that extends MUnit's `FunSuite` with specialized assertion helpers for validation results. + +## Usage + +Add the `valar-munit` dependency to your build and extend the `ValarSuite` trait in your test classes: + +```scala +import net.ghoula.valar.munit.ValarSuite + +class MyValidatorSpec extends ValarSuite { + test("valid data passes validation") { + val result = MyValidator.validate(validData) + val value = assertValid(result) + + // You can make additional assertions on the validated value + assertEquals(value.name, "Expected Name") + } +} +``` + +## Assertion Helpers + +The `ValarSuite` trait provides three main assertion helpers: + +### 1. `assertValid` + +Asserts that a `ValidationResult` is `Valid` and returns the validated value for further assertions: + +```scala +test("valid data passes validation") { + val result = MyValidator.validate(validData) + val value = assertValid(result) + + // Additional assertions on the validated value + assertEquals(value.id, 123) +} +``` + +You can also provide a custom clue message: + +```scala +assertValid(result, "User validation should succeed with valid data") +``` + +### 2. `assertHasOneError` + +Asserts that a `ValidationResult` is `Invalid` and contains exactly one error. This is ideal for testing individual validation rules: + +```scala +test("empty name is rejected") { + val result = User.validate(User("", 25)) + + assertHasOneError(result) { + case ValidationError("user.name.empty", _, _) => // Success + case other => fail(s"Expected name.empty error but got: $other") + } +} +``` + +### 3. `assertInvalid` + +Asserts that a `ValidationResult` is `Invalid`. Use this for complex cases where multiple, accumulated errors are expected: + +```scala +test("multiple validation errors are accumulated") { + val result = User.validate(User("", -5)) + + assertInvalid(result) { errors => + assertEquals(errors.size, 2) + assert(errors.exists(_.key.contains("name.empty"))) + assert(errors.exists(_.key.contains("age.negative"))) + } +} +``` + +## Benefits + +The `ValarSuite` trait provides several benefits for testing validation logic: + +1. **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. +2. **Better Error Messages**: When assertions fail, you get detailed error reports with pretty-printed validation errors. +3. **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated value. \ No newline at end of file diff --git a/docs-src/translator/README.md b/docs-src/translator/README.md index 84bfb9a..d744dc3 100644 --- a/docs-src/translator/README.md +++ b/docs-src/translator/README.md @@ -1,14 +1,3 @@ -Thank you, that's a critical detail to get right in the documentation. You are correct; the `ValidationObserver` is a foundational pattern that lives in `valar-core`, making it available to all users. - -This makes the `valar-translator` module a perfect example of an optional, specialized extension that builds upon and composes with the core library's features. - -I'll update the `README.md` to reflect this relationship accurately. - ------ - -### Final README.md for `valar-translator` - -````markdown # valar-translator The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. @@ -35,9 +24,9 @@ given myTranslator: Translator with { ) } } -```` +``` -### 2\. Call `translateErrors()` +### 2. Call `translateErrors()` Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. @@ -48,15 +37,13 @@ val translatedResult = result.translateErrors() // translatedResult now contains errors with localized messages ``` ------ - ## Composing with Core Features (like ValidationObserver) -The `valar-translator` module is designed to compose cleanly with features from the core library. A prime example is the **`ValidationObserver` pattern**, a general-purpose tool for side-effects (like logging or metrics) that is **available directly in `valar-core`**. +The `valar-translator` module is designed to compose cleanly with features from the core library. A prime example is the **`ValidationObserver` pattern**, a general-purpose tool for side effects (like logging or metrics) that is **available directly in `valar-core`**. While the two patterns serve different purposes, they can be chained together for a powerful workflow: -* **`ValidationObserver` (Side-Effect)**: Reacts to a result without changing it. +* **`ValidationObserver` (Side Effect)**: Reacts to a result without changing it. * **`Translator` (Data Transformation)**: Refines a result by localizing error messages. A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. @@ -74,6 +61,3 @@ val result = User.validate(invalidUser) // The final `result` contains user-friendly, translated messages, // while the original, structured error was sent to your metrics system. ``` - -``` -``` \ No newline at end of file diff --git a/munit/README.md b/munit/README.md new file mode 100644 index 0000000..864ef5c --- /dev/null +++ b/munit/README.md @@ -0,0 +1,84 @@ +# valar-munit + +The `valar-munit` module provides testing utilities for Valar validation logic using the [MUnit](https://scalameta.org/munit/) testing framework. It introduces the `ValarSuite` trait that extends MUnit's `FunSuite` with specialized assertion helpers for validation results. + +## Usage + +Add the `valar-munit` dependency to your build and extend the `ValarSuite` trait in your test classes: + +```scala +import net.ghoula.valar.munit.ValarSuite + +class MyValidatorSpec extends ValarSuite { + test("valid data passes validation") { + val result = MyValidator.validate(validData) + val value = assertValid(result) + + // You can make additional assertions on the validated value + assertEquals(value.name, "Expected Name") + } +} +``` + +## Assertion Helpers + +The `ValarSuite` trait provides three main assertion helpers: + +### 1. `assertValid` + +Asserts that a `ValidationResult` is `Valid` and returns the validated value for further assertions: + +```scala +test("valid data passes validation") { + val result = MyValidator.validate(validData) + val value = assertValid(result) + + // Additional assertions on the validated value + assertEquals(value.id, 123) +} +``` + +You can also provide a custom clue message: + +```scala +assertValid(result, "User validation should succeed with valid data") +``` + +### 2. `assertHasOneError` + +Asserts that a `ValidationResult` is `Invalid` and contains exactly one error. This is ideal for testing individual validation rules: + +```scala +test("empty name is rejected") { + val result = User.validate(User("", 25)) + + assertHasOneError(result) { + case ValidationError("user.name.empty", _, _) => // Success + case other => fail(s"Expected name.empty error but got: $other") + } +} +``` + +### 3. `assertInvalid` + +Asserts that a `ValidationResult` is `Invalid`. Use this for complex cases where multiple, accumulated errors are expected: + +```scala +test("multiple validation errors are accumulated") { + val result = User.validate(User("", -5)) + + assertInvalid(result) { errors => + assertEquals(errors.size, 2) + assert(errors.exists(_.key.contains("name.empty"))) + assert(errors.exists(_.key.contains("age.negative"))) + } +} +``` + +## Benefits + +The `ValarSuite` trait provides several benefits for testing validation logic: + +1. **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. +2. **Better Error Messages**: When assertions fail, you get detailed error reports with pretty-printed validation errors. +3. **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated value. \ No newline at end of file diff --git a/translator/README.md b/translator/README.md new file mode 100644 index 0000000..d744dc3 --- /dev/null +++ b/translator/README.md @@ -0,0 +1,63 @@ +# valar-translator + +The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. + +## Usage + +The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. + +### 1. Implement the `Translator` Trait + +Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. + +```scala +import net.ghoula.valar.translator.Translator +import net.ghoula.valar.ValidationErrors.ValidationError + +// Assuming you have an I18n library +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key and format with its arguments + I18n.lookup( + error.key.getOrElse("error.unknown"), + error.args + ) + } +} +``` + +### 2. Call `translateErrors()` + +Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. + +```scala +val result = User.validate(someData) // An Invalid ValidationResult +val translatedResult = result.translateErrors() + +// translatedResult now contains errors with localized messages +``` + +## Composing with Core Features (like ValidationObserver) + +The `valar-translator` module is designed to compose cleanly with features from the core library. A prime example is the **`ValidationObserver` pattern**, a general-purpose tool for side effects (like logging or metrics) that is **available directly in `valar-core`**. + +While the two patterns serve different purposes, they can be chained together for a powerful workflow: + +* **`ValidationObserver` (Side Effect)**: Reacts to a result without changing it. +* **`Translator` (Data Transformation)**: Refines a result by localizing error messages. + +A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. + +```scala +// Given a defined `metricsObserver` from your application +// and a `myTranslator` from this module... + +val result = User.validate(invalidUser) + // 1. First, observe the raw result using the core ValidationObserver. + .observe() + // 2. Then, translate the errors for presentation using the Translator. + .translateErrors() + +// The final `result` contains user-friendly, translated messages, +// while the original, structured error was sent to your metrics system. +``` diff --git a/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala b/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala index cb7f8de..d9bc882 100644 --- a/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala +++ b/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala @@ -5,53 +5,52 @@ import munit.{FunSuite, Location} import net.ghoula.valar.ValidationErrors.ValidationError import net.ghoula.valar.ValidationResult -/** A base suite for MUnit tests that provides validation-specific assertion helpers. - * - * This suite provides a complete toolbox for testing Valar's validation logic: - * - `assertValid` for success cases. - * - `assertHasOneError` for testing single validation rules. - * - `assertInvalid` for testing complex error accumulation. +/** A base trait for test suites that use Valar, providing convenient assertion helpers for working + * with ValidationResult. */ trait ValarSuite extends FunSuite { - /** Asserts that a `ValidationResult` is `Valid`. - * @return - * The validated value `A` on success, allowing for chained assertions. + /** Asserts that a ValidationResult is Invalid and contains exactly one error, then allows further + * assertions on that single error. + * + * @param result + * The ValidationResult to inspect. + * @param clue + * A clue to provide if the assertion fails. + * @param body + * A function that takes the single ValidationError and performs further checks. */ - def assertValid[A](result: ValidationResult[A], clue: => Any = "Expected Valid, but got Invalid")(using - loc: Location - ): A = { - result match { - case ValidationResult.Valid(value) => value - case ValidationResult.Invalid(errors) => - val errorReport = errors.map(e => s" - ${e.prettyPrint(2)}").mkString("\n") - fail(s"$clue. Errors:\n$errorReport") - } + def assertHasOneError[A](result: ValidationResult[A], clue: Any = "Expected exactly one validation error")( + body: ValidationError => Unit + )(using loc: Location): Unit = { + assertHasNErrors(result, 1, clue) { errors => body(errors.head) } } - /** Asserts that a `ValidationResult` is `Invalid` and contains exactly one error. This is the - * ideal helper for testing individual validation rules. + /** Asserts that a ValidationResult is Invalid and contains a specific number of errors, then + * allows further assertions on the collection of errors. * * @param result - * The `ValidationResult` to check. - * @param pf - * A partial function to run assertions on the single `ValidationError`. - * @return - * The single `ValidationError` on success. + * The ValidationResult to inspect. + * @param expectedSize + * The expected number of errors. + * @param clue + * A clue to provide if the assertion fails. + * @param body + * A function that takes the Vector of ValidationErrors and performs further checks. */ - def assertHasOneError( - result: ValidationResult[?] - )(pf: PartialFunction[ValidationError, Unit])(using loc: Location): ValidationError = { - val errors = assertInvalid(result) { - case allErrors if allErrors.size == 1 => - case allErrors => fail(s"Expected a single validation error, but found ${allErrors.size}.") - } - val singleError = errors.head - if (!pf.isDefinedAt(singleError)) { - fail(s"Partial function was not defined for the validation error:\n - ${singleError.prettyPrint(2)}") + def assertHasNErrors[A](result: ValidationResult[A], expectedSize: Int, clue: Any = "Mismatched number of errors")( + body: Vector[ValidationError] => Unit + )(using loc: Location): Unit = { + result match { + case ValidationResult.Valid(value) => + fail(s"Expected $expectedSize validation errors, but the result was Valid($value).") + case ValidationResult.Invalid(errors) => + if (errors.size == expectedSize) { + body(errors) + } else { + fail(s"$clue. Expected $expectedSize errors, but found ${errors.size}.") + } } - pf(singleError) - singleError } /** Asserts that a `ValidationResult` is `Invalid`. Use this for complex cases where multiple, @@ -64,8 +63,8 @@ trait ValarSuite extends FunSuite { * @return * The `Vector[ValidationError]` on success. */ - def assertInvalid( - result: ValidationResult[?] + def assertInvalid[A]( + result: ValidationResult[A] )(pf: PartialFunction[Vector[ValidationError], Unit])(using loc: Location): Vector[ValidationError] = { result match { case ValidationResult.Valid(value) => diff --git a/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala b/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala index a6cd430..acc3369 100644 --- a/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala +++ b/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala @@ -1,8 +1,8 @@ package net.ghoula.valar.translator -import munit.FunSuite import net.ghoula.valar.ValidationErrors.ValidationError import net.ghoula.valar.ValidationResult +import net.ghoula.valar.munit.ValarSuite /** Provides a comprehensive test suite for the [[Translator]] typeclass and its associated * `translateErrors` extension method. @@ -16,13 +16,10 @@ import net.ghoula.valar.ValidationResult * guarantees that the translation is not applied recursively to nested child errors, maintaining * the original state of the error hierarchy. */ -class TranslatorSpec extends FunSuite { +class TranslatorSpec extends ValarSuite { test("translateErrors on a Valid result should return the instance unchanged") { - given failingTranslator: Translator = (error: ValidationError) => { - fail(s"Translator should not be invoked for a Valid result, but was called for: ${error.message}") - "" // This line is unreachable but required for type correctness - } + given Translator = error => fail(s"Translator should not be invoked, but was called for: ${error.message}") val validResult = ValidationResult.Valid("all good") val result = validResult.translateErrors() @@ -31,7 +28,7 @@ class TranslatorSpec extends FunSuite { } test("translateErrors on an Invalid result should translate messages and preserve all other properties") { - given testTranslator: Translator = (error: ValidationError) => s"translated: ${error.message}" + given Translator = error => s"translated: ${error.message}" val originalError = ValidationError( message = "A test error", @@ -46,36 +43,43 @@ class TranslatorSpec extends FunSuite { val translatedResult = invalidResult.translateErrors() - translatedResult match { - case ValidationResult.Valid(_) => fail("Expected an Invalid result but got a Valid one.") - case ValidationResult.Invalid(Vector(translatedError)) => - assertEquals(translatedError.message, "translated: A test error") - assertEquals(translatedError.fieldPath, originalError.fieldPath) - assertEquals(translatedError.children, originalError.children) - assertEquals(translatedError.code, originalError.code) - assertEquals(translatedError.severity, originalError.severity) - assertEquals(translatedError.expected, originalError.expected) - assertEquals(translatedError.actual, originalError.actual) - case _ => fail("Expected an Invalid result with a single error.") + assertHasOneError(translatedResult) { translatedError => + assertEquals(translatedError.message, "translated: A test error") + assertEquals(translatedError.fieldPath, originalError.fieldPath) + assertEquals(translatedError.children, originalError.children) + assertEquals(translatedError.code, originalError.code) + assertEquals(translatedError.severity, originalError.severity) + assertEquals(translatedError.expected, originalError.expected) + assertEquals(translatedError.actual, originalError.actual) } } + test("translateErrors should correctly translate multiple errors in an Invalid result") { + given Translator = error => s"translated: ${error.message}" + + val error1 = ValidationError("First error") + val error2 = ValidationError("Second error") + val invalidResult = ValidationResult.Invalid(Vector(error1, error2)) + + val translatedResult = invalidResult.translateErrors() + + assertHasNErrors(translatedResult, 2)(translatedErrors => + assertEquals(translatedErrors.map(_.message), Vector("translated: First error", "translated: Second error")) + ) + } + test("translateErrors should not apply translation recursively to nested child errors") { - given testTranslator: Translator = (error: ValidationError) => s"translated: ${error.message}" + given Translator = error => s"translated: ${error.message}" val childError = ValidationError("This is a child error") val parentError = ValidationError("This is a parent error", children = Vector(childError)) val invalidResult = ValidationResult.invalid(parentError) - val translatedResult = invalidResult.translateErrors() - translatedResult match { - case ValidationResult.Valid(_) => fail("Expected an Invalid result but got a Valid one.") - case ValidationResult.Invalid(Vector(translatedParent)) => - assertEquals(translatedParent.message, "translated: This is a parent error") - assertEquals(translatedParent.children.headOption, Some(childError)) - assertEquals(translatedParent.children.head.message, "This is a child error") - case _ => fail("Expected an Invalid result with a single parent error.") + assertHasOneError(translatedResult) { translatedParent => + assertEquals(translatedParent.message, "translated: This is a parent error") + assertEquals(translatedParent.children.headOption, Some(childError)) + assertEquals(translatedParent.children.head.message, "This is a child error") } } } From 777b7af819a459cfb53f919dbb657192f9053b8c Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 20:06:40 +0200 Subject: [PATCH 07/19] d w i p --- docs-src/MIGRATION.md | 80 +++++++++- docs-src/README.md | 148 +++++++++++++----- docs-src/munit/README.md | 96 ++++++++---- docs-src/translator/README.md | 4 + munit/README.md | 84 ---------- translator/README.md | 4 + valar-core/README.md | 94 +++++++++++ .../net/ghoula/valar/munit/ValarSuite.scala | 46 ++++++ .../ghoula/valar/munit/ValarSuiteSpec.scala | 134 ++++++++++++++++ 9 files changed, 542 insertions(+), 148 deletions(-) delete mode 100644 munit/README.md create mode 100644 valar-core/README.md create mode 100644 valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala diff --git a/docs-src/MIGRATION.md b/docs-src/MIGRATION.md index 80d3b0b..12b09e1 100644 --- a/docs-src/MIGRATION.md +++ b/docs-src/MIGRATION.md @@ -1,5 +1,81 @@ # Migration Guide +## Migrating from v0.4.8 to v0.5.0 + +Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: + +1. **New ValidationObserver trait** for observing validation outcomes without altering the flow +2. **New valar-translator module** for internationalization support of validation error messages +3. **Enhanced ValarSuite** with improved testing utilities +4. **Reworked macros** for better performance and modern Scala 3 features +5. **MiMa checks** to ensure binary compatibility between versions + +### Update build.sbt: + +```scala +// Update core library +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Add optional translator module (if needed) +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +// Update testing utilities (if used) +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test + +// Alternatively, use bundle versions with all dependencies included +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0-bundle" +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0-bundle" +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0-bundle" % Test +``` + +Your existing validation code will continue to work without any changes. + +### Using the New Features + +#### ValidationObserver + +The ValidationObserver trait allows you to observe validation results without altering the flow: + +```scala +import net.ghoula.valar.* +import org.slf4j.LoggerFactory + +// Define a custom observer that logs validation results +given loggingObserver: ValidationObserver with { + private val logger = LoggerFactory.getLogger("ValidationAnalytics") + + def onResult[A](result: ValidationResult[A]): Unit = result match { + case ValidationResult.Valid(_) => + logger.info("Validation succeeded") + case ValidationResult.Invalid(errors) => + logger.warn(s"Validation failed with ${errors.size} errors") + } +} + +// Use the observer in your validation flow +val result = User.validate(user).observe() +``` + +#### valar-translator + +The valar-translator module provides internationalization support: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.translator.Translator + +// Implement the Translator trait with your i18n library +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key and format with its arguments + I18n.lookup(error.key.getOrElse("error.unknown"), error.args) + } +} + +// Use the translator in your validation flow +val result = User.validate(user).translateErrors() +``` + ## Migrating from v0.3.0 to v0.4.8 The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular @@ -71,7 +147,7 @@ val result = summon[Validator[Email]].validate(email) given stringValidator: Validator[String] with { ... } given emailValidator: Validator[Email] with { ... } } - + // Be explicit about which one to use import validators.emailValidator ``` @@ -81,7 +157,7 @@ val result = summon[Validator[Email]].validate(email) ```scala given generalStringValidator: Validator[String] with { ... } given specificEmailValidator: Validator[Email] with { ... } - + // Use the specific one explicitly val result = specificEmailValidator.validate(email) ``` diff --git a/docs-src/README.md b/docs-src/README.md index 5e8b14a..084fb12 100644 --- a/docs-src/README.md +++ b/docs-src/README.md @@ -8,14 +8,14 @@ Valar is a validation library for Scala 3 designed for clarity and ease of use. metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages useful for debugging or user feedback. -## **✨ What's New in 0.4.8** +## **✨ What's New in 0.5.X** -* **🚀 Bundle Mode**: All modules now offer a `-bundle` version that includes all dependencies, making it easier to use - in projects. -* **🎯 Platform-Specific Artifacts**: Valar provides dedicated artifacts for both JVM (`valar-core_3`, `valar-munit_3`) - and Scala Native (`valar-core_native0.5_3`, `valar-munit_native0.5_3`) platforms. -* **📦 Modular Architecture**: The library is split into focused modules: `valar-core` for core validation functionality - and the optional `valar-munit` for enhanced testing utilities. +* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, perfect for logging, metrics collection, or auditing with zero overhead when not used. +* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the `Translator` typeclass. +* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust validation testing. +* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time validation. +* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. +* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for better developer experience. ## **Key Features** @@ -38,12 +38,14 @@ detailed error messages useful for debugging or user feedback. Valar provides artifacts for both JVM and Scala Native platforms: -| Module | Platform | Artifact ID | Standard Version | Bundle Version | -|-----------|----------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | -| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | -| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | -| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | +| Module | Platform | Artifact ID | Standard Version | Bundle Version | +|-----------------|----------|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | +| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | +| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | +| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | +| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | +| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | The **bundle versions** (with `-bundle` suffix) include all dependencies, making them easier to use in projects that don't need fine-grained dependency control. @@ -56,14 +58,18 @@ Add the following to your build.sbt: ```scala // The core validation library (JVM & Scala Native) -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Optional: For internationalization (i18n) support +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" // Optional: For enhanced testing with MUnit -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test // Alternatively, use bundle versions with all dependencies included -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0-bundle" +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0-bundle" +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0-bundle" % Test ``` ## **Basic Usage Example** @@ -210,10 +216,96 @@ includes: Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. -## **Migration Guide from v0.3.0** +## **ValidationObserver** + +The `ValidationObserver` trait provides a powerful mechanism to decouple validation logic from cross-cutting concerns such as logging, metrics collection, or auditing: + +```scala +import net.ghoula.valar.* +import org.slf4j.LoggerFactory + +// Define a custom observer that logs validation results +given loggingObserver: ValidationObserver with { + private val logger = LoggerFactory.getLogger("ValidationAnalytics") + + def onResult[A](result: ValidationResult[A]): Unit = result match { + case ValidationResult.Valid(_) => + logger.info("Validation succeeded") + case ValidationResult.Invalid(errors) => + logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") + } +} + +// Use the observer in your validation flow +val result = User.validate(user) + .observe() // The observer's onResult is called here + .map(_.toUpperCase) +``` + +Key features of ValidationObserver: +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Chainable**: Works seamlessly with other operations in the validation pipeline +* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect + +## **Internationalization with valar-translator** + +The `valar-translator` module provides internationalization (i18n) support for validation error messages: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.translator.Translator + +// Implement the Translator trait with your i18n library +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key and format with its arguments + I18n.lookup( + error.key.getOrElse("error.unknown"), + error.args + ) + } +} + +// Use the translator in your validation flow +val result = User.validate(user) + .observe() // Optional: observe the raw result first + .translateErrors() // Translate errors for user presentation +``` + +The `valar-translator` module is designed to: +* Integrate with any i18n library through the `Translator` typeclass +* Compose cleanly with other Valar features like ValidationObserver +* Provide a clear separation between validation logic and presentation concerns + +## **Migration Guide from v0.4.8 to v0.5.0** + +Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: -The main breaking change since v0.4.0 is the **artifact name has changed** from valar to valar-core to support the new -modular architecture. +1. **New ValidationObserver trait** for observing validation outcomes +2. **New valar-translator module** for internationalization support +3. **Enhanced ValarSuite** with improved testing utilities +4. **Reworked macros** for better performance and modern Scala 3 features +5. **MiMa checks** to ensure binary compatibility + +To upgrade to v0.5.0, update your build.sbt: + +```scala +// Update core library +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Add optional translator module (if needed) +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +// Update testing utilities (if used) +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +Your existing validation code will continue to work without any changes. + +## **Migration Guide from v0.3.0 to v0.4.8** + +The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular architecture. 1. **Update build.sbt**: ```scala @@ -221,23 +313,9 @@ modular architecture. libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" // With this (note the triple %%% for cross-platform support): - libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" - ``` - -2. **Add optional testing utilities** (if desired): - ```scala - libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test + libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" ``` -3. **For simplified dependency management** (optional): - ```scala - // Use bundle versions with all dependencies included - libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test - ``` - -Your existing validation code will continue to work without any changes. - ## **Compatibility** * **Scala:** 3.7+ diff --git a/docs-src/munit/README.md b/docs-src/munit/README.md index 864ef5c..3c24cf2 100644 --- a/docs-src/munit/README.md +++ b/docs-src/munit/README.md @@ -1,10 +1,19 @@ # valar-munit -The `valar-munit` module provides testing utilities for Valar validation logic using the [MUnit](https://scalameta.org/munit/) testing framework. It introduces the `ValarSuite` trait that extends MUnit's `FunSuite` with specialized assertion helpers for validation results. +The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It +introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. + +## Installation + +Add the valar-munit dependency to your build.sbt: + +```scala +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` ## Usage -Add the `valar-munit` dependency to your build and extend the `ValarSuite` trait in your test classes: +Extend the ValarSuite trait in your test classes to get access to the assertion helpers. ```scala import net.ghoula.valar.munit.ValarSuite @@ -13,7 +22,7 @@ class MyValidatorSpec extends ValarSuite { test("valid data passes validation") { val result = MyValidator.validate(validData) val value = assertValid(result) - + // You can make additional assertions on the validated value assertEquals(value.name, "Expected Name") } @@ -22,63 +31,96 @@ class MyValidatorSpec extends ValarSuite { ## Assertion Helpers -The `ValarSuite` trait provides three main assertion helpers: +The ValarSuite trait provides several assertion helpers for different validation testing scenarios. -### 1. `assertValid` +### 1. assertValid -Asserts that a `ValidationResult` is `Valid` and returns the validated value for further assertions: +Asserts that a ValidationResult is Valid and returns the validated value for further assertions. ```scala test("valid data passes validation") { val result = MyValidator.validate(validData) val value = assertValid(result) - + // Additional assertions on the validated value assertEquals(value.id, 123) } ``` -You can also provide a custom clue message: +### 2. assertHasOneError + +Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual +validation rules. ```scala -assertValid(result, "User validation should succeed with valid data") +test("empty name is rejected") { + val result = User.validate(User("", 25)) + + assertHasOneError(result) { error => + assertEquals(error.fieldPath, List("name")) + assert(error.message.contains("empty")) + } +} ``` -### 2. `assertHasOneError` +### 3. assertHasNErrors -Asserts that a `ValidationResult` is `Invalid` and contains exactly one error. This is ideal for testing individual validation rules: +Asserts that a ValidationResult is Invalid and contains exactly N errors. ```scala -test("empty name is rejected") { - val result = User.validate(User("", 25)) - - assertHasOneError(result) { - case ValidationError("user.name.empty", _, _) => // Success - case other => fail(s"Expected name.empty error but got: $other") +test("multiple specific errors are reported") { + val result = User.validate(User("", -5)) + + assertHasNErrors(result, 2) { errors => + // Assert on the collection of exactly 2 errors + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) } } ``` -### 3. `assertInvalid` +### 4. assertInvalid -Asserts that a `ValidationResult` is `Invalid`. Use this for complex cases where multiple, accumulated errors are expected: +Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, +accumulated errors are expected. ```scala test("multiple validation errors are accumulated") { val result = User.validate(User("", -5)) - - assertInvalid(result) { errors => + + assertInvalid(result) { + case errors if errors.size == 2 => + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } +} +``` + +### 5. assertInvalidWith + +Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular +function. This is a simpler alternative to assertInvalid. + +```scala +test("validation fails with expected errors") { + val result = User.validate(User("", -5)) + + assertInvalidWith(result) { errors => assertEquals(errors.size, 2) - assert(errors.exists(_.key.contains("name.empty"))) - assert(errors.exists(_.key.contains("age.negative"))) + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) } } ``` ## Benefits -The `ValarSuite` trait provides several benefits for testing validation logic: +- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. + +- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. + +- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. + +- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated value. -1. **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. -2. **Better Error Messages**: When assertions fail, you get detailed error reports with pretty-printed validation errors. -3. **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated value. \ No newline at end of file +- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match your testing preferences. diff --git a/docs-src/translator/README.md b/docs-src/translator/README.md index d744dc3..0a373b3 100644 --- a/docs-src/translator/README.md +++ b/docs-src/translator/README.md @@ -1,5 +1,9 @@ # valar-translator +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. ## Usage diff --git a/munit/README.md b/munit/README.md deleted file mode 100644 index 864ef5c..0000000 --- a/munit/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# valar-munit - -The `valar-munit` module provides testing utilities for Valar validation logic using the [MUnit](https://scalameta.org/munit/) testing framework. It introduces the `ValarSuite` trait that extends MUnit's `FunSuite` with specialized assertion helpers for validation results. - -## Usage - -Add the `valar-munit` dependency to your build and extend the `ValarSuite` trait in your test classes: - -```scala -import net.ghoula.valar.munit.ValarSuite - -class MyValidatorSpec extends ValarSuite { - test("valid data passes validation") { - val result = MyValidator.validate(validData) - val value = assertValid(result) - - // You can make additional assertions on the validated value - assertEquals(value.name, "Expected Name") - } -} -``` - -## Assertion Helpers - -The `ValarSuite` trait provides three main assertion helpers: - -### 1. `assertValid` - -Asserts that a `ValidationResult` is `Valid` and returns the validated value for further assertions: - -```scala -test("valid data passes validation") { - val result = MyValidator.validate(validData) - val value = assertValid(result) - - // Additional assertions on the validated value - assertEquals(value.id, 123) -} -``` - -You can also provide a custom clue message: - -```scala -assertValid(result, "User validation should succeed with valid data") -``` - -### 2. `assertHasOneError` - -Asserts that a `ValidationResult` is `Invalid` and contains exactly one error. This is ideal for testing individual validation rules: - -```scala -test("empty name is rejected") { - val result = User.validate(User("", 25)) - - assertHasOneError(result) { - case ValidationError("user.name.empty", _, _) => // Success - case other => fail(s"Expected name.empty error but got: $other") - } -} -``` - -### 3. `assertInvalid` - -Asserts that a `ValidationResult` is `Invalid`. Use this for complex cases where multiple, accumulated errors are expected: - -```scala -test("multiple validation errors are accumulated") { - val result = User.validate(User("", -5)) - - assertInvalid(result) { errors => - assertEquals(errors.size, 2) - assert(errors.exists(_.key.contains("name.empty"))) - assert(errors.exists(_.key.contains("age.negative"))) - } -} -``` - -## Benefits - -The `ValarSuite` trait provides several benefits for testing validation logic: - -1. **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. -2. **Better Error Messages**: When assertions fail, you get detailed error reports with pretty-printed validation errors. -3. **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated value. \ No newline at end of file diff --git a/translator/README.md b/translator/README.md index d744dc3..0a373b3 100644 --- a/translator/README.md +++ b/translator/README.md @@ -1,5 +1,9 @@ # valar-translator +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. ## Usage diff --git a/valar-core/README.md b/valar-core/README.md new file mode 100644 index 0000000..fcd70e0 --- /dev/null +++ b/valar-core/README.md @@ -0,0 +1,94 @@ +# valar-core + +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + +The `valar-core` module provides the core validation functionality for Valar, a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages. + +## Key Components + +### ValidationResult + +Represents the outcome of validation as either Valid(value) or Invalid(errors): + +```scala +import net.ghoula.valar.ValidationErrors.ValidationError + +enum ValidationResult[+A] { + case Valid(value: A) + case Invalid(errors: Vector[ValidationError]) +} +``` + +### ValidationError + +Opaque type providing rich context for validation errors, including: + +* **message**: Human-readable description of the error. +* **fieldPath**: Path to the field causing the error (e.g., user.address.street). +* **code**: Optional application-specific error codes. +* **severity**: Optional severity indicator (Error, Warning). +* **expected/actual**: Information about expected and actual values. +* **children**: Nested errors for structured reporting. + +### Validator[A] + +A typeclass defining validation logic for a given type: + +```scala +import net.ghoula.valar.ValidationResult + +trait Validator[A] { + def validate(a: A): ValidationResult[A] +} +``` + +Validators can be automatically derived for case classes using deriveValidatorMacro. + +### ValidationObserver + +The `ValidationObserver` trait provides a mechanism to decouple validation logic from cross-cutting concerns such as logging, metrics collection, or auditing: + +```scala +import net.ghoula.valar.* +import org.slf4j.LoggerFactory + +// Define a custom observer that logs validation results +given loggingObserver: ValidationObserver with { + private val logger = LoggerFactory.getLogger("ValidationAnalytics") + + def onResult[A](result: ValidationResult[A]): Unit = result match { + case ValidationResult.Valid(_) => + logger.info("Validation succeeded") + case ValidationResult.Invalid(errors) => + logger.warn(s"Validation failed with ${errors.size} errors") + } +} + +// Use the observer in your validation flow +val result = User.validate(user).observe() +``` + +Key features of ValidationObserver: +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Chainable**: Works seamlessly with other operations in the validation pipeline +* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect + +## Built-in Validators + +Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This includes: + +* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, Short, Char, Unit. +* **Other Scala Types:** BigInt, BigDecimal, Symbol. +* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. +* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their element/key/value types). +* **Tuple Types:** Named tuples and regular tuples. +* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. + +Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. + +## Usage + +For detailed usage examples and more information, please refer to the [main Valar documentation](https://github.com/hakimjonas/valar). \ No newline at end of file diff --git a/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala b/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala index d9bc882..fab210b 100644 --- a/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala +++ b/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala @@ -10,6 +10,25 @@ import net.ghoula.valar.ValidationResult */ trait ValarSuite extends FunSuite { + /** Asserts that a ValidationResult is Valid and returns the validated value for further + * assertions. + * + * @param result + * The ValidationResult to inspect. + * @param clue + * A clue to provide if the assertion fails. + * @return + * The validated value if the result is Valid. + */ + def assertValid[A](result: ValidationResult[A], clue: Any = "Expected Valid result")(using loc: Location): A = { + result match { + case ValidationResult.Valid(value) => value + case ValidationResult.Invalid(errors) => + val errorReport = errors.map(e => s" - ${e.prettyPrint(2)}").mkString("\n") + fail(s"$clue, but got Invalid with errors:\n$errorReport") + } + } + /** Asserts that a ValidationResult is Invalid and contains exactly one error, then allows further * assertions on that single error. * @@ -78,4 +97,31 @@ trait ValarSuite extends FunSuite { errors } } + + /** Asserts that a ValidationResult is Invalid and allows flexible assertions on the error + * collection. This is a simpler alternative to `assertInvalid` that works with regular + * functions. + * + * @param result + * The ValidationResult to inspect. + * @param clue + * A clue to provide if the assertion fails. + * @param body + * A function that takes the Vector of ValidationErrors and performs further checks. + * @return + * The Vector of ValidationErrors on success. + */ + def assertInvalidWith[A]( + result: ValidationResult[A], + clue: Any = "Expected Invalid result" + )(body: Vector[ValidationError] => Unit)(using loc: Location): Vector[ValidationError] = { + result match { + case ValidationResult.Valid(value) => + fail(s"$clue, but got Valid($value)") + case ValidationResult.Invalid(errors) => + body(errors) + errors + } + } + } diff --git a/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala b/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala new file mode 100644 index 0000000..caea088 --- /dev/null +++ b/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala @@ -0,0 +1,134 @@ +package net.ghoula.valar.munit + +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.ValidationResult + +/** Tests the `ValarSuite` trait to ensure its assertion helpers are correct and reliable. + */ +class ValarSuiteSpec extends ValarSuite { + + private val validResult = ValidationResult.Valid("success") + private val singleErrorResult = ValidationResult.invalid(ValidationError("single error")) + private val multipleErrorsResult = ValidationResult.invalid( + Vector( + ValidationError("first error", fieldPath = List("field1")), + ValidationError("second error", fieldPath = List("field2")) + ) + ) + + test("assertValid should return value when result is Valid") { + val value = assertValid(validResult) + assertEquals(value, "success") + } + + test("assertValid should fail when result is Invalid") { + intercept[munit.FailException] { + assertValid(singleErrorResult) + } + } + + test("assertHasOneError should succeed when result has exactly one error") { + assertHasOneError(singleErrorResult) { error => + assertEquals(error.message, "single error") + } + } + + test("assertHasOneError should fail when result is Valid") { + intercept[munit.FailException] { + assertHasOneError(validResult)(_ => ()) + } + } + + test("assertHasOneError should fail when result has multiple errors") { + intercept[munit.FailException] { + assertHasOneError(multipleErrorsResult)(_ => ()) + } + } + + test("assertHasNErrors should succeed when result has exactly N errors") { + assertHasNErrors(multipleErrorsResult, 2) { errors => + assertEquals(errors.size, 2) + assertEquals(errors.head.message, "first error") + assertEquals(errors.last.message, "second error") + } + } + + test("assertHasNErrors should fail when result is Valid") { + intercept[munit.FailException] { + assertHasNErrors(validResult, 1)(_ => ()) + } + } + + test("assertHasNErrors should fail when error count doesn't match") { + intercept[munit.FailException] { + assertHasNErrors(singleErrorResult, 2)(_ => ()) + } + } + + test("assertInvalid should succeed when result is Invalid and partial function matches") { + val errors = assertInvalid(multipleErrorsResult) { + case vector if vector.size == 2 => + assert(vector.exists(_.fieldPath == List("field1"))) + assert(vector.exists(_.fieldPath == List("field2"))) + } + assertEquals(errors.size, 2) + } + + test("assertInvalid should fail when result is Valid") { + intercept[munit.FailException] { + assertInvalid(validResult) { case _ => () } + } + } + + test("assertInvalid should fail when partial function doesn't match") { + intercept[munit.FailException] { + assertInvalid(singleErrorResult) { + case errors if errors.size == 2 => + () + } + } + } + + test("assertInvalidWith should succeed when result is Invalid") { + val errors = assertInvalidWith(singleErrorResult) { errors => + assertEquals(errors.size, 1) + assertEquals(errors.head.message, "single error") + } + assertEquals(errors.size, 1) + } + + test("assertInvalidWith should fail when result is Valid") { + intercept[munit.FailException] { + assertInvalidWith(validResult)(_ => ()) + } + } + + test("assertion failures should provide meaningful error messages") { + val exception = intercept[munit.FailException] { + assertValid(singleErrorResult, "Should be valid") + } + assert(exception.getMessage.contains("Should be valid")) + assert(exception.getMessage.contains("single error")) + } + + test("assertions should work with all ValidationError features") { + val complexError = ValidationError( + message = "Complex validation error", + fieldPath = List("user", "profile", "email"), + code = Some("EMAIL_INVALID"), + severity = Some("ERROR"), + expected = Some("valid email format"), + actual = Some("invalid@") + ) + val complexResult = ValidationResult.invalid(complexError) + + assertHasOneError(complexResult) { error => + assertEquals(error.message, "Complex validation error") + assertEquals(error.fieldPath, List("user", "profile", "email")) + assertEquals(error.code, Some("EMAIL_INVALID")) + assertEquals(error.severity, Some("ERROR")) + assertEquals(error.expected, Some("valid email format")) + assertEquals(error.actual, Some("invalid@")) + } + } +} From 617510438a59e92806b38cf833fd0b6b74ad09db Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 22:48:48 +0200 Subject: [PATCH 08/19] d w i p --- build.sbt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index 7a096fc..9bba917 100644 --- a/build.sbt +++ b/build.sbt @@ -63,8 +63,8 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) usePgpKeyHex("9614A0CE1CE76975"), useGpgAgent := true, // --- MiMa & TASTy-MiMa Configuration --- - mimaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8"), - tastyMiMaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8"), + mimaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8-bundle"), + tastyMiMaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8-bundle"), // --- Library Dependencies --- libraryDependencies ++= Seq( "io.github.cquiroz" %%% "scala-java-time" % "2.6.0", @@ -98,8 +98,8 @@ lazy val valarMunit = crossProject(JVMPlatform, NativePlatform) name := "valar-munit", usePgpKeyHex("9614A0CE1CE76975"), useGpgAgent := true, - mimaPreviousArtifacts := Set.empty, - tastyMiMaPreviousArtifacts := Set.empty, + mimaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8-bundle"), + tastyMiMaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8-bundle"), libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" ) .jvmSettings( From d99e5af2e90e95b97eec613c1cfbc3e9742fd4e7 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 23:20:38 +0200 Subject: [PATCH 09/19] d w i p --- build.sbt | 8 +++--- docs-src/MIGRATION.md | 49 +++++++++++++++++++---------------- docs-src/README.md | 43 +++++++++++++++--------------- docs-src/munit/README.md | 10 +++++-- docs-src/translator/README.md | 25 +++++++++++++++--- 5 files changed, 82 insertions(+), 53 deletions(-) diff --git a/build.sbt b/build.sbt index 9bba917..1f7a771 100644 --- a/build.sbt +++ b/build.sbt @@ -63,8 +63,8 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) usePgpKeyHex("9614A0CE1CE76975"), useGpgAgent := true, // --- MiMa & TASTy-MiMa Configuration --- - mimaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8-bundle"), - tastyMiMaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8-bundle"), + mimaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release + tastyMiMaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release // --- Library Dependencies --- libraryDependencies ++= Seq( "io.github.cquiroz" %%% "scala-java-time" % "2.6.0", @@ -98,8 +98,8 @@ lazy val valarMunit = crossProject(JVMPlatform, NativePlatform) name := "valar-munit", usePgpKeyHex("9614A0CE1CE76975"), useGpgAgent := true, - mimaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8-bundle"), - tastyMiMaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8-bundle"), + mimaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release + tastyMiMaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" ) .jvmSettings( diff --git a/docs-src/MIGRATION.md b/docs-src/MIGRATION.md index 12b09e1..09688c4 100644 --- a/docs-src/MIGRATION.md +++ b/docs-src/MIGRATION.md @@ -16,16 +16,11 @@ Version 0.5.0 introduces several new features while maintaining backward compati // Update core library libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" -// Add optional translator module (if needed) +// Add the optional translator module (if needed) libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" // Update testing utilities (if used) libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test - -// Alternatively, use bundle versions with all dependencies included -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0-bundle" -libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0-bundle" -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0-bundle" % Test ``` Your existing validation code will continue to work without any changes. @@ -64,11 +59,23 @@ The valar-translator module provides internationalization support: import net.ghoula.valar.* import net.ghoula.valar.translator.Translator -// Implement the Translator trait with your i18n library +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key and format with its arguments - I18n.lookup(error.key.getOrElse("error.unknown"), error.args) + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( + error.key.getOrElse("error.unknown"), + error.message // Fall back to the original message if the key is not found + ) } } @@ -88,26 +95,24 @@ architecture. libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" // With this (note the triple %%% for cross-platform support): -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" // Add optional testing utilities (if desired): -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test - -// Alternatively, use bundle versions with all dependencies included: -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test ``` -### Available Artifacts +> **Note:** v0.4.8 used bundle versions (`-bundle` suffix) that included all dependencies. Starting from v0.5.0, we've moved to the standard approach without bundle versions for simpler dependency management. + +### Available Artifacts for v0.4.8 -The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). If you need to reference a specific artifact directly, here are all the available options: +The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). For v0.4.8, only bundle versions are available: -| Module | Platform | Artifact ID | Standard Version | Bundle Version | -|--------|----------|-------------------------|------------------------------------------------------|-------------------------------------------------------------| -| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8"` | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | -| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | -| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8"` | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | -| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | +| Module | Platform | Artifact ID | Bundle Version | +|--------|----------|-------------------------|-------------------------------------------------------------| +| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | +| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | +| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | +| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | Your existing validation code will continue to work without any changes. diff --git a/docs-src/README.md b/docs-src/README.md index 084fb12..a3e3385 100644 --- a/docs-src/README.md +++ b/docs-src/README.md @@ -38,17 +38,14 @@ detailed error messages useful for debugging or user feedback. Valar provides artifacts for both JVM and Scala Native platforms: -| Module | Platform | Artifact ID | Standard Version | Bundle Version | -|-----------------|----------|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | -| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | -| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | -| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | -| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | -| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | - -The **bundle versions** (with `-bundle` suffix) include all dependencies, making them easier to use in projects that -don't need fine-grained dependency control. +| Module | Platform | Artifact ID | Maven Central | +|-----------------|----------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | +| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | +| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | +| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | +| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | +| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. @@ -65,11 +62,6 @@ libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" // Optional: For enhanced testing with MUnit libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test - -// Alternatively, use bundle versions with all dependencies included -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0-bundle" -libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0-bundle" -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0-bundle" % Test ``` ## **Basic Usage Example** @@ -256,13 +248,22 @@ The `valar-translator` module provides internationalization (i18n) support for v import net.ghoula.valar.* import net.ghoula.valar.translator.Translator -// Implement the Translator trait with your i18n library +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key and format with its arguments - I18n.lookup( + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( error.key.getOrElse("error.unknown"), - error.args + error.message // Fall back to the original message if key is not found ) } } @@ -313,7 +314,7 @@ The main breaking change in v0.4.0 was the **artifact name change** from valar t libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" // With this (note the triple %%% for cross-platform support): - libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ``` ## **Compatibility** diff --git a/docs-src/munit/README.md b/docs-src/munit/README.md index 3c24cf2..67756cb 100644 --- a/docs-src/munit/README.md +++ b/docs-src/munit/README.md @@ -1,5 +1,9 @@ # valar-munit +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. @@ -121,6 +125,8 @@ test("validation fails with expected errors") { - **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. -- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated value. +- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated + value. -- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match your testing preferences. +- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match + your testing preferences. diff --git a/docs-src/translator/README.md b/docs-src/translator/README.md index 0a373b3..c8566a3 100644 --- a/docs-src/translator/README.md +++ b/docs-src/translator/README.md @@ -6,6 +6,14 @@ The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. +## Installation + +Add the valar-translator dependency to your build.sbt: + +```scala +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" +``` + ## Usage The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. @@ -18,13 +26,22 @@ Create a `given` instance of `Translator` that contains your localization logic. import net.ghoula.valar.translator.Translator import net.ghoula.valar.ValidationErrors.ValidationError -// Assuming you have an I18n library +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key and format with its arguments - I18n.lookup( + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( error.key.getOrElse("error.unknown"), - error.args + error.message // Fall back to the original message if the key is not found ) } } From 38159aef9de75836a04efaf5171aee2038b2b962 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 8 Jul 2025 23:48:42 +0200 Subject: [PATCH 10/19] ...for it --- build.sbt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.sbt b/build.sbt index 1f7a771..0f2e5f7 100644 --- a/build.sbt +++ b/build.sbt @@ -65,6 +65,7 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) // --- MiMa & TASTy-MiMa Configuration --- mimaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release tastyMiMaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release + mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set // --- Library Dependencies --- libraryDependencies ++= Seq( "io.github.cquiroz" %%% "scala-java-time" % "2.6.0", @@ -100,6 +101,7 @@ lazy val valarMunit = crossProject(JVMPlatform, NativePlatform) useGpgAgent := true, mimaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release tastyMiMaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release + mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" ) .jvmSettings( @@ -126,6 +128,7 @@ lazy val valarTranslator = crossProject(JVMPlatform, NativePlatform) useGpgAgent := true, mimaPreviousArtifacts := Set.empty, tastyMiMaPreviousArtifacts := Set.empty, + mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set, libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" % Test ) .jvmSettings( From 1862a3d0ca327b1bb80220acd90c48b33fd24fb5 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 00:24:13 +0200 Subject: [PATCH 11/19] build --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 0f2e5f7..bd29f2b 100644 --- a/build.sbt +++ b/build.sbt @@ -79,7 +79,7 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) addCommandAlias("prepare", "scalafixAll; scalafmtAll; scalafmtSbt"), addCommandAlias( "check", - "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck; mimaReportBinaryIssues; tastyMiMaReportIssues" + "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck" ) ) .jvmConfigure(_.enablePlugins(MdocPlugin)) From d024f2b1169c2beee59e4604c8b9944c44518ac2 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 02:24:13 +0200 Subject: [PATCH 12/19] out of sync --- .../net/ghoula/valar/AsyncValidator.scala | 222 +++++++++ .../scala/net/ghoula/valar/Validator.scala | 442 ++---------------- .../ghoula/valar/internal/Derivation.scala | 343 ++++++++++++++ .../net/ghoula/valar/AsyncValidatorSpec.scala | 268 +++++++++++ .../net/ghoula/valar/TupleValidatorSpec.scala | 4 +- .../net/ghoula/valar/ValidationSpec.scala | 10 +- 6 files changed, 867 insertions(+), 422 deletions(-) create mode 100644 valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala create mode 100644 valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala create mode 100644 valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala diff --git a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala new file mode 100644 index 0000000..489f94a --- /dev/null +++ b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala @@ -0,0 +1,222 @@ +package net.ghoula.valar + +import java.time.* +import java.util.UUID +import scala.concurrent.{ExecutionContext, Future} +import scala.deriving.Mirror +import scala.quoted.{Expr, Quotes, Type} + +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.internal.Derivation + +/** A typeclass for defining custom asynchronous validation logic for type `A`. + * + * This is used for validations that involve non-blocking I/O, such as checking for uniqueness in a + * database or calling an external service. + * + * @tparam A + * the type to be validated + */ +trait AsyncValidator[A] { + + /** Asynchronously validate an instance of type `A`. + * + * @param a + * the instance to validate + * @param ec + * the execution context for the Future + * @return + * a `Future` containing the `ValidationResult[A]` + */ + def validateAsync(a: A)(using ec: ExecutionContext): Future[ValidationResult[A]] +} + +/** Companion object for the [[AsyncValidator]] typeclass. */ +object AsyncValidator { + + /** Summons an implicit [[AsyncValidator]] instance for type `A`. */ + def apply[A](using v: AsyncValidator[A]): AsyncValidator[A] = v + + /** Lifts a synchronous `Validator` into an `AsyncValidator`. + * + * This allows synchronous validators to be used seamlessly in an asynchronous validation chain. + * + * @param v + * the synchronous validator to lift + * @return + * an `AsyncValidator` that wraps the result in a `Future.successful`. + */ + def fromSync[A](v: Validator[A]): AsyncValidator[A] = new AsyncValidator[A] { + def validateAsync(a: A)(using ec: ExecutionContext): Future[ValidationResult[A]] = + Future.successful(v.validate(a)) + } + + // === Given instances for common types === + + /** Default async validator for `Option[A]`. */ + given optionAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Option[A]] with { + def validateAsync(opt: Option[A])(using ec: ExecutionContext): Future[ValidationResult[Option[A]]] = + opt match { + case None => Future.successful(ValidationResult.Valid(None)) + case Some(value) => + v.validateAsync(value).map { + case ValidationResult.Valid(a) => ValidationResult.Valid(Some(a)) + case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) + } + } + } + + /** Validates a `List[A]` by validating each element asynchronously. */ + given listAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[List[A]] with { + def validateAsync(xs: List[A])(using ec: ExecutionContext): Future[ValidationResult[List[A]]] = { + val futureResults = xs.zipWithIndex.map { case (item, _) => + v.validateAsync(item).map { + case ValidationResult.Valid(a) => ValidationResult.Valid(a) + case ValidationResult.Invalid(errors) => + // Don't annotate with index, let the derivation handle field paths + ValidationResult.Invalid(errors) + } + } + + Future.sequence(futureResults).map { results => + val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], List.empty[A])) { + case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) + case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) + } + if (errors.isEmpty) ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) + } + } + } + + /** Validates a `Seq[A]` by validating each element asynchronously. */ + given seqAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Seq[A]] with { + def validateAsync(xs: Seq[A])(using ec: ExecutionContext): Future[ValidationResult[Seq[A]]] = { + val futureResults = xs.zipWithIndex.map { case (item, _) => + v.validateAsync(item).map { + case ValidationResult.Valid(a) => ValidationResult.Valid(a) + case ValidationResult.Invalid(errors) => + ValidationResult.Invalid(errors) + } + } + + Future.sequence(futureResults).map { results => + val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Seq.empty[A])) { + case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) + case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) + } + if (errors.isEmpty) ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) + } + } + } + + /** Validates a `Vector[A]` by validating each element asynchronously. */ + given vectorAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Vector[A]] with { + def validateAsync(xs: Vector[A])(using ec: ExecutionContext): Future[ValidationResult[Vector[A]]] = { + val futureResults = xs.zipWithIndex.map { case (item, _) => + v.validateAsync(item).map { + case ValidationResult.Valid(a) => ValidationResult.Valid(a) + case ValidationResult.Invalid(errors) => + ValidationResult.Invalid(errors) + } + } + + Future.sequence(futureResults).map { results => + val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Vector.empty[A])) { + case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) + case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) + } + if (errors.isEmpty) ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) + } + } + } + + /** Validates a `Set[A]` by validating each element asynchronously. */ + given setAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Set[A]] with { + def validateAsync(xs: Set[A])(using ec: ExecutionContext): Future[ValidationResult[Set[A]]] = { + val futureResults = xs.map(v.validateAsync(_)) + + Future.sequence(futureResults).map { results => + val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Set.empty[A])) { + case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals + a) + case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) + } + if (errors.isEmpty) ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) + } + } + } + + /** Validates a `Map[K, V]` by validating each key and value asynchronously. */ + given mapAsyncValidator[K, V](using vk: AsyncValidator[K], vv: AsyncValidator[V]): AsyncValidator[Map[K, V]] with { + def validateAsync(m: Map[K, V])(using ec: ExecutionContext): Future[ValidationResult[Map[K, V]]] = { + val futureResults = m.map { case (k, v) => + val futureKey = vk.validateAsync(k).map { + case ValidationResult.Valid(kk) => ValidationResult.Valid(kk) + case ValidationResult.Invalid(es) => + ValidationResult.Invalid(es.map(_.annotateField("key", k.getClass.getSimpleName))) + } + val futureValue = vv.validateAsync(v).map { + case ValidationResult.Valid(vv) => ValidationResult.Valid(vv) + case ValidationResult.Invalid(es) => + ValidationResult.Invalid(es.map(_.annotateField("value", v.getClass.getSimpleName))) + } + + for { + keyResult <- futureKey + valueResult <- futureValue + } yield keyResult.zip(valueResult) + } + + Future.sequence(futureResults).map { results => + val (errors, validPairs) = results.foldLeft((Vector.empty[ValidationError], Map.empty[K, V])) { + case ((errs, acc), ValidationResult.Valid(pair)) => (errs, acc + pair) + case ((errs, acc), ValidationResult.Invalid(e)) => (errs ++ e, acc) + } + if (errors.isEmpty) ValidationResult.Valid(validPairs) else ValidationResult.Invalid(errors) + } + } + } + + // === Lift all the built-in Validator instances to AsyncValidator === + // These are only used as fallbacks when no custom validators are provided + + /** Async validator for non-negative integers. */ + given nonNegativeIntAsyncValidator: AsyncValidator[Int] = fromSync(Validator.nonNegativeIntValidator) + + /** Async validator for finite floats. */ + given finiteFloatAsyncValidator: AsyncValidator[Float] = fromSync(Validator.finiteFloatValidator) + + /** Async validator for finite doubles. */ + given finiteDoubleAsyncValidator: AsyncValidator[Double] = fromSync(Validator.finiteDoubleValidator) + + /** Async validator for non-empty strings. */ + given nonEmptyStringAsyncValidator: AsyncValidator[String] = fromSync(Validator.nonEmptyStringValidator) + + // Pass-through validators for basic types + given booleanAsyncValidator: AsyncValidator[Boolean] = fromSync(Validator.booleanValidator) + given byteAsyncValidator: AsyncValidator[Byte] = fromSync(Validator.byteValidator) + given shortAsyncValidator: AsyncValidator[Short] = fromSync(Validator.shortValidator) + given longAsyncValidator: AsyncValidator[Long] = fromSync(Validator.longValidator) + given charAsyncValidator: AsyncValidator[Char] = fromSync(Validator.charValidator) + given unitAsyncValidator: AsyncValidator[Unit] = fromSync(Validator.unitValidator) + given bigIntAsyncValidator: AsyncValidator[BigInt] = fromSync(Validator.bigIntValidator) + given bigDecimalAsyncValidator: AsyncValidator[BigDecimal] = fromSync(Validator.bigDecimalValidator) + given symbolAsyncValidator: AsyncValidator[Symbol] = fromSync(Validator.symbolValidator) + given uuidAsyncValidator: AsyncValidator[UUID] = fromSync(Validator.uuidValidator) + given instantAsyncValidator: AsyncValidator[Instant] = fromSync(Validator.instantValidator) + given localDateAsyncValidator: AsyncValidator[LocalDate] = fromSync(Validator.localDateValidator) + given localTimeAsyncValidator: AsyncValidator[LocalTime] = fromSync(Validator.localTimeValidator) + given localDateTimeAsyncValidator: AsyncValidator[LocalDateTime] = fromSync(Validator.localDateTimeValidator) + given zonedDateTimeAsyncValidator: AsyncValidator[ZonedDateTime] = fromSync(Validator.zonedDateTimeValidator) + given javaDurationAsyncValidator: AsyncValidator[Duration] = fromSync(Validator.durationValidator) + + /** Automatically derives an `AsyncValidator` for case classes using Scala 3 macros. */ + inline def derive[T](using m: Mirror.ProductOf[T]): AsyncValidator[T] = + ${ deriveImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } + + /** Macro implementation for deriving an `AsyncValidator`. */ + private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] + )(using q: Quotes): Expr[AsyncValidator[T]] = { + Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = true).asExprOf[AsyncValidator[T]] + } +} diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala index 9151da7..e6069fe 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala @@ -11,7 +11,7 @@ import scala.reflect.ClassTag import net.ghoula.valar.ValidationErrors.ValidationError import net.ghoula.valar.ValidationHelpers.* import net.ghoula.valar.ValidationResult.{validateUnion, given} -import net.ghoula.valar.internal.MacroHelper +import net.ghoula.valar.internal.Derivation /** A typeclass for defining custom validation logic for type `A`. * @@ -39,6 +39,8 @@ object Validator { /** Summons an implicit [[Validator]] instance for type `A`. */ def apply[A](using v: Validator[A]): Validator[A] = v + // ... keep all the existing given instances exactly as they are ... + /** Validates that an Int is non-negative (>= 0). Uses [[ValidationHelpers.nonNegativeInt]]. */ given nonNegativeIntValidator: Validator[Int] with { def validate(i: Int): ValidationResult[Int] = nonNegativeInt(i) @@ -63,31 +65,13 @@ object Validator { def validate(s: String): ValidationResult[String] = nonEmpty(s) } - /** Default validator for `Option[A]`. If the option is `Some(a)`, it validates the inner `a` - * using the implicit `Validator[A]`. If the option is `None`, it is considered `Valid`. - * Accumulates errors from the inner validation if `Some`. - * - * @tparam A - * the inner type of the Option. - * @param v - * the implicit validator for the inner type `A`. - * @return - * A `Validator[Option[A]]`. - */ + /** Default validator for `Option[A]`. */ given optionValidator[A](using v: Validator[A]): Validator[Option[A]] with { def validate(opt: Option[A]): ValidationResult[Option[A]] = optional(opt)(using v) } - /** Validates a `List[A]` by validating each element using the implicit `Validator[A]`. - * Accumulates all errors found in invalid elements. - * @tparam A - * the element type. - * @param v - * the implicit validator for the element type `A`. - * @return - * A `Validator[List[A]]`. - */ + /** Validates a `List[A]` by validating each element. */ given listValidator[A](using v: Validator[A]): Validator[List[A]] with { def validate(xs: List[A]): ValidationResult[List[A]] = { val results = xs.map(v.validate) @@ -99,16 +83,7 @@ object Validator { } } - /** Validates a `Seq[A]` by validating each element using the implicit `Validator[A]`. Accumulates - * all errors found in invalid elements. - * - * @tparam A - * the element type. - * @param v - * the implicit validator for the element type `A`. - * @return - * A `Validator[Seq[A]]`. - */ + /** Validates a `Seq[A]` by validating each element. */ given seqValidator[A](using v: Validator[A]): Validator[Seq[A]] with { def validate(xs: Seq[A]): ValidationResult[Seq[A]] = { val results = xs.map(v.validate) @@ -120,16 +95,7 @@ object Validator { } } - /** Validates a `Vector[A]` by validating each element using the implicit `Validator[A]`. - * Accumulates all errors found in invalid elements. - * - * @tparam A - * the element type. - * @param v - * the implicit validator for the element type `A`. - * @return - * A `Validator[Vector[A]]`. - */ + /** Validates a `Vector[A]` by validating each element. */ given vectorValidator[A](using v: Validator[A]): Validator[Vector[A]] with { def validate(xs: Vector[A]): ValidationResult[Vector[A]] = { val results = xs.map(v.validate) @@ -141,18 +107,7 @@ object Validator { } } - /** Validates a `Set[A]` by validating each element using the implicit `Validator[A]`. Accumulates - * all errors found in invalid elements. - * - * @note - * The order of accumulated errors from a Set is not guaranteed due to its unordered nature. - * @tparam A - * the element type. - * @param v - * the implicit validator for the element type `A`. - * @return - * A `Validator[Set[A]]`. - */ + /** Validates a `Set[A]` by validating each element. */ given setValidator[A](using v: Validator[A]): Validator[Set[A]] with { def validate(xs: Set[A]): ValidationResult[Set[A]] = { val results = xs.map(v.validate) @@ -164,21 +119,7 @@ object Validator { } } - /** Validates a `Map[K, V]` by validating each key with `Validator[K]` and each value with - * `Validator[V]`. Accumulates all errors from invalid keys and values. Errors are annotated with - * context indicating whether they originated from a 'key' or a 'value'. - * - * @tparam K - * the key type. - * @tparam V - * the value type. - * @param vk - * the implicit validator for the key type `K`. - * @param vv - * the implicit validator for the value type `V`. - * @return - * A `Validator[Map[K, V]]`. - */ + /** Validates a `Map[K, V]` by validating each key and value. */ given mapValidator[K, V](using vk: Validator[K], vv: Validator[V]): Validator[Map[K, V]] with { def validate(m: Map[K, V]): ValidationResult[Map[K, V]] = { val results = m.map { case (k, v) => @@ -206,26 +147,7 @@ object Validator { } } - /** Helper method for validating iterable collections and building results. - * - * This method provides a generic implementation for validating any iterable collection of - * elements. It applies the provided validator to each element, accumulates any validation - * errors, and constructs a new collection of the same type containing only valid elements if all - * validations succeed. - * - * @tparam A - * the element type to be validated - * @tparam C - * the collection type constructor (e.g., Array, Vector) - * @param xs - * the iterable collection of elements to validate - * @param builder - * a function that constructs a collection of type C from a Vector of valid elements - * @param v - * the implicit validator for type A - * @return - * a ValidationResult containing either the valid collection or accumulated errors - */ + /** Helper for validating iterable collections. */ private def validateIterable[A, C[_]]( xs: Iterable[A], builder: Vector[A] => C[A] @@ -243,402 +165,92 @@ object Validator { else ValidationResult.Invalid(errors) } - /** Validates an `Array[A]` by validating each element using the implicit `Validator[A]`. - * Accumulates all errors found in invalid elements. - * - * @tparam A - * the element type. - * @param v - * the implicit validator for the element type `A`. - * @param ct - * implicit ClassTag required for creating the resulting Array. - * @return - * A `Validator[Array[A]]`. - */ + /** Validates an `Array[A]`. */ given arrayValidator[A](using v: Validator[A], ct: ClassTag[A]): Validator[Array[A]] with { def validate(xs: Array[A]): ValidationResult[Array[A]] = validateIterable(xs, (validValues: Vector[A]) => validValues.toArray) } - /** Validates an `ArraySeq[A]` by validating each element using the implicit `Validator[A]`. - * Accumulates all errors found in invalid elements. - * - * @tparam A - * the element type. - * @param v - * the implicit validator for the element type `A`. - * @param ct - * implicit ClassTag required for the underlying Array. - * @return - * A `Validator[ArraySeq[A]]`. - */ + /** Validates an `ArraySeq[A]`. */ given arraySeqValidator[A](using v: Validator[A], ct: ClassTag[A]): Validator[ArraySeq[A]] with { def validate(xs: ArraySeq[A]): ValidationResult[ArraySeq[A]] = validateIterable(xs, (validValues: Vector[A]) => ArraySeq.unsafeWrapArray(validValues.toArray)) } - /** Validates an intersection type `A & B` by applying both `Validator[A]` and `Validator[B]`. The - * result is `Valid` only if *both* underlying validators succeed. If either or both fail, their - * errors are accumulated using `zip`. - * - * @tparam A - * the first type in the intersection. - * @tparam B - * the second type in the intersection. - * @param va - * the implicit validator for type `A`. - * @param vb - * the implicit validator for type `B`. - * @return - * A `Validator[A & B]`. - */ + /** Validates an intersection type `A & B`. */ given intersectionValidator[A, B](using va: Validator[A], vb: Validator[B]): Validator[A & B] with { def validate(ab: A & B): ValidationResult[A & B] = va.validate(ab).zip(vb.validate(ab)).map(_ => ab) } - /** Validates a union type `A | B`. It attempts to validate the input value first as type `A` and - * then as type `B`. The result is `Valid` if *either* validation succeeds (preferring the result - * for `A` if both succeed). If both underlying validations fail, it returns an `Invalid` result - * containing a summary error wrapping the errors from both attempts. Delegates to - * [[ValidationResult.validateUnion]]. - * - * @tparam A - * the first type in the union. - * @tparam B - * the second type in the union. - * @param va - * the implicit validator for type `A`. - * @param vb - * the implicit validator for type `B`. - * @param ctA - * implicit ClassTag required for runtime type checking for `A`. - * @param ctB - * implicit ClassTag required for runtime type checking for `B`. - * @return - * A `Validator[A | B]`. - */ - given unionValidator[A, B](using va: Validator[A], vb: Validator[B], ctA: ClassTag[A], ctB: ClassTag[B]): Validator[ - A | B - ] with { + /** Validates a union type `A | B`. */ + given unionValidator[A, B](using + va: Validator[A], + vb: Validator[B], + ctA: ClassTag[A], + ctB: ClassTag[B] + ): Validator[A | B] with { def validate(value: A | B): ValidationResult[A | B] = validateUnion[A, B](value)(using va, vb, ctA, ctB) } - /** This section provides "pass-through" `given` instances that always return `Valid`. They are - * marked as `inline` to allow the compiler to eliminate the validation overhead, making them - * zero-cost abstractions when used by the `deriveValidatorMacro`. - */ - - /** Pass-through validator for Boolean values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ + /** This section provides "pass-through" `given` instances that always return `Valid`. */ inline given booleanValidator: Validator[Boolean] with { def validate(b: Boolean): ValidationResult[Boolean] = ValidationResult.Valid(b) } - - /** Pass-through validator for Byte values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given byteValidator: Validator[Byte] with { def validate(b: Byte): ValidationResult[Byte] = ValidationResult.Valid(b) } - - /** Pass-through validator for Short values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given shortValidator: Validator[Short] with { def validate(s: Short): ValidationResult[Short] = ValidationResult.Valid(s) } - - /** Pass-through validator for Long values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given longValidator: Validator[Long] with { def validate(l: Long): ValidationResult[Long] = ValidationResult.Valid(l) } - - /** Pass-through validator for Char values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given charValidator: Validator[Char] with { def validate(c: Char): ValidationResult[Char] = ValidationResult.Valid(c) } - - /** Pass-through validator for Unit values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given unitValidator: Validator[Unit] with { def validate(u: Unit): ValidationResult[Unit] = ValidationResult.Valid(u) } - - /** Pass-through validator for BigInt values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given bigIntValidator: Validator[BigInt] with { def validate(bi: BigInt): ValidationResult[BigInt] = ValidationResult.Valid(bi) } - - /** Pass-through validator for BigDecimal values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given bigDecimalValidator: Validator[BigDecimal] with { def validate(bd: BigDecimal): ValidationResult[BigDecimal] = ValidationResult.Valid(bd) } - - /** Pass-through validator for Symbol values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given symbolValidator: Validator[Symbol] with { def validate(s: Symbol): ValidationResult[Symbol] = ValidationResult.Valid(s) } - - /** Pass-through validator for UUID values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given uuidValidator: Validator[UUID] with { def validate(v: UUID): ValidationResult[UUID] = ValidationResult.Valid(v) } - - /** Pass-through validator for Instant values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given instantValidator: Validator[Instant] with { def validate(v: Instant): ValidationResult[Instant] = ValidationResult.Valid(v) } - - /** Pass-through validator for LocalDate values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given localDateValidator: Validator[LocalDate] with { def validate(v: LocalDate): ValidationResult[LocalDate] = ValidationResult.Valid(v) } - - /** Pass-through validator for LocalTime values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given localTimeValidator: Validator[LocalTime] with { def validate(v: LocalTime): ValidationResult[LocalTime] = ValidationResult.Valid(v) } - - /** Pass-through validator for LocalDateTime values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given localDateTimeValidator: Validator[LocalDateTime] with { def validate(v: LocalDateTime): ValidationResult[LocalDateTime] = ValidationResult.Valid(v) } - - /** Pass-through validator for ZonedDateTime values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given zonedDateTimeValidator: Validator[ZonedDateTime] with { def validate(v: ZonedDateTime): ValidationResult[ZonedDateTime] = ValidationResult.Valid(v) } - - /** Pass-through validator for Duration values. - * - * Always returns a valid result without additional validation. Marked as `inline` for compiler - * optimization. - */ inline given durationValidator: Validator[Duration] with { def validate(v: Duration): ValidationResult[Duration] = ValidationResult.Valid(v) } - /** Automatically derives a `Validator` for case classes using Scala 3 macros. - * - * Derivation is recursive, validating each field using implicitly available validators. Errors - * from nested fields are aggregated and annotated with clear field context. - * - * @tparam T - * case class type to derive validator for - * @param m - * implicit Scala 3 Mirror for reflection - * @return - * Validator[T] automatically derived validator instance - */ - inline def deriveValidatorMacro[T](using m: Mirror.ProductOf[T]): Validator[T] = - ${ deriveValidatorMacroImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } + /** Automatically derives a `Validator` for case classes using Scala 3 macros. */ + inline def derive[T](using m: Mirror.ProductOf[T]): Validator[T] = + ${ deriveImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } - /** Implementation of the `deriveValidatorMacro` method. - * - * This macro implementation generates a validator for a product type (case class) by: - * 1. Summoning validators for each field. - * 2. Extracting field names from the mirror. - * 3. Determining which fields are Options - for null-safety handling. - * 4. Generating code that validates each field and accumulates errors. - * - * @tparam T - * the product type (case class) for which to derive a validator - * @tparam Elems - * tuple type representing the types of all fields in T - * @tparam Labels - * tuple type representing the names of all fields in T - * @param m - * expression containing the product mirror for type T - * @param q - * the Quotes context for macro expansion - * @return - * an expression that constructs a Validator[T] - */ - private def deriveValidatorMacroImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + /** Macro implementation for deriving a `Validator`. */ + private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( m: Expr[Mirror.ProductOf[T]] )(using q: Quotes): Expr[Validator[T]] = { - import q.reflect.* - - val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] - val fieldLabels: List[String] = getLabels[Labels] - val isOptionList: List[Boolean] = getIsOptionFlags[Elems] - - val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) - val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) - val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) - - '{ - new Validator[T] { - def validate(a: T): ValidationResult[T] = { - a match { - case product: Product => - val validators = ${ validatorsExpr } - val labels = ${ fieldLabelsExpr } - val isOptionFlags = ${ isOptionListExpr } - - val results = product.productIterator.zipWithIndex.map { case (fieldValue, i) => - val label = labels(i) - val isOption = isOptionFlags(i) - - if (Option(fieldValue).isEmpty && !isOption) { - ValidationResult.invalid( - ValidationError( - message = s"Field '$label' must not be null.", - fieldPath = List(label), - expected = Some("non-null value"), - actual = Some("null") - ) - ) - } else { - val validator = validators(i) - validator.validate(fieldValue) match { - case ValidationResult.Valid(v) => ValidationResult.Valid(v) - case ValidationResult.Invalid(errs) => - val fieldTypeName = Option(fieldValue).map(_.getClass.getSimpleName).getOrElse("null") - ValidationResult.Invalid(errs.map(_.annotateField(label, fieldTypeName))) - } - } - }.toList - - val allErrors = results.collect { case ValidationResult.Invalid(e) => e }.flatten.toVector - if (allErrors.isEmpty) { - val validValues = results.collect { case ValidationResult.Valid(v) => v } - ValidationResult.Valid($m.fromProduct(Tuple.fromArray(validValues.toArray))) - } else { - ValidationResult.Invalid(allErrors) - } - } - } - } - } - } - - /** Summons validators for each element type in a tuple. - * - * This helper method is used by the macro implementation to get validator instances for each - * field in a product type. It recursively processes the tuple of element types, summoning a - * validator for each type and converting it to Validator[Any] for uniform handling. - * - * @tparam Elems - * tuple type representing the types for which to summon validators - * @param q - * the Quotes context for macro expansion - * @return - * a list of expressions, each constructing a Validator[Any] - */ - private def summonValidators[Elems <: Tuple: Type](using q: Quotes): List[Expr[Validator[Any]]] = { - import q.reflect.* - Type.of[Elems] match { - case '[EmptyTuple] => Nil - case '[h *: t] => - val validatorExpr = Expr.summon[Validator[h]].getOrElse { - report.errorAndAbort(s"Could not find a given Validator for type ${Type.show[h]}") - } - '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] - } - } - - /** Extracts field names from a tuple type of string literals. - * - * This helper method is used by the macro implementation to obtain the names of fields in a - * product type. It recursively processes the tuple of string literal types, extracting each name - * as a String. - * - * @tparam Labels - * tuple type of string literals representing field names - * @param q - * the Quotes context for macro expansion - * @return - * a list of field names as strings - */ - private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { - import q.reflect.* - def loop(tpe: TypeRepr): List[String] = tpe.dealias match { - case AppliedType(_, List(head, tail)) => - head match { - case ConstantType(StringConstant(label)) => label :: loop(tail) - case _ => report.errorAndAbort(s"Macro error: Expected a literal string for a label, but got ${head.show}") - } - case t if t =:= TypeRepr.of[EmptyTuple] => Nil - case _ => report.errorAndAbort(s"Macro error: The labels tuple was not structured as expected: ${tpe.show}") - } - - loop(TypeRepr.of[Labels]) - } - - /** Determines which fields in a product type are Options. - * - * This helper method is used by the macro implementation to identify which fields in a product - * type are Option types. This information is used for null-safety handling during validation. - * - * @tparam Elems - * tuple type representing the types of all fields - * @param q - * the Quotes context for macro expansion - * @return - * a list of booleans indicating whether each field is an Option type - */ - private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { - import q.reflect.* - Type.of[Elems] match { - case '[EmptyTuple] => Nil - case '[h *: t] => - (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] - } + Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = false).asExprOf[Validator[T]] } } diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala new file mode 100644 index 0000000..3941d64 --- /dev/null +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala @@ -0,0 +1,343 @@ +package net.ghoula.valar.internal + +import scala.concurrent.{ExecutionContext, Future} +import scala.deriving.Mirror +import scala.quoted.{Expr, Quotes, Type} + +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.{AsyncValidator, ValidationResult, Validator} + +/** Internal derivation engine for automatically generating validator instances. + * + * This object provides the core macro infrastructure for deriving both synchronous and + * asynchronous validators for product types (case classes). It handles the compile-time generation + * of validation logic, field introspection, and error annotation. + * + * @note + * This object is strictly for internal use by Valar's macro system and is not part of the public + * API. All methods, signatures, and behavior are subject to change without notice in future + * versions. + * + * @since 0.5.0 + */ +object Derivation { + + /** Processes validation results from multiple fields into a single consolidated result. + * + * This method aggregates validation outcomes from all fields of a product type. If any field + * validation fails, all errors are collected and returned as an `Invalid` result. If all + * validations succeed, the validated values are used to reconstruct the original product type + * using the provided `Mirror`. + * + * @param results + * The validation results from each field of the product type. + * @param mirror + * The mirror instance used to reconstruct the product type from validated field values. + * @tparam T + * The product type being validated. + * @return + * A `ValidationResult[T]` containing either the reconstructed valid product or accumulated + * errors. + */ + private def processResults[T]( + results: List[ValidationResult[Any]], + mirror: Mirror.ProductOf[T] + ): ValidationResult[T] = { + val allErrors = results.collect { case ValidationResult.Invalid(e) => e }.flatten.toVector + if (allErrors.isEmpty) { + val validValues = results.collect { case ValidationResult.Valid(v) => v } + ValidationResult.Valid(mirror.fromProduct(Tuple.fromArray(validValues.toArray))) + } else { + ValidationResult.Invalid(allErrors) + } + } + + /** Enhances validation results with field-specific context information. + * + * This method annotates validation errors with the field name and type information, providing + * better debugging and error reporting capabilities. Valid results are passed through unchanged. + * + * @param result + * The validation result to annotate. + * @param label + * The field name for error context. + * @param fieldValue + * The field value used to extract type information. + * @return + * The validation result with enhanced error context if invalid, or unchanged if valid. + */ + private def annotateErrors( + result: ValidationResult[Any], + label: String, + fieldValue: Any + ): ValidationResult[Any] = { + result match { + case ValidationResult.Valid(v) => ValidationResult.Valid(v) + case ValidationResult.Invalid(errs) => + val fieldTypeName = Option(fieldValue).map(_.getClass.getSimpleName).getOrElse("null") + ValidationResult.Invalid(errs.map(_.annotateField(label, fieldTypeName))) + } + } + + /** Applies validation logic to each field of a product type with null-safety handling. + * + * This method iterates through the fields of a product type, applying the appropriate validation + * logic to each field. It handles null values appropriately based on whether the field is + * optional, and provides consistent error handling for both synchronous and asynchronous + * validation scenarios. + * + * @param product + * The product instance whose fields are being validated. + * @param validators + * The sequence of validators corresponding to each field. + * @param labels + * The field names for error reporting. + * @param isOptionFlags + * Flags indicating which fields are optional (Option types). + * @param validateAndAnnotate + * Function to apply validation and annotation to a field. + * @param handleNull + * Function to handle null values in non-optional fields. + * @tparam V + * The validator type (either `Validator` or `AsyncValidator`). + * @tparam R + * The result type (either `ValidationResult` or `Future[ValidationResult]`). + * @return + * A list of validation results for each field. + */ + private def validateProduct[V, R]( + product: Product, + validators: Seq[V], + labels: List[String], + isOptionFlags: List[Boolean], + validateAndAnnotate: (V, Any, String) => R, + handleNull: String => R + ): List[R] = { + product.productIterator.zipWithIndex.map { case (fieldValue, i) => + val label = labels(i) + if (Option(fieldValue).isEmpty && !isOptionFlags(i)) { + handleNull(label) + } else { + val validator = validators(i) + validateAndAnnotate(validator, fieldValue, label) + } + }.toList + } + + /** Extracts field names from a compile-time tuple of string literal types. + * + * This method recursively processes a tuple type containing string literals (typically from + * `Mirror.MirroredElemLabels`) to extract the actual field names as a runtime `List[String]`. It + * performs compile-time validation to ensure all labels are string literals. + * + * @param q + * The quotes context for macro operations. + * @tparam Labels + * The tuple type containing string literal types for field names. + * @return + * A list of field names extracted from the tuple type. + * @throws Compilation + * error if any label is not a string literal. + */ + private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { + import q.reflect.* + def loop(tpe: TypeRepr): List[String] = tpe.dealias match { + case AppliedType(_, List(head, tail)) => + head match { + case ConstantType(StringConstant(label)) => label :: loop(tail) + case _ => + report.errorAndAbort( + s"Invalid field label type: expected string literal, found ${head.show}. " + + "This typically indicates a structural issue with the case class definition." + ) + } + case t if t =:= TypeRepr.of[EmptyTuple] => Nil + case _ => + report.errorAndAbort( + s"Invalid label tuple structure: ${tpe.show}. " + + "This may indicate an incompatible case class or tuple definition." + ) + } + loop(TypeRepr.of[Labels]) + } + + /** Analyzes field types to identify which fields are optional (`Option[T]`). + * + * This method examines each field type in a product type to determine if it's an `Option` type. + * This information is used during validation to handle null values appropriately - null values + * are acceptable for optional fields but trigger validation errors for required fields. + * + * @param q + * The quotes context for macro operations. + * @tparam Elems + * The tuple type containing all field types. + * @return + * A list of boolean flags indicating which fields are optional. + */ + private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { + import q.reflect.* + Type.of[Elems] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] + } + } + + /** Generates validator instances for product types using compile-time reflection. + * + * This is the core derivation method that generates either synchronous or asynchronous + * validators based on the `isAsync` parameter. It performs compile-time introspection of the + * product type, extracts field information, summons appropriate validators for each field, and + * generates optimized validation logic. + * + * The generated validators handle: + * - Field-by-field validation using appropriate validator instances + * - Error accumulation and proper error context annotation + * - Null-safety for optional vs required fields + * - Automatic lifting of synchronous validators in async contexts + * - Exception handling for asynchronous operations + * + * @param m + * The mirror instance for the product type being validated. + * @param isAsync + * Flag indicating whether to generate an `AsyncValidator` (true) or `Validator` (false). + * @param q + * The quotes context for macro operations. + * @tparam T + * The product type for which to generate a validator. + * @tparam Elems + * The tuple type containing all field types. + * @tparam Labels + * The tuple type containing all field names as string literals. + * @return + * An expression representing the generated validator instance. + * @throws Compilation + * error if required validator instances cannot be found for any field type. + */ + def deriveValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]], + isAsync: Boolean + )(using q: Quotes): Expr[Any] = { + import q.reflect.* + + val fieldLabels: List[String] = getLabels[Labels] + val isOptionList: List[Boolean] = getIsOptionFlags[Elems] + val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) + val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) + + if (isAsync) { + def summonAsyncOrSync[E <: Tuple: Type]: List[Expr[AsyncValidator[Any]]] = + Type.of[E] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).getOrElse { + report.errorAndAbort( + s"Cannot derive AsyncValidator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + + "Please provide a given instance of either Validator[${Type.show[h]}] or AsyncValidator[${Type.show[h]}]." + ) + } + + val finalExpr = validatorExpr.asTerm.tpe.asType match { + case '[AsyncValidator[h]] => validatorExpr + case '[Validator[h]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } + } + + '{ MacroHelper.upcastTo[AsyncValidator[Any]](${ finalExpr }) } :: summonAsyncOrSync[t] + } + + val fieldValidators: List[Expr[AsyncValidator[Any]]] = summonAsyncOrSync[Elems] + val validatorsExpr: Expr[Seq[AsyncValidator[Any]]] = Expr.ofSeq(fieldValidators) + + '{ + new AsyncValidator[T] { + def validateAsync(a: T)(using ec: ExecutionContext): Future[ValidationResult[T]] = { + a match { + case product: Product => + val validators = ${ validatorsExpr } + val labels = ${ fieldLabelsExpr } + val isOptionFlags = ${ isOptionListExpr } + + val fieldResultsF = validateProduct( + product, + validators, + labels, + isOptionFlags, + validateAndAnnotate = (v, fv, l) => v.validateAsync(fv).map(annotateErrors(_, l, fv)), + handleNull = l => + Future.successful( + ValidationResult.invalid( + ValidationError( + s"Field '$l' must not be null.", + List(l), + expected = Some("non-null value"), + actual = Some("null") + ) + ) + ) + ) + + val allResultsF: Future[List[ValidationResult[Any]]] = + Future.sequence(fieldResultsF.map { f => + f.recover { case scala.util.control.NonFatal(ex) => + ValidationResult.invalid( + ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") + ) + } + }) + + allResultsF.map(processResults(_, ${ m })) + } + } + } + }.asExprOf[Any] + } else { + def summonValidators[E <: Tuple: Type]: List[Expr[Validator[Any]]] = + Type.of[E] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + val validatorExpr = Expr.summon[Validator[h]].getOrElse { + report.errorAndAbort( + s"Cannot derive Validator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + + "Please provide a given instance of Validator[${Type.show[h]}]." + ) + } + '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] + } + + val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] + val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) + + '{ + new Validator[T] { + def validate(a: T): ValidationResult[T] = { + a match { + case product: Product => + val validators = ${ validatorsExpr } + val labels = ${ fieldLabelsExpr } + val isOptionFlags = ${ isOptionListExpr } + + val results = validateProduct( + product, + validators, + labels, + isOptionFlags, + validateAndAnnotate = (v, fv, l) => annotateErrors(v.validate(fv), l, fv), + handleNull = l => + ValidationResult.invalid( + ValidationError( + s"Field '$l' must not be null.", + List(l), + expected = Some("non-null value"), + actual = Some("null") + ) + ) + ) + + processResults(results, ${ m }) + } + } + } + }.asExprOf[Any] + } + } +} diff --git a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala new file mode 100644 index 0000000..2bfda9c --- /dev/null +++ b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala @@ -0,0 +1,268 @@ +package net.ghoula.valar + +import munit.FunSuite +import net.ghoula.valar.ValidationErrors.ValidationError + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.* +import scala.concurrent.{Await, Future} + +/** Provides a comprehensive test suite for the [[AsyncValidator]] typeclass and its derivation. + * + * This spec verifies all core functionalities of the asynchronous validation mechanism: + * - Successful validation of valid objects. + * - Correct handling of failures from synchronous validators within an async context. + * - Correct handling of failures from native asynchronous validators. + * - Proper accumulation of errors from both sync and async sources. + * - Correct validation of nested case classes with proper error path annotation. + * - Robustness against null values, optional fields, collections, and exceptions within Futures. + */ +class AsyncValidatorSpec extends FunSuite { + + // --- Test Models --- + + /** A simple case class for basic validation tests. */ + private case class User(name: String, age: Int) + + /** A nested case class for testing recursive derivation. */ + private case class Company(name: String, owner: User) + + /** A case class to test null handling. */ + private case class Team(lead: User, name: String) + + /** A case class for testing collection validation. */ + private case class Post(title: String, comments: List[Comment]) + + /** A simple model for items within a collection. */ + private case class Comment(author: String, text: String) + + /** A case class for testing optional field validation. */ + private case class UserProfile(username: String, email: Option[String]) + + // --- Custom Validators --- + + /** A standard synchronous validator for non-empty strings. */ + private given syncStringValidator: Validator[String] with { + def validate(value: String): ValidationResult[String] = + if (value.nonEmpty) ValidationResult.Valid(value) + else ValidationResult.invalid(ValidationError("Sync: String must not be empty")) + } + + /** A standard synchronous validator for non-negative integers. */ + private given syncIntValidator: Validator[Int] with { + def validate(value: Int): ValidationResult[Int] = + if (value >= 0) ValidationResult.Valid(value) + else ValidationResult.invalid(ValidationError("Sync: Age must be non-negative")) + } + + /** A native asynchronous validator that simulates a DB check for usernames. */ + private given asyncUsernameValidator: AsyncValidator[String] with { + def validateAsync(name: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = + Future { + if (name.toLowerCase == "admin" || name.toLowerCase == "root") { + ValidationResult.invalid(ValidationError(s"Async: Username '$name' is reserved.")) + } else { + syncStringValidator.validate(name) + } + } + } + + /** A native asynchronous validator that simulates a profanity filter. */ + private given asyncCommentTextValidator: AsyncValidator[String] with { + def validateAsync(text: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = + Future { + if (text.toLowerCase.contains("heck")) { + ValidationResult.invalid(ValidationError("Async: Comment contains profanity.")) + } else { + syncStringValidator.validate(text) + } + } + } + + /** A native asynchronous validator for email formats. */ + private given asyncEmailValidator: AsyncValidator[String] with { + def validateAsync(email: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = + Future { + if (email.contains("@")) ValidationResult.Valid(email) + else ValidationResult.invalid(ValidationError("Async: Email format is invalid.")) + } + } + + // --- Derived Validator Instances --- + + /** User validator using custom validators for both name and age. */ + private given userAsyncValidator: AsyncValidator[User] = { + given AsyncValidator[String] = asyncUsernameValidator + given AsyncValidator[Int] = AsyncValidator.fromSync(syncIntValidator) + AsyncValidator.derive + } + + // Company and Team both use the same validator setup as User (String for names, Int for ages) + private given companyAsyncValidator: AsyncValidator[Company] = AsyncValidator.derive + private given teamAsyncValidator: AsyncValidator[Team] = AsyncValidator.derive + + /** A derived validator for Comment that uses the async profanity filter for the `text` field. */ + private given commentAsyncValidator: AsyncValidator[Comment] = { + given AsyncValidator[String] = asyncCommentTextValidator + AsyncValidator.derive + } + + /** A derived validator for Post that will use the async `Comment` validator. */ + private given postAsyncValidator: AsyncValidator[Post] = { + given AsyncValidator[String] = AsyncValidator.fromSync(syncStringValidator) + AsyncValidator.derive + } + + /** A derived validator for UserProfile that needs different validators for username and email + * fields. We need to create a custom validator since the macro can't distinguish between + * different String fields. + */ + private given userProfileAsyncValidator: AsyncValidator[UserProfile] = new AsyncValidator[UserProfile] { + def validateAsync( + profile: UserProfile + )(using ec: concurrent.ExecutionContext): Future[ValidationResult[UserProfile]] = { + val usernameValidation = asyncUsernameValidator.validateAsync(profile.username) + val emailValidation = profile.email match { + case Some(email) => asyncEmailValidator.validateAsync(email).map(_.map(Some(_))) + case None => Future.successful(ValidationResult.Valid(None)) + } + + for { + nameResult <- usernameValidation + emailResult <- emailValidation + } yield { + nameResult.zip(emailResult).map { case (name, email) => + UserProfile(name, email) + } + } + } + } + + // --- Test Cases --- + + test("validateAsync should succeed for a valid object") { + val validUser = User("John", 30) + val futureResult = userAsyncValidator.validateAsync(validUser) + futureResult.map(result => assertEquals(result, ValidationResult.Valid(validUser))) + } + + test("validateAsync should handle synchronous validation failures") { + val invalidUser = User("John", -5) + val futureResult = userAsyncValidator.validateAsync(invalidUser) + futureResult.map { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 1) + assert(errors.head.message.contains("Sync: Age must be non-negative")) + case _ => fail("Expected Invalid result") + } + } + + test("validateAsync should handle asynchronous validation failures") { + val invalidUser = User("admin", 30) + val futureResult = userAsyncValidator.validateAsync(invalidUser) + futureResult.map { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 1) + assert(errors.head.message.contains("Async: Username 'admin' is reserved.")) + case _ => fail("Expected Invalid result") + } + } + + test("validateAsync should accumulate errors from both sync and async validators") { + val invalidUser = User("root", -10) + val futureResult = userAsyncValidator.validateAsync(invalidUser) + futureResult.map { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 2) + assert(errors.exists(_.message.contains("Async: Username 'root' is reserved."))) + assert(errors.exists(_.message.contains("Sync: Age must be non-negative"))) + case _ => fail("Expected Invalid result") + } + } + + test("validateAsync should handle nested case classes and annotate error paths correctly") { + val invalidCompany = Company("BadCorp", User("", -1)) + val futureResult = companyAsyncValidator.validateAsync(invalidCompany) + futureResult.map { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 2) + val nameError = errors.find(_.fieldPath.contains("name")).get + val ageError = errors.find(_.fieldPath.contains("age")).get + assertEquals(nameError.fieldPath, List("owner", "name")) + assertEquals(ageError.fieldPath, List("owner", "age")) + case _ => fail("Expected Invalid result") + } + } + + test("validateAsync should fail if a non-optional field is null") { + @SuppressWarnings(Array("scalafix:DisableSyntax.null")) + val invalidTeam = Team(null, "The A-Team") + val result = Await.result(teamAsyncValidator.validateAsync(invalidTeam), 1.second) + result match { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 1) + assert(errors.head.message.contains("Field 'lead' must not be null.")) + case _ => fail("Expected Invalid result for null field") + } + } + + test("validateAsync should recover from a failed Future in a validator") { + val failingValidator: AsyncValidator[String] = new AsyncValidator[String] { + def validateAsync(a: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = + Future.failed(new RuntimeException("DB error")) + } + case class Service(endpoint: String) + given serviceValidator: AsyncValidator[Service] = { + given AsyncValidator[String] = failingValidator + AsyncValidator.derive + } + val service = Service("https://example.com") + val futureResult = serviceValidator.validateAsync(service) + futureResult.map { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 1) + assert(errors.head.message.contains("Asynchronous validation failed unexpectedly")) + case _ => fail("Expected Invalid result from a failed future") + } + } + + test("validateAsync should handle collections with async validators") { + val post = Post( + "My Thoughts", + List(Comment("Alice", "Great post!"), Comment("Bob", "What the heck?"), Comment("Charlie", "")) + ) + val futureResult = postAsyncValidator.validateAsync(post) + futureResult.map { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 2) + assert(errors.exists(e => e.message.contains("profanity") && e.fieldPath.contains("comments"))) + assert(errors.exists(e => e.message.contains("empty") && e.fieldPath.contains("comments"))) + case _ => fail("Expected Invalid result for collection validation") + } + } + + test("validateAsync should handle optional fields with async validators") { + val invalidProfile = UserProfile("testuser", Some("not-an-email")) + val validProfileNoEmail = UserProfile("testuser", None) + + val invalidResultF = userProfileAsyncValidator.validateAsync(invalidProfile) + val validResultF = userProfileAsyncValidator.validateAsync(validProfileNoEmail) + + for { + invalidResult <- invalidResultF + validResult <- validResultF + } yield { + invalidResult match { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 1) + assert(errors.head.message.contains("Email format is invalid")) + case _ => fail("Expected Invalid result for bad email") + } + + validResult match { + case ValidationResult.Valid(_) => () + case _ => fail("Expected Valid result for None email") + } + } + } +} diff --git a/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala index e897041..93563e5 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala @@ -25,11 +25,11 @@ class TupleValidatorSpec extends FunSuite { /** Tuple validator for regular tuples. */ private given tupleValidator[A, B](using va: Validator[A], vb: Validator[B]): Validator[(A, B)] = - Validator.deriveValidatorMacro + Validator.derive /** Named tuple validator using automatic derivation. */ private given namedTupleValidator: Validator[(name: String, age: Int)] = - Validator.deriveValidatorMacro + Validator.derive test("Regular tuples should be validated with default validators") { val validTuple = ("hello", 42) diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala index c9e1d96..392fe6b 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala @@ -7,7 +7,7 @@ import scala.collection.immutable.ArraySeq import net.ghoula.valar.ErrorAccumulator import net.ghoula.valar.ValidationErrors.{ValidationError, ValidationException} import net.ghoula.valar.ValidationHelpers.* -import net.ghoula.valar.Validator.deriveValidatorMacro +import net.ghoula.valar.Validator.derive /** Comprehensive test suite for Valar's validation system. * @@ -50,16 +50,16 @@ class ValidationSpec extends FunSuite { /** Test case classes for macro derivation testing. */ private case class User(name: String, age: Option[Int]) - private given Validator[User] = deriveValidatorMacro + private given Validator[User] = derive private case class Address(street: String, city: String, zip: Int) - private given Validator[Address] = deriveValidatorMacro + private given Validator[Address] = derive private case class Company(name: String, address: Address, ceo: Option[User]) - private given Validator[Company] = deriveValidatorMacro + private given Validator[Company] = derive private case class NullFieldTest(name: String, age: Int) - private given Validator[NullFieldTest] = deriveValidatorMacro + private given Validator[NullFieldTest] = derive /** Tests for collection type validators. */ From 22e03b94d218a2702cef501f3f1db99d9d259155 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 02:51:43 +0200 Subject: [PATCH 13/19] out of cookies --- .../net/ghoula/valar/AsyncValidator.scala | 361 ++++++++++++++---- .../net/ghoula/valar/AsyncValidatorSpec.scala | 76 +++- 2 files changed, 333 insertions(+), 104 deletions(-) diff --git a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala index 489f94a..0d60ba4 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala @@ -51,9 +51,74 @@ object AsyncValidator { Future.successful(v.validate(a)) } - // === Given instances for common types === + /** Generic helper method for folding validation results into errors and valid values. + * + * @param results + * the sequence of validation results to fold + * @param emptyAcc + * the empty accumulator for valid values + * @param addToAcc + * function to add a valid value to the accumulator + * @return + * a tuple containing accumulated errors and valid values + */ + private def foldValidationResults[A, B]( + results: Iterable[ValidationResult[A]], + emptyAcc: B, + addToAcc: (B, A) => B + ): (Vector[ValidationError], B) = { + results.foldLeft((Vector.empty[ValidationError], emptyAcc)) { + case ((errs, acc), ValidationResult.Valid(value)) => (errs, addToAcc(acc, value)) + case ((errs, acc), ValidationResult.Invalid(e)) => (errs ++ e, acc) + } + } - /** Default async validator for `Option[A]`. */ + /** Generic helper method for validating collections asynchronously. + * + * This method eliminates code duplication by providing a common validation pattern for different + * collection types. It validates each element in the collection asynchronously and accumulates + * both errors and valid results. + * + * @param items + * the collection of items to validate + * @param validator + * the validator for individual items + * @param buildResult + * function to construct the final collection from valid items + * @param ec + * execution context for async operations + * @return + * a Future containing the validation result + */ + private def validateCollection[A, C[_]]( + items: Iterable[A], + validator: AsyncValidator[A], + buildResult: Iterable[A] => C[A] + )(using ec: ExecutionContext): Future[ValidationResult[C[A]]] = { + val futureResults = items.map { item => + validator.validateAsync(item).map { + case ValidationResult.Valid(a) => ValidationResult.Valid(a) + case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) + } + } + + Future.sequence(futureResults).map { results => + val (errors, validValues) = foldValidationResults(results, Vector.empty[A], _ :+ _) + if (errors.isEmpty) ValidationResult.Valid(buildResult(validValues)) + else ValidationResult.Invalid(errors) + } + } + + /** Asynchronous validator for optional values. + * + * Validates an `Option[A]` by delegating to the underlying validator only when the value is + * present. Empty options are considered valid by default. + * + * @param v + * the validator for the wrapped type A + * @return + * an AsyncValidator that handles optional values + */ given optionAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Option[A]] with { def validateAsync(opt: Option[A])(using ec: ExecutionContext): Future[ValidationResult[Option[A]]] = opt match { @@ -66,86 +131,87 @@ object AsyncValidator { } } - /** Validates a `List[A]` by validating each element asynchronously. */ + /** Asynchronous validator for lists. + * + * Validates a `List[A]` by applying the element validator to each item in the list + * asynchronously. All validation futures are executed concurrently, and their results are + * collected. Errors from individual elements are accumulated while preserving the order of valid + * elements. + * + * @param v + * the validator for list elements + * @return + * an AsyncValidator that handles lists + */ given listAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[List[A]] with { - def validateAsync(xs: List[A])(using ec: ExecutionContext): Future[ValidationResult[List[A]]] = { - val futureResults = xs.zipWithIndex.map { case (item, _) => - v.validateAsync(item).map { - case ValidationResult.Valid(a) => ValidationResult.Valid(a) - case ValidationResult.Invalid(errors) => - // Don't annotate with index, let the derivation handle field paths - ValidationResult.Invalid(errors) - } - } - - Future.sequence(futureResults).map { results => - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], List.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) - } - if (errors.isEmpty) ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validateAsync(xs: List[A])(using ec: ExecutionContext): Future[ValidationResult[List[A]]] = + validateCollection(xs, v, _.toList) } - /** Validates a `Seq[A]` by validating each element asynchronously. */ + /** Asynchronous validator for sequences. + * + * Validates a `Seq[A]` by applying the element validator to each item in the sequence + * asynchronously. All validation futures are executed concurrently, and their results are + * collected. Errors from individual elements are accumulated while preserving the order of valid + * elements. + * + * @param v + * the validator for sequence elements + * @return + * an AsyncValidator that handles sequences + */ given seqAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Seq[A]] with { - def validateAsync(xs: Seq[A])(using ec: ExecutionContext): Future[ValidationResult[Seq[A]]] = { - val futureResults = xs.zipWithIndex.map { case (item, _) => - v.validateAsync(item).map { - case ValidationResult.Valid(a) => ValidationResult.Valid(a) - case ValidationResult.Invalid(errors) => - ValidationResult.Invalid(errors) - } - } - - Future.sequence(futureResults).map { results => - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Seq.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) - } - if (errors.isEmpty) ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validateAsync(xs: Seq[A])(using ec: ExecutionContext): Future[ValidationResult[Seq[A]]] = + validateCollection(xs, v, _.toSeq) } - /** Validates a `Vector[A]` by validating each element asynchronously. */ + /** Asynchronous validator for vectors. + * + * Validates a `Vector[A]` by applying the element validator to each item in the vector + * asynchronously. All validation futures are executed concurrently, and their results are + * collected. Errors from individual elements are accumulated while preserving the order of valid + * elements. + * + * @param v + * the validator for vector elements + * @return + * an AsyncValidator that handles vectors + */ given vectorAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Vector[A]] with { - def validateAsync(xs: Vector[A])(using ec: ExecutionContext): Future[ValidationResult[Vector[A]]] = { - val futureResults = xs.zipWithIndex.map { case (item, _) => - v.validateAsync(item).map { - case ValidationResult.Valid(a) => ValidationResult.Valid(a) - case ValidationResult.Invalid(errors) => - ValidationResult.Invalid(errors) - } - } - - Future.sequence(futureResults).map { results => - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Vector.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) - } - if (errors.isEmpty) ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validateAsync(xs: Vector[A])(using ec: ExecutionContext): Future[ValidationResult[Vector[A]]] = + validateCollection(xs, v, _.toVector) } - /** Validates a `Set[A]` by validating each element asynchronously. */ + /** Asynchronous validator for sets. + * + * Validates a `Set[A]` by applying the element validator to each item in the set asynchronously. + * All validation futures are executed concurrently, and their results are collected. Errors from + * individual elements are accumulated while preserving the valid elements in the resulting set. + * + * @param v + * the validator for set elements + * @return + * an AsyncValidator that handles sets + */ given setAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Set[A]] with { - def validateAsync(xs: Set[A])(using ec: ExecutionContext): Future[ValidationResult[Set[A]]] = { - val futureResults = xs.map(v.validateAsync(_)) - - Future.sequence(futureResults).map { results => - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Set.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals + a) - case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) - } - if (errors.isEmpty) ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validateAsync(xs: Set[A])(using ec: ExecutionContext): Future[ValidationResult[Set[A]]] = + validateCollection(xs, v, _.toSet) } - /** Validates a `Map[K, V]` by validating each key and value asynchronously. */ + /** Asynchronous validator for maps. + * + * Validates a `Map[K, V]` by applying the key validator to each key and the value validator to + * each value asynchronously. All validation futures are executed concurrently, and their results + * are collected. Errors from individual keys and values are accumulated with proper field path + * annotation, while valid key-value pairs are preserved in the resulting map. + * + * @param vk + * the validator for map keys + * @param vv + * the validator for map values + * @return + * an AsyncValidator that handles maps + */ given mapAsyncValidator[K, V](using vk: AsyncValidator[K], vv: AsyncValidator[V]): AsyncValidator[Map[K, V]] with { def validateAsync(m: Map[K, V])(using ec: ExecutionContext): Future[ValidationResult[Map[K, V]]] = { val futureResults = m.map { case (k, v) => @@ -167,53 +233,180 @@ object AsyncValidator { } Future.sequence(futureResults).map { results => - val (errors, validPairs) = results.foldLeft((Vector.empty[ValidationError], Map.empty[K, V])) { - case ((errs, acc), ValidationResult.Valid(pair)) => (errs, acc + pair) - case ((errs, acc), ValidationResult.Invalid(e)) => (errs ++ e, acc) - } + val (errors, validPairs) = foldValidationResults(results, Map.empty[K, V], _ + _) if (errors.isEmpty) ValidationResult.Valid(validPairs) else ValidationResult.Invalid(errors) } } } - // === Lift all the built-in Validator instances to AsyncValidator === - // These are only used as fallbacks when no custom validators are provided - - /** Async validator for non-negative integers. */ + /** Asynchronous validator for non-negative integers. + * + * Validates that an integer value is non-negative (>= 0). This validator is lifted from the + * corresponding synchronous validator and is used as a fallback when no custom integer validator + * is provided. + */ given nonNegativeIntAsyncValidator: AsyncValidator[Int] = fromSync(Validator.nonNegativeIntValidator) - /** Async validator for finite floats. */ + /** Asynchronous validator for finite floating-point numbers. + * + * Validates that a float value is finite (not NaN or infinite). This validator is lifted from + * the corresponding synchronous validator and is used as a fallback when no custom float + * validator is provided. + */ given finiteFloatAsyncValidator: AsyncValidator[Float] = fromSync(Validator.finiteFloatValidator) - /** Async validator for finite doubles. */ + /** Asynchronous validator for finite double-precision numbers. + * + * Validates that a double value is finite (not NaN or infinite). This validator is lifted from + * the corresponding synchronous validator and is used as a fallback when no custom double + * validator is provided. + */ given finiteDoubleAsyncValidator: AsyncValidator[Double] = fromSync(Validator.finiteDoubleValidator) - /** Async validator for non-empty strings. */ + /** Asynchronous validator for non-empty strings. + * + * Validates that a string value is not empty. This validator is lifted from the corresponding + * synchronous validator and is used as a fallback when no custom string validator is provided. + */ given nonEmptyStringAsyncValidator: AsyncValidator[String] = fromSync(Validator.nonEmptyStringValidator) - // Pass-through validators for basic types + /** Asynchronous validator for boolean values. + * + * Pass-through validator for boolean values that always succeeds. This validator is lifted from + * the corresponding synchronous validator and provides consistent async behavior. + */ given booleanAsyncValidator: AsyncValidator[Boolean] = fromSync(Validator.booleanValidator) + + /** Asynchronous validator for byte values. + * + * Pass-through validator for byte values that always succeeds. This validator is lifted from the + * corresponding synchronous validator and provides consistent async behavior. + */ given byteAsyncValidator: AsyncValidator[Byte] = fromSync(Validator.byteValidator) + + /** Asynchronous validator for short values. + * + * Pass-through validator for short values that always succeeds. This validator is lifted from + * the corresponding synchronous validator and provides consistent async behavior. + */ given shortAsyncValidator: AsyncValidator[Short] = fromSync(Validator.shortValidator) + + /** Asynchronous validator for long values. + * + * Pass-through validator for long values that always succeeds. This validator is lifted from the + * corresponding synchronous validator and provides consistent async behavior. + */ given longAsyncValidator: AsyncValidator[Long] = fromSync(Validator.longValidator) + + /** Asynchronous validator for character values. + * + * Pass-through validator for character values that always succeeds. This validator is lifted + * from the corresponding synchronous validator and provides consistent async behavior. + */ given charAsyncValidator: AsyncValidator[Char] = fromSync(Validator.charValidator) + + /** Asynchronous validator for unit values. + * + * Pass-through validator for unit values that always succeeds. This validator is lifted from the + * corresponding synchronous validator and provides consistent async behavior. + */ given unitAsyncValidator: AsyncValidator[Unit] = fromSync(Validator.unitValidator) + + /** Asynchronous validator for arbitrary precision integers. + * + * Pass-through validator for BigInt values that always succeeds. This validator is lifted from + * the corresponding synchronous validator and provides consistent async behavior. + */ given bigIntAsyncValidator: AsyncValidator[BigInt] = fromSync(Validator.bigIntValidator) + + /** Asynchronous validator for arbitrary precision decimal numbers. + * + * Pass-through validator for BigDecimal values that always succeeds. This validator is lifted + * from the corresponding synchronous validator and provides consistent async behavior. + */ given bigDecimalAsyncValidator: AsyncValidator[BigDecimal] = fromSync(Validator.bigDecimalValidator) + + /** Asynchronous validator for symbol values. + * + * Pass-through validator for symbol values that always succeeds. This validator is lifted from + * the corresponding synchronous validator and provides consistent async behavior. + */ given symbolAsyncValidator: AsyncValidator[Symbol] = fromSync(Validator.symbolValidator) + + /** Asynchronous validator for UUID values. + * + * Pass-through validator for UUID values that always succeeds. This validator is lifted from the + * corresponding synchronous validator and provides consistent async behavior. + */ given uuidAsyncValidator: AsyncValidator[UUID] = fromSync(Validator.uuidValidator) + + /** Asynchronous validator for instant values. + * + * Pass-through validator for Instant values that always succeeds. This validator is lifted from + * the corresponding synchronous validator and provides consistent async behavior. + */ given instantAsyncValidator: AsyncValidator[Instant] = fromSync(Validator.instantValidator) + + /** Asynchronous validator for local date values. + * + * Pass-through validator for LocalDate values that always succeeds. This validator is lifted + * from the corresponding synchronous validator and provides consistent async behavior. + */ given localDateAsyncValidator: AsyncValidator[LocalDate] = fromSync(Validator.localDateValidator) + + /** Asynchronous validator for local time values. + * + * Pass-through validator for LocalTime values that always succeeds. This validator is lifted + * from the corresponding synchronous validator and provides consistent async behavior. + */ given localTimeAsyncValidator: AsyncValidator[LocalTime] = fromSync(Validator.localTimeValidator) + + /** Asynchronous validator for local date-time values. + * + * Pass-through validator for LocalDateTime values that always succeeds. This validator is lifted + * from the corresponding synchronous validator and provides consistent async behavior. + */ given localDateTimeAsyncValidator: AsyncValidator[LocalDateTime] = fromSync(Validator.localDateTimeValidator) + + /** Asynchronous validator for zoned date-time values. + * + * Pass-through validator for ZonedDateTime values that always succeeds. This validator is lifted + * from the corresponding synchronous validator and provides consistent async behavior. + */ given zonedDateTimeAsyncValidator: AsyncValidator[ZonedDateTime] = fromSync(Validator.zonedDateTimeValidator) + + /** Asynchronous validator for duration values. + * + * Pass-through validator for Duration values that always succeeds. This validator is lifted from + * the corresponding synchronous validator and provides consistent async behavior. + */ given javaDurationAsyncValidator: AsyncValidator[Duration] = fromSync(Validator.durationValidator) - /** Automatically derives an `AsyncValidator` for case classes using Scala 3 macros. */ + /** Automatically derives an `AsyncValidator` for case classes using Scala 3 macros. + * + * This method provides compile-time derivation of async validators for product types by + * analyzing the case class structure and generating appropriate validation logic that validates + * each field using the corresponding validator in scope. + * + * @param m + * the Mirror.ProductOf evidence for the type T + * @return + * a derived AsyncValidator instance for type T + */ inline def derive[T](using m: Mirror.ProductOf[T]): AsyncValidator[T] = ${ deriveImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } - /** Macro implementation for deriving an `AsyncValidator`. */ + /** Macro implementation for deriving an `AsyncValidator`. + * + * This method implements the actual macro logic for generating async validator instances at + * compile time. It delegates to the internal Derivation utility with the async flag set to true + * to generate appropriate asynchronous validation code. + * + * @param m + * the Mirror.ProductOf expression + * @return + * an expression representing the derived AsyncValidator + */ private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( m: Expr[Mirror.ProductOf[T]] )(using q: Quotes): Expr[AsyncValidator[T]] = { diff --git a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala index 2bfda9c..c9515ac 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala @@ -1,12 +1,13 @@ package net.ghoula.valar import munit.FunSuite -import net.ghoula.valar.ValidationErrors.ValidationError import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.* import scala.concurrent.{Await, Future} +import net.ghoula.valar.ValidationErrors.ValidationError + /** Provides a comprehensive test suite for the [[AsyncValidator]] typeclass and its derivation. * * This spec verifies all core functionalities of the asynchronous validation mechanism: @@ -19,8 +20,6 @@ import scala.concurrent.{Await, Future} */ class AsyncValidatorSpec extends FunSuite { - // --- Test Models --- - /** A simple case class for basic validation tests. */ private case class User(name: String, age: Int) @@ -39,8 +38,6 @@ class AsyncValidatorSpec extends FunSuite { /** A case class for testing optional field validation. */ private case class UserProfile(username: String, email: Option[String]) - // --- Custom Validators --- - /** A standard synchronous validator for non-empty strings. */ private given syncStringValidator: Validator[String] with { def validate(value: String): ValidationResult[String] = @@ -55,7 +52,12 @@ class AsyncValidatorSpec extends FunSuite { else ValidationResult.invalid(ValidationError("Sync: Age must be non-negative")) } - /** A native asynchronous validator that simulates a DB check for usernames. */ + /** A native asynchronous validator that simulates a database check for usernames. + * + * This validator checks if a username is reserved (e.g., "admin", "root") by simulating an + * asynchronous database lookup. If the username is not reserved, it delegates to the synchronous + * string validator for basic validation. + */ private given asyncUsernameValidator: AsyncValidator[String] with { def validateAsync(name: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = Future { @@ -67,7 +69,12 @@ class AsyncValidatorSpec extends FunSuite { } } - /** A native asynchronous validator that simulates a profanity filter. */ + /** A native asynchronous validator that simulates a profanity filter. + * + * This validator checks if a text contains profanity by simulating an asynchronous profanity + * checking service. If no profanity is detected, it delegates to the synchronous string + * validator for basic validation. + */ private given asyncCommentTextValidator: AsyncValidator[String] with { def validateAsync(text: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = Future { @@ -79,7 +86,12 @@ class AsyncValidatorSpec extends FunSuite { } } - /** A native asynchronous validator for email formats. */ + /** A native asynchronous validator for email formats. + * + * This validator performs basic email format validation by checking for the presence of an '@' + * symbol. In a real application, this would typically involve more sophisticated email + * validation logic or external service calls. + */ private given asyncEmailValidator: AsyncValidator[String] with { def validateAsync(email: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = Future { @@ -88,34 +100,60 @@ class AsyncValidatorSpec extends FunSuite { } } - // --- Derived Validator Instances --- - - /** User validator using custom validators for both name and age. */ + /** User validator using custom validators for both name and age fields. + * + * This validator demonstrates how to set up specific validators for different field types within + * a case class. The username field uses the asynchronous username validator, while the age field + * uses a synchronous validator lifted to async. + */ private given userAsyncValidator: AsyncValidator[User] = { given AsyncValidator[String] = asyncUsernameValidator given AsyncValidator[Int] = AsyncValidator.fromSync(syncIntValidator) AsyncValidator.derive } - // Company and Team both use the same validator setup as User (String for names, Int for ages) + /** Company validator that reuses the user validation logic. + * + * This validator demonstrates automatic derivation where the existing user validator is used for + * the nested User field, and the string validator is used for the company name. + */ private given companyAsyncValidator: AsyncValidator[Company] = AsyncValidator.derive + + /** Team validator that reuses the user validation logic. + * + * This validator demonstrates automatic derivation where the existing user validator is used for + * the nested User field, and the string validator is used for the team name. + */ private given teamAsyncValidator: AsyncValidator[Team] = AsyncValidator.derive - /** A derived validator for Comment that uses the async profanity filter for the `text` field. */ + /** A derived validator for Comment that uses the async profanity filter for the text field. + * + * This validator demonstrates how to use a specific validator for text content that requires + * asynchronous profanity checking while using the standard validator for the author field. + */ private given commentAsyncValidator: AsyncValidator[Comment] = { given AsyncValidator[String] = asyncCommentTextValidator AsyncValidator.derive } - /** A derived validator for Post that will use the async `Comment` validator. */ + /** A derived validator for Post that uses the async Comment validator for the comments-field. + * + * This validator demonstrates validation of collections where each item in the collection + * requires asynchronous validation. The title field uses a synchronous validator, while the + * comments-field uses the async comment validator. + */ private given postAsyncValidator: AsyncValidator[Post] = { given AsyncValidator[String] = AsyncValidator.fromSync(syncStringValidator) AsyncValidator.derive } - /** A derived validator for UserProfile that needs different validators for username and email - * fields. We need to create a custom validator since the macro can't distinguish between - * different String fields. + /** A custom validator for UserProfile that handles different validation logic for username and + * email fields. + * + * This validator demonstrates how to create custom validation logic when the automatic + * derivation cannot distinguish between different String fields that require different + * validation rules. The username field uses the username validator, while the optional email + * field uses the email validator. */ private given userProfileAsyncValidator: AsyncValidator[UserProfile] = new AsyncValidator[UserProfile] { def validateAsync( @@ -138,8 +176,6 @@ class AsyncValidatorSpec extends FunSuite { } } - // --- Test Cases --- - test("validateAsync should succeed for a valid object") { val validUser = User("John", 30) val futureResult = userAsyncValidator.validateAsync(validUser) @@ -235,7 +271,7 @@ class AsyncValidatorSpec extends FunSuite { futureResult.map { case ValidationResult.Invalid(errors) => assertEquals(errors.size, 2) - assert(errors.exists(e => e.message.contains("profanity") && e.fieldPath.contains("comments"))) + assert(errors.exists(e => e.message.contains("profanity") && e.fieldPath == List("comments", "text"))) assert(errors.exists(e => e.message.contains("empty") && e.fieldPath.contains("comments"))) case _ => fail("Expected Invalid result for collection validation") } From 15090a4680a48f7f21698b31e6b2f7fc35b873a4 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 03:00:06 +0200 Subject: [PATCH 14/19] ready for performance review --- MIGRATION.md | 176 ++++++++++++++++++++ README.md | 330 +++++++++++++++++++++++++++++++++++++ munit/README.md | 132 +++++++++++++++ translator/README.md | 25 ++- valar-munit/README.md | 132 +++++++++++++++ valar-translator/README.md | 84 ++++++++++ 6 files changed, 875 insertions(+), 4 deletions(-) create mode 100644 MIGRATION.md create mode 100644 README.md create mode 100644 munit/README.md create mode 100644 valar-munit/README.md create mode 100644 valar-translator/README.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..09688c4 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,176 @@ +# Migration Guide + +## Migrating from v0.4.8 to v0.5.0 + +Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: + +1. **New ValidationObserver trait** for observing validation outcomes without altering the flow +2. **New valar-translator module** for internationalization support of validation error messages +3. **Enhanced ValarSuite** with improved testing utilities +4. **Reworked macros** for better performance and modern Scala 3 features +5. **MiMa checks** to ensure binary compatibility between versions + +### Update build.sbt: + +```scala +// Update core library +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Add the optional translator module (if needed) +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +// Update testing utilities (if used) +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +Your existing validation code will continue to work without any changes. + +### Using the New Features + +#### ValidationObserver + +The ValidationObserver trait allows you to observe validation results without altering the flow: + +```scala +import net.ghoula.valar.* +import org.slf4j.LoggerFactory + +// Define a custom observer that logs validation results +given loggingObserver: ValidationObserver with { + private val logger = LoggerFactory.getLogger("ValidationAnalytics") + + def onResult[A](result: ValidationResult[A]): Unit = result match { + case ValidationResult.Valid(_) => + logger.info("Validation succeeded") + case ValidationResult.Invalid(errors) => + logger.warn(s"Validation failed with ${errors.size} errors") + } +} + +// Use the observer in your validation flow +val result = User.validate(user).observe() +``` + +#### valar-translator + +The valar-translator module provides internationalization support: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.translator.Translator + +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( + error.key.getOrElse("error.unknown"), + error.message // Fall back to the original message if the key is not found + ) + } +} + +// Use the translator in your validation flow +val result = User.validate(user).translateErrors() +``` + +## Migrating from v0.3.0 to v0.4.8 + +The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular +architecture. + +### Update build.sbt: + +```scala +// Replace this: +libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" + +// With this (note the triple %%% for cross-platform support): +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" + +// Add optional testing utilities (if desired): +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test +``` + +> **Note:** v0.4.8 used bundle versions (`-bundle` suffix) that included all dependencies. Starting from v0.5.0, we've moved to the standard approach without bundle versions for simpler dependency management. + +### Available Artifacts for v0.4.8 + +The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). For v0.4.8, only bundle versions are available: + +| Module | Platform | Artifact ID | Bundle Version | +|--------|----------|-------------------------|-------------------------------------------------------------| +| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | +| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | +| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | +| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | + +Your existing validation code will continue to work without any changes. + +## Note on Scala 3.7+ Givens Prioritization + +Scala 3.7 changes how the compiler resolves given instances when multiple candidates are available. Previously, the +compiler would select the most specific subtype, but now it chooses based on different prioritization rules. + +This may affect your code if: + +* You have multiple validator instances for the same type or related types (e.g., through type aliases or inheritance). +* You rely on implicit resolution to select the correct validator. + +### Potential Issues + +The most common issue is with type aliases, where you might have defined: + +```scala +type Email = String + +// General validator +given Validator[String] with { ... } + +// More specific validator +given Validator[Email] with { ... } + +// Which one gets used? In 3.6 vs. 3.7 it might be different! +val result = summon[Validator[Email]].validate(email) +``` + +### Solutions + +1. **Explicit imports**: Place validators in objects and import only the ones you need. + + ```scala + object validators { + given stringValidator: Validator[String] with { ... } + given emailValidator: Validator[Email] with { ... } + } + + // Be explicit about which one to use + import validators.emailValidator + ``` + +2. **Named instances**: Give your validators explicit names and use them directly. + + ```scala + given generalStringValidator: Validator[String] with { ... } + given specificEmailValidator: Validator[Email] with { ... } + + // Use the specific one explicitly + val result = specificEmailValidator.validate(email) + ``` + +3. **Extension methods**: Define your validation logic as extension methods to avoid ambiguity. + + ```scala + extension (email: Email) { + def validate: ValidationResult[Email] = { ... } + } + ``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3e3385 --- /dev/null +++ b/README.md @@ -0,0 +1,330 @@ +# **Valar – Type-Safe Validation for Scala 3** + +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + +Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and +metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, +detailed error messages useful for debugging or user feedback. + +## **✨ What's New in 0.5.X** + +* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, perfect for logging, metrics collection, or auditing with zero overhead when not used. +* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the `Translator` typeclass. +* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust validation testing. +* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time validation. +* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. +* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for better developer experience. + +## **Key Features** + +* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using + ValidationResult\[A\]. Eliminate runtime errors caused by unexpected validation states. +* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, + significantly reducing repetitive validation logic. Focus on your rules, not the wiring. +* **Flexible Error Handling:** Choose the strategy that fits your use case: + * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in + UIs or API responses). + * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. +* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation + rule specifics (like expected vs. actual values), and optional codes/severity. +* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward + compatibility. +* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, + opaque types, and macros for a modern, expressive API. + +## **Available Artifacts** + +Valar provides artifacts for both JVM and Scala Native platforms: + +| Module | Platform | Artifact ID | Maven Central | +|-----------------|----------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | +| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | +| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | +| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | +| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | +| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | + +> **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. + +## **Installation** + +Add the following to your build.sbt: + +```scala +// The core validation library (JVM & Scala Native) +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Optional: For internationalization (i18n) support +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +// Optional: For enhanced testing with MUnit +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +## **Basic Usage Example** + +Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int (non-negative). + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.ValidationResult.{Invalid, Valid} +import net.ghoula.valar.ValidationHelpers.* + +case class User(name: String, age: Option[Int]) + +// Define a custom validator for String +given Validator[String] with { + def validate(value: String): ValidationResult[String] = + nonEmpty(value, _ => "Name must not be empty") +} + +// Define a custom validator for Int +given Validator[Int] with { + def validate(value: Int): ValidationResult[Int] = + nonNegativeInt(value, i => s"Age must be non-negative, got $i") +} + +// Automatically derive a Validator for the case class User using the givens above +given Validator[User] = Validator.deriveValidatorMacro + +val user = User("", Some(-10)) +val result: ValidationResult[User] = Validator[User].validate(user) + +result match { + case Valid(validUser) => println(s"Valid user: $validUser") + case Invalid(errors) => + println("Validation Failed:") + println(errors.map(_.prettyPrint(indent = 2)).mkString("\n")) +} +``` + +## **Testing with valar-munit** + +The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to +make your tests clean and expressive. + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.munit.ValarSuite + +class UserValidationSuite extends ValarSuite { + + // A given Validator for User must be in scope + given Validator[User] = Validator.deriveValidatorMacro + + test("a valid user should pass validation") { + val result = Validator[User].validate(User("John", Some(25))) + val validUser = assertValid(result) // Fails test if Invalid, returns User if Valid + assertEquals(validUser.name, "John") + } + + test("a single validation error should be reported correctly") { + val result = Validator[User].validate(User("", Some(25))) + + // Use assertHasOneError for the common case of a single error + assertHasOneError(result) { error => + assertEquals(error.fieldPath, List("name")) + assert(error.message.contains("empty")) + } + } + + test("multiple validation errors should be accumulated") { + val result = Validator[User].validate(User("", Some(-10))) + + // Use assertInvalid for testing error accumulation + assertInvalid(result) { errors => + assertEquals(errors.size, 2) + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } + } +} +``` + +## **Core Components** + +### **ValidationResult** + +Represents the outcome of validation as either Valid(value) or Invalid(errors): + +```scala +import net.ghoula.valar.ValidationErrors.ValidationError + +enum ValidationResult[+A] { + case Valid(value: A) + case Invalid(errors: Vector[ValidationError]) +} +``` + +### **ValidationError** + +Opaque type providing rich context for validation errors, including: + +* **message**: Human-readable description of the error. +* **fieldPath**: Path to the field causing the error (e.g., user.address.street). +* **code**: Optional application-specific error codes. +* **severity**: Optional severity indicator (Error, Warning). +* **expected/actual**: Information about expected and actual values. +* **children**: Nested errors for structured reporting. + +### **Validator[A]** + +A typeclass defining validation logic for a given type: + +```scala +import net.ghoula.valar.ValidationResult + +trait Validator[A] { + def validate(a: A): ValidationResult[A] +} +``` + +Validators can be automatically derived for case classes using deriveValidatorMacro. + +**Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances +to be available in scope for **all** field types within the case class. If a validator for any field type is missing, +**compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the +"Built-in Validators" section for types supported out-of-the-box. + +## **Built-in Validators** + +Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This +includes: + +* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, + Short, Char, Unit. +* **Other Scala Types:** BigInt, BigDecimal, Symbol. +* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, + java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. +* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their + element/key/value types). +* **Tuple Types:** Named tuples and regular tuples. +* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. + +Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are +**pass-through** validators. You should define custom validators if you need specific constraints for these types. + +## **ValidationObserver** + +The `ValidationObserver` trait provides a powerful mechanism to decouple validation logic from cross-cutting concerns such as logging, metrics collection, or auditing: + +```scala +import net.ghoula.valar.* +import org.slf4j.LoggerFactory + +// Define a custom observer that logs validation results +given loggingObserver: ValidationObserver with { + private val logger = LoggerFactory.getLogger("ValidationAnalytics") + + def onResult[A](result: ValidationResult[A]): Unit = result match { + case ValidationResult.Valid(_) => + logger.info("Validation succeeded") + case ValidationResult.Invalid(errors) => + logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") + } +} + +// Use the observer in your validation flow +val result = User.validate(user) + .observe() // The observer's onResult is called here + .map(_.toUpperCase) +``` + +Key features of ValidationObserver: +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Chainable**: Works seamlessly with other operations in the validation pipeline +* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect + +## **Internationalization with valar-translator** + +The `valar-translator` module provides internationalization (i18n) support for validation error messages: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.translator.Translator + +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( + error.key.getOrElse("error.unknown"), + error.message // Fall back to the original message if key is not found + ) + } +} + +// Use the translator in your validation flow +val result = User.validate(user) + .observe() // Optional: observe the raw result first + .translateErrors() // Translate errors for user presentation +``` + +The `valar-translator` module is designed to: +* Integrate with any i18n library through the `Translator` typeclass +* Compose cleanly with other Valar features like ValidationObserver +* Provide a clear separation between validation logic and presentation concerns + +## **Migration Guide from v0.4.8 to v0.5.0** + +Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: + +1. **New ValidationObserver trait** for observing validation outcomes +2. **New valar-translator module** for internationalization support +3. **Enhanced ValarSuite** with improved testing utilities +4. **Reworked macros** for better performance and modern Scala 3 features +5. **MiMa checks** to ensure binary compatibility + +To upgrade to v0.5.0, update your build.sbt: + +```scala +// Update core library +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Add optional translator module (if needed) +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +// Update testing utilities (if used) +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +Your existing validation code will continue to work without any changes. + +## **Migration Guide from v0.3.0 to v0.4.8** + +The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular architecture. + +1. **Update build.sbt**: + ```scala + // Replace this: + libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" + + // With this (note the triple %%% for cross-platform support): + libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" + ``` + +## **Compatibility** + +* **Scala:** 3.7+ +* **Platforms:** JVM, Scala Native +* **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, + cross-platform support for the `java.time` API. + +## **License** + +Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) +file for details. diff --git a/munit/README.md b/munit/README.md new file mode 100644 index 0000000..67756cb --- /dev/null +++ b/munit/README.md @@ -0,0 +1,132 @@ +# valar-munit + +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + +The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It +introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. + +## Installation + +Add the valar-munit dependency to your build.sbt: + +```scala +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +## Usage + +Extend the ValarSuite trait in your test classes to get access to the assertion helpers. + +```scala +import net.ghoula.valar.munit.ValarSuite + +class MyValidatorSpec extends ValarSuite { + test("valid data passes validation") { + val result = MyValidator.validate(validData) + val value = assertValid(result) + + // You can make additional assertions on the validated value + assertEquals(value.name, "Expected Name") + } +} +``` + +## Assertion Helpers + +The ValarSuite trait provides several assertion helpers for different validation testing scenarios. + +### 1. assertValid + +Asserts that a ValidationResult is Valid and returns the validated value for further assertions. + +```scala +test("valid data passes validation") { + val result = MyValidator.validate(validData) + val value = assertValid(result) + + // Additional assertions on the validated value + assertEquals(value.id, 123) +} +``` + +### 2. assertHasOneError + +Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual +validation rules. + +```scala +test("empty name is rejected") { + val result = User.validate(User("", 25)) + + assertHasOneError(result) { error => + assertEquals(error.fieldPath, List("name")) + assert(error.message.contains("empty")) + } +} +``` + +### 3. assertHasNErrors + +Asserts that a ValidationResult is Invalid and contains exactly N errors. + +```scala +test("multiple specific errors are reported") { + val result = User.validate(User("", -5)) + + assertHasNErrors(result, 2) { errors => + // Assert on the collection of exactly 2 errors + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } +} +``` + +### 4. assertInvalid + +Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, +accumulated errors are expected. + +```scala +test("multiple validation errors are accumulated") { + val result = User.validate(User("", -5)) + + assertInvalid(result) { + case errors if errors.size == 2 => + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } +} +``` + +### 5. assertInvalidWith + +Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular +function. This is a simpler alternative to assertInvalid. + +```scala +test("validation fails with expected errors") { + val result = User.validate(User("", -5)) + + assertInvalidWith(result) { errors => + assertEquals(errors.size, 2) + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } +} +``` + +## Benefits + +- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. + +- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. + +- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. + +- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated + value. + +- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match + your testing preferences. diff --git a/translator/README.md b/translator/README.md index 0a373b3..c8566a3 100644 --- a/translator/README.md +++ b/translator/README.md @@ -6,6 +6,14 @@ The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. +## Installation + +Add the valar-translator dependency to your build.sbt: + +```scala +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" +``` + ## Usage The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. @@ -18,13 +26,22 @@ Create a `given` instance of `Translator` that contains your localization logic. import net.ghoula.valar.translator.Translator import net.ghoula.valar.ValidationErrors.ValidationError -// Assuming you have an I18n library +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key and format with its arguments - I18n.lookup( + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( error.key.getOrElse("error.unknown"), - error.args + error.message // Fall back to the original message if the key is not found ) } } diff --git a/valar-munit/README.md b/valar-munit/README.md new file mode 100644 index 0000000..67756cb --- /dev/null +++ b/valar-munit/README.md @@ -0,0 +1,132 @@ +# valar-munit + +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + +The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It +introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. + +## Installation + +Add the valar-munit dependency to your build.sbt: + +```scala +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +## Usage + +Extend the ValarSuite trait in your test classes to get access to the assertion helpers. + +```scala +import net.ghoula.valar.munit.ValarSuite + +class MyValidatorSpec extends ValarSuite { + test("valid data passes validation") { + val result = MyValidator.validate(validData) + val value = assertValid(result) + + // You can make additional assertions on the validated value + assertEquals(value.name, "Expected Name") + } +} +``` + +## Assertion Helpers + +The ValarSuite trait provides several assertion helpers for different validation testing scenarios. + +### 1. assertValid + +Asserts that a ValidationResult is Valid and returns the validated value for further assertions. + +```scala +test("valid data passes validation") { + val result = MyValidator.validate(validData) + val value = assertValid(result) + + // Additional assertions on the validated value + assertEquals(value.id, 123) +} +``` + +### 2. assertHasOneError + +Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual +validation rules. + +```scala +test("empty name is rejected") { + val result = User.validate(User("", 25)) + + assertHasOneError(result) { error => + assertEquals(error.fieldPath, List("name")) + assert(error.message.contains("empty")) + } +} +``` + +### 3. assertHasNErrors + +Asserts that a ValidationResult is Invalid and contains exactly N errors. + +```scala +test("multiple specific errors are reported") { + val result = User.validate(User("", -5)) + + assertHasNErrors(result, 2) { errors => + // Assert on the collection of exactly 2 errors + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } +} +``` + +### 4. assertInvalid + +Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, +accumulated errors are expected. + +```scala +test("multiple validation errors are accumulated") { + val result = User.validate(User("", -5)) + + assertInvalid(result) { + case errors if errors.size == 2 => + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } +} +``` + +### 5. assertInvalidWith + +Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular +function. This is a simpler alternative to assertInvalid. + +```scala +test("validation fails with expected errors") { + val result = User.validate(User("", -5)) + + assertInvalidWith(result) { errors => + assertEquals(errors.size, 2) + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } +} +``` + +## Benefits + +- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. + +- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. + +- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. + +- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated + value. + +- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match + your testing preferences. diff --git a/valar-translator/README.md b/valar-translator/README.md new file mode 100644 index 0000000..c8566a3 --- /dev/null +++ b/valar-translator/README.md @@ -0,0 +1,84 @@ +# valar-translator + +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + +The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. + +## Installation + +Add the valar-translator dependency to your build.sbt: + +```scala +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" +``` + +## Usage + +The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. + +### 1. Implement the `Translator` Trait + +Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. + +```scala +import net.ghoula.valar.translator.Translator +import net.ghoula.valar.ValidationErrors.ValidationError + +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( + error.key.getOrElse("error.unknown"), + error.message // Fall back to the original message if the key is not found + ) + } +} +``` + +### 2. Call `translateErrors()` + +Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. + +```scala +val result = User.validate(someData) // An Invalid ValidationResult +val translatedResult = result.translateErrors() + +// translatedResult now contains errors with localized messages +``` + +## Composing with Core Features (like ValidationObserver) + +The `valar-translator` module is designed to compose cleanly with features from the core library. A prime example is the **`ValidationObserver` pattern**, a general-purpose tool for side effects (like logging or metrics) that is **available directly in `valar-core`**. + +While the two patterns serve different purposes, they can be chained together for a powerful workflow: + +* **`ValidationObserver` (Side Effect)**: Reacts to a result without changing it. +* **`Translator` (Data Transformation)**: Refines a result by localizing error messages. + +A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. + +```scala +// Given a defined `metricsObserver` from your application +// and a `myTranslator` from this module... + +val result = User.validate(invalidUser) + // 1. First, observe the raw result using the core ValidationObserver. + .observe() + // 2. Then, translate the errors for presentation using the Translator. + .translateErrors() + +// The final `result` contains user-friendly, translated messages, +// while the original, structured error was sent to your metrics system. +``` From d437b0bf8f3ec5644e98981e70f27ca3026b0f8b Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 09:46:12 +0200 Subject: [PATCH 15/19] ready for the final push --- MIGRATION.md | 176 ---------- README.md | 330 ------------------ docs-src/MIGRATION.md | 36 +- docs-src/README.md | 153 +++++--- docs-src/translator/README.md | 38 +- .../net/ghoula/valar/ValidationObserver.scala | 100 ++++-- 6 files changed, 231 insertions(+), 602 deletions(-) delete mode 100644 MIGRATION.md delete mode 100644 README.md diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 09688c4..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,176 +0,0 @@ -# Migration Guide - -## Migrating from v0.4.8 to v0.5.0 - -Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: - -1. **New ValidationObserver trait** for observing validation outcomes without altering the flow -2. **New valar-translator module** for internationalization support of validation error messages -3. **Enhanced ValarSuite** with improved testing utilities -4. **Reworked macros** for better performance and modern Scala 3 features -5. **MiMa checks** to ensure binary compatibility between versions - -### Update build.sbt: - -```scala -// Update core library -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" - -// Add the optional translator module (if needed) -libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" - -// Update testing utilities (if used) -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -``` - -Your existing validation code will continue to work without any changes. - -### Using the New Features - -#### ValidationObserver - -The ValidationObserver trait allows you to observe validation results without altering the flow: - -```scala -import net.ghoula.valar.* -import org.slf4j.LoggerFactory - -// Define a custom observer that logs validation results -given loggingObserver: ValidationObserver with { - private val logger = LoggerFactory.getLogger("ValidationAnalytics") - - def onResult[A](result: ValidationResult[A]): Unit = result match { - case ValidationResult.Valid(_) => - logger.info("Validation succeeded") - case ValidationResult.Invalid(errors) => - logger.warn(s"Validation failed with ${errors.size} errors") - } -} - -// Use the observer in your validation flow -val result = User.validate(user).observe() -``` - -#### valar-translator - -The valar-translator module provides internationalization support: - -```scala -import net.ghoula.valar.* -import net.ghoula.valar.translator.Translator - -// --- Example Setup --- -// In a real application, this would come from a properties file or other i18n system. -val translations: Map[String, String] = Map( - "error.string.nonEmpty" -> "The field must not be empty.", - "error.int.nonNegative" -> "The value cannot be negative.", - "error.unknown" -> "An unexpected validation error occurred." -) - -// --- Implementation of the Translator trait --- -given myTranslator: Translator with { - def translate(error: ValidationError): String = { - // Logic to look up the error's key in your translation map. - // The `.getOrElse` provides a safe fallback. - translations.getOrElse( - error.key.getOrElse("error.unknown"), - error.message // Fall back to the original message if the key is not found - ) - } -} - -// Use the translator in your validation flow -val result = User.validate(user).translateErrors() -``` - -## Migrating from v0.3.0 to v0.4.8 - -The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular -architecture. - -### Update build.sbt: - -```scala -// Replace this: -libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" - -// With this (note the triple %%% for cross-platform support): -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - -// Add optional testing utilities (if desired): -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test -``` - -> **Note:** v0.4.8 used bundle versions (`-bundle` suffix) that included all dependencies. Starting from v0.5.0, we've moved to the standard approach without bundle versions for simpler dependency management. - -### Available Artifacts for v0.4.8 - -The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). For v0.4.8, only bundle versions are available: - -| Module | Platform | Artifact ID | Bundle Version | -|--------|----------|-------------------------|-------------------------------------------------------------| -| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | -| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | -| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | -| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | - -Your existing validation code will continue to work without any changes. - -## Note on Scala 3.7+ Givens Prioritization - -Scala 3.7 changes how the compiler resolves given instances when multiple candidates are available. Previously, the -compiler would select the most specific subtype, but now it chooses based on different prioritization rules. - -This may affect your code if: - -* You have multiple validator instances for the same type or related types (e.g., through type aliases or inheritance). -* You rely on implicit resolution to select the correct validator. - -### Potential Issues - -The most common issue is with type aliases, where you might have defined: - -```scala -type Email = String - -// General validator -given Validator[String] with { ... } - -// More specific validator -given Validator[Email] with { ... } - -// Which one gets used? In 3.6 vs. 3.7 it might be different! -val result = summon[Validator[Email]].validate(email) -``` - -### Solutions - -1. **Explicit imports**: Place validators in objects and import only the ones you need. - - ```scala - object validators { - given stringValidator: Validator[String] with { ... } - given emailValidator: Validator[Email] with { ... } - } - - // Be explicit about which one to use - import validators.emailValidator - ``` - -2. **Named instances**: Give your validators explicit names and use them directly. - - ```scala - given generalStringValidator: Validator[String] with { ... } - given specificEmailValidator: Validator[Email] with { ... } - - // Use the specific one explicitly - val result = specificEmailValidator.validate(email) - ``` - -3. **Extension methods**: Define your validation logic as extension methods to avoid ambiguity. - - ```scala - extension (email: Email) { - def validate: ValidationResult[Email] = { ... } - } - ``` diff --git a/README.md b/README.md deleted file mode 100644 index a3e3385..0000000 --- a/README.md +++ /dev/null @@ -1,330 +0,0 @@ -# **Valar – Type-Safe Validation for Scala 3** - -[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) -[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) - -Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and -metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, -detailed error messages useful for debugging or user feedback. - -## **✨ What's New in 0.5.X** - -* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, perfect for logging, metrics collection, or auditing with zero overhead when not used. -* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the `Translator` typeclass. -* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust validation testing. -* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time validation. -* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for better developer experience. - -## **Key Features** - -* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using - ValidationResult\[A\]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. -* **Flexible Error Handling:** Choose the strategy that fits your use case: - * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in - UIs or API responses). - * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. -* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation - rule specifics (like expected vs. actual values), and optional codes/severity. -* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward - compatibility. -* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. - -## **Available Artifacts** - -Valar provides artifacts for both JVM and Scala Native platforms: - -| Module | Platform | Artifact ID | Maven Central | -|-----------------|----------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | -| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | -| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | -| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | -| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | -| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | - -> **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. - -## **Installation** - -Add the following to your build.sbt: - -```scala -// The core validation library (JVM & Scala Native) -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" - -// Optional: For internationalization (i18n) support -libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" - -// Optional: For enhanced testing with MUnit -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -``` - -## **Basic Usage Example** - -Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int (non-negative). - -```scala -import net.ghoula.valar.* -import net.ghoula.valar.ValidationErrors.ValidationError -import net.ghoula.valar.ValidationResult.{Invalid, Valid} -import net.ghoula.valar.ValidationHelpers.* - -case class User(name: String, age: Option[Int]) - -// Define a custom validator for String -given Validator[String] with { - def validate(value: String): ValidationResult[String] = - nonEmpty(value, _ => "Name must not be empty") -} - -// Define a custom validator for Int -given Validator[Int] with { - def validate(value: Int): ValidationResult[Int] = - nonNegativeInt(value, i => s"Age must be non-negative, got $i") -} - -// Automatically derive a Validator for the case class User using the givens above -given Validator[User] = Validator.deriveValidatorMacro - -val user = User("", Some(-10)) -val result: ValidationResult[User] = Validator[User].validate(user) - -result match { - case Valid(validUser) => println(s"Valid user: $validUser") - case Invalid(errors) => - println("Validation Failed:") - println(errors.map(_.prettyPrint(indent = 2)).mkString("\n")) -} -``` - -## **Testing with valar-munit** - -The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to -make your tests clean and expressive. - -```scala -import net.ghoula.valar.* -import net.ghoula.valar.munit.ValarSuite - -class UserValidationSuite extends ValarSuite { - - // A given Validator for User must be in scope - given Validator[User] = Validator.deriveValidatorMacro - - test("a valid user should pass validation") { - val result = Validator[User].validate(User("John", Some(25))) - val validUser = assertValid(result) // Fails test if Invalid, returns User if Valid - assertEquals(validUser.name, "John") - } - - test("a single validation error should be reported correctly") { - val result = Validator[User].validate(User("", Some(25))) - - // Use assertHasOneError for the common case of a single error - assertHasOneError(result) { error => - assertEquals(error.fieldPath, List("name")) - assert(error.message.contains("empty")) - } - } - - test("multiple validation errors should be accumulated") { - val result = Validator[User].validate(User("", Some(-10))) - - // Use assertInvalid for testing error accumulation - assertInvalid(result) { errors => - assertEquals(errors.size, 2) - assert(errors.exists(_.fieldPath.contains("name"))) - assert(errors.exists(_.fieldPath.contains("age"))) - } - } -} -``` - -## **Core Components** - -### **ValidationResult** - -Represents the outcome of validation as either Valid(value) or Invalid(errors): - -```scala -import net.ghoula.valar.ValidationErrors.ValidationError - -enum ValidationResult[+A] { - case Valid(value: A) - case Invalid(errors: Vector[ValidationError]) -} -``` - -### **ValidationError** - -Opaque type providing rich context for validation errors, including: - -* **message**: Human-readable description of the error. -* **fieldPath**: Path to the field causing the error (e.g., user.address.street). -* **code**: Optional application-specific error codes. -* **severity**: Optional severity indicator (Error, Warning). -* **expected/actual**: Information about expected and actual values. -* **children**: Nested errors for structured reporting. - -### **Validator[A]** - -A typeclass defining validation logic for a given type: - -```scala -import net.ghoula.valar.ValidationResult - -trait Validator[A] { - def validate(a: A): ValidationResult[A] -} -``` - -Validators can be automatically derived for case classes using deriveValidatorMacro. - -**Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances -to be available in scope for **all** field types within the case class. If a validator for any field type is missing, -**compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the -"Built-in Validators" section for types supported out-of-the-box. - -## **Built-in Validators** - -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This -includes: - -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, - Short, Char, Unit. -* **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, - java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their - element/key/value types). -* **Tuple Types:** Named tuples and regular tuples. -* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. - -Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are -**pass-through** validators. You should define custom validators if you need specific constraints for these types. - -## **ValidationObserver** - -The `ValidationObserver` trait provides a powerful mechanism to decouple validation logic from cross-cutting concerns such as logging, metrics collection, or auditing: - -```scala -import net.ghoula.valar.* -import org.slf4j.LoggerFactory - -// Define a custom observer that logs validation results -given loggingObserver: ValidationObserver with { - private val logger = LoggerFactory.getLogger("ValidationAnalytics") - - def onResult[A](result: ValidationResult[A]): Unit = result match { - case ValidationResult.Valid(_) => - logger.info("Validation succeeded") - case ValidationResult.Invalid(errors) => - logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") - } -} - -// Use the observer in your validation flow -val result = User.validate(user) - .observe() // The observer's onResult is called here - .map(_.toUpperCase) -``` - -Key features of ValidationObserver: -* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -* **Non-Intrusive**: Observes validation results without altering the validation flow -* **Chainable**: Works seamlessly with other operations in the validation pipeline -* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect - -## **Internationalization with valar-translator** - -The `valar-translator` module provides internationalization (i18n) support for validation error messages: - -```scala -import net.ghoula.valar.* -import net.ghoula.valar.translator.Translator - -// --- Example Setup --- -// In a real application, this would come from a properties file or other i18n system. -val translations: Map[String, String] = Map( - "error.string.nonEmpty" -> "The field must not be empty.", - "error.int.nonNegative" -> "The value cannot be negative.", - "error.unknown" -> "An unexpected validation error occurred." -) - -// --- Implementation of the Translator trait --- -given myTranslator: Translator with { - def translate(error: ValidationError): String = { - // Logic to look up the error's key in your translation map. - // The `.getOrElse` provides a safe fallback. - translations.getOrElse( - error.key.getOrElse("error.unknown"), - error.message // Fall back to the original message if key is not found - ) - } -} - -// Use the translator in your validation flow -val result = User.validate(user) - .observe() // Optional: observe the raw result first - .translateErrors() // Translate errors for user presentation -``` - -The `valar-translator` module is designed to: -* Integrate with any i18n library through the `Translator` typeclass -* Compose cleanly with other Valar features like ValidationObserver -* Provide a clear separation between validation logic and presentation concerns - -## **Migration Guide from v0.4.8 to v0.5.0** - -Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: - -1. **New ValidationObserver trait** for observing validation outcomes -2. **New valar-translator module** for internationalization support -3. **Enhanced ValarSuite** with improved testing utilities -4. **Reworked macros** for better performance and modern Scala 3 features -5. **MiMa checks** to ensure binary compatibility - -To upgrade to v0.5.0, update your build.sbt: - -```scala -// Update core library -libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" - -// Add optional translator module (if needed) -libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" - -// Update testing utilities (if used) -libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -``` - -Your existing validation code will continue to work without any changes. - -## **Migration Guide from v0.3.0 to v0.4.8** - -The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular architecture. - -1. **Update build.sbt**: - ```scala - // Replace this: - libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" - - // With this (note the triple %%% for cross-platform support): - libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - ``` - -## **Compatibility** - -* **Scala:** 3.7+ -* **Platforms:** JVM, Scala Native -* **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, - cross-platform support for the `java.time` API. - -## **License** - -Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) -file for details. diff --git a/docs-src/MIGRATION.md b/docs-src/MIGRATION.md index 09688c4..43faf88 100644 --- a/docs-src/MIGRATION.md +++ b/docs-src/MIGRATION.md @@ -27,7 +27,15 @@ Your existing validation code will continue to work without any changes. ### Using the New Features -#### ValidationObserver +#### Core Extensibility Pattern (ValidationObserver) + +The ValidationObserver pattern has been added to valar-core as the **standard way to extend Valar**. This pattern provides: + +* A consistent API for integrating with external systems +* Zero-cost abstractions when extensions aren't used +* Type-safe composition with other Valar features + +Future Valar modules (like valar-cats-effect and valar-zio) will build upon this pattern, making it the **recommended approach** for anyone building custom Valar extensions. The ValidationObserver trait allows you to observe validation results without altering the flow: @@ -43,14 +51,23 @@ given loggingObserver: ValidationObserver with { case ValidationResult.Valid(_) => logger.info("Validation succeeded") case ValidationResult.Invalid(errors) => - logger.warn(s"Validation failed with ${errors.size} errors") + logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") } } // Use the observer in your validation flow -val result = User.validate(user).observe() +val result = User.validate(user) + .observe() // The observer's onResult is called here + .map(_.toUpperCase) ``` +Key features of ValidationObserver: + +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Chainable**: Works seamlessly with other operations in the validation pipeline +* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect + #### valar-translator The valar-translator module provides internationalization support: @@ -80,13 +97,20 @@ given myTranslator: Translator with { } // Use the translator in your validation flow -val result = User.validate(user).translateErrors() +val result = User.validate(user) + .observe() // Optional: observe the raw result first + .translateErrors() // Translate errors for user presentation ``` +The `valar-translator` module is designed to: + +* Integrate with any i18n library through the `Translator` typeclass +* Compose cleanly with other Valar features like ValidationObserver +* Provide a clear separation between validation logic and presentation concerns + ## Migrating from v0.3.0 to v0.4.8 -The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular -architecture. +The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the new modular architecture. ### Update build.sbt: diff --git a/docs-src/README.md b/docs-src/README.md index a3e3385..47d0815 100644 --- a/docs-src/README.md +++ b/docs-src/README.md @@ -4,9 +4,7 @@ [![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and -metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, -detailed error messages useful for debugging or user feedback. +Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages useful for debugging or user feedback. ## **✨ What's New in 0.5.X** @@ -15,37 +13,64 @@ detailed error messages useful for debugging or user feedback. * **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust validation testing. * **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time validation. * **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for better developer experience. +* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer experience. ## **Key Features** -* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using - ValidationResult\[A\]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. +* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. +* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, significantly reducing repetitive validation logic. Focus on your rules, not the wiring. * **Flexible Error Handling:** Choose the strategy that fits your use case: - * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in - UIs or API responses). - * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. -* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation - rule specifics (like expected vs. actual values), and optional codes/severity. -* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward - compatibility. -* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. + * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in UIs or API responses). + * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. +* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation rule specifics (like expected vs. actual values), and optional codes/severity. +* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward compatibility. +* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, opaque types, and macros for a modern, expressive API. + +## **Extensibility Pattern** + +Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to integrate with external systems without modifying the core validation logic. + +### The ValidationObserver Pattern + +The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: + +```scala +trait ValidationObserver { + def onResult[A](result: ValidationResult[A]): Unit +} +``` + +This pattern offers several advantages: + +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Composable**: Works seamlessly with other Valar features and can be chained +* **Type-Safe**: Leverages Scala's type system for compile-time safety + +### Examples of Extensions Using This Pattern + +Current implementations are following this pattern: +- **Logging**: Log validation outcomes for debugging and monitoring +- **Metrics**: Collect validation statistics for performance analysis +- **Auditing**: Track validation events for compliance and security + +Future extensions planned: +- **valar-cats-effect**: Async validation with IO-based observers +- **valar-zio**: ZIO-based validation with resource management +- **Context-aware validation**: Observers that can access request-scoped data ## **Available Artifacts** Valar provides artifacts for both JVM and Scala Native platforms: -| Module | Platform | Artifact ID | Maven Central | -|-----------------|----------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | -| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | -| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | -| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | -| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | -| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | +| Module | Platform | Artifact ID | Maven Central | +|----------------|----------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | +| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | +| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | +| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | +| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | +| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. @@ -104,15 +129,13 @@ result match { ## **Testing with valar-munit** -The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to -make your tests clean and expressive. +The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to make your tests clean and expressive. ```scala import net.ghoula.valar.* import net.ghoula.valar.munit.ValarSuite class UserValidationSuite extends ValarSuite { - // A given Validator for User must be in scope given Validator[User] = Validator.deriveValidatorMacro @@ -124,7 +147,6 @@ class UserValidationSuite extends ValarSuite { test("a single validation error should be reported correctly") { val result = Validator[User].validate(User("", Some(25))) - // Use assertHasOneError for the common case of a single error assertHasOneError(result) { error => assertEquals(error.fieldPath, List("name")) @@ -134,7 +156,6 @@ class UserValidationSuite extends ValarSuite { test("multiple validation errors should be accumulated") { val result = Validator[User].validate(User("", Some(-10))) - // Use assertInvalid for testing error accumulation assertInvalid(result) { errors => assertEquals(errors.size, 2) @@ -185,32 +206,29 @@ trait Validator[A] { Validators can be automatically derived for case classes using deriveValidatorMacro. -**Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances -to be available in scope for **all** field types within the case class. If a validator for any field type is missing, -**compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the -"Built-in Validators" section for types supported out-of-the-box. +**Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances to be available in scope for **all** field types within the case class. If a validator for any field type is missing, **compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the "Built-in Validators" section for types supported out-of-the-box. ## **Built-in Validators** -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This -includes: +Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This includes: -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, - Short, Char, Unit. +* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, Short, Char, Unit. * **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, - java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their - element/key/value types). +* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. +* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their element/key/value types). * **Tuple Types:** Named tuples and regular tuples. * **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. -Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are -**pass-through** validators. You should define custom validators if you need specific constraints for these types. +Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. + +## **ValidationObserver, The Core Extensibility Pattern** -## **ValidationObserver** +The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending Valar with custom functionality. This pattern allows you to: -The `ValidationObserver` trait provides a powerful mechanism to decouple validation logic from cross-cutting concerns such as logging, metrics collection, or auditing: +- **Integrate with external systems** (logging, metrics, monitoring) +- **Add side effects** without modifying validation logic +- **Build composable extensions** that work together seamlessly +- **Maintain zero overhead** when extensions aren't needed ```scala import net.ghoula.valar.* @@ -234,7 +252,26 @@ val result = User.validate(user) .map(_.toUpperCase) ``` +### Building Custom Extensions + +When building extensions for Valar, follow the ValidationObserver pattern: + +```scala +// Your custom extension trait +trait MyCustomExtension extends ValidationObserver { + def onResult[A](result: ValidationResult[A]): Unit = { + // Your custom logic here + } +} + +// Usage remains clean and composable +val result = User.validate(user) + .observe() // Uses your custom extension + .map(processUser) +``` + Key features of ValidationObserver: + * **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code * **Non-Intrusive**: Observes validation results without altering the validation flow * **Chainable**: Works seamlessly with other operations in the validation pipeline @@ -263,7 +300,7 @@ given myTranslator: Translator with { // The `.getOrElse` provides a safe fallback. translations.getOrElse( error.key.getOrElse("error.unknown"), - error.message // Fall back to the original message if key is not found + error.message // Fall back to the original message if the key is not found ) } } @@ -275,6 +312,7 @@ val result = User.validate(user) ``` The `valar-translator` module is designed to: + * Integrate with any i18n library through the `Translator` typeclass * Compose cleanly with other Valar features like ValidationObserver * Provide a clear separation between validation logic and presentation concerns @@ -295,7 +333,7 @@ To upgrade to v0.5.0, update your build.sbt: // Update core library libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" -// Add optional translator module (if needed) +// Add the optional translator module (if needed) libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" // Update testing utilities (if used) @@ -309,22 +347,21 @@ Your existing validation code will continue to work without any changes. The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular architecture. 1. **Update build.sbt**: - ```scala - // Replace this: - libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" - // With this (note the triple %%% for cross-platform support): - libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - ``` +```scala +// Replace this: +libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" + +// With this (note the triple %%% for cross-platform support): +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" +``` ## **Compatibility** * **Scala:** 3.7+ * **Platforms:** JVM, Scala Native -* **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, - cross-platform support for the `java.time` API. +* **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, cross-platform support for the `java.time` API. ## **License** -Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) -file for details. +Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) file for details. \ No newline at end of file diff --git a/docs-src/translator/README.md b/docs-src/translator/README.md index c8566a3..f1401bb 100644 --- a/docs-src/translator/README.md +++ b/docs-src/translator/README.md @@ -58,27 +58,41 @@ val translatedResult = result.translateErrors() // translatedResult now contains errors with localized messages ``` -## Composing with Core Features (like ValidationObserver) +## Integration with the ValidationObserver Extensibility Pattern -The `valar-translator` module is designed to compose cleanly with features from the core library. A prime example is the **`ValidationObserver` pattern**, a general-purpose tool for side effects (like logging or metrics) that is **available directly in `valar-core`**. +The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. -While the two patterns serve different purposes, they can be chained together for a powerful workflow: +This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: -* **`ValidationObserver` (Side Effect)**: Reacts to a result without changing it. -* **`Translator` (Data Transformation)**: Refines a result by localizing error messages. +* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result +* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization + +While these serve different purposes, they're designed to work together in a clean, composable way: A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. ```scala -// Given a defined `metricsObserver` from your application -// and a `myTranslator` from this module... +// Given a defined extension using the ValidationObserver pattern +given metricsObserver: ValidationObserver with { + def onResult[A](result: ValidationResult[A]): Unit = { + // Record validation metrics to your monitoring system + } +} + +// And a translator implementation for localization +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Translate errors using your i18n system + } +} +// Both extensions work together through the same pattern val result = User.validate(invalidUser) - // 1. First, observe the raw result using the core ValidationObserver. - .observe() - // 2. Then, translate the errors for presentation using the Translator. + // First, observe the raw result using the core ValidationObserver pattern + .observe() + // Then, translate the errors for presentation (also built on the same pattern) .translateErrors() -// The final `result` contains user-friendly, translated messages, -// while the original, structured error was sent to your metrics system. +// This demonstrates how all Valar extensions follow the same architectural pattern, +// allowing them to compose together seamlessly ``` diff --git a/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala index 700d2e0..d404688 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala @@ -1,36 +1,89 @@ package net.ghoula.valar import net.ghoula.valar.ValidationResult -/** Defines a contract for observing the outcomes of validation processes. +/** Defines the foundational extensibility pattern for Valar. * - * This typeclass provides a powerful mechanism to decouple validation logic from cross-cutting - * concerns such as logging, metrics collection, or auditing. By implementing this trait and - * providing it as a `given` instance, developers can seamlessly integrate Valar with external - * monitoring and diagnostic systems. + * This typeclass represents Valar's canonical pattern for extension development. It's designed to + * be the standard way to build integrations and extensions for the validation library. By + * implementing this trait and providing it as a `given` instance, developers can: + * + * - Extend Valar with cross-cutting concerns (logging, metrics, auditing) + * - Build composable extensions that work together seamlessly + * - Integrate with external monitoring and diagnostic systems + * - Create specialized behaviors without modifying validation logic * * @see - * [[ValidationObserver.noOpObserver]] for the default, zero-overhead implementation that is used + * [[ValidationObserver.noOpObserver]] for the default, zero-overhead implementation used * when no custom observer is provided. * + * ==Architectural Pattern== + * + * The `ValidationObserver` pattern is the recommended approach for extending Valar's capabilities. + * By using this pattern, you benefit from: + * + * - A standardized, type-safe interface for integrating with Valar + * - Zero-cost abstractions through the inline implementation when not used + * - Clean composition with other features (like the translator module) + * - Future compatibility with upcoming Valar modules (planned: valar-cats-effect, valar-zio) + * + * When implementing extensions to Valar, prefer extending this trait over creating alternative + * patterns. + * * @example - * {{{ import org.slf4j.LoggerFactory + * Building a simple extension for validation logging: + * {{{ + * import org.slf4j.LoggerFactory + * + * // 1. Define your extension by implementing ValidationObserver + * given loggingObserver: ValidationObserver with { + * private val logger = LoggerFactory.getLogger("ValidationAnalytics") * - * // An example implementation that logs validation results using SLF4J. given loggingObserver: - * ValidationObserver with { private val logger = LoggerFactory.getLogger("ValidationAnalytics") + * def onResult[A](result: ValidationResult[A]): Unit = result match { + * case ValidationResult.Valid(_) => + * logger.info("Validation succeeded.") + * case ValidationResult.Invalid(errors) => + * logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") + * } + * } * - * def onResult[A](result: ValidationResult[A]): Unit = result match { case - * ValidationResult.Valid(_) => logger.info("Validation succeeded.") case - * ValidationResult.Invalid(errors) => logger.warn(s"Validation failed with ${errors.size} errors: - * ${errors.map(_.message).mkString(", ")}") } } + * // 2. Use your extension with the standard observe() pattern + * val result = someValidation().observe() // The observer is automatically used * - * // Now, any call to .observe() will automatically use the loggingObserver. val result = - * someValidation().observe() }}} + * // 3. Extensions compose cleanly with other Valar features + * val processedResult = someValidation() + * .observe() // Trigger logging/metrics through your observer + * .map(transform) + * // Can be chained with other extensions like translator + * }}} + * + * Creating a reusable extension module: + * {{{ + * // Define a specialized observer for metrics collection + * trait MetricsObserver extends ValidationObserver { + * def recordMetric(name: String, value: Double): Unit + * + * def onResult[A](result: ValidationResult[A]): Unit = result match { + * case ValidationResult.Valid(_) => + * recordMetric("validation.success", 1.0) + * case ValidationResult.Invalid(errors) => + * recordMetric("validation.failure", 1.0) + * recordMetric("validation.error.count", errors.size.toDouble) + * } + * } + * + * // Concrete implementation for a specific metrics library + * given PrometheusMetricsObserver: MetricsObserver with { + * def recordMetric(name: String, value: Double): Unit = { + * // Implementation using Prometheus client + * } + * } + * }}} */ trait ValidationObserver { /** A callback executed for each `ValidationResult` passed to the `observe` method. * - * Implementations of this method can inspect the result and trigger side-effects, such as + * Implementations of this method can inspect the result and trigger side effects, such as * writing to a log, incrementing a metrics counter, or sending an alert. This method should not * throw exceptions. * @@ -62,13 +115,20 @@ extension [A](vr: ValidationResult[A]) { /** Applies the in-scope `ValidationObserver` to this `ValidationResult`. * - * This extension method enables side-effecting operations on a validation result without - * altering the flow of validation logic. It returns the original result, allowing for seamless - * method chaining. + * This extension method is the primary interface for the ValidationObserver extension pattern. + * It enables side-effecting operations and extensions to be applied to a validation result + * without altering the validation logic or flow. It returns the original result unchanged, + * allowing for seamless method chaining with other operations. + * + * ===Extension Pattern Entry Point=== + * + * This method serves as the standardized entry point for all extensions built on the + * ValidationObserver pattern. Current and future Valar modules that follow this pattern will be + * usable through this consistent interface. * * This method is declared `inline` to facilitate powerful compile-time optimizations. If the * default [[ValidationObserver.noOpObserver]] is in scope, the compiler will eliminate this - * entire method call from the generated bytecode. + * entire method call from the generated bytecode, ensuring zero runtime overhead. * * @param observer * The `ValidationObserver` instance provided by the implicit context. From c7b58403f2a1ae2f4218efc095e0b2ef6a364201 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 10:44:26 +0200 Subject: [PATCH 16/19] mark that bench --- build.sbt | 17 ++- project/plugins.sbt | 3 + valar-benchmarks/README.md | 139 ++++++++++++++++++ .../valar/benchmarks/ValarBenchmark.scala | 108 ++++++++++++++ .../net/ghoula/valar/ValidationObserver.scala | 4 +- 5 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 valar-benchmarks/README.md create mode 100644 valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala diff --git a/build.sbt b/build.sbt index bd29f2b..275fb37 100644 --- a/build.sbt +++ b/build.sbt @@ -47,7 +47,8 @@ lazy val root = (project in file(".")) valarMunitJVM, valarMunitNative, valarTranslatorJVM, - valarTranslatorNative + valarTranslatorNative, + valarBenchmarks ) .settings( name := "valar-root", @@ -143,6 +144,20 @@ lazy val valarTranslator = crossProject(JVMPlatform, NativePlatform) .nativeSettings( testFrameworks += new TestFramework("munit.Framework") ) +// ===== Benchmarks Module ===== +lazy val valarBenchmarks = project + .in(file("valar-benchmarks")) + .dependsOn(valarCoreJVM) + .enablePlugins(JmhPlugin) + .settings( + name := "valar-benchmarks", + publish / skip := true, + libraryDependencies ++= Seq( + "org.openjdk.jmh" % "jmh-core" % "1.37", + "org.openjdk.jmh" % "jmh-generator-annprocess" % "1.37" + ) + ) + // ===== Convenience Aliases ===== lazy val valarCoreJVM = valarCore.jvm lazy val valarCoreNative = valarCore.native diff --git a/project/plugins.sbt b/project/plugins.sbt index 2b223de..7f55807 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -22,3 +22,6 @@ addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.8") addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") // For TASTy compatibility checking (for Scala 3 inlines/macros) addSbtPlugin("ch.epfl.scala" % "sbt-tasty-mima" % "1.3.0") + +// Benchmarking +addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7") diff --git a/valar-benchmarks/README.md b/valar-benchmarks/README.md new file mode 100644 index 0000000..f70ce8a --- /dev/null +++ b/valar-benchmarks/README.md @@ -0,0 +1,139 @@ +# Valar Benchmarks + +This module contains JMH (Java Microbenchmark Harness) benchmarks for the Valar validation library. The benchmarks measure the performance of critical validation paths to help identify performance characteristics and potential optimizations. + +## Overview + +The benchmark suite covers: +- **Synchronous validation** of simple and nested case classes +- **Asynchronous validation** with a mix of sync and async rules +- **Valid and invalid data paths** to understand performance differences + +## Benchmark Results + +Based on the latest run (JDK 21.0.7, OpenJDK 64-Bit Server VM): + +| Benchmark | Mode | Score | Error | Units | +|----------------------|------|------------|-------------|-------| +| `syncSimpleValid` | avgt | 44.628 | ± 6.746 | ns/op | +| `syncSimpleInvalid` | avgt | 149.155 | ± 7.124 | ns/op | +| `syncNestedValid` | avgt | 108.968 | ± 7.300 | ns/op | +| `syncNestedInvalid` | avgt | 449.783 | ± 18.373 | ns/op | +| `asyncSimpleValid` | avgt | 13,212.036 | ± 1,114.597 | ns/op | +| `asyncSimpleInvalid` | avgt | 13,465.022 | ± 214.379 | ns/op | +| `asyncNestedValid` | avgt | 14,513.056 | ± 1,023.942 | ns/op | +| `asyncNestedInvalid` | avgt | 15,432.503 | ± 2,592.103 | ns/op | + +## Performance Analysis + +### 🚀 Synchronous Performance is Excellent + +The validation for simple, valid objects completes in **~45 nanoseconds**. This is incredibly fast and proves that for the "happy path," the library adds negligible overhead. The slightly higher numbers for invalid and nested cases (~150–450 ns) are also excellent and are expected, as they account for: + +- Creation of `ValidationError` objects for invalid cases +- Recursive validation calls for nested structures +- Error accumulation logic + +**Key takeaway**: Synchronous validation is extremely fast with minimal overhead. + +### ⚡ Asynchronous Performance is As Expected + +The async benchmarks show results in the **~13–16 microsecond range** (13,00016,000 ns). This is excellent and exactly what we should expect. The "cost" here is not from our validation logic but from the inherent overhead of: + +- Creating `Future` instances +- Managing the `ExecutionContext` +- The `Await.result` call in the benchmark (blocking on async results) + +**Key takeaway**: Our async logic is efficient and correctly builds on Scala's non-blocking primitives without introducing performance bottlenecks. + +### Summary + +- **Sync validation**: Negligible overhead, perfect for high-throughput scenarios +- **Async validation**: Adds only the expected Future abstraction overhead +- **Valid vs Invalid**: Invalid cases show expected slight overhead due to error object creation +- **Simple vs Nested**: Nested validation scales linearly with complexity + +The results confirm that Valar introduces no significant performance penalties beyond what's inherent to the chosen execution model (sync vs. async). + +## Running Benchmarks + +### Run All Benchmarks +```bash +sbt "valarBenchmarks / Jmh / run" +``` +``` +### Run Specific Benchmarks +``` bash +# Run only sync benchmarks +sbt "valarBenchmarks / Jmh / run .*sync.*" + +# Run only async benchmarks +sbt "valarBenchmarks / Jmh / run .*async.*" + +# Run only valid cases +sbt "valarBenchmarks / Jmh / run .*Valid.*" +``` +### Customize Benchmark Parameters +``` bash +# Run with custom iterations and warmup +sbt "valarBenchmarks / Jmh / run -i 10 -wi 5 -f 2" + +# Run with different output format +sbt "valarBenchmarks / Jmh / run -rf json" +``` +### List Available Benchmarks +``` bash +sbt "valarBenchmarks / Jmh / run -l" +``` +## Benchmark Configuration +The benchmarks are configured with: +- : five iterations, 1 second each **Warmup** +- : five iterations, 1 second each **Measurement** +- : 1 fork **Fork** +- : Average time (ns/op) **Mode** +- **Threads**: 1 thread + +## Test Data +The benchmarks use the following test models: +``` scala +case class SimpleUser(name: String, age: Int) +case class NestedCompany(name: String, owner: SimpleUser) +``` +With validation rules: +- must be non-empty `name` +- must be non-negative `age` + +## Understanding Results +- **ns/op**: Nanoseconds per operation (lower is better) +- **Error**: 99.9% confidence interval +- **Mode avgt**: Average time across all iterations + +## Profiling +For deeper performance analysis, you can use JMH's built-in profilers: +``` bash +# CPU profiling +sbt "valarBenchmarks / Jmh / run -prof comp" + +# Memory allocation profiling +sbt "valarBenchmarks / Jmh / run -prof gc" + +# Stack profiling +sbt "valarBenchmarks / Jmh / run -prof stack" +``` +## Adding New Benchmarks +To add new benchmarks: +1. Add your benchmark method to `ValarBenchmark.scala` +2. Annotate it with `@Benchmark` +3. Ensure it returns a meaningful value to prevent dead code elimination +4. Follow the existing naming conventions (`sync`/`async` + `Simple`/`Nested` + /`Invalid`) `Valid` + +## Dependencies +- JMH 1.37 +- Scala 3.7.1 +- OpenJDK 21+ + +## Notes +- Results may vary based on JVM version, hardware, and system load +- Always run benchmarks multiple times to ensure consistency +- Consider JVM warm-up effects when interpreting results +- The async benchmarks include overhead, which inflates the numbers compared to pure async execution `Await.result` diff --git a/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala b/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala new file mode 100644 index 0000000..0b81978 --- /dev/null +++ b/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala @@ -0,0 +1,108 @@ +package net.ghoula.valar.benchmarks + +import org.openjdk.jmh.annotations.* + +import java.util.concurrent.TimeUnit +import scala.concurrent.Await +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.duration.* + +import net.ghoula.valar.* +import net.ghoula.valar.ValidationErrors.ValidationError + +/** Defines the JMH benchmark suite for Valar. + * + * This suite measures the performance of critical validation paths, including + * - Synchronous validation of simple and nested case classes. + * - Asynchronous validation with a mix of sync and async rules. + * + * To run these benchmarks, use the sbt command: `valarBenchmarks / Jmh / run` + */ +@State(Scope.Thread) +@BenchmarkMode(Array(Mode.AverageTime)) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +@Fork(1) +class ValarBenchmark { + + // --- Test Data and Models --- + + case class SimpleUser(name: String, age: Int) + case class NestedCompany(name: String, owner: SimpleUser) + + private val validUser: SimpleUser = SimpleUser("John Doe", 30) + private val invalidUser: SimpleUser = SimpleUser("", -1) + private val validCompany: NestedCompany = NestedCompany("Valid Corp", validUser) + private val invalidCompany: NestedCompany = NestedCompany("", invalidUser) + + // --- Synchronous Validators --- + + given syncStringValidator: Validator[String] with { + def validate(value: String): ValidationResult[String] = + if (value.nonEmpty) ValidationResult.Valid(value) + else ValidationResult.invalid(ValidationError("String is empty")) + } + + given syncIntValidator: Validator[Int] with { + def validate(value: Int): ValidationResult[Int] = + if (value >= 0) ValidationResult.Valid(value) + else ValidationResult.invalid(ValidationError("Int is negative")) + } + + given syncUserValidator: Validator[SimpleUser] = Validator.derive + given syncCompanyValidator: Validator[NestedCompany] = Validator.derive + + // --- Asynchronous Validators --- + + given asyncStringValidator: AsyncValidator[String] with { + def validateAsync(name: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = + Future.successful(syncStringValidator.validate(name)) + } + + given asyncUserValidator: AsyncValidator[SimpleUser] = AsyncValidator.derive + given asyncCompanyValidator: AsyncValidator[NestedCompany] = AsyncValidator.derive + + // --- Benchmarks --- + + @Benchmark + def syncSimpleValid(): ValidationResult[SimpleUser] = { + syncUserValidator.validate(validUser) + } + + @Benchmark + def syncSimpleInvalid(): ValidationResult[SimpleUser] = { + syncUserValidator.validate(invalidUser) + } + + @Benchmark + def syncNestedValid(): ValidationResult[NestedCompany] = { + syncCompanyValidator.validate(validCompany) + } + + @Benchmark + def syncNestedInvalid(): ValidationResult[NestedCompany] = { + syncCompanyValidator.validate(invalidCompany) + } + + @Benchmark + def asyncSimpleValid(): ValidationResult[SimpleUser] = { + Await.result(asyncUserValidator.validateAsync(validUser), 1.second) + } + + @Benchmark + def asyncSimpleInvalid(): ValidationResult[SimpleUser] = { + Await.result(asyncUserValidator.validateAsync(invalidUser), 1.second) + } + + @Benchmark + def asyncNestedValid(): ValidationResult[NestedCompany] = { + Await.result(asyncCompanyValidator.validateAsync(validCompany), 1.second) + } + + @Benchmark + def asyncNestedInvalid(): ValidationResult[NestedCompany] = { + Await.result(asyncCompanyValidator.validateAsync(invalidCompany), 1.second) + } +} diff --git a/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala index d404688..1b673a1 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala @@ -13,8 +13,8 @@ import net.ghoula.valar.ValidationResult * - Create specialized behaviors without modifying validation logic * * @see - * [[ValidationObserver.noOpObserver]] for the default, zero-overhead implementation used - * when no custom observer is provided. + * [[ValidationObserver.noOpObserver]] for the default, zero-overhead implementation used when no + * custom observer is provided. * * ==Architectural Pattern== * From 5b35b42596675e398b58715c7bea0532ee8080c9 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 10:58:44 +0200 Subject: [PATCH 17/19] ready? --- MIGRATION.md | 200 ++++++++++++++++++ README.md | 402 +++++++++++++++++++++++++++++++++++++ docs-src/README.md | 101 +++++++--- translator/README.md | 38 ++-- valar-translator/README.md | 38 ++-- 5 files changed, 722 insertions(+), 57 deletions(-) create mode 100644 MIGRATION.md create mode 100644 README.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..43faf88 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,200 @@ +# Migration Guide + +## Migrating from v0.4.8 to v0.5.0 + +Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: + +1. **New ValidationObserver trait** for observing validation outcomes without altering the flow +2. **New valar-translator module** for internationalization support of validation error messages +3. **Enhanced ValarSuite** with improved testing utilities +4. **Reworked macros** for better performance and modern Scala 3 features +5. **MiMa checks** to ensure binary compatibility between versions + +### Update build.sbt: + +```scala +// Update core library +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Add the optional translator module (if needed) +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +// Update testing utilities (if used) +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +Your existing validation code will continue to work without any changes. + +### Using the New Features + +#### Core Extensibility Pattern (ValidationObserver) + +The ValidationObserver pattern has been added to valar-core as the **standard way to extend Valar**. This pattern provides: + +* A consistent API for integrating with external systems +* Zero-cost abstractions when extensions aren't used +* Type-safe composition with other Valar features + +Future Valar modules (like valar-cats-effect and valar-zio) will build upon this pattern, making it the **recommended approach** for anyone building custom Valar extensions. + +The ValidationObserver trait allows you to observe validation results without altering the flow: + +```scala +import net.ghoula.valar.* +import org.slf4j.LoggerFactory + +// Define a custom observer that logs validation results +given loggingObserver: ValidationObserver with { + private val logger = LoggerFactory.getLogger("ValidationAnalytics") + + def onResult[A](result: ValidationResult[A]): Unit = result match { + case ValidationResult.Valid(_) => + logger.info("Validation succeeded") + case ValidationResult.Invalid(errors) => + logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") + } +} + +// Use the observer in your validation flow +val result = User.validate(user) + .observe() // The observer's onResult is called here + .map(_.toUpperCase) +``` + +Key features of ValidationObserver: + +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Chainable**: Works seamlessly with other operations in the validation pipeline +* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect + +#### valar-translator + +The valar-translator module provides internationalization support: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.translator.Translator + +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( + error.key.getOrElse("error.unknown"), + error.message // Fall back to the original message if the key is not found + ) + } +} + +// Use the translator in your validation flow +val result = User.validate(user) + .observe() // Optional: observe the raw result first + .translateErrors() // Translate errors for user presentation +``` + +The `valar-translator` module is designed to: + +* Integrate with any i18n library through the `Translator` typeclass +* Compose cleanly with other Valar features like ValidationObserver +* Provide a clear separation between validation logic and presentation concerns + +## Migrating from v0.3.0 to v0.4.8 + +The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the new modular architecture. + +### Update build.sbt: + +```scala +// Replace this: +libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" + +// With this (note the triple %%% for cross-platform support): +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" + +// Add optional testing utilities (if desired): +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test +``` + +> **Note:** v0.4.8 used bundle versions (`-bundle` suffix) that included all dependencies. Starting from v0.5.0, we've moved to the standard approach without bundle versions for simpler dependency management. + +### Available Artifacts for v0.4.8 + +The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). For v0.4.8, only bundle versions are available: + +| Module | Platform | Artifact ID | Bundle Version | +|--------|----------|-------------------------|-------------------------------------------------------------| +| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | +| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | +| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | +| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | + +Your existing validation code will continue to work without any changes. + +## Note on Scala 3.7+ Givens Prioritization + +Scala 3.7 changes how the compiler resolves given instances when multiple candidates are available. Previously, the +compiler would select the most specific subtype, but now it chooses based on different prioritization rules. + +This may affect your code if: + +* You have multiple validator instances for the same type or related types (e.g., through type aliases or inheritance). +* You rely on implicit resolution to select the correct validator. + +### Potential Issues + +The most common issue is with type aliases, where you might have defined: + +```scala +type Email = String + +// General validator +given Validator[String] with { ... } + +// More specific validator +given Validator[Email] with { ... } + +// Which one gets used? In 3.6 vs. 3.7 it might be different! +val result = summon[Validator[Email]].validate(email) +``` + +### Solutions + +1. **Explicit imports**: Place validators in objects and import only the ones you need. + + ```scala + object validators { + given stringValidator: Validator[String] with { ... } + given emailValidator: Validator[Email] with { ... } + } + + // Be explicit about which one to use + import validators.emailValidator + ``` + +2. **Named instances**: Give your validators explicit names and use them directly. + + ```scala + given generalStringValidator: Validator[String] with { ... } + given specificEmailValidator: Validator[Email] with { ... } + + // Use the specific one explicitly + val result = specificEmailValidator.validate(email) + ``` + +3. **Extension methods**: Define your validation logic as extension methods to avoid ambiguity. + + ```scala + extension (email: Email) { + def validate: ValidationResult[Email] = { ... } + } + ``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ee98a1 --- /dev/null +++ b/README.md @@ -0,0 +1,402 @@ +# **Valar – Type-Safe Validation for Scala 3** + +[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) +[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + +Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and +metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, +detailed error messages useful for debugging or user feedback. + +## **✨ What's New in 0.5.X** + +* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, + perfect for logging, metrics collection, or auditing with zero overhead when not used. +* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the + `Translator` typeclass. +* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust + validation testing. +* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time + validation. +* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. +* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer + experience. + +## **Key Features** + +* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using + ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. +* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, + significantly reducing repetitive validation logic. Focus on your rules, not the wiring. +* **Flexible Error Handling:** Choose the strategy that fits your use case: + * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in + UIs or API responses). + * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. +* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation + rule specifics (like expected vs. actual values), and optional codes/severity. +* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward + compatibility. +* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, + opaque types, and macros for a modern, expressive API. + +## **Extensibility Pattern** + +Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to +integrate with external systems without modifying the core validation logic. + +### The ValidationObserver Pattern + +The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: + +```scala +trait ValidationObserver { + def onResult[A](result: ValidationResult[A]): Unit +} +``` + +This pattern offers several advantages: + +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Composable**: Works seamlessly with other Valar features and can be chained +* **Type-Safe**: Leverages Scala's type system for compile-time safety + +### Examples of Extensions Using This Pattern + +Current implementations are following this pattern: + +- **Logging**: Log validation outcomes for debugging and monitoring +- **Metrics**: Collect validation statistics for performance analysis +- **Auditing**: Track validation events for compliance and security + +Future extensions planned: + +- **valar-cats-effect**: Async validation with IO-based observers +- **valar-zio**: ZIO-based validation with resource management +- **Context-aware validation**: Observers that can access request-scoped data + +## **Available Artifacts** + +Valar provides artifacts for both JVM and Scala Native platforms: + +| Module | Platform | Artifact ID | Maven Central | +|----------------|----------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | +| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | +| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | +| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | +| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | +| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | + +> **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. + +## **Additional Resources** + +- 📊 **[Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md)**: Detailed JMH benchmark results and analysis +- 🧪 **[Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md)**: Enhanced testing utilities with ValarSuite +- 🌐 **[Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md)**: i18n support for validation error messages +## **Installation** + +Add the following to your build.sbt: + +```scala +// The core validation library (JVM & Scala Native) +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Optional: For internationalization (i18n) support +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +// Optional: For enhanced testing with MUnit +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +## **Basic Usage Example** + +Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int ( +non-negative). + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.ValidationResult.{Invalid, Valid} +import net.ghoula.valar.ValidationHelpers.* + +case class User(name: String, age: Option[Int]) + +// Define a custom validator for String +given Validator[String] with { + def validate(value: String): ValidationResult[String] = + nonEmpty(value, _ => "Name must not be empty") +} + +// Define a custom validator for Int +given Validator[Int] with { + def validate(value: Int): ValidationResult[Int] = + nonNegativeInt(value, i => s"Age must be non-negative, got $i") +} + +// Automatically derive a Validator for the case class User using the givens above +given Validator[User] = Validator.deriveValidatorMacro + +val user = User("", Some(-10)) +val result: ValidationResult[User] = Validator[User].validate(user) + +result match { + case Valid(validUser) => println(s"Valid user: $validUser") + case Invalid(errors) => + println("Validation Failed:") + println(errors.map(_.prettyPrint(indent = 2)).mkString("\n")) +} +``` + +## **Testing with valar-munit** + +The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to +make your tests clean and expressive. + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.munit.ValarSuite + +class UserValidationSuite extends ValarSuite { + // A given Validator for User must be in scope + given Validator[User] = Validator.deriveValidatorMacro + + test("a valid user should pass validation") { + val result = Validator[User].validate(User("John", Some(25))) + val validUser = assertValid(result) // Fails test if Invalid, returns User if Valid + assertEquals(validUser.name, "John") + } + + test("a single validation error should be reported correctly") { + val result = Validator[User].validate(User("", Some(25))) + // Use assertHasOneError for the common case of a single error + assertHasOneError(result) { error => + assertEquals(error.fieldPath, List("name")) + assert(error.message.contains("empty")) + } + } + + test("multiple validation errors should be accumulated") { + val result = Validator[User].validate(User("", Some(-10))) + // Use assertInvalid for testing error accumulation + assertInvalid(result) { errors => + assertEquals(errors.size, 2) + assert(errors.exists(_.fieldPath.contains("name"))) + assert(errors.exists(_.fieldPath.contains("age"))) + } + } +} +``` + +## **Core Components** + +### **ValidationResult** + +Represents the outcome of validation as either Valid(value) or Invalid(errors): + +```scala +import net.ghoula.valar.ValidationErrors.ValidationError + +enum ValidationResult[+A] { + case Valid(value: A) + case Invalid(errors: Vector[ValidationError]) +} +``` + +### **ValidationError** + +Opaque type providing rich context for validation errors, including: + +* **message**: Human-readable description of the error. +* **fieldPath**: Path to the field causing the error (e.g., user.address.street). +* **code**: Optional application-specific error codes. +* **severity**: Optional severity indicator (Error, Warning). +* **expected/actual**: Information about expected and actual values. +* **children**: Nested errors for structured reporting. + +### **Validator[A]** + +A typeclass defining validation logic for a given type: + +```scala +import net.ghoula.valar.ValidationResult + +trait Validator[A] { + def validate(a: A): ValidationResult[A] +} +``` + +Validators can be automatically derived for case classes using deriveValidatorMacro. + +**Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances +to be available in scope for **all** field types within the case class. If a validator for any field type is missing, * +*compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the " +Built-in Validators" section for types supported out-of-the-box. + +## **Built-in Validators** + +Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This +includes: + +* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, + Short, Char, Unit. +* **Other Scala Types:** BigInt, BigDecimal, Symbol. +* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, + java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. +* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their + element/key/value types). +* **Tuple Types:** Named tuples and regular tuples. +* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. + +Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are +**pass-through** validators. You should define custom validators if you need specific constraints for these types. + +## **ValidationObserver, The Core Extensibility Pattern** + +The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending +Valar with custom functionality. This pattern allows you to: + +- **Integrate with external systems** (logging, metrics, monitoring) +- **Add side effects** without modifying validation logic +- **Build composable extensions** that work together seamlessly +- **Maintain zero overhead** when extensions aren't needed + +```scala +import net.ghoula.valar.* +import org.slf4j.LoggerFactory + +// Define a custom observer that logs validation results +given loggingObserver: ValidationObserver with { + private val logger = LoggerFactory.getLogger("ValidationAnalytics") + + def onResult[A](result: ValidationResult[A]): Unit = result match { + case ValidationResult.Valid(_) => + logger.info("Validation succeeded") + case ValidationResult.Invalid(errors) => + logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") + } +} + +// Use the observer in your validation flow +val result = User.validate(user) + .observe() // The observer's onResult is called here + .map(_.toUpperCase) +``` + +### Building Custom Extensions + +When building extensions for Valar, follow the ValidationObserver pattern: + +```scala +// Your custom extension trait +trait MyCustomExtension extends ValidationObserver { + def onResult[A](result: ValidationResult[A]): Unit = { + // Your custom logic here + } +} + +// Usage remains clean and composable +val result = User.validate(user) + .observe() // Uses your custom extension + .map(processUser) +``` + +Key features of ValidationObserver: + +* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code +* **Non-Intrusive**: Observes validation results without altering the validation flow +* **Chainable**: Works seamlessly with other operations in the validation pipeline +* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect + +## **Internationalization with valar-translator** + +The `valar-translator` module provides internationalization (i18n) support for validation error messages: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.translator.Translator + +// --- Example Setup --- +// In a real application, this would come from a properties file or other i18n system. +val translations: Map[String, String] = Map( + "error.string.nonEmpty" -> "The field must not be empty.", + "error.int.nonNegative" -> "The value cannot be negative.", + "error.unknown" -> "An unexpected validation error occurred." +) + +// --- Implementation of the Translator trait --- +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Logic to look up the error's key in your translation map. + // The `.getOrElse` provides a safe fallback. + translations.getOrElse( + error.key.getOrElse("error.unknown"), + error.message // Fall back to the original message if the key is not found + ) + } +} + +// Use the translator in your validation flow +val result = User.validate(user) + .observe() // Optional: observe the raw result first + .translateErrors() // Translate errors for user presentation +``` + +The `valar-translator` module is designed to: + +* Integrate with any i18n library through the `Translator` typeclass +* Compose cleanly with other Valar features like ValidationObserver +* Provide a clear separation between validation logic and presentation concerns + +## **Migration Guide from v0.4.8 to v0.5.0** + +Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: + +1. **New ValidationObserver trait** for observing validation outcomes +2. **New valar-translator module** for internationalization support +3. **Enhanced ValarSuite** with improved testing utilities +4. **Reworked macros** for better performance and modern Scala 3 features +5. **MiMa checks** to ensure binary compatibility + +To upgrade to v0.5.0, update your build.sbt: + +```scala +// Update core library +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +// Add the optional translator module (if needed) +libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +// Update testing utilities (if used) +libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test +``` + +Your existing validation code will continue to work without any changes. + +## **Migration Guide from v0.3.0 to v0.4.8** + +The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular +architecture. + +1. **Update build.sbt**: + +```scala +// Replace this: +libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" + +// With this (note the triple %%% for cross-platform support): +libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" +``` + +## **Compatibility** + +* **Scala:** 3.7+ +* **Platforms:** JVM, Scala Native +* **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, + cross-platform support for the `java.time` API. + +## **License** + +Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) +file for details. \ No newline at end of file diff --git a/docs-src/README.md b/docs-src/README.md index 47d0815..2ee98a1 100644 --- a/docs-src/README.md +++ b/docs-src/README.md @@ -4,31 +4,45 @@ [![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages useful for debugging or user feedback. +Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and +metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, +detailed error messages useful for debugging or user feedback. ## **✨ What's New in 0.5.X** -* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, perfect for logging, metrics collection, or auditing with zero overhead when not used. -* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the `Translator` typeclass. -* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust validation testing. -* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time validation. +* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, + perfect for logging, metrics collection, or auditing with zero overhead when not used. +* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the + `Translator` typeclass. +* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust + validation testing. +* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time + validation. * **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer experience. +* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer + experience. ## **Key Features** -* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, significantly reducing repetitive validation logic. Focus on your rules, not the wiring. +* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using + ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. +* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, + significantly reducing repetitive validation logic. Focus on your rules, not the wiring. * **Flexible Error Handling:** Choose the strategy that fits your use case: - * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in UIs or API responses). - * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. -* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation rule specifics (like expected vs. actual values), and optional codes/severity. -* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward compatibility. -* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, opaque types, and macros for a modern, expressive API. + * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in + UIs or API responses). + * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. +* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation + rule specifics (like expected vs. actual values), and optional codes/severity. +* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward + compatibility. +* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, + opaque types, and macros for a modern, expressive API. ## **Extensibility Pattern** -Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to integrate with external systems without modifying the core validation logic. +Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to +integrate with external systems without modifying the core validation logic. ### The ValidationObserver Pattern @@ -50,11 +64,13 @@ This pattern offers several advantages: ### Examples of Extensions Using This Pattern Current implementations are following this pattern: + - **Logging**: Log validation outcomes for debugging and monitoring - **Metrics**: Collect validation statistics for performance analysis - **Auditing**: Track validation events for compliance and security Future extensions planned: + - **valar-cats-effect**: Async validation with IO-based observers - **valar-zio**: ZIO-based validation with resource management - **Context-aware validation**: Observers that can access request-scoped data @@ -74,6 +90,11 @@ Valar provides artifacts for both JVM and Scala Native platforms: > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. +## **Additional Resources** + +- 📊 **[Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md)**: Detailed JMH benchmark results and analysis +- 🧪 **[Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md)**: Enhanced testing utilities with ValarSuite +- 🌐 **[Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md)**: i18n support for validation error messages ## **Installation** Add the following to your build.sbt: @@ -91,7 +112,8 @@ libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ## **Basic Usage Example** -Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int (non-negative). +Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int ( +non-negative). ```scala import net.ghoula.valar.* @@ -129,7 +151,8 @@ result match { ## **Testing with valar-munit** -The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to make your tests clean and expressive. +The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to +make your tests clean and expressive. ```scala import net.ghoula.valar.* @@ -206,24 +229,33 @@ trait Validator[A] { Validators can be automatically derived for case classes using deriveValidatorMacro. -**Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances to be available in scope for **all** field types within the case class. If a validator for any field type is missing, **compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the "Built-in Validators" section for types supported out-of-the-box. +**Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances +to be available in scope for **all** field types within the case class. If a validator for any field type is missing, * +*compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the " +Built-in Validators" section for types supported out-of-the-box. ## **Built-in Validators** -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This includes: +Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This +includes: -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, Short, Char, Unit. +* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, + Short, Char, Unit. * **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their element/key/value types). +* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, + java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. +* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their + element/key/value types). * **Tuple Types:** Named tuples and regular tuples. * **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. -Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. +Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are +**pass-through** validators. You should define custom validators if you need specific constraints for these types. ## **ValidationObserver, The Core Extensibility Pattern** -The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending Valar with custom functionality. This pattern allows you to: +The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending +Valar with custom functionality. This pattern allows you to: - **Integrate with external systems** (logging, metrics, monitoring) - **Add side effects** without modifying validation logic @@ -239,16 +271,16 @@ given loggingObserver: ValidationObserver with { private val logger = LoggerFactory.getLogger("ValidationAnalytics") def onResult[A](result: ValidationResult[A]): Unit = result match { - case ValidationResult.Valid(_) => + case ValidationResult.Valid(_) => logger.info("Validation succeeded") - case ValidationResult.Invalid(errors) => + case ValidationResult.Invalid(errors) => logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") } } // Use the observer in your validation flow val result = User.validate(user) - .observe() // The observer's onResult is called here + .observe() // The observer's onResult is called here .map(_.toUpperCase) ``` @@ -266,7 +298,7 @@ trait MyCustomExtension extends ValidationObserver { // Usage remains clean and composable val result = User.validate(user) - .observe() // Uses your custom extension + .observe() // Uses your custom extension .map(processUser) ``` @@ -290,7 +322,7 @@ import net.ghoula.valar.translator.Translator val translations: Map[String, String] = Map( "error.string.nonEmpty" -> "The field must not be empty.", "error.int.nonNegative" -> "The value cannot be negative.", - "error.unknown" -> "An unexpected validation error occurred." + "error.unknown" -> "An unexpected validation error occurred." ) // --- Implementation of the Translator trait --- @@ -307,8 +339,8 @@ given myTranslator: Translator with { // Use the translator in your validation flow val result = User.validate(user) - .observe() // Optional: observe the raw result first - .translateErrors() // Translate errors for user presentation + .observe() // Optional: observe the raw result first + .translateErrors() // Translate errors for user presentation ``` The `valar-translator` module is designed to: @@ -344,7 +376,8 @@ Your existing validation code will continue to work without any changes. ## **Migration Guide from v0.3.0 to v0.4.8** -The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular architecture. +The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular +architecture. 1. **Update build.sbt**: @@ -360,8 +393,10 @@ libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" * **Scala:** 3.7+ * **Platforms:** JVM, Scala Native -* **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, cross-platform support for the `java.time` API. +* **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, + cross-platform support for the `java.time` API. ## **License** -Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) file for details. \ No newline at end of file +Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) +file for details. \ No newline at end of file diff --git a/translator/README.md b/translator/README.md index c8566a3..f1401bb 100644 --- a/translator/README.md +++ b/translator/README.md @@ -58,27 +58,41 @@ val translatedResult = result.translateErrors() // translatedResult now contains errors with localized messages ``` -## Composing with Core Features (like ValidationObserver) +## Integration with the ValidationObserver Extensibility Pattern -The `valar-translator` module is designed to compose cleanly with features from the core library. A prime example is the **`ValidationObserver` pattern**, a general-purpose tool for side effects (like logging or metrics) that is **available directly in `valar-core`**. +The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. -While the two patterns serve different purposes, they can be chained together for a powerful workflow: +This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: -* **`ValidationObserver` (Side Effect)**: Reacts to a result without changing it. -* **`Translator` (Data Transformation)**: Refines a result by localizing error messages. +* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result +* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization + +While these serve different purposes, they're designed to work together in a clean, composable way: A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. ```scala -// Given a defined `metricsObserver` from your application -// and a `myTranslator` from this module... +// Given a defined extension using the ValidationObserver pattern +given metricsObserver: ValidationObserver with { + def onResult[A](result: ValidationResult[A]): Unit = { + // Record validation metrics to your monitoring system + } +} + +// And a translator implementation for localization +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Translate errors using your i18n system + } +} +// Both extensions work together through the same pattern val result = User.validate(invalidUser) - // 1. First, observe the raw result using the core ValidationObserver. - .observe() - // 2. Then, translate the errors for presentation using the Translator. + // First, observe the raw result using the core ValidationObserver pattern + .observe() + // Then, translate the errors for presentation (also built on the same pattern) .translateErrors() -// The final `result` contains user-friendly, translated messages, -// while the original, structured error was sent to your metrics system. +// This demonstrates how all Valar extensions follow the same architectural pattern, +// allowing them to compose together seamlessly ``` diff --git a/valar-translator/README.md b/valar-translator/README.md index c8566a3..f1401bb 100644 --- a/valar-translator/README.md +++ b/valar-translator/README.md @@ -58,27 +58,41 @@ val translatedResult = result.translateErrors() // translatedResult now contains errors with localized messages ``` -## Composing with Core Features (like ValidationObserver) +## Integration with the ValidationObserver Extensibility Pattern -The `valar-translator` module is designed to compose cleanly with features from the core library. A prime example is the **`ValidationObserver` pattern**, a general-purpose tool for side effects (like logging or metrics) that is **available directly in `valar-core`**. +The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. -While the two patterns serve different purposes, they can be chained together for a powerful workflow: +This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: -* **`ValidationObserver` (Side Effect)**: Reacts to a result without changing it. -* **`Translator` (Data Transformation)**: Refines a result by localizing error messages. +* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result +* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization + +While these serve different purposes, they're designed to work together in a clean, composable way: A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. ```scala -// Given a defined `metricsObserver` from your application -// and a `myTranslator` from this module... +// Given a defined extension using the ValidationObserver pattern +given metricsObserver: ValidationObserver with { + def onResult[A](result: ValidationResult[A]): Unit = { + // Record validation metrics to your monitoring system + } +} + +// And a translator implementation for localization +given myTranslator: Translator with { + def translate(error: ValidationError): String = { + // Translate errors using your i18n system + } +} +// Both extensions work together through the same pattern val result = User.validate(invalidUser) - // 1. First, observe the raw result using the core ValidationObserver. - .observe() - // 2. Then, translate the errors for presentation using the Translator. + // First, observe the raw result using the core ValidationObserver pattern + .observe() + // Then, translate the errors for presentation (also built on the same pattern) .translateErrors() -// The final `result` contains user-friendly, translated messages, -// while the original, structured error was sent to your metrics system. +// This demonstrates how all Valar extensions follow the same architectural pattern, +// allowing them to compose together seamlessly ``` From 7b5ff1a442d2e1d8ab98e17f5b11eba5221c8c1d Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 11:18:48 +0200 Subject: [PATCH 18/19] reviewed --- README.md | 18 +- diff.txt | 4514 +++++++++++++++++++++++++++++++++ docs-src/README.md | 18 +- docs-src/translator/README.md | 12 +- translator/README.md | 12 +- valar-core/README.md | 94 - valar-translator/README.md | 12 +- 7 files changed, 4550 insertions(+), 130 deletions(-) create mode 100644 diff.txt delete mode 100644 valar-core/README.md diff --git a/README.md b/README.md index 2ee98a1..5cd73d1 100644 --- a/README.md +++ b/README.md @@ -279,9 +279,9 @@ given loggingObserver: ValidationObserver with { } // Use the observer in your validation flow -val result = User.validate(user) +val result = Validator[User].validate(user) .observe() // The observer's onResult is called here - .map(_.toUpperCase) + .map(validatedUser => validatedUser.copy(name = validatedUser.name.trim)) ``` ### Building Custom Extensions @@ -297,7 +297,7 @@ trait MyCustomExtension extends ValidationObserver { } // Usage remains clean and composable -val result = User.validate(user) +val result = Validator[User].validate(user) .observe() // Uses your custom extension .map(processUser) ``` @@ -328,17 +328,17 @@ val translations: Map[String, String] = Map( // --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key in your translation map. - // The `.getOrElse` provides a safe fallback. + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") translations.getOrElse( - error.key.getOrElse("error.unknown"), - error.message // Fall back to the original message if the key is not found + translationKey, + error.message // Fall back to the original message if no translation is found ) } } // Use the translator in your validation flow -val result = User.validate(user) +val result = Validator[User].validate(user) .observe() // Optional: observe the raw result first .translateErrors() // Translate errors for user presentation ``` @@ -399,4 +399,4 @@ libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ## **License** Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) -file for details. \ No newline at end of file +file for details. diff --git a/diff.txt b/diff.txt new file mode 100644 index 0000000..c498423 --- /dev/null +++ b/diff.txt @@ -0,0 +1,4514 @@ +diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml +index f3e77df..c259d3b 100644 +--- a/.github/workflows/scala.yml ++++ b/.github/workflows/scala.yml +@@ -14,17 +14,17 @@ jobs: + - name: Checkout repository + uses: actions/checkout@v4 + +- - name: Set up JDK 17 ++ - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' +- java-version: '17' ++ java-version: '21' + cache: 'sbt' + + - name: Set up sbt + uses: sbt/setup-sbt@v1 + +- - name: Check formatting and code style ++ - name: Run all checks (style, formatting, API compatibility) + run: sbt check + + - name: Run all tests on JVM +@@ -51,11 +51,11 @@ jobs: + with: + fetch-depth: 0 # Fetch full history for dynver/release notes + +- - name: Set up JDK 17 ++ - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' +- java-version: '17' ++ java-version: '21' + cache: 'sbt' + + - name: Set up sbt launcher +diff --git a/MIGRATION.md b/MIGRATION.md +index 80d3b0b..43faf88 100644 +--- a/MIGRATION.md ++++ b/MIGRATION.md +@@ -1,9 +1,116 @@ + # Migration Guide + ++## Migrating from v0.4.8 to v0.5.0 ++ ++Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: ++ ++1. **New ValidationObserver trait** for observing validation outcomes without altering the flow ++2. **New valar-translator module** for internationalization support of validation error messages ++3. **Enhanced ValarSuite** with improved testing utilities ++4. **Reworked macros** for better performance and modern Scala 3 features ++5. **MiMa checks** to ensure binary compatibility between versions ++ ++### Update build.sbt: ++ ++```scala ++// Update core library ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" ++ ++// Add the optional translator module (if needed) ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" ++ ++// Update testing utilities (if used) ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ++``` ++ ++Your existing validation code will continue to work without any changes. ++ ++### Using the New Features ++ ++#### Core Extensibility Pattern (ValidationObserver) ++ ++The ValidationObserver pattern has been added to valar-core as the **standard way to extend Valar**. This pattern provides: ++ ++* A consistent API for integrating with external systems ++* Zero-cost abstractions when extensions aren't used ++* Type-safe composition with other Valar features ++ ++Future Valar modules (like valar-cats-effect and valar-zio) will build upon this pattern, making it the **recommended approach** for anyone building custom Valar extensions. ++ ++The ValidationObserver trait allows you to observe validation results without altering the flow: ++ ++```scala ++import net.ghoula.valar.* ++import org.slf4j.LoggerFactory ++ ++// Define a custom observer that logs validation results ++given loggingObserver: ValidationObserver with { ++ private val logger = LoggerFactory.getLogger("ValidationAnalytics") ++ ++ def onResult[A](result: ValidationResult[A]): Unit = result match { ++ case ValidationResult.Valid(_) => ++ logger.info("Validation succeeded") ++ case ValidationResult.Invalid(errors) => ++ logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") ++ } ++} ++ ++// Use the observer in your validation flow ++val result = User.validate(user) ++ .observe() // The observer's onResult is called here ++ .map(_.toUpperCase) ++``` ++ ++Key features of ValidationObserver: ++ ++* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code ++* **Non-Intrusive**: Observes validation results without altering the validation flow ++* **Chainable**: Works seamlessly with other operations in the validation pipeline ++* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect ++ ++#### valar-translator ++ ++The valar-translator module provides internationalization support: ++ ++```scala ++import net.ghoula.valar.* ++import net.ghoula.valar.translator.Translator ++ ++// --- Example Setup --- ++// In a real application, this would come from a properties file or other i18n system. ++val translations: Map[String, String] = Map( ++ "error.string.nonEmpty" -> "The field must not be empty.", ++ "error.int.nonNegative" -> "The value cannot be negative.", ++ "error.unknown" -> "An unexpected validation error occurred." ++) ++ ++// --- Implementation of the Translator trait --- ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Logic to look up the error's key in your translation map. ++ // The `.getOrElse` provides a safe fallback. ++ translations.getOrElse( ++ error.key.getOrElse("error.unknown"), ++ error.message // Fall back to the original message if the key is not found ++ ) ++ } ++} ++ ++// Use the translator in your validation flow ++val result = User.validate(user) ++ .observe() // Optional: observe the raw result first ++ .translateErrors() // Translate errors for user presentation ++``` ++ ++The `valar-translator` module is designed to: ++ ++* Integrate with any i18n library through the `Translator` typeclass ++* Compose cleanly with other Valar features like ValidationObserver ++* Provide a clear separation between validation logic and presentation concerns ++ + ## Migrating from v0.3.0 to v0.4.8 + +-The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular +-architecture. ++The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the new modular architecture. + + ### Update build.sbt: + +@@ -12,26 +119,24 @@ architecture. + libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" + + // With this (note the triple %%% for cross-platform support): +-libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" + + // Add optional testing utilities (if desired): +-libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test +- +-// Alternatively, use bundle versions with all dependencies included: +-libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" + libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test + ``` + +-### Available Artifacts ++> **Note:** v0.4.8 used bundle versions (`-bundle` suffix) that included all dependencies. Starting from v0.5.0, we've moved to the standard approach without bundle versions for simpler dependency management. ++ ++### Available Artifacts for v0.4.8 + +-The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). If you need to reference a specific artifact directly, here are all the available options: ++The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). For v0.4.8, only bundle versions are available: + +-| Module | Platform | Artifact ID | Standard Version | Bundle Version | +-|--------|----------|-------------------------|------------------------------------------------------|-------------------------------------------------------------| +-| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8"` | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | +-| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | +-| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8"` | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | +-| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | ++| Module | Platform | Artifact ID | Bundle Version | ++|--------|----------|-------------------------|-------------------------------------------------------------| ++| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | ++| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | ++| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | ++| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | + + Your existing validation code will continue to work without any changes. + +@@ -71,7 +176,7 @@ val result = summon[Validator[Email]].validate(email) + given stringValidator: Validator[String] with { ... } + given emailValidator: Validator[Email] with { ... } + } +- ++ + // Be explicit about which one to use + import validators.emailValidator + ``` +@@ -81,7 +186,7 @@ val result = summon[Validator[Email]].validate(email) + ```scala + given generalStringValidator: Validator[String] with { ... } + given specificEmailValidator: Validator[Email] with { ... } +- ++ + // Use the specific one explicitly + val result = specificEmailValidator.validate(email) + ``` +diff --git a/README.md b/README.md +index 5e8b14a..2ee98a1 100644 +--- a/README.md ++++ b/README.md +@@ -8,19 +8,24 @@ Valar is a validation library for Scala 3 designed for clarity and ease of use. + metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, + detailed error messages useful for debugging or user feedback. + +-## **✨ What's New in 0.4.8** +- +-* **🚀 Bundle Mode**: All modules now offer a `-bundle` version that includes all dependencies, making it easier to use +- in projects. +-* **🎯 Platform-Specific Artifacts**: Valar provides dedicated artifacts for both JVM (`valar-core_3`, `valar-munit_3`) +- and Scala Native (`valar-core_native0.5_3`, `valar-munit_native0.5_3`) platforms. +-* **📦 Modular Architecture**: The library is split into focused modules: `valar-core` for core validation functionality +- and the optional `valar-munit` for enhanced testing utilities. ++## **✨ What's New in 0.5.X** ++ ++* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, ++ perfect for logging, metrics collection, or auditing with zero overhead when not used. ++* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the ++ `Translator` typeclass. ++* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust ++ validation testing. ++* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time ++ validation. ++* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. ++* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer ++ experience. + + ## **Key Features** + + * **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using +- ValidationResult\[A\]. Eliminate runtime errors caused by unexpected validation states. ++ ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. + * **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, + significantly reducing repetitive validation logic. Focus on your rules, not the wiring. + * **Flexible Error Handling:** Choose the strategy that fits your use case: +@@ -34,41 +39,81 @@ detailed error messages useful for debugging or user feedback. + * **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, + opaque types, and macros for a modern, expressive API. + ++## **Extensibility Pattern** ++ ++Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to ++integrate with external systems without modifying the core validation logic. ++ ++### The ValidationObserver Pattern ++ ++The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: ++ ++```scala ++trait ValidationObserver { ++ def onResult[A](result: ValidationResult[A]): Unit ++} ++``` ++ ++This pattern offers several advantages: ++ ++* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code ++* **Non-Intrusive**: Observes validation results without altering the validation flow ++* **Composable**: Works seamlessly with other Valar features and can be chained ++* **Type-Safe**: Leverages Scala's type system for compile-time safety ++ ++### Examples of Extensions Using This Pattern ++ ++Current implementations are following this pattern: ++ ++- **Logging**: Log validation outcomes for debugging and monitoring ++- **Metrics**: Collect validation statistics for performance analysis ++- **Auditing**: Track validation events for compliance and security ++ ++Future extensions planned: ++ ++- **valar-cats-effect**: Async validation with IO-based observers ++- **valar-zio**: ZIO-based validation with resource management ++- **Context-aware validation**: Observers that can access request-scoped data ++ + ## **Available Artifacts** + + Valar provides artifacts for both JVM and Scala Native platforms: + +-| Module | Platform | Artifact ID | Standard Version | Bundle Version | +-|-----------|----------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +-| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | +-| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | +-| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | +-| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | +- +-The **bundle versions** (with `-bundle` suffix) include all dependencies, making them easier to use in projects that +-don't need fine-grained dependency control. ++| Module | Platform | Artifact ID | Maven Central | ++|----------------|----------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ++| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | ++| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | ++| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | ++| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | ++| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | ++| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | + + > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. + ++## **Additional Resources** ++ ++- 📊 **[Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md)**: Detailed JMH benchmark results and analysis ++- 🧪 **[Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md)**: Enhanced testing utilities with ValarSuite ++- 🌐 **[Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md)**: i18n support for validation error messages + ## **Installation** + + Add the following to your build.sbt: + + ```scala + // The core validation library (JVM & Scala Native) +-libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +-// Optional: For enhanced testing with MUnit +-libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test ++// Optional: For internationalization (i18n) support ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +-// Alternatively, use bundle versions with all dependencies included +-libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" +-libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test ++// Optional: For enhanced testing with MUnit ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test + ``` + + ## **Basic Usage Example** + +-Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int (non-negative). ++Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int ( ++non-negative). + + ```scala + import net.ghoula.valar.* +@@ -114,7 +159,6 @@ import net.ghoula.valar.* + import net.ghoula.valar.munit.ValarSuite + + class UserValidationSuite extends ValarSuite { +- + // A given Validator for User must be in scope + given Validator[User] = Validator.deriveValidatorMacro + +@@ -126,7 +170,6 @@ class UserValidationSuite extends ValarSuite { + + test("a single validation error should be reported correctly") { + val result = Validator[User].validate(User("", Some(25))) +- + // Use assertHasOneError for the common case of a single error + assertHasOneError(result) { error => + assertEquals(error.fieldPath, List("name")) +@@ -136,7 +179,6 @@ class UserValidationSuite extends ValarSuite { + + test("multiple validation errors should be accumulated") { + val result = Validator[User].validate(User("", Some(-10))) +- + // Use assertInvalid for testing error accumulation + assertInvalid(result) { errors => + assertEquals(errors.size, 2) +@@ -188,9 +230,9 @@ trait Validator[A] { + Validators can be automatically derived for case classes using deriveValidatorMacro. + + **Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances +-to be available in scope for **all** field types within the case class. If a validator for any field type is missing, +-**compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the +-"Built-in Validators" section for types supported out-of-the-box. ++to be available in scope for **all** field types within the case class. If a validator for any field type is missing, * ++*compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the " ++Built-in Validators" section for types supported out-of-the-box. + + ## **Built-in Validators** + +@@ -210,34 +252,143 @@ includes: + Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are + **pass-through** validators. You should define custom validators if you need specific constraints for these types. + +-## **Migration Guide from v0.3.0** ++## **ValidationObserver, The Core Extensibility Pattern** + +-The main breaking change since v0.4.0 is the **artifact name has changed** from valar to valar-core to support the new +-modular architecture. ++The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending ++Valar with custom functionality. This pattern allows you to: + +-1. **Update build.sbt**: +- ```scala +- // Replace this: +- libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" +- +- // With this (note the triple %%% for cross-platform support): +- libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" +- ``` +- +-2. **Add optional testing utilities** (if desired): +- ```scala +- libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test +- ``` +- +-3. **For simplified dependency management** (optional): +- ```scala +- // Use bundle versions with all dependencies included +- libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" +- libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test +- ``` ++- **Integrate with external systems** (logging, metrics, monitoring) ++- **Add side effects** without modifying validation logic ++- **Build composable extensions** that work together seamlessly ++- **Maintain zero overhead** when extensions aren't needed ++ ++```scala ++import net.ghoula.valar.* ++import org.slf4j.LoggerFactory ++ ++// Define a custom observer that logs validation results ++given loggingObserver: ValidationObserver with { ++ private val logger = LoggerFactory.getLogger("ValidationAnalytics") ++ ++ def onResult[A](result: ValidationResult[A]): Unit = result match { ++ case ValidationResult.Valid(_) => ++ logger.info("Validation succeeded") ++ case ValidationResult.Invalid(errors) => ++ logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") ++ } ++} ++ ++// Use the observer in your validation flow ++val result = User.validate(user) ++ .observe() // The observer's onResult is called here ++ .map(_.toUpperCase) ++``` ++ ++### Building Custom Extensions ++ ++When building extensions for Valar, follow the ValidationObserver pattern: ++ ++```scala ++// Your custom extension trait ++trait MyCustomExtension extends ValidationObserver { ++ def onResult[A](result: ValidationResult[A]): Unit = { ++ // Your custom logic here ++ } ++} ++ ++// Usage remains clean and composable ++val result = User.validate(user) ++ .observe() // Uses your custom extension ++ .map(processUser) ++``` ++ ++Key features of ValidationObserver: ++ ++* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code ++* **Non-Intrusive**: Observes validation results without altering the validation flow ++* **Chainable**: Works seamlessly with other operations in the validation pipeline ++* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect ++ ++## **Internationalization with valar-translator** ++ ++The `valar-translator` module provides internationalization (i18n) support for validation error messages: ++ ++```scala ++import net.ghoula.valar.* ++import net.ghoula.valar.translator.Translator ++ ++// --- Example Setup --- ++// In a real application, this would come from a properties file or other i18n system. ++val translations: Map[String, String] = Map( ++ "error.string.nonEmpty" -> "The field must not be empty.", ++ "error.int.nonNegative" -> "The value cannot be negative.", ++ "error.unknown" -> "An unexpected validation error occurred." ++) ++ ++// --- Implementation of the Translator trait --- ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Logic to look up the error's key in your translation map. ++ // The `.getOrElse` provides a safe fallback. ++ translations.getOrElse( ++ error.key.getOrElse("error.unknown"), ++ error.message // Fall back to the original message if the key is not found ++ ) ++ } ++} ++ ++// Use the translator in your validation flow ++val result = User.validate(user) ++ .observe() // Optional: observe the raw result first ++ .translateErrors() // Translate errors for user presentation ++``` ++ ++The `valar-translator` module is designed to: ++ ++* Integrate with any i18n library through the `Translator` typeclass ++* Compose cleanly with other Valar features like ValidationObserver ++* Provide a clear separation between validation logic and presentation concerns ++ ++## **Migration Guide from v0.4.8 to v0.5.0** ++ ++Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: ++ ++1. **New ValidationObserver trait** for observing validation outcomes ++2. **New valar-translator module** for internationalization support ++3. **Enhanced ValarSuite** with improved testing utilities ++4. **Reworked macros** for better performance and modern Scala 3 features ++5. **MiMa checks** to ensure binary compatibility ++ ++To upgrade to v0.5.0, update your build.sbt: ++ ++```scala ++// Update core library ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" ++ ++// Add the optional translator module (if needed) ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" ++ ++// Update testing utilities (if used) ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ++``` + + Your existing validation code will continue to work without any changes. + ++## **Migration Guide from v0.3.0 to v0.4.8** ++ ++The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular ++architecture. ++ ++1. **Update build.sbt**: ++ ++```scala ++// Replace this: ++libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" ++ ++// With this (note the triple %%% for cross-platform support): ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ++``` ++ + ## **Compatibility** + + * **Scala:** 3.7+ +@@ -248,4 +399,4 @@ Your existing validation code will continue to work without any changes. + ## **License** + + Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) +-file for details. ++file for details. +\ No newline at end of file +diff --git a/build.sbt b/build.sbt +index 99de259..275fb37 100644 +--- a/build.sbt ++++ b/build.sbt +@@ -45,7 +45,10 @@ lazy val root = (project in file(".")) + valarCoreJVM, + valarCoreNative, + valarMunitJVM, +- valarMunitNative ++ valarMunitNative, ++ valarTranslatorJVM, ++ valarTranslatorNative, ++ valarBenchmarks + ) + .settings( + name := "valar-root", +@@ -61,8 +64,9 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) + usePgpKeyHex("9614A0CE1CE76975"), + useGpgAgent := true, + // --- MiMa & TASTy-MiMa Configuration --- +- mimaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8"), +- tastyMiMaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8"), ++ mimaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release ++ tastyMiMaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release ++ mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set + // --- Library Dependencies --- + libraryDependencies ++= Seq( + "io.github.cquiroz" %%% "scala-java-time" % "2.6.0", +@@ -73,11 +77,10 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) + .jvmSettings( + mdocIn := file("docs-src"), + mdocOut := file("."), +- // --- Updated Check Command --- + addCommandAlias("prepare", "scalafixAll; scalafmtAll; scalafmtSbt"), + addCommandAlias( + "check", +- "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck; mimaReportBinaryIssues; tastyMiMaReportIssues" ++ "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck" + ) + ) + .jvmConfigure(_.enablePlugins(MdocPlugin)) +@@ -97,16 +100,68 @@ lazy val valarMunit = crossProject(JVMPlatform, NativePlatform) + name := "valar-munit", + usePgpKeyHex("9614A0CE1CE76975"), + useGpgAgent := true, ++ mimaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release ++ tastyMiMaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release ++ mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set ++ libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" ++ ) ++ .jvmSettings( ++ mdocIn := file("docs-src/munit"), ++ mdocOut := file("valar-munit"), ++ mdocVariables := Map( ++ "VERSION" -> version.value, ++ "SCALA_VERSION" -> scalaVersion.value ++ ) ++ ) ++ .jvmConfigure(_.enablePlugins(MdocPlugin)) ++ .nativeSettings( ++ testFrameworks += new TestFramework("munit.Framework") ++ ) ++ ++lazy val valarTranslator = crossProject(JVMPlatform, NativePlatform) ++ .crossType(CrossType.Pure) ++ .in(file("valar-translator")) ++ .dependsOn(valarCore, valarMunit % Test) ++ .settings(sonatypeSettings *) ++ .settings( ++ name := "valar-translator", ++ usePgpKeyHex("9614A0CE1CE76975"), ++ useGpgAgent := true, + mimaPreviousArtifacts := Set.empty, + tastyMiMaPreviousArtifacts := Set.empty, +- libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" ++ mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set, ++ libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" % Test ++ ) ++ .jvmSettings( ++ mdocIn := file("docs-src/translator"), ++ mdocOut := file("valar-translator"), ++ mdocVariables := Map( ++ "VERSION" -> version.value, ++ "SCALA_VERSION" -> scalaVersion.value ++ ) + ) ++ .jvmConfigure(_.enablePlugins(MdocPlugin)) + .nativeSettings( + testFrameworks += new TestFramework("munit.Framework") + ) ++// ===== Benchmarks Module ===== ++lazy val valarBenchmarks = project ++ .in(file("valar-benchmarks")) ++ .dependsOn(valarCoreJVM) ++ .enablePlugins(JmhPlugin) ++ .settings( ++ name := "valar-benchmarks", ++ publish / skip := true, ++ libraryDependencies ++= Seq( ++ "org.openjdk.jmh" % "jmh-core" % "1.37", ++ "org.openjdk.jmh" % "jmh-generator-annprocess" % "1.37" ++ ) ++ ) + + // ===== Convenience Aliases ===== + lazy val valarCoreJVM = valarCore.jvm + lazy val valarCoreNative = valarCore.native + lazy val valarMunitJVM = valarMunit.jvm + lazy val valarMunitNative = valarMunit.native ++lazy val valarTranslatorJVM = valarTranslator.jvm ++lazy val valarTranslatorNative = valarTranslator.native +diff --git a/docs-src/MIGRATION.md b/docs-src/MIGRATION.md +index 80d3b0b..43faf88 100644 +--- a/docs-src/MIGRATION.md ++++ b/docs-src/MIGRATION.md +@@ -1,9 +1,116 @@ + # Migration Guide + ++## Migrating from v0.4.8 to v0.5.0 ++ ++Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: ++ ++1. **New ValidationObserver trait** for observing validation outcomes without altering the flow ++2. **New valar-translator module** for internationalization support of validation error messages ++3. **Enhanced ValarSuite** with improved testing utilities ++4. **Reworked macros** for better performance and modern Scala 3 features ++5. **MiMa checks** to ensure binary compatibility between versions ++ ++### Update build.sbt: ++ ++```scala ++// Update core library ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" ++ ++// Add the optional translator module (if needed) ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" ++ ++// Update testing utilities (if used) ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ++``` ++ ++Your existing validation code will continue to work without any changes. ++ ++### Using the New Features ++ ++#### Core Extensibility Pattern (ValidationObserver) ++ ++The ValidationObserver pattern has been added to valar-core as the **standard way to extend Valar**. This pattern provides: ++ ++* A consistent API for integrating with external systems ++* Zero-cost abstractions when extensions aren't used ++* Type-safe composition with other Valar features ++ ++Future Valar modules (like valar-cats-effect and valar-zio) will build upon this pattern, making it the **recommended approach** for anyone building custom Valar extensions. ++ ++The ValidationObserver trait allows you to observe validation results without altering the flow: ++ ++```scala ++import net.ghoula.valar.* ++import org.slf4j.LoggerFactory ++ ++// Define a custom observer that logs validation results ++given loggingObserver: ValidationObserver with { ++ private val logger = LoggerFactory.getLogger("ValidationAnalytics") ++ ++ def onResult[A](result: ValidationResult[A]): Unit = result match { ++ case ValidationResult.Valid(_) => ++ logger.info("Validation succeeded") ++ case ValidationResult.Invalid(errors) => ++ logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") ++ } ++} ++ ++// Use the observer in your validation flow ++val result = User.validate(user) ++ .observe() // The observer's onResult is called here ++ .map(_.toUpperCase) ++``` ++ ++Key features of ValidationObserver: ++ ++* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code ++* **Non-Intrusive**: Observes validation results without altering the validation flow ++* **Chainable**: Works seamlessly with other operations in the validation pipeline ++* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect ++ ++#### valar-translator ++ ++The valar-translator module provides internationalization support: ++ ++```scala ++import net.ghoula.valar.* ++import net.ghoula.valar.translator.Translator ++ ++// --- Example Setup --- ++// In a real application, this would come from a properties file or other i18n system. ++val translations: Map[String, String] = Map( ++ "error.string.nonEmpty" -> "The field must not be empty.", ++ "error.int.nonNegative" -> "The value cannot be negative.", ++ "error.unknown" -> "An unexpected validation error occurred." ++) ++ ++// --- Implementation of the Translator trait --- ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Logic to look up the error's key in your translation map. ++ // The `.getOrElse` provides a safe fallback. ++ translations.getOrElse( ++ error.key.getOrElse("error.unknown"), ++ error.message // Fall back to the original message if the key is not found ++ ) ++ } ++} ++ ++// Use the translator in your validation flow ++val result = User.validate(user) ++ .observe() // Optional: observe the raw result first ++ .translateErrors() // Translate errors for user presentation ++``` ++ ++The `valar-translator` module is designed to: ++ ++* Integrate with any i18n library through the `Translator` typeclass ++* Compose cleanly with other Valar features like ValidationObserver ++* Provide a clear separation between validation logic and presentation concerns ++ + ## Migrating from v0.3.0 to v0.4.8 + +-The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular +-architecture. ++The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the new modular architecture. + + ### Update build.sbt: + +@@ -12,26 +119,24 @@ architecture. + libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" + + // With this (note the triple %%% for cross-platform support): +-libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" + + // Add optional testing utilities (if desired): +-libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test +- +-// Alternatively, use bundle versions with all dependencies included: +-libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" + libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test + ``` + +-### Available Artifacts ++> **Note:** v0.4.8 used bundle versions (`-bundle` suffix) that included all dependencies. Starting from v0.5.0, we've moved to the standard approach without bundle versions for simpler dependency management. ++ ++### Available Artifacts for v0.4.8 + +-The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). If you need to reference a specific artifact directly, here are all the available options: ++The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). For v0.4.8, only bundle versions are available: + +-| Module | Platform | Artifact ID | Standard Version | Bundle Version | +-|--------|----------|-------------------------|------------------------------------------------------|-------------------------------------------------------------| +-| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8"` | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | +-| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | +-| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8"` | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | +-| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | ++| Module | Platform | Artifact ID | Bundle Version | ++|--------|----------|-------------------------|-------------------------------------------------------------| ++| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | ++| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | ++| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | ++| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | + + Your existing validation code will continue to work without any changes. + +@@ -71,7 +176,7 @@ val result = summon[Validator[Email]].validate(email) + given stringValidator: Validator[String] with { ... } + given emailValidator: Validator[Email] with { ... } + } +- ++ + // Be explicit about which one to use + import validators.emailValidator + ``` +@@ -81,7 +186,7 @@ val result = summon[Validator[Email]].validate(email) + ```scala + given generalStringValidator: Validator[String] with { ... } + given specificEmailValidator: Validator[Email] with { ... } +- ++ + // Use the specific one explicitly + val result = specificEmailValidator.validate(email) + ``` +diff --git a/docs-src/README.md b/docs-src/README.md +index 5e8b14a..2ee98a1 100644 +--- a/docs-src/README.md ++++ b/docs-src/README.md +@@ -8,19 +8,24 @@ Valar is a validation library for Scala 3 designed for clarity and ease of use. + metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, + detailed error messages useful for debugging or user feedback. + +-## **✨ What's New in 0.4.8** +- +-* **🚀 Bundle Mode**: All modules now offer a `-bundle` version that includes all dependencies, making it easier to use +- in projects. +-* **🎯 Platform-Specific Artifacts**: Valar provides dedicated artifacts for both JVM (`valar-core_3`, `valar-munit_3`) +- and Scala Native (`valar-core_native0.5_3`, `valar-munit_native0.5_3`) platforms. +-* **📦 Modular Architecture**: The library is split into focused modules: `valar-core` for core validation functionality +- and the optional `valar-munit` for enhanced testing utilities. ++## **✨ What's New in 0.5.X** ++ ++* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, ++ perfect for logging, metrics collection, or auditing with zero overhead when not used. ++* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the ++ `Translator` typeclass. ++* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust ++ validation testing. ++* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time ++ validation. ++* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. ++* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer ++ experience. + + ## **Key Features** + + * **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using +- ValidationResult\[A\]. Eliminate runtime errors caused by unexpected validation states. ++ ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. + * **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, + significantly reducing repetitive validation logic. Focus on your rules, not the wiring. + * **Flexible Error Handling:** Choose the strategy that fits your use case: +@@ -34,41 +39,81 @@ detailed error messages useful for debugging or user feedback. + * **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, + opaque types, and macros for a modern, expressive API. + ++## **Extensibility Pattern** ++ ++Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to ++integrate with external systems without modifying the core validation logic. ++ ++### The ValidationObserver Pattern ++ ++The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: ++ ++```scala ++trait ValidationObserver { ++ def onResult[A](result: ValidationResult[A]): Unit ++} ++``` ++ ++This pattern offers several advantages: ++ ++* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code ++* **Non-Intrusive**: Observes validation results without altering the validation flow ++* **Composable**: Works seamlessly with other Valar features and can be chained ++* **Type-Safe**: Leverages Scala's type system for compile-time safety ++ ++### Examples of Extensions Using This Pattern ++ ++Current implementations are following this pattern: ++ ++- **Logging**: Log validation outcomes for debugging and monitoring ++- **Metrics**: Collect validation statistics for performance analysis ++- **Auditing**: Track validation events for compliance and security ++ ++Future extensions planned: ++ ++- **valar-cats-effect**: Async validation with IO-based observers ++- **valar-zio**: ZIO-based validation with resource management ++- **Context-aware validation**: Observers that can access request-scoped data ++ + ## **Available Artifacts** + + Valar provides artifacts for both JVM and Scala Native platforms: + +-| Module | Platform | Artifact ID | Standard Version | Bundle Version | +-|-----------|----------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +-| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | +-| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | +-| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | +-| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | +- +-The **bundle versions** (with `-bundle` suffix) include all dependencies, making them easier to use in projects that +-don't need fine-grained dependency control. ++| Module | Platform | Artifact ID | Maven Central | ++|----------------|----------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ++| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | ++| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | ++| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | ++| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | ++| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | ++| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | + + > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. + ++## **Additional Resources** ++ ++- 📊 **[Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md)**: Detailed JMH benchmark results and analysis ++- 🧪 **[Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md)**: Enhanced testing utilities with ValarSuite ++- 🌐 **[Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md)**: i18n support for validation error messages + ## **Installation** + + Add the following to your build.sbt: + + ```scala + // The core validation library (JVM & Scala Native) +-libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" + +-// Optional: For enhanced testing with MUnit +-libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test ++// Optional: For internationalization (i18n) support ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" + +-// Alternatively, use bundle versions with all dependencies included +-libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" +-libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test ++// Optional: For enhanced testing with MUnit ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test + ``` + + ## **Basic Usage Example** + +-Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int (non-negative). ++Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int ( ++non-negative). + + ```scala + import net.ghoula.valar.* +@@ -114,7 +159,6 @@ import net.ghoula.valar.* + import net.ghoula.valar.munit.ValarSuite + + class UserValidationSuite extends ValarSuite { +- + // A given Validator for User must be in scope + given Validator[User] = Validator.deriveValidatorMacro + +@@ -126,7 +170,6 @@ class UserValidationSuite extends ValarSuite { + + test("a single validation error should be reported correctly") { + val result = Validator[User].validate(User("", Some(25))) +- + // Use assertHasOneError for the common case of a single error + assertHasOneError(result) { error => + assertEquals(error.fieldPath, List("name")) +@@ -136,7 +179,6 @@ class UserValidationSuite extends ValarSuite { + + test("multiple validation errors should be accumulated") { + val result = Validator[User].validate(User("", Some(-10))) +- + // Use assertInvalid for testing error accumulation + assertInvalid(result) { errors => + assertEquals(errors.size, 2) +@@ -188,9 +230,9 @@ trait Validator[A] { + Validators can be automatically derived for case classes using deriveValidatorMacro. + + **Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances +-to be available in scope for **all** field types within the case class. If a validator for any field type is missing, +-**compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the +-"Built-in Validators" section for types supported out-of-the-box. ++to be available in scope for **all** field types within the case class. If a validator for any field type is missing, * ++*compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the " ++Built-in Validators" section for types supported out-of-the-box. + + ## **Built-in Validators** + +@@ -210,34 +252,143 @@ includes: + Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are + **pass-through** validators. You should define custom validators if you need specific constraints for these types. + +-## **Migration Guide from v0.3.0** ++## **ValidationObserver, The Core Extensibility Pattern** + +-The main breaking change since v0.4.0 is the **artifact name has changed** from valar to valar-core to support the new +-modular architecture. ++The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending ++Valar with custom functionality. This pattern allows you to: + +-1. **Update build.sbt**: +- ```scala +- // Replace this: +- libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" +- +- // With this (note the triple %%% for cross-platform support): +- libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" +- ``` +- +-2. **Add optional testing utilities** (if desired): +- ```scala +- libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test +- ``` +- +-3. **For simplified dependency management** (optional): +- ```scala +- // Use bundle versions with all dependencies included +- libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" +- libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test +- ``` ++- **Integrate with external systems** (logging, metrics, monitoring) ++- **Add side effects** without modifying validation logic ++- **Build composable extensions** that work together seamlessly ++- **Maintain zero overhead** when extensions aren't needed ++ ++```scala ++import net.ghoula.valar.* ++import org.slf4j.LoggerFactory ++ ++// Define a custom observer that logs validation results ++given loggingObserver: ValidationObserver with { ++ private val logger = LoggerFactory.getLogger("ValidationAnalytics") ++ ++ def onResult[A](result: ValidationResult[A]): Unit = result match { ++ case ValidationResult.Valid(_) => ++ logger.info("Validation succeeded") ++ case ValidationResult.Invalid(errors) => ++ logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") ++ } ++} ++ ++// Use the observer in your validation flow ++val result = User.validate(user) ++ .observe() // The observer's onResult is called here ++ .map(_.toUpperCase) ++``` ++ ++### Building Custom Extensions ++ ++When building extensions for Valar, follow the ValidationObserver pattern: ++ ++```scala ++// Your custom extension trait ++trait MyCustomExtension extends ValidationObserver { ++ def onResult[A](result: ValidationResult[A]): Unit = { ++ // Your custom logic here ++ } ++} ++ ++// Usage remains clean and composable ++val result = User.validate(user) ++ .observe() // Uses your custom extension ++ .map(processUser) ++``` ++ ++Key features of ValidationObserver: ++ ++* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code ++* **Non-Intrusive**: Observes validation results without altering the validation flow ++* **Chainable**: Works seamlessly with other operations in the validation pipeline ++* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect ++ ++## **Internationalization with valar-translator** ++ ++The `valar-translator` module provides internationalization (i18n) support for validation error messages: ++ ++```scala ++import net.ghoula.valar.* ++import net.ghoula.valar.translator.Translator ++ ++// --- Example Setup --- ++// In a real application, this would come from a properties file or other i18n system. ++val translations: Map[String, String] = Map( ++ "error.string.nonEmpty" -> "The field must not be empty.", ++ "error.int.nonNegative" -> "The value cannot be negative.", ++ "error.unknown" -> "An unexpected validation error occurred." ++) ++ ++// --- Implementation of the Translator trait --- ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Logic to look up the error's key in your translation map. ++ // The `.getOrElse` provides a safe fallback. ++ translations.getOrElse( ++ error.key.getOrElse("error.unknown"), ++ error.message // Fall back to the original message if the key is not found ++ ) ++ } ++} ++ ++// Use the translator in your validation flow ++val result = User.validate(user) ++ .observe() // Optional: observe the raw result first ++ .translateErrors() // Translate errors for user presentation ++``` ++ ++The `valar-translator` module is designed to: ++ ++* Integrate with any i18n library through the `Translator` typeclass ++* Compose cleanly with other Valar features like ValidationObserver ++* Provide a clear separation between validation logic and presentation concerns ++ ++## **Migration Guide from v0.4.8 to v0.5.0** ++ ++Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: ++ ++1. **New ValidationObserver trait** for observing validation outcomes ++2. **New valar-translator module** for internationalization support ++3. **Enhanced ValarSuite** with improved testing utilities ++4. **Reworked macros** for better performance and modern Scala 3 features ++5. **MiMa checks** to ensure binary compatibility ++ ++To upgrade to v0.5.0, update your build.sbt: ++ ++```scala ++// Update core library ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" ++ ++// Add the optional translator module (if needed) ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" ++ ++// Update testing utilities (if used) ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ++``` + + Your existing validation code will continue to work without any changes. + ++## **Migration Guide from v0.3.0 to v0.4.8** ++ ++The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular ++architecture. ++ ++1. **Update build.sbt**: ++ ++```scala ++// Replace this: ++libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" ++ ++// With this (note the triple %%% for cross-platform support): ++libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ++``` ++ + ## **Compatibility** + + * **Scala:** 3.7+ +@@ -248,4 +399,4 @@ Your existing validation code will continue to work without any changes. + ## **License** + + Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) +-file for details. ++file for details. +\ No newline at end of file +diff --git a/docs-src/munit/README.md b/docs-src/munit/README.md +new file mode 100644 +index 0000000..67756cb +--- /dev/null ++++ b/docs-src/munit/README.md +@@ -0,0 +1,132 @@ ++# valar-munit ++ ++[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) ++[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) ++[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) ++ ++The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It ++introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. ++ ++## Installation ++ ++Add the valar-munit dependency to your build.sbt: ++ ++```scala ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ++``` ++ ++## Usage ++ ++Extend the ValarSuite trait in your test classes to get access to the assertion helpers. ++ ++```scala ++import net.ghoula.valar.munit.ValarSuite ++ ++class MyValidatorSpec extends ValarSuite { ++ test("valid data passes validation") { ++ val result = MyValidator.validate(validData) ++ val value = assertValid(result) ++ ++ // You can make additional assertions on the validated value ++ assertEquals(value.name, "Expected Name") ++ } ++} ++``` ++ ++## Assertion Helpers ++ ++The ValarSuite trait provides several assertion helpers for different validation testing scenarios. ++ ++### 1. assertValid ++ ++Asserts that a ValidationResult is Valid and returns the validated value for further assertions. ++ ++```scala ++test("valid data passes validation") { ++ val result = MyValidator.validate(validData) ++ val value = assertValid(result) ++ ++ // Additional assertions on the validated value ++ assertEquals(value.id, 123) ++} ++``` ++ ++### 2. assertHasOneError ++ ++Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual ++validation rules. ++ ++```scala ++test("empty name is rejected") { ++ val result = User.validate(User("", 25)) ++ ++ assertHasOneError(result) { error => ++ assertEquals(error.fieldPath, List("name")) ++ assert(error.message.contains("empty")) ++ } ++} ++``` ++ ++### 3. assertHasNErrors ++ ++Asserts that a ValidationResult is Invalid and contains exactly N errors. ++ ++```scala ++test("multiple specific errors are reported") { ++ val result = User.validate(User("", -5)) ++ ++ assertHasNErrors(result, 2) { errors => ++ // Assert on the collection of exactly 2 errors ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++### 4. assertInvalid ++ ++Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, ++accumulated errors are expected. ++ ++```scala ++test("multiple validation errors are accumulated") { ++ val result = User.validate(User("", -5)) ++ ++ assertInvalid(result) { ++ case errors if errors.size == 2 => ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++### 5. assertInvalidWith ++ ++Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular ++function. This is a simpler alternative to assertInvalid. ++ ++```scala ++test("validation fails with expected errors") { ++ val result = User.validate(User("", -5)) ++ ++ assertInvalidWith(result) { errors => ++ assertEquals(errors.size, 2) ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++## Benefits ++ ++- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. ++ ++- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. ++ ++- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. ++ ++- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated ++ value. ++ ++- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match ++ your testing preferences. +diff --git a/docs-src/translator/README.md b/docs-src/translator/README.md +new file mode 100644 +index 0000000..f1401bb +--- /dev/null ++++ b/docs-src/translator/README.md +@@ -0,0 +1,98 @@ ++# valar-translator ++ ++[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) ++[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) ++[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) ++ ++The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. ++ ++## Installation ++ ++Add the valar-translator dependency to your build.sbt: ++ ++```scala ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" ++``` ++ ++## Usage ++ ++The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. ++ ++### 1. Implement the `Translator` Trait ++ ++Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. ++ ++```scala ++import net.ghoula.valar.translator.Translator ++import net.ghoula.valar.ValidationErrors.ValidationError ++ ++// --- Example Setup --- ++// In a real application, this would come from a properties file or other i18n system. ++val translations: Map[String, String] = Map( ++ "error.string.nonEmpty" -> "The field must not be empty.", ++ "error.int.nonNegative" -> "The value cannot be negative.", ++ "error.unknown" -> "An unexpected validation error occurred." ++) ++ ++// --- Implementation of the Translator trait --- ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Logic to look up the error's key in your translation map. ++ // The `.getOrElse` provides a safe fallback. ++ translations.getOrElse( ++ error.key.getOrElse("error.unknown"), ++ error.message // Fall back to the original message if the key is not found ++ ) ++ } ++} ++``` ++ ++### 2. Call `translateErrors()` ++ ++Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. ++ ++```scala ++val result = User.validate(someData) // An Invalid ValidationResult ++val translatedResult = result.translateErrors() ++ ++// translatedResult now contains errors with localized messages ++``` ++ ++## Integration with the ValidationObserver Extensibility Pattern ++ ++The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. ++ ++This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: ++ ++* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result ++* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization ++ ++While these serve different purposes, they're designed to work together in a clean, composable way: ++ ++A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. ++ ++```scala ++// Given a defined extension using the ValidationObserver pattern ++given metricsObserver: ValidationObserver with { ++ def onResult[A](result: ValidationResult[A]): Unit = { ++ // Record validation metrics to your monitoring system ++ } ++} ++ ++// And a translator implementation for localization ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Translate errors using your i18n system ++ } ++} ++ ++// Both extensions work together through the same pattern ++val result = User.validate(invalidUser) ++ // First, observe the raw result using the core ValidationObserver pattern ++ .observe() ++ // Then, translate the errors for presentation (also built on the same pattern) ++ .translateErrors() ++ ++// This demonstrates how all Valar extensions follow the same architectural pattern, ++// allowing them to compose together seamlessly ++``` +diff --git a/munit/README.md b/munit/README.md +new file mode 100644 +index 0000000..67756cb +--- /dev/null ++++ b/munit/README.md +@@ -0,0 +1,132 @@ ++# valar-munit ++ ++[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) ++[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) ++[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) ++ ++The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It ++introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. ++ ++## Installation ++ ++Add the valar-munit dependency to your build.sbt: ++ ++```scala ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ++``` ++ ++## Usage ++ ++Extend the ValarSuite trait in your test classes to get access to the assertion helpers. ++ ++```scala ++import net.ghoula.valar.munit.ValarSuite ++ ++class MyValidatorSpec extends ValarSuite { ++ test("valid data passes validation") { ++ val result = MyValidator.validate(validData) ++ val value = assertValid(result) ++ ++ // You can make additional assertions on the validated value ++ assertEquals(value.name, "Expected Name") ++ } ++} ++``` ++ ++## Assertion Helpers ++ ++The ValarSuite trait provides several assertion helpers for different validation testing scenarios. ++ ++### 1. assertValid ++ ++Asserts that a ValidationResult is Valid and returns the validated value for further assertions. ++ ++```scala ++test("valid data passes validation") { ++ val result = MyValidator.validate(validData) ++ val value = assertValid(result) ++ ++ // Additional assertions on the validated value ++ assertEquals(value.id, 123) ++} ++``` ++ ++### 2. assertHasOneError ++ ++Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual ++validation rules. ++ ++```scala ++test("empty name is rejected") { ++ val result = User.validate(User("", 25)) ++ ++ assertHasOneError(result) { error => ++ assertEquals(error.fieldPath, List("name")) ++ assert(error.message.contains("empty")) ++ } ++} ++``` ++ ++### 3. assertHasNErrors ++ ++Asserts that a ValidationResult is Invalid and contains exactly N errors. ++ ++```scala ++test("multiple specific errors are reported") { ++ val result = User.validate(User("", -5)) ++ ++ assertHasNErrors(result, 2) { errors => ++ // Assert on the collection of exactly 2 errors ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++### 4. assertInvalid ++ ++Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, ++accumulated errors are expected. ++ ++```scala ++test("multiple validation errors are accumulated") { ++ val result = User.validate(User("", -5)) ++ ++ assertInvalid(result) { ++ case errors if errors.size == 2 => ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++### 5. assertInvalidWith ++ ++Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular ++function. This is a simpler alternative to assertInvalid. ++ ++```scala ++test("validation fails with expected errors") { ++ val result = User.validate(User("", -5)) ++ ++ assertInvalidWith(result) { errors => ++ assertEquals(errors.size, 2) ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++## Benefits ++ ++- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. ++ ++- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. ++ ++- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. ++ ++- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated ++ value. ++ ++- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match ++ your testing preferences. +diff --git a/project/plugins.sbt b/project/plugins.sbt +index 2b223de..7f55807 100644 +--- a/project/plugins.sbt ++++ b/project/plugins.sbt +@@ -22,3 +22,6 @@ addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.8") + addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") + // For TASTy compatibility checking (for Scala 3 inlines/macros) + addSbtPlugin("ch.epfl.scala" % "sbt-tasty-mima" % "1.3.0") ++ ++// Benchmarking ++addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7") +diff --git a/translator/README.md b/translator/README.md +new file mode 100644 +index 0000000..f1401bb +--- /dev/null ++++ b/translator/README.md +@@ -0,0 +1,98 @@ ++# valar-translator ++ ++[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) ++[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) ++[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) ++ ++The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. ++ ++## Installation ++ ++Add the valar-translator dependency to your build.sbt: ++ ++```scala ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" ++``` ++ ++## Usage ++ ++The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. ++ ++### 1. Implement the `Translator` Trait ++ ++Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. ++ ++```scala ++import net.ghoula.valar.translator.Translator ++import net.ghoula.valar.ValidationErrors.ValidationError ++ ++// --- Example Setup --- ++// In a real application, this would come from a properties file or other i18n system. ++val translations: Map[String, String] = Map( ++ "error.string.nonEmpty" -> "The field must not be empty.", ++ "error.int.nonNegative" -> "The value cannot be negative.", ++ "error.unknown" -> "An unexpected validation error occurred." ++) ++ ++// --- Implementation of the Translator trait --- ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Logic to look up the error's key in your translation map. ++ // The `.getOrElse` provides a safe fallback. ++ translations.getOrElse( ++ error.key.getOrElse("error.unknown"), ++ error.message // Fall back to the original message if the key is not found ++ ) ++ } ++} ++``` ++ ++### 2. Call `translateErrors()` ++ ++Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. ++ ++```scala ++val result = User.validate(someData) // An Invalid ValidationResult ++val translatedResult = result.translateErrors() ++ ++// translatedResult now contains errors with localized messages ++``` ++ ++## Integration with the ValidationObserver Extensibility Pattern ++ ++The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. ++ ++This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: ++ ++* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result ++* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization ++ ++While these serve different purposes, they're designed to work together in a clean, composable way: ++ ++A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. ++ ++```scala ++// Given a defined extension using the ValidationObserver pattern ++given metricsObserver: ValidationObserver with { ++ def onResult[A](result: ValidationResult[A]): Unit = { ++ // Record validation metrics to your monitoring system ++ } ++} ++ ++// And a translator implementation for localization ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Translate errors using your i18n system ++ } ++} ++ ++// Both extensions work together through the same pattern ++val result = User.validate(invalidUser) ++ // First, observe the raw result using the core ValidationObserver pattern ++ .observe() ++ // Then, translate the errors for presentation (also built on the same pattern) ++ .translateErrors() ++ ++// This demonstrates how all Valar extensions follow the same architectural pattern, ++// allowing them to compose together seamlessly ++``` +diff --git a/valar-benchmarks/README.md b/valar-benchmarks/README.md +new file mode 100644 +index 0000000..f70ce8a +--- /dev/null ++++ b/valar-benchmarks/README.md +@@ -0,0 +1,139 @@ ++# Valar Benchmarks ++ ++This module contains JMH (Java Microbenchmark Harness) benchmarks for the Valar validation library. The benchmarks measure the performance of critical validation paths to help identify performance characteristics and potential optimizations. ++ ++## Overview ++ ++The benchmark suite covers: ++- **Synchronous validation** of simple and nested case classes ++- **Asynchronous validation** with a mix of sync and async rules ++- **Valid and invalid data paths** to understand performance differences ++ ++## Benchmark Results ++ ++Based on the latest run (JDK 21.0.7, OpenJDK 64-Bit Server VM): ++ ++| Benchmark | Mode | Score | Error | Units | ++|----------------------|------|------------|-------------|-------| ++| `syncSimpleValid` | avgt | 44.628 | ± 6.746 | ns/op | ++| `syncSimpleInvalid` | avgt | 149.155 | ± 7.124 | ns/op | ++| `syncNestedValid` | avgt | 108.968 | ± 7.300 | ns/op | ++| `syncNestedInvalid` | avgt | 449.783 | ± 18.373 | ns/op | ++| `asyncSimpleValid` | avgt | 13,212.036 | ± 1,114.597 | ns/op | ++| `asyncSimpleInvalid` | avgt | 13,465.022 | ± 214.379 | ns/op | ++| `asyncNestedValid` | avgt | 14,513.056 | ± 1,023.942 | ns/op | ++| `asyncNestedInvalid` | avgt | 15,432.503 | ± 2,592.103 | ns/op | ++ ++## Performance Analysis ++ ++### 🚀 Synchronous Performance is Excellent ++ ++The validation for simple, valid objects completes in **~45 nanoseconds**. This is incredibly fast and proves that for the "happy path," the library adds negligible overhead. The slightly higher numbers for invalid and nested cases (~150–450 ns) are also excellent and are expected, as they account for: ++ ++- Creation of `ValidationError` objects for invalid cases ++- Recursive validation calls for nested structures ++- Error accumulation logic ++ ++**Key takeaway**: Synchronous validation is extremely fast with minimal overhead. ++ ++### ⚡ Asynchronous Performance is As Expected ++ ++The async benchmarks show results in the **~13–16 microsecond range** (13,00016,000 ns). This is excellent and exactly what we should expect. The "cost" here is not from our validation logic but from the inherent overhead of: ++ ++- Creating `Future` instances ++- Managing the `ExecutionContext` ++- The `Await.result` call in the benchmark (blocking on async results) ++ ++**Key takeaway**: Our async logic is efficient and correctly builds on Scala's non-blocking primitives without introducing performance bottlenecks. ++ ++### Summary ++ ++- **Sync validation**: Negligible overhead, perfect for high-throughput scenarios ++- **Async validation**: Adds only the expected Future abstraction overhead ++- **Valid vs Invalid**: Invalid cases show expected slight overhead due to error object creation ++- **Simple vs Nested**: Nested validation scales linearly with complexity ++ ++The results confirm that Valar introduces no significant performance penalties beyond what's inherent to the chosen execution model (sync vs. async). ++ ++## Running Benchmarks ++ ++### Run All Benchmarks ++```bash ++sbt "valarBenchmarks / Jmh / run" ++``` ++``` ++### Run Specific Benchmarks ++``` bash ++# Run only sync benchmarks ++sbt "valarBenchmarks / Jmh / run .*sync.*" ++ ++# Run only async benchmarks ++sbt "valarBenchmarks / Jmh / run .*async.*" ++ ++# Run only valid cases ++sbt "valarBenchmarks / Jmh / run .*Valid.*" ++``` ++### Customize Benchmark Parameters ++``` bash ++# Run with custom iterations and warmup ++sbt "valarBenchmarks / Jmh / run -i 10 -wi 5 -f 2" ++ ++# Run with different output format ++sbt "valarBenchmarks / Jmh / run -rf json" ++``` ++### List Available Benchmarks ++``` bash ++sbt "valarBenchmarks / Jmh / run -l" ++``` ++## Benchmark Configuration ++The benchmarks are configured with: ++- : five iterations, 1 second each **Warmup** ++- : five iterations, 1 second each **Measurement** ++- : 1 fork **Fork** ++- : Average time (ns/op) **Mode** ++- **Threads**: 1 thread ++ ++## Test Data ++The benchmarks use the following test models: ++``` scala ++case class SimpleUser(name: String, age: Int) ++case class NestedCompany(name: String, owner: SimpleUser) ++``` ++With validation rules: ++- must be non-empty `name` ++- must be non-negative `age` ++ ++## Understanding Results ++- **ns/op**: Nanoseconds per operation (lower is better) ++- **Error**: 99.9% confidence interval ++- **Mode avgt**: Average time across all iterations ++ ++## Profiling ++For deeper performance analysis, you can use JMH's built-in profilers: ++``` bash ++# CPU profiling ++sbt "valarBenchmarks / Jmh / run -prof comp" ++ ++# Memory allocation profiling ++sbt "valarBenchmarks / Jmh / run -prof gc" ++ ++# Stack profiling ++sbt "valarBenchmarks / Jmh / run -prof stack" ++``` ++## Adding New Benchmarks ++To add new benchmarks: ++1. Add your benchmark method to `ValarBenchmark.scala` ++2. Annotate it with `@Benchmark` ++3. Ensure it returns a meaningful value to prevent dead code elimination ++4. Follow the existing naming conventions (`sync`/`async` + `Simple`/`Nested` + /`Invalid`) `Valid` ++ ++## Dependencies ++- JMH 1.37 ++- Scala 3.7.1 ++- OpenJDK 21+ ++ ++## Notes ++- Results may vary based on JVM version, hardware, and system load ++- Always run benchmarks multiple times to ensure consistency ++- Consider JVM warm-up effects when interpreting results ++- The async benchmarks include overhead, which inflates the numbers compared to pure async execution `Await.result` +diff --git a/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala b/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala +new file mode 100644 +index 0000000..0b81978 +--- /dev/null ++++ b/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala +@@ -0,0 +1,108 @@ ++package net.ghoula.valar.benchmarks ++ ++import org.openjdk.jmh.annotations.* ++ ++import java.util.concurrent.TimeUnit ++import scala.concurrent.Await ++import scala.concurrent.ExecutionContext.Implicits.global ++import scala.concurrent.Future ++import scala.concurrent.duration.* ++ ++import net.ghoula.valar.* ++import net.ghoula.valar.ValidationErrors.ValidationError ++ ++/** Defines the JMH benchmark suite for Valar. ++ * ++ * This suite measures the performance of critical validation paths, including ++ * - Synchronous validation of simple and nested case classes. ++ * - Asynchronous validation with a mix of sync and async rules. ++ * ++ * To run these benchmarks, use the sbt command: `valarBenchmarks / Jmh / run` ++ */ ++@State(Scope.Thread) ++@BenchmarkMode(Array(Mode.AverageTime)) ++@OutputTimeUnit(TimeUnit.NANOSECONDS) ++@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) ++@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) ++@Fork(1) ++class ValarBenchmark { ++ ++ // --- Test Data and Models --- ++ ++ case class SimpleUser(name: String, age: Int) ++ case class NestedCompany(name: String, owner: SimpleUser) ++ ++ private val validUser: SimpleUser = SimpleUser("John Doe", 30) ++ private val invalidUser: SimpleUser = SimpleUser("", -1) ++ private val validCompany: NestedCompany = NestedCompany("Valid Corp", validUser) ++ private val invalidCompany: NestedCompany = NestedCompany("", invalidUser) ++ ++ // --- Synchronous Validators --- ++ ++ given syncStringValidator: Validator[String] with { ++ def validate(value: String): ValidationResult[String] = ++ if (value.nonEmpty) ValidationResult.Valid(value) ++ else ValidationResult.invalid(ValidationError("String is empty")) ++ } ++ ++ given syncIntValidator: Validator[Int] with { ++ def validate(value: Int): ValidationResult[Int] = ++ if (value >= 0) ValidationResult.Valid(value) ++ else ValidationResult.invalid(ValidationError("Int is negative")) ++ } ++ ++ given syncUserValidator: Validator[SimpleUser] = Validator.derive ++ given syncCompanyValidator: Validator[NestedCompany] = Validator.derive ++ ++ // --- Asynchronous Validators --- ++ ++ given asyncStringValidator: AsyncValidator[String] with { ++ def validateAsync(name: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = ++ Future.successful(syncStringValidator.validate(name)) ++ } ++ ++ given asyncUserValidator: AsyncValidator[SimpleUser] = AsyncValidator.derive ++ given asyncCompanyValidator: AsyncValidator[NestedCompany] = AsyncValidator.derive ++ ++ // --- Benchmarks --- ++ ++ @Benchmark ++ def syncSimpleValid(): ValidationResult[SimpleUser] = { ++ syncUserValidator.validate(validUser) ++ } ++ ++ @Benchmark ++ def syncSimpleInvalid(): ValidationResult[SimpleUser] = { ++ syncUserValidator.validate(invalidUser) ++ } ++ ++ @Benchmark ++ def syncNestedValid(): ValidationResult[NestedCompany] = { ++ syncCompanyValidator.validate(validCompany) ++ } ++ ++ @Benchmark ++ def syncNestedInvalid(): ValidationResult[NestedCompany] = { ++ syncCompanyValidator.validate(invalidCompany) ++ } ++ ++ @Benchmark ++ def asyncSimpleValid(): ValidationResult[SimpleUser] = { ++ Await.result(asyncUserValidator.validateAsync(validUser), 1.second) ++ } ++ ++ @Benchmark ++ def asyncSimpleInvalid(): ValidationResult[SimpleUser] = { ++ Await.result(asyncUserValidator.validateAsync(invalidUser), 1.second) ++ } ++ ++ @Benchmark ++ def asyncNestedValid(): ValidationResult[NestedCompany] = { ++ Await.result(asyncCompanyValidator.validateAsync(validCompany), 1.second) ++ } ++ ++ @Benchmark ++ def asyncNestedInvalid(): ValidationResult[NestedCompany] = { ++ Await.result(asyncCompanyValidator.validateAsync(invalidCompany), 1.second) ++ } ++} +diff --git a/valar-core/README.md b/valar-core/README.md +new file mode 100644 +index 0000000..fcd70e0 +--- /dev/null ++++ b/valar-core/README.md +@@ -0,0 +1,94 @@ ++# valar-core ++ ++[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) ++[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) ++[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) ++ ++The `valar-core` module provides the core validation functionality for Valar, a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages. ++ ++## Key Components ++ ++### ValidationResult ++ ++Represents the outcome of validation as either Valid(value) or Invalid(errors): ++ ++```scala ++import net.ghoula.valar.ValidationErrors.ValidationError ++ ++enum ValidationResult[+A] { ++ case Valid(value: A) ++ case Invalid(errors: Vector[ValidationError]) ++} ++``` ++ ++### ValidationError ++ ++Opaque type providing rich context for validation errors, including: ++ ++* **message**: Human-readable description of the error. ++* **fieldPath**: Path to the field causing the error (e.g., user.address.street). ++* **code**: Optional application-specific error codes. ++* **severity**: Optional severity indicator (Error, Warning). ++* **expected/actual**: Information about expected and actual values. ++* **children**: Nested errors for structured reporting. ++ ++### Validator[A] ++ ++A typeclass defining validation logic for a given type: ++ ++```scala ++import net.ghoula.valar.ValidationResult ++ ++trait Validator[A] { ++ def validate(a: A): ValidationResult[A] ++} ++``` ++ ++Validators can be automatically derived for case classes using deriveValidatorMacro. ++ ++### ValidationObserver ++ ++The `ValidationObserver` trait provides a mechanism to decouple validation logic from cross-cutting concerns such as logging, metrics collection, or auditing: ++ ++```scala ++import net.ghoula.valar.* ++import org.slf4j.LoggerFactory ++ ++// Define a custom observer that logs validation results ++given loggingObserver: ValidationObserver with { ++ private val logger = LoggerFactory.getLogger("ValidationAnalytics") ++ ++ def onResult[A](result: ValidationResult[A]): Unit = result match { ++ case ValidationResult.Valid(_) => ++ logger.info("Validation succeeded") ++ case ValidationResult.Invalid(errors) => ++ logger.warn(s"Validation failed with ${errors.size} errors") ++ } ++} ++ ++// Use the observer in your validation flow ++val result = User.validate(user).observe() ++``` ++ ++Key features of ValidationObserver: ++* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code ++* **Non-Intrusive**: Observes validation results without altering the validation flow ++* **Chainable**: Works seamlessly with other operations in the validation pipeline ++* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect ++ ++## Built-in Validators ++ ++Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This includes: ++ ++* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, Short, Char, Unit. ++* **Other Scala Types:** BigInt, BigDecimal, Symbol. ++* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. ++* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their element/key/value types). ++* **Tuple Types:** Named tuples and regular tuples. ++* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. ++ ++Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. ++ ++## Usage ++ ++For detailed usage examples and more information, please refer to the [main Valar documentation](https://github.com/hakimjonas/valar). +\ No newline at end of file +diff --git a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala +new file mode 100644 +index 0000000..0d60ba4 +--- /dev/null ++++ b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala +@@ -0,0 +1,415 @@ ++package net.ghoula.valar ++ ++import java.time.* ++import java.util.UUID ++import scala.concurrent.{ExecutionContext, Future} ++import scala.deriving.Mirror ++import scala.quoted.{Expr, Quotes, Type} ++ ++import net.ghoula.valar.ValidationErrors.ValidationError ++import net.ghoula.valar.internal.Derivation ++ ++/** A typeclass for defining custom asynchronous validation logic for type `A`. ++ * ++ * This is used for validations that involve non-blocking I/O, such as checking for uniqueness in a ++ * database or calling an external service. ++ * ++ * @tparam A ++ * the type to be validated ++ */ ++trait AsyncValidator[A] { ++ ++ /** Asynchronously validate an instance of type `A`. ++ * ++ * @param a ++ * the instance to validate ++ * @param ec ++ * the execution context for the Future ++ * @return ++ * a `Future` containing the `ValidationResult[A]` ++ */ ++ def validateAsync(a: A)(using ec: ExecutionContext): Future[ValidationResult[A]] ++} ++ ++/** Companion object for the [[AsyncValidator]] typeclass. */ ++object AsyncValidator { ++ ++ /** Summons an implicit [[AsyncValidator]] instance for type `A`. */ ++ def apply[A](using v: AsyncValidator[A]): AsyncValidator[A] = v ++ ++ /** Lifts a synchronous `Validator` into an `AsyncValidator`. ++ * ++ * This allows synchronous validators to be used seamlessly in an asynchronous validation chain. ++ * ++ * @param v ++ * the synchronous validator to lift ++ * @return ++ * an `AsyncValidator` that wraps the result in a `Future.successful`. ++ */ ++ def fromSync[A](v: Validator[A]): AsyncValidator[A] = new AsyncValidator[A] { ++ def validateAsync(a: A)(using ec: ExecutionContext): Future[ValidationResult[A]] = ++ Future.successful(v.validate(a)) ++ } ++ ++ /** Generic helper method for folding validation results into errors and valid values. ++ * ++ * @param results ++ * the sequence of validation results to fold ++ * @param emptyAcc ++ * the empty accumulator for valid values ++ * @param addToAcc ++ * function to add a valid value to the accumulator ++ * @return ++ * a tuple containing accumulated errors and valid values ++ */ ++ private def foldValidationResults[A, B]( ++ results: Iterable[ValidationResult[A]], ++ emptyAcc: B, ++ addToAcc: (B, A) => B ++ ): (Vector[ValidationError], B) = { ++ results.foldLeft((Vector.empty[ValidationError], emptyAcc)) { ++ case ((errs, acc), ValidationResult.Valid(value)) => (errs, addToAcc(acc, value)) ++ case ((errs, acc), ValidationResult.Invalid(e)) => (errs ++ e, acc) ++ } ++ } ++ ++ /** Generic helper method for validating collections asynchronously. ++ * ++ * This method eliminates code duplication by providing a common validation pattern for different ++ * collection types. It validates each element in the collection asynchronously and accumulates ++ * both errors and valid results. ++ * ++ * @param items ++ * the collection of items to validate ++ * @param validator ++ * the validator for individual items ++ * @param buildResult ++ * function to construct the final collection from valid items ++ * @param ec ++ * execution context for async operations ++ * @return ++ * a Future containing the validation result ++ */ ++ private def validateCollection[A, C[_]]( ++ items: Iterable[A], ++ validator: AsyncValidator[A], ++ buildResult: Iterable[A] => C[A] ++ )(using ec: ExecutionContext): Future[ValidationResult[C[A]]] = { ++ val futureResults = items.map { item => ++ validator.validateAsync(item).map { ++ case ValidationResult.Valid(a) => ValidationResult.Valid(a) ++ case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) ++ } ++ } ++ ++ Future.sequence(futureResults).map { results => ++ val (errors, validValues) = foldValidationResults(results, Vector.empty[A], _ :+ _) ++ if (errors.isEmpty) ValidationResult.Valid(buildResult(validValues)) ++ else ValidationResult.Invalid(errors) ++ } ++ } ++ ++ /** Asynchronous validator for optional values. ++ * ++ * Validates an `Option[A]` by delegating to the underlying validator only when the value is ++ * present. Empty options are considered valid by default. ++ * ++ * @param v ++ * the validator for the wrapped type A ++ * @return ++ * an AsyncValidator that handles optional values ++ */ ++ given optionAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Option[A]] with { ++ def validateAsync(opt: Option[A])(using ec: ExecutionContext): Future[ValidationResult[Option[A]]] = ++ opt match { ++ case None => Future.successful(ValidationResult.Valid(None)) ++ case Some(value) => ++ v.validateAsync(value).map { ++ case ValidationResult.Valid(a) => ValidationResult.Valid(Some(a)) ++ case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) ++ } ++ } ++ } ++ ++ /** Asynchronous validator for lists. ++ * ++ * Validates a `List[A]` by applying the element validator to each item in the list ++ * asynchronously. All validation futures are executed concurrently, and their results are ++ * collected. Errors from individual elements are accumulated while preserving the order of valid ++ * elements. ++ * ++ * @param v ++ * the validator for list elements ++ * @return ++ * an AsyncValidator that handles lists ++ */ ++ given listAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[List[A]] with { ++ def validateAsync(xs: List[A])(using ec: ExecutionContext): Future[ValidationResult[List[A]]] = ++ validateCollection(xs, v, _.toList) ++ } ++ ++ /** Asynchronous validator for sequences. ++ * ++ * Validates a `Seq[A]` by applying the element validator to each item in the sequence ++ * asynchronously. All validation futures are executed concurrently, and their results are ++ * collected. Errors from individual elements are accumulated while preserving the order of valid ++ * elements. ++ * ++ * @param v ++ * the validator for sequence elements ++ * @return ++ * an AsyncValidator that handles sequences ++ */ ++ given seqAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Seq[A]] with { ++ def validateAsync(xs: Seq[A])(using ec: ExecutionContext): Future[ValidationResult[Seq[A]]] = ++ validateCollection(xs, v, _.toSeq) ++ } ++ ++ /** Asynchronous validator for vectors. ++ * ++ * Validates a `Vector[A]` by applying the element validator to each item in the vector ++ * asynchronously. All validation futures are executed concurrently, and their results are ++ * collected. Errors from individual elements are accumulated while preserving the order of valid ++ * elements. ++ * ++ * @param v ++ * the validator for vector elements ++ * @return ++ * an AsyncValidator that handles vectors ++ */ ++ given vectorAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Vector[A]] with { ++ def validateAsync(xs: Vector[A])(using ec: ExecutionContext): Future[ValidationResult[Vector[A]]] = ++ validateCollection(xs, v, _.toVector) ++ } ++ ++ /** Asynchronous validator for sets. ++ * ++ * Validates a `Set[A]` by applying the element validator to each item in the set asynchronously. ++ * All validation futures are executed concurrently, and their results are collected. Errors from ++ * individual elements are accumulated while preserving the valid elements in the resulting set. ++ * ++ * @param v ++ * the validator for set elements ++ * @return ++ * an AsyncValidator that handles sets ++ */ ++ given setAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Set[A]] with { ++ def validateAsync(xs: Set[A])(using ec: ExecutionContext): Future[ValidationResult[Set[A]]] = ++ validateCollection(xs, v, _.toSet) ++ } ++ ++ /** Asynchronous validator for maps. ++ * ++ * Validates a `Map[K, V]` by applying the key validator to each key and the value validator to ++ * each value asynchronously. All validation futures are executed concurrently, and their results ++ * are collected. Errors from individual keys and values are accumulated with proper field path ++ * annotation, while valid key-value pairs are preserved in the resulting map. ++ * ++ * @param vk ++ * the validator for map keys ++ * @param vv ++ * the validator for map values ++ * @return ++ * an AsyncValidator that handles maps ++ */ ++ given mapAsyncValidator[K, V](using vk: AsyncValidator[K], vv: AsyncValidator[V]): AsyncValidator[Map[K, V]] with { ++ def validateAsync(m: Map[K, V])(using ec: ExecutionContext): Future[ValidationResult[Map[K, V]]] = { ++ val futureResults = m.map { case (k, v) => ++ val futureKey = vk.validateAsync(k).map { ++ case ValidationResult.Valid(kk) => ValidationResult.Valid(kk) ++ case ValidationResult.Invalid(es) => ++ ValidationResult.Invalid(es.map(_.annotateField("key", k.getClass.getSimpleName))) ++ } ++ val futureValue = vv.validateAsync(v).map { ++ case ValidationResult.Valid(vv) => ValidationResult.Valid(vv) ++ case ValidationResult.Invalid(es) => ++ ValidationResult.Invalid(es.map(_.annotateField("value", v.getClass.getSimpleName))) ++ } ++ ++ for { ++ keyResult <- futureKey ++ valueResult <- futureValue ++ } yield keyResult.zip(valueResult) ++ } ++ ++ Future.sequence(futureResults).map { results => ++ val (errors, validPairs) = foldValidationResults(results, Map.empty[K, V], _ + _) ++ if (errors.isEmpty) ValidationResult.Valid(validPairs) else ValidationResult.Invalid(errors) ++ } ++ } ++ } ++ ++ /** Asynchronous validator for non-negative integers. ++ * ++ * Validates that an integer value is non-negative (>= 0). This validator is lifted from the ++ * corresponding synchronous validator and is used as a fallback when no custom integer validator ++ * is provided. ++ */ ++ given nonNegativeIntAsyncValidator: AsyncValidator[Int] = fromSync(Validator.nonNegativeIntValidator) ++ ++ /** Asynchronous validator for finite floating-point numbers. ++ * ++ * Validates that a float value is finite (not NaN or infinite). This validator is lifted from ++ * the corresponding synchronous validator and is used as a fallback when no custom float ++ * validator is provided. ++ */ ++ given finiteFloatAsyncValidator: AsyncValidator[Float] = fromSync(Validator.finiteFloatValidator) ++ ++ /** Asynchronous validator for finite double-precision numbers. ++ * ++ * Validates that a double value is finite (not NaN or infinite). This validator is lifted from ++ * the corresponding synchronous validator and is used as a fallback when no custom double ++ * validator is provided. ++ */ ++ given finiteDoubleAsyncValidator: AsyncValidator[Double] = fromSync(Validator.finiteDoubleValidator) ++ ++ /** Asynchronous validator for non-empty strings. ++ * ++ * Validates that a string value is not empty. This validator is lifted from the corresponding ++ * synchronous validator and is used as a fallback when no custom string validator is provided. ++ */ ++ given nonEmptyStringAsyncValidator: AsyncValidator[String] = fromSync(Validator.nonEmptyStringValidator) ++ ++ /** Asynchronous validator for boolean values. ++ * ++ * Pass-through validator for boolean values that always succeeds. This validator is lifted from ++ * the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given booleanAsyncValidator: AsyncValidator[Boolean] = fromSync(Validator.booleanValidator) ++ ++ /** Asynchronous validator for byte values. ++ * ++ * Pass-through validator for byte values that always succeeds. This validator is lifted from the ++ * corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given byteAsyncValidator: AsyncValidator[Byte] = fromSync(Validator.byteValidator) ++ ++ /** Asynchronous validator for short values. ++ * ++ * Pass-through validator for short values that always succeeds. This validator is lifted from ++ * the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given shortAsyncValidator: AsyncValidator[Short] = fromSync(Validator.shortValidator) ++ ++ /** Asynchronous validator for long values. ++ * ++ * Pass-through validator for long values that always succeeds. This validator is lifted from the ++ * corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given longAsyncValidator: AsyncValidator[Long] = fromSync(Validator.longValidator) ++ ++ /** Asynchronous validator for character values. ++ * ++ * Pass-through validator for character values that always succeeds. This validator is lifted ++ * from the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given charAsyncValidator: AsyncValidator[Char] = fromSync(Validator.charValidator) ++ ++ /** Asynchronous validator for unit values. ++ * ++ * Pass-through validator for unit values that always succeeds. This validator is lifted from the ++ * corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given unitAsyncValidator: AsyncValidator[Unit] = fromSync(Validator.unitValidator) ++ ++ /** Asynchronous validator for arbitrary precision integers. ++ * ++ * Pass-through validator for BigInt values that always succeeds. This validator is lifted from ++ * the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given bigIntAsyncValidator: AsyncValidator[BigInt] = fromSync(Validator.bigIntValidator) ++ ++ /** Asynchronous validator for arbitrary precision decimal numbers. ++ * ++ * Pass-through validator for BigDecimal values that always succeeds. This validator is lifted ++ * from the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given bigDecimalAsyncValidator: AsyncValidator[BigDecimal] = fromSync(Validator.bigDecimalValidator) ++ ++ /** Asynchronous validator for symbol values. ++ * ++ * Pass-through validator for symbol values that always succeeds. This validator is lifted from ++ * the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given symbolAsyncValidator: AsyncValidator[Symbol] = fromSync(Validator.symbolValidator) ++ ++ /** Asynchronous validator for UUID values. ++ * ++ * Pass-through validator for UUID values that always succeeds. This validator is lifted from the ++ * corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given uuidAsyncValidator: AsyncValidator[UUID] = fromSync(Validator.uuidValidator) ++ ++ /** Asynchronous validator for instant values. ++ * ++ * Pass-through validator for Instant values that always succeeds. This validator is lifted from ++ * the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given instantAsyncValidator: AsyncValidator[Instant] = fromSync(Validator.instantValidator) ++ ++ /** Asynchronous validator for local date values. ++ * ++ * Pass-through validator for LocalDate values that always succeeds. This validator is lifted ++ * from the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given localDateAsyncValidator: AsyncValidator[LocalDate] = fromSync(Validator.localDateValidator) ++ ++ /** Asynchronous validator for local time values. ++ * ++ * Pass-through validator for LocalTime values that always succeeds. This validator is lifted ++ * from the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given localTimeAsyncValidator: AsyncValidator[LocalTime] = fromSync(Validator.localTimeValidator) ++ ++ /** Asynchronous validator for local date-time values. ++ * ++ * Pass-through validator for LocalDateTime values that always succeeds. This validator is lifted ++ * from the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given localDateTimeAsyncValidator: AsyncValidator[LocalDateTime] = fromSync(Validator.localDateTimeValidator) ++ ++ /** Asynchronous validator for zoned date-time values. ++ * ++ * Pass-through validator for ZonedDateTime values that always succeeds. This validator is lifted ++ * from the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given zonedDateTimeAsyncValidator: AsyncValidator[ZonedDateTime] = fromSync(Validator.zonedDateTimeValidator) ++ ++ /** Asynchronous validator for duration values. ++ * ++ * Pass-through validator for Duration values that always succeeds. This validator is lifted from ++ * the corresponding synchronous validator and provides consistent async behavior. ++ */ ++ given javaDurationAsyncValidator: AsyncValidator[Duration] = fromSync(Validator.durationValidator) ++ ++ /** Automatically derives an `AsyncValidator` for case classes using Scala 3 macros. ++ * ++ * This method provides compile-time derivation of async validators for product types by ++ * analyzing the case class structure and generating appropriate validation logic that validates ++ * each field using the corresponding validator in scope. ++ * ++ * @param m ++ * the Mirror.ProductOf evidence for the type T ++ * @return ++ * a derived AsyncValidator instance for type T ++ */ ++ inline def derive[T](using m: Mirror.ProductOf[T]): AsyncValidator[T] = ++ ${ deriveImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } ++ ++ /** Macro implementation for deriving an `AsyncValidator`. ++ * ++ * This method implements the actual macro logic for generating async validator instances at ++ * compile time. It delegates to the internal Derivation utility with the async flag set to true ++ * to generate appropriate asynchronous validation code. ++ * ++ * @param m ++ * the Mirror.ProductOf expression ++ * @return ++ * an expression representing the derived AsyncValidator ++ */ ++ private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( ++ m: Expr[Mirror.ProductOf[T]] ++ )(using q: Quotes): Expr[AsyncValidator[T]] = { ++ Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = true).asExprOf[AsyncValidator[T]] ++ } ++} +diff --git a/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala +new file mode 100644 +index 0000000..1b673a1 +--- /dev/null ++++ b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala +@@ -0,0 +1,150 @@ ++package net.ghoula.valar ++import net.ghoula.valar.ValidationResult ++ ++/** Defines the foundational extensibility pattern for Valar. ++ * ++ * This typeclass represents Valar's canonical pattern for extension development. It's designed to ++ * be the standard way to build integrations and extensions for the validation library. By ++ * implementing this trait and providing it as a `given` instance, developers can: ++ * ++ * - Extend Valar with cross-cutting concerns (logging, metrics, auditing) ++ * - Build composable extensions that work together seamlessly ++ * - Integrate with external monitoring and diagnostic systems ++ * - Create specialized behaviors without modifying validation logic ++ * ++ * @see ++ * [[ValidationObserver.noOpObserver]] for the default, zero-overhead implementation used when no ++ * custom observer is provided. ++ * ++ * ==Architectural Pattern== ++ * ++ * The `ValidationObserver` pattern is the recommended approach for extending Valar's capabilities. ++ * By using this pattern, you benefit from: ++ * ++ * - A standardized, type-safe interface for integrating with Valar ++ * - Zero-cost abstractions through the inline implementation when not used ++ * - Clean composition with other features (like the translator module) ++ * - Future compatibility with upcoming Valar modules (planned: valar-cats-effect, valar-zio) ++ * ++ * When implementing extensions to Valar, prefer extending this trait over creating alternative ++ * patterns. ++ * ++ * @example ++ * Building a simple extension for validation logging: ++ * {{{ ++ * import org.slf4j.LoggerFactory ++ * ++ * // 1. Define your extension by implementing ValidationObserver ++ * given loggingObserver: ValidationObserver with { ++ * private val logger = LoggerFactory.getLogger("ValidationAnalytics") ++ * ++ * def onResult[A](result: ValidationResult[A]): Unit = result match { ++ * case ValidationResult.Valid(_) => ++ * logger.info("Validation succeeded.") ++ * case ValidationResult.Invalid(errors) => ++ * logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") ++ * } ++ * } ++ * ++ * // 2. Use your extension with the standard observe() pattern ++ * val result = someValidation().observe() // The observer is automatically used ++ * ++ * // 3. Extensions compose cleanly with other Valar features ++ * val processedResult = someValidation() ++ * .observe() // Trigger logging/metrics through your observer ++ * .map(transform) ++ * // Can be chained with other extensions like translator ++ * }}} ++ * ++ * Creating a reusable extension module: ++ * {{{ ++ * // Define a specialized observer for metrics collection ++ * trait MetricsObserver extends ValidationObserver { ++ * def recordMetric(name: String, value: Double): Unit ++ * ++ * def onResult[A](result: ValidationResult[A]): Unit = result match { ++ * case ValidationResult.Valid(_) => ++ * recordMetric("validation.success", 1.0) ++ * case ValidationResult.Invalid(errors) => ++ * recordMetric("validation.failure", 1.0) ++ * recordMetric("validation.error.count", errors.size.toDouble) ++ * } ++ * } ++ * ++ * // Concrete implementation for a specific metrics library ++ * given PrometheusMetricsObserver: MetricsObserver with { ++ * def recordMetric(name: String, value: Double): Unit = { ++ * // Implementation using Prometheus client ++ * } ++ * } ++ * }}} ++ */ ++trait ValidationObserver { ++ ++ /** A callback executed for each `ValidationResult` passed to the `observe` method. ++ * ++ * Implementations of this method can inspect the result and trigger side effects, such as ++ * writing to a log, incrementing a metrics counter, or sending an alert. This method should not ++ * throw exceptions. ++ * ++ * @tparam A ++ * The type of the value within the ValidationResult. ++ * @param result ++ * The `ValidationResult` to be observed. ++ */ ++ def onResult[A](result: ValidationResult[A]): Unit ++} ++ ++object ValidationObserver { ++ ++ /** The default, "no-op" `ValidationObserver` that performs no action. ++ * ++ * This instance is provided as an `inline given`. This is a critical optimization feature. When ++ * this default observer is in scope, the Scala compiler, in conjunction with the `inline` ++ * `observe()` extension method, will perform full dead-code elimination. ++ * ++ * This ensures that the observability feature is truly zero-cost and has no performance overhead ++ * unless a custom `ValidationObserver` is explicitly provided. ++ */ ++ inline given noOpObserver: ValidationObserver with { ++ def onResult[A](result: ValidationResult[A]): Unit = () // No operation ++ } ++} ++ ++extension [A](vr: ValidationResult[A]) { ++ ++ /** Applies the in-scope `ValidationObserver` to this `ValidationResult`. ++ * ++ * This extension method is the primary interface for the ValidationObserver extension pattern. ++ * It enables side-effecting operations and extensions to be applied to a validation result ++ * without altering the validation logic or flow. It returns the original result unchanged, ++ * allowing for seamless method chaining with other operations. ++ * ++ * ===Extension Pattern Entry Point=== ++ * ++ * This method serves as the standardized entry point for all extensions built on the ++ * ValidationObserver pattern. Current and future Valar modules that follow this pattern will be ++ * usable through this consistent interface. ++ * ++ * This method is declared `inline` to facilitate powerful compile-time optimizations. If the ++ * default [[ValidationObserver.noOpObserver]] is in scope, the compiler will eliminate this ++ * entire method call from the generated bytecode, ensuring zero runtime overhead. ++ * ++ * @param observer ++ * The `ValidationObserver` instance provided by the implicit context. ++ * @return ++ * The original, unmodified `ValidationResult`, to allow for method chaining. ++ * @example ++ * {{{ import net.ghoula.valar.Validator import net.ghoula.valar.ValidationResult ++ * ++ * def validateUsername(name: String): ValidationResult[String] = ??? ++ * ++ * // Assuming a `given ValidationObserver` is in scope val result = ++ * validateUsername("test-user") .observe() // The observer's onResult is called here ++ * .map(_.toUpperCase) }}} ++ */ ++ inline def observe()(using observer: ValidationObserver): ValidationResult[A] = { ++ observer.onResult(vr) ++ vr ++ } ++} +diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +index 89a1d2e..e6069fe 100644 +--- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala ++++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +@@ -11,7 +11,7 @@ import scala.reflect.ClassTag + import net.ghoula.valar.ValidationErrors.ValidationError + import net.ghoula.valar.ValidationHelpers.* + import net.ghoula.valar.ValidationResult.{validateUnion, given} +-import net.ghoula.valar.internal.MacroHelper ++import net.ghoula.valar.internal.Derivation + + /** A typeclass for defining custom validation logic for type `A`. + * +@@ -39,6 +39,8 @@ object Validator { + /** Summons an implicit [[Validator]] instance for type `A`. */ + def apply[A](using v: Validator[A]): Validator[A] = v + ++ // ... keep all the existing given instances exactly as they are ... ++ + /** Validates that an Int is non-negative (>= 0). Uses [[ValidationHelpers.nonNegativeInt]]. */ + given nonNegativeIntValidator: Validator[Int] with { + def validate(i: Int): ValidationResult[Int] = nonNegativeInt(i) +@@ -63,31 +65,13 @@ object Validator { + def validate(s: String): ValidationResult[String] = nonEmpty(s) + } + +- /** Default validator for `Option[A]`. If the option is `Some(a)`, it validates the inner `a` +- * using the implicit `Validator[A]`. If the option is `None`, it is considered `Valid`. +- * Accumulates errors from the inner validation if `Some`. +- * +- * @tparam A +- * the inner type of the Option. +- * @param v +- * the implicit validator for the inner type `A`. +- * @return +- * A `Validator[Option[A]]`. +- */ ++ /** Default validator for `Option[A]`. */ + given optionValidator[A](using v: Validator[A]): Validator[Option[A]] with { + def validate(opt: Option[A]): ValidationResult[Option[A]] = + optional(opt)(using v) + } + +- /** Validates a `List[A]` by validating each element using the implicit `Validator[A]`. +- * Accumulates all errors found in invalid elements. +- * @tparam A +- * the element type. +- * @param v +- * the implicit validator for the element type `A`. +- * @return +- * A `Validator[List[A]]`. +- */ ++ /** Validates a `List[A]` by validating each element. */ + given listValidator[A](using v: Validator[A]): Validator[List[A]] with { + def validate(xs: List[A]): ValidationResult[List[A]] = { + val results = xs.map(v.validate) +@@ -99,16 +83,7 @@ object Validator { + } + } + +- /** Validates a `Seq[A]` by validating each element using the implicit `Validator[A]`. Accumulates +- * all errors found in invalid elements. +- * +- * @tparam A +- * the element type. +- * @param v +- * the implicit validator for the element type `A`. +- * @return +- * A `Validator[Seq[A]]`. +- */ ++ /** Validates a `Seq[A]` by validating each element. */ + given seqValidator[A](using v: Validator[A]): Validator[Seq[A]] with { + def validate(xs: Seq[A]): ValidationResult[Seq[A]] = { + val results = xs.map(v.validate) +@@ -120,16 +95,7 @@ object Validator { + } + } + +- /** Validates a `Vector[A]` by validating each element using the implicit `Validator[A]`. +- * Accumulates all errors found in invalid elements. +- * +- * @tparam A +- * the element type. +- * @param v +- * the implicit validator for the element type `A`. +- * @return +- * A `Validator[Vector[A]]`. +- */ ++ /** Validates a `Vector[A]` by validating each element. */ + given vectorValidator[A](using v: Validator[A]): Validator[Vector[A]] with { + def validate(xs: Vector[A]): ValidationResult[Vector[A]] = { + val results = xs.map(v.validate) +@@ -141,18 +107,7 @@ object Validator { + } + } + +- /** Validates a `Set[A]` by validating each element using the implicit `Validator[A]`. Accumulates +- * all errors found in invalid elements. +- * +- * @note +- * The order of accumulated errors from a Set is not guaranteed due to its unordered nature. +- * @tparam A +- * the element type. +- * @param v +- * the implicit validator for the element type `A`. +- * @return +- * A `Validator[Set[A]]`. +- */ ++ /** Validates a `Set[A]` by validating each element. */ + given setValidator[A](using v: Validator[A]): Validator[Set[A]] with { + def validate(xs: Set[A]): ValidationResult[Set[A]] = { + val results = xs.map(v.validate) +@@ -164,21 +119,7 @@ object Validator { + } + } + +- /** Validates a `Map[K, V]` by validating each key with `Validator[K]` and each value with +- * `Validator[V]`. Accumulates all errors from invalid keys and values. Errors are annotated with +- * context indicating whether they originated from a 'key' or a 'value'. +- * +- * @tparam K +- * the key type. +- * @tparam V +- * the value type. +- * @param vk +- * the implicit validator for the key type `K`. +- * @param vv +- * the implicit validator for the value type `V`. +- * @return +- * A `Validator[Map[K, V]]`. +- */ ++ /** Validates a `Map[K, V]` by validating each key and value. */ + given mapValidator[K, V](using vk: Validator[K], vv: Validator[V]): Validator[Map[K, V]] with { + def validate(m: Map[K, V]): ValidationResult[Map[K, V]] = { + val results = m.map { case (k, v) => +@@ -206,8 +147,7 @@ object Validator { + } + } + +- /** Helper method for validating iterable collections and building results. +- */ ++ /** Helper for validating iterable collections. */ + private def validateIterable[A, C[_]]( + xs: Iterable[A], + builder: Vector[A] => C[A] +@@ -225,262 +165,92 @@ object Validator { + else ValidationResult.Invalid(errors) + } + +- /** Validates an `Array[A]` by validating each element using the implicit `Validator[A]`. +- * Accumulates all errors found in invalid elements. +- * +- * @tparam A +- * the element type. +- * @param v +- * the implicit validator for the element type `A`. +- * @param ct +- * implicit ClassTag required for creating the resulting Array. +- * @return +- * A `Validator[Array[A]]`. +- */ ++ /** Validates an `Array[A]`. */ + given arrayValidator[A](using v: Validator[A], ct: ClassTag[A]): Validator[Array[A]] with { + def validate(xs: Array[A]): ValidationResult[Array[A]] = + validateIterable(xs, (validValues: Vector[A]) => validValues.toArray) + } + +- /** Validates an `ArraySeq[A]` by validating each element using the implicit `Validator[A]`. +- * Accumulates all errors found in invalid elements. +- * +- * @tparam A +- * the element type. +- * @param v +- * the implicit validator for the element type `A`. +- * @param ct +- * implicit ClassTag required for the underlying Array. +- * @return +- * A `Validator[ArraySeq[A]]`. +- */ ++ /** Validates an `ArraySeq[A]`. */ + given arraySeqValidator[A](using v: Validator[A], ct: ClassTag[A]): Validator[ArraySeq[A]] with { + def validate(xs: ArraySeq[A]): ValidationResult[ArraySeq[A]] = + validateIterable(xs, (validValues: Vector[A]) => ArraySeq.unsafeWrapArray(validValues.toArray)) + } + +- /** Validates an intersection type `A & B` by applying both `Validator[A]` and `Validator[B]`. The +- * result is `Valid` only if *both* underlying validators succeed. If either or both fail, their +- * errors are accumulated using `zip`. +- * +- * @tparam A +- * the first type in the intersection. +- * @tparam B +- * the second type in the intersection. +- * @param va +- * the implicit validator for type `A`. +- * @param vb +- * the implicit validator for type `B`. +- * @return +- * A `Validator[A & B]`. +- */ ++ /** Validates an intersection type `A & B`. */ + given intersectionValidator[A, B](using va: Validator[A], vb: Validator[B]): Validator[A & B] with { + def validate(ab: A & B): ValidationResult[A & B] = + va.validate(ab).zip(vb.validate(ab)).map(_ => ab) + } + +- /** Validates a union type `A | B`. It attempts to validate the input value first as type `A` and +- * then as type `B`. The result is `Valid` if *either* validation succeeds (preferring the result +- * for `A` if both succeed). If both underlying validations fail, it returns an `Invalid` result +- * containing a summary error wrapping the errors from both attempts. Delegates to +- * [[ValidationResult.validateUnion]]. +- * +- * @tparam A +- * the first type in the union. +- * @tparam B +- * the second type in the union. +- * @param va +- * the implicit validator for type `A`. +- * @param vb +- * the implicit validator for type `B`. +- * @param ctA +- * implicit ClassTag required for runtime type checking for `A`. +- * @param ctB +- * implicit ClassTag required for runtime type checking for `B`. +- * @return +- * A `Validator[A | B]`. +- */ +- given unionValidator[A, B](using va: Validator[A], vb: Validator[B], ctA: ClassTag[A], ctB: ClassTag[B]): Validator[ +- A | B +- ] with { ++ /** Validates a union type `A | B`. */ ++ given unionValidator[A, B](using ++ va: Validator[A], ++ vb: Validator[B], ++ ctA: ClassTag[A], ++ ctB: ClassTag[B] ++ ): Validator[A | B] with { + def validate(value: A | B): ValidationResult[A | B] = validateUnion[A, B](value)(using va, vb, ctA, ctB) + } + +- /** This section provides "pass-through" `given` instances that always return `Valid`. They are +- * marked as `inline` to allow the compiler to eliminate the validation overhead, making them +- * zero-cost abstractions when used by the `deriveValidatorMacro`. +- */ ++ /** This section provides "pass-through" `given` instances that always return `Valid`. */ + inline given booleanValidator: Validator[Boolean] with { + def validate(b: Boolean): ValidationResult[Boolean] = ValidationResult.Valid(b) + } +- + inline given byteValidator: Validator[Byte] with { + def validate(b: Byte): ValidationResult[Byte] = ValidationResult.Valid(b) + } +- + inline given shortValidator: Validator[Short] with { + def validate(s: Short): ValidationResult[Short] = ValidationResult.Valid(s) + } +- + inline given longValidator: Validator[Long] with { + def validate(l: Long): ValidationResult[Long] = ValidationResult.Valid(l) + } +- + inline given charValidator: Validator[Char] with { + def validate(c: Char): ValidationResult[Char] = ValidationResult.Valid(c) + } +- + inline given unitValidator: Validator[Unit] with { + def validate(u: Unit): ValidationResult[Unit] = ValidationResult.Valid(u) + } +- + inline given bigIntValidator: Validator[BigInt] with { + def validate(bi: BigInt): ValidationResult[BigInt] = ValidationResult.Valid(bi) + } +- + inline given bigDecimalValidator: Validator[BigDecimal] with { + def validate(bd: BigDecimal): ValidationResult[BigDecimal] = ValidationResult.Valid(bd) + } +- + inline given symbolValidator: Validator[Symbol] with { + def validate(s: Symbol): ValidationResult[Symbol] = ValidationResult.Valid(s) + } +- + inline given uuidValidator: Validator[UUID] with { + def validate(v: UUID): ValidationResult[UUID] = ValidationResult.Valid(v) + } +- + inline given instantValidator: Validator[Instant] with { + def validate(v: Instant): ValidationResult[Instant] = ValidationResult.Valid(v) + } +- + inline given localDateValidator: Validator[LocalDate] with { + def validate(v: LocalDate): ValidationResult[LocalDate] = ValidationResult.Valid(v) + } +- + inline given localTimeValidator: Validator[LocalTime] with { + def validate(v: LocalTime): ValidationResult[LocalTime] = ValidationResult.Valid(v) + } +- + inline given localDateTimeValidator: Validator[LocalDateTime] with { + def validate(v: LocalDateTime): ValidationResult[LocalDateTime] = ValidationResult.Valid(v) + } +- + inline given zonedDateTimeValidator: Validator[ZonedDateTime] with { + def validate(v: ZonedDateTime): ValidationResult[ZonedDateTime] = ValidationResult.Valid(v) + } +- + inline given durationValidator: Validator[Duration] with { + def validate(v: Duration): ValidationResult[Duration] = ValidationResult.Valid(v) + } + +- /** Automatically derives a `Validator` for case classes using Scala 3 macros. +- * +- * Derivation is recursive, validating each field using implicitly available validators. Errors +- * from nested fields are aggregated and annotated with clear field context. +- * +- * @tparam T +- * case class type to derive validator for +- * @param m +- * implicit Scala 3 Mirror for reflection +- * @return +- * Validator[T] automatically derived validator instance +- */ +- inline def deriveValidatorMacro[T](using m: Mirror.ProductOf[T]): Validator[T] = +- ${ deriveValidatorMacroImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } ++ /** Automatically derives a `Validator` for case classes using Scala 3 macros. */ ++ inline def derive[T](using m: Mirror.ProductOf[T]): Validator[T] = ++ ${ deriveImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } + +- private def deriveValidatorMacroImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( ++ /** Macro implementation for deriving a `Validator`. */ ++ private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] + )(using q: Quotes): Expr[Validator[T]] = { +- import q.reflect.* +- +- val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] +- val fieldLabels: List[String] = getLabels[Labels] +- val isOptionList: List[Boolean] = getIsOptionFlags[Elems] +- +- val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) +- val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) +- val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) +- +- '{ +- new Validator[T] { +- def validate(a: T): ValidationResult[T] = { +- a match { +- case product: Product => +- val validators = ${ validatorsExpr } +- val labels = ${ fieldLabelsExpr } +- val isOptionFlags = ${ isOptionListExpr } +- +- val results = product.productIterator.zipWithIndex.map { case (fieldValue, i) => +- val label = labels(i) +- val isOption = isOptionFlags(i) +- +- if (Option(fieldValue).isEmpty && !isOption) { +- ValidationResult.invalid( +- ValidationError( +- message = s"Field '$label' must not be null.", +- fieldPath = List(label), +- expected = Some("non-null value"), +- actual = Some("null") +- ) +- ) +- } else { +- val validator = validators(i) +- validator.validate(fieldValue) match { +- case ValidationResult.Valid(v) => ValidationResult.Valid(v) +- case ValidationResult.Invalid(errs) => +- val fieldTypeName = Option(fieldValue).map(_.getClass.getSimpleName).getOrElse("null") +- ValidationResult.Invalid(errs.map(_.annotateField(label, fieldTypeName))) +- } +- } +- }.toList +- +- val allErrors = results.collect { case ValidationResult.Invalid(e) => e }.flatten.toVector +- if (allErrors.isEmpty) { +- val validValues = results.collect { case ValidationResult.Valid(v) => v } +- ValidationResult.Valid($m.fromProduct(Tuple.fromArray(validValues.toArray))) +- } else { +- ValidationResult.Invalid(allErrors) +- } +- } +- } +- } +- } +- } +- +- private def summonValidators[Elems <: Tuple: Type](using q: Quotes): List[Expr[Validator[Any]]] = { +- import q.reflect.* +- Type.of[Elems] match { +- case '[EmptyTuple] => Nil +- case '[h *: t] => +- val validatorExpr = Expr.summon[Validator[h]].getOrElse { +- report.errorAndAbort(s"Could not find a given Validator for type ${Type.show[h]}") +- } +- '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] +- } +- } +- +- private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { +- import q.reflect.* +- def loop(tpe: TypeRepr): List[String] = tpe.dealias match { +- case AppliedType(_, List(head, tail)) => +- head match { +- case ConstantType(StringConstant(label)) => label :: loop(tail) +- case _ => report.errorAndAbort(s"Macro error: Expected a literal string for a label, but got ${head.show}") +- } +- case t if t =:= TypeRepr.of[EmptyTuple] => Nil +- case _ => report.errorAndAbort(s"Macro error: The labels tuple was not structured as expected: ${tpe.show}") +- } +- +- loop(TypeRepr.of[Labels]) +- } +- +- private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { +- import q.reflect.* +- Type.of[Elems] match { +- case '[EmptyTuple] => Nil +- case '[h *: t] => +- (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] +- } ++ Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = false).asExprOf[Validator[T]] + } + } +diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala +new file mode 100644 +index 0000000..3941d64 +--- /dev/null ++++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala +@@ -0,0 +1,343 @@ ++package net.ghoula.valar.internal ++ ++import scala.concurrent.{ExecutionContext, Future} ++import scala.deriving.Mirror ++import scala.quoted.{Expr, Quotes, Type} ++ ++import net.ghoula.valar.ValidationErrors.ValidationError ++import net.ghoula.valar.{AsyncValidator, ValidationResult, Validator} ++ ++/** Internal derivation engine for automatically generating validator instances. ++ * ++ * This object provides the core macro infrastructure for deriving both synchronous and ++ * asynchronous validators for product types (case classes). It handles the compile-time generation ++ * of validation logic, field introspection, and error annotation. ++ * ++ * @note ++ * This object is strictly for internal use by Valar's macro system and is not part of the public ++ * API. All methods, signatures, and behavior are subject to change without notice in future ++ * versions. ++ * ++ * @since 0.5.0 ++ */ ++object Derivation { ++ ++ /** Processes validation results from multiple fields into a single consolidated result. ++ * ++ * This method aggregates validation outcomes from all fields of a product type. If any field ++ * validation fails, all errors are collected and returned as an `Invalid` result. If all ++ * validations succeed, the validated values are used to reconstruct the original product type ++ * using the provided `Mirror`. ++ * ++ * @param results ++ * The validation results from each field of the product type. ++ * @param mirror ++ * The mirror instance used to reconstruct the product type from validated field values. ++ * @tparam T ++ * The product type being validated. ++ * @return ++ * A `ValidationResult[T]` containing either the reconstructed valid product or accumulated ++ * errors. ++ */ ++ private def processResults[T]( ++ results: List[ValidationResult[Any]], ++ mirror: Mirror.ProductOf[T] ++ ): ValidationResult[T] = { ++ val allErrors = results.collect { case ValidationResult.Invalid(e) => e }.flatten.toVector ++ if (allErrors.isEmpty) { ++ val validValues = results.collect { case ValidationResult.Valid(v) => v } ++ ValidationResult.Valid(mirror.fromProduct(Tuple.fromArray(validValues.toArray))) ++ } else { ++ ValidationResult.Invalid(allErrors) ++ } ++ } ++ ++ /** Enhances validation results with field-specific context information. ++ * ++ * This method annotates validation errors with the field name and type information, providing ++ * better debugging and error reporting capabilities. Valid results are passed through unchanged. ++ * ++ * @param result ++ * The validation result to annotate. ++ * @param label ++ * The field name for error context. ++ * @param fieldValue ++ * The field value used to extract type information. ++ * @return ++ * The validation result with enhanced error context if invalid, or unchanged if valid. ++ */ ++ private def annotateErrors( ++ result: ValidationResult[Any], ++ label: String, ++ fieldValue: Any ++ ): ValidationResult[Any] = { ++ result match { ++ case ValidationResult.Valid(v) => ValidationResult.Valid(v) ++ case ValidationResult.Invalid(errs) => ++ val fieldTypeName = Option(fieldValue).map(_.getClass.getSimpleName).getOrElse("null") ++ ValidationResult.Invalid(errs.map(_.annotateField(label, fieldTypeName))) ++ } ++ } ++ ++ /** Applies validation logic to each field of a product type with null-safety handling. ++ * ++ * This method iterates through the fields of a product type, applying the appropriate validation ++ * logic to each field. It handles null values appropriately based on whether the field is ++ * optional, and provides consistent error handling for both synchronous and asynchronous ++ * validation scenarios. ++ * ++ * @param product ++ * The product instance whose fields are being validated. ++ * @param validators ++ * The sequence of validators corresponding to each field. ++ * @param labels ++ * The field names for error reporting. ++ * @param isOptionFlags ++ * Flags indicating which fields are optional (Option types). ++ * @param validateAndAnnotate ++ * Function to apply validation and annotation to a field. ++ * @param handleNull ++ * Function to handle null values in non-optional fields. ++ * @tparam V ++ * The validator type (either `Validator` or `AsyncValidator`). ++ * @tparam R ++ * The result type (either `ValidationResult` or `Future[ValidationResult]`). ++ * @return ++ * A list of validation results for each field. ++ */ ++ private def validateProduct[V, R]( ++ product: Product, ++ validators: Seq[V], ++ labels: List[String], ++ isOptionFlags: List[Boolean], ++ validateAndAnnotate: (V, Any, String) => R, ++ handleNull: String => R ++ ): List[R] = { ++ product.productIterator.zipWithIndex.map { case (fieldValue, i) => ++ val label = labels(i) ++ if (Option(fieldValue).isEmpty && !isOptionFlags(i)) { ++ handleNull(label) ++ } else { ++ val validator = validators(i) ++ validateAndAnnotate(validator, fieldValue, label) ++ } ++ }.toList ++ } ++ ++ /** Extracts field names from a compile-time tuple of string literal types. ++ * ++ * This method recursively processes a tuple type containing string literals (typically from ++ * `Mirror.MirroredElemLabels`) to extract the actual field names as a runtime `List[String]`. It ++ * performs compile-time validation to ensure all labels are string literals. ++ * ++ * @param q ++ * The quotes context for macro operations. ++ * @tparam Labels ++ * The tuple type containing string literal types for field names. ++ * @return ++ * A list of field names extracted from the tuple type. ++ * @throws Compilation ++ * error if any label is not a string literal. ++ */ ++ private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { ++ import q.reflect.* ++ def loop(tpe: TypeRepr): List[String] = tpe.dealias match { ++ case AppliedType(_, List(head, tail)) => ++ head match { ++ case ConstantType(StringConstant(label)) => label :: loop(tail) ++ case _ => ++ report.errorAndAbort( ++ s"Invalid field label type: expected string literal, found ${head.show}. " + ++ "This typically indicates a structural issue with the case class definition." ++ ) ++ } ++ case t if t =:= TypeRepr.of[EmptyTuple] => Nil ++ case _ => ++ report.errorAndAbort( ++ s"Invalid label tuple structure: ${tpe.show}. " + ++ "This may indicate an incompatible case class or tuple definition." ++ ) ++ } ++ loop(TypeRepr.of[Labels]) ++ } ++ ++ /** Analyzes field types to identify which fields are optional (`Option[T]`). ++ * ++ * This method examines each field type in a product type to determine if it's an `Option` type. ++ * This information is used during validation to handle null values appropriately - null values ++ * are acceptable for optional fields but trigger validation errors for required fields. ++ * ++ * @param q ++ * The quotes context for macro operations. ++ * @tparam Elems ++ * The tuple type containing all field types. ++ * @return ++ * A list of boolean flags indicating which fields are optional. ++ */ ++ private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { ++ import q.reflect.* ++ Type.of[Elems] match { ++ case '[EmptyTuple] => Nil ++ case '[h *: t] => ++ (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] ++ } ++ } ++ ++ /** Generates validator instances for product types using compile-time reflection. ++ * ++ * This is the core derivation method that generates either synchronous or asynchronous ++ * validators based on the `isAsync` parameter. It performs compile-time introspection of the ++ * product type, extracts field information, summons appropriate validators for each field, and ++ * generates optimized validation logic. ++ * ++ * The generated validators handle: ++ * - Field-by-field validation using appropriate validator instances ++ * - Error accumulation and proper error context annotation ++ * - Null-safety for optional vs required fields ++ * - Automatic lifting of synchronous validators in async contexts ++ * - Exception handling for asynchronous operations ++ * ++ * @param m ++ * The mirror instance for the product type being validated. ++ * @param isAsync ++ * Flag indicating whether to generate an `AsyncValidator` (true) or `Validator` (false). ++ * @param q ++ * The quotes context for macro operations. ++ * @tparam T ++ * The product type for which to generate a validator. ++ * @tparam Elems ++ * The tuple type containing all field types. ++ * @tparam Labels ++ * The tuple type containing all field names as string literals. ++ * @return ++ * An expression representing the generated validator instance. ++ * @throws Compilation ++ * error if required validator instances cannot be found for any field type. ++ */ ++ def deriveValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( ++ m: Expr[Mirror.ProductOf[T]], ++ isAsync: Boolean ++ )(using q: Quotes): Expr[Any] = { ++ import q.reflect.* ++ ++ val fieldLabels: List[String] = getLabels[Labels] ++ val isOptionList: List[Boolean] = getIsOptionFlags[Elems] ++ val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) ++ val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) ++ ++ if (isAsync) { ++ def summonAsyncOrSync[E <: Tuple: Type]: List[Expr[AsyncValidator[Any]]] = ++ Type.of[E] match { ++ case '[EmptyTuple] => Nil ++ case '[h *: t] => ++ val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).getOrElse { ++ report.errorAndAbort( ++ s"Cannot derive AsyncValidator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + ++ "Please provide a given instance of either Validator[${Type.show[h]}] or AsyncValidator[${Type.show[h]}]." ++ ) ++ } ++ ++ val finalExpr = validatorExpr.asTerm.tpe.asType match { ++ case '[AsyncValidator[h]] => validatorExpr ++ case '[Validator[h]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } ++ } ++ ++ '{ MacroHelper.upcastTo[AsyncValidator[Any]](${ finalExpr }) } :: summonAsyncOrSync[t] ++ } ++ ++ val fieldValidators: List[Expr[AsyncValidator[Any]]] = summonAsyncOrSync[Elems] ++ val validatorsExpr: Expr[Seq[AsyncValidator[Any]]] = Expr.ofSeq(fieldValidators) ++ ++ '{ ++ new AsyncValidator[T] { ++ def validateAsync(a: T)(using ec: ExecutionContext): Future[ValidationResult[T]] = { ++ a match { ++ case product: Product => ++ val validators = ${ validatorsExpr } ++ val labels = ${ fieldLabelsExpr } ++ val isOptionFlags = ${ isOptionListExpr } ++ ++ val fieldResultsF = validateProduct( ++ product, ++ validators, ++ labels, ++ isOptionFlags, ++ validateAndAnnotate = (v, fv, l) => v.validateAsync(fv).map(annotateErrors(_, l, fv)), ++ handleNull = l => ++ Future.successful( ++ ValidationResult.invalid( ++ ValidationError( ++ s"Field '$l' must not be null.", ++ List(l), ++ expected = Some("non-null value"), ++ actual = Some("null") ++ ) ++ ) ++ ) ++ ) ++ ++ val allResultsF: Future[List[ValidationResult[Any]]] = ++ Future.sequence(fieldResultsF.map { f => ++ f.recover { case scala.util.control.NonFatal(ex) => ++ ValidationResult.invalid( ++ ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") ++ ) ++ } ++ }) ++ ++ allResultsF.map(processResults(_, ${ m })) ++ } ++ } ++ } ++ }.asExprOf[Any] ++ } else { ++ def summonValidators[E <: Tuple: Type]: List[Expr[Validator[Any]]] = ++ Type.of[E] match { ++ case '[EmptyTuple] => Nil ++ case '[h *: t] => ++ val validatorExpr = Expr.summon[Validator[h]].getOrElse { ++ report.errorAndAbort( ++ s"Cannot derive Validator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + ++ "Please provide a given instance of Validator[${Type.show[h]}]." ++ ) ++ } ++ '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] ++ } ++ ++ val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] ++ val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) ++ ++ '{ ++ new Validator[T] { ++ def validate(a: T): ValidationResult[T] = { ++ a match { ++ case product: Product => ++ val validators = ${ validatorsExpr } ++ val labels = ${ fieldLabelsExpr } ++ val isOptionFlags = ${ isOptionListExpr } ++ ++ val results = validateProduct( ++ product, ++ validators, ++ labels, ++ isOptionFlags, ++ validateAndAnnotate = (v, fv, l) => annotateErrors(v.validate(fv), l, fv), ++ handleNull = l => ++ ValidationResult.invalid( ++ ValidationError( ++ s"Field '$l' must not be null.", ++ List(l), ++ expected = Some("non-null value"), ++ actual = Some("null") ++ ) ++ ) ++ ) ++ ++ processResults(results, ${ m }) ++ } ++ } ++ } ++ }.asExprOf[Any] ++ } ++ } ++} +diff --git a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala +new file mode 100644 +index 0000000..c9515ac +--- /dev/null ++++ b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala +@@ -0,0 +1,304 @@ ++package net.ghoula.valar ++ ++import munit.FunSuite ++ ++import scala.concurrent.ExecutionContext.Implicits.global ++import scala.concurrent.duration.* ++import scala.concurrent.{Await, Future} ++ ++import net.ghoula.valar.ValidationErrors.ValidationError ++ ++/** Provides a comprehensive test suite for the [[AsyncValidator]] typeclass and its derivation. ++ * ++ * This spec verifies all core functionalities of the asynchronous validation mechanism: ++ * - Successful validation of valid objects. ++ * - Correct handling of failures from synchronous validators within an async context. ++ * - Correct handling of failures from native asynchronous validators. ++ * - Proper accumulation of errors from both sync and async sources. ++ * - Correct validation of nested case classes with proper error path annotation. ++ * - Robustness against null values, optional fields, collections, and exceptions within Futures. ++ */ ++class AsyncValidatorSpec extends FunSuite { ++ ++ /** A simple case class for basic validation tests. */ ++ private case class User(name: String, age: Int) ++ ++ /** A nested case class for testing recursive derivation. */ ++ private case class Company(name: String, owner: User) ++ ++ /** A case class to test null handling. */ ++ private case class Team(lead: User, name: String) ++ ++ /** A case class for testing collection validation. */ ++ private case class Post(title: String, comments: List[Comment]) ++ ++ /** A simple model for items within a collection. */ ++ private case class Comment(author: String, text: String) ++ ++ /** A case class for testing optional field validation. */ ++ private case class UserProfile(username: String, email: Option[String]) ++ ++ /** A standard synchronous validator for non-empty strings. */ ++ private given syncStringValidator: Validator[String] with { ++ def validate(value: String): ValidationResult[String] = ++ if (value.nonEmpty) ValidationResult.Valid(value) ++ else ValidationResult.invalid(ValidationError("Sync: String must not be empty")) ++ } ++ ++ /** A standard synchronous validator for non-negative integers. */ ++ private given syncIntValidator: Validator[Int] with { ++ def validate(value: Int): ValidationResult[Int] = ++ if (value >= 0) ValidationResult.Valid(value) ++ else ValidationResult.invalid(ValidationError("Sync: Age must be non-negative")) ++ } ++ ++ /** A native asynchronous validator that simulates a database check for usernames. ++ * ++ * This validator checks if a username is reserved (e.g., "admin", "root") by simulating an ++ * asynchronous database lookup. If the username is not reserved, it delegates to the synchronous ++ * string validator for basic validation. ++ */ ++ private given asyncUsernameValidator: AsyncValidator[String] with { ++ def validateAsync(name: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = ++ Future { ++ if (name.toLowerCase == "admin" || name.toLowerCase == "root") { ++ ValidationResult.invalid(ValidationError(s"Async: Username '$name' is reserved.")) ++ } else { ++ syncStringValidator.validate(name) ++ } ++ } ++ } ++ ++ /** A native asynchronous validator that simulates a profanity filter. ++ * ++ * This validator checks if a text contains profanity by simulating an asynchronous profanity ++ * checking service. If no profanity is detected, it delegates to the synchronous string ++ * validator for basic validation. ++ */ ++ private given asyncCommentTextValidator: AsyncValidator[String] with { ++ def validateAsync(text: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = ++ Future { ++ if (text.toLowerCase.contains("heck")) { ++ ValidationResult.invalid(ValidationError("Async: Comment contains profanity.")) ++ } else { ++ syncStringValidator.validate(text) ++ } ++ } ++ } ++ ++ /** A native asynchronous validator for email formats. ++ * ++ * This validator performs basic email format validation by checking for the presence of an '@' ++ * symbol. In a real application, this would typically involve more sophisticated email ++ * validation logic or external service calls. ++ */ ++ private given asyncEmailValidator: AsyncValidator[String] with { ++ def validateAsync(email: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = ++ Future { ++ if (email.contains("@")) ValidationResult.Valid(email) ++ else ValidationResult.invalid(ValidationError("Async: Email format is invalid.")) ++ } ++ } ++ ++ /** User validator using custom validators for both name and age fields. ++ * ++ * This validator demonstrates how to set up specific validators for different field types within ++ * a case class. The username field uses the asynchronous username validator, while the age field ++ * uses a synchronous validator lifted to async. ++ */ ++ private given userAsyncValidator: AsyncValidator[User] = { ++ given AsyncValidator[String] = asyncUsernameValidator ++ given AsyncValidator[Int] = AsyncValidator.fromSync(syncIntValidator) ++ AsyncValidator.derive ++ } ++ ++ /** Company validator that reuses the user validation logic. ++ * ++ * This validator demonstrates automatic derivation where the existing user validator is used for ++ * the nested User field, and the string validator is used for the company name. ++ */ ++ private given companyAsyncValidator: AsyncValidator[Company] = AsyncValidator.derive ++ ++ /** Team validator that reuses the user validation logic. ++ * ++ * This validator demonstrates automatic derivation where the existing user validator is used for ++ * the nested User field, and the string validator is used for the team name. ++ */ ++ private given teamAsyncValidator: AsyncValidator[Team] = AsyncValidator.derive ++ ++ /** A derived validator for Comment that uses the async profanity filter for the text field. ++ * ++ * This validator demonstrates how to use a specific validator for text content that requires ++ * asynchronous profanity checking while using the standard validator for the author field. ++ */ ++ private given commentAsyncValidator: AsyncValidator[Comment] = { ++ given AsyncValidator[String] = asyncCommentTextValidator ++ AsyncValidator.derive ++ } ++ ++ /** A derived validator for Post that uses the async Comment validator for the comments-field. ++ * ++ * This validator demonstrates validation of collections where each item in the collection ++ * requires asynchronous validation. The title field uses a synchronous validator, while the ++ * comments-field uses the async comment validator. ++ */ ++ private given postAsyncValidator: AsyncValidator[Post] = { ++ given AsyncValidator[String] = AsyncValidator.fromSync(syncStringValidator) ++ AsyncValidator.derive ++ } ++ ++ /** A custom validator for UserProfile that handles different validation logic for username and ++ * email fields. ++ * ++ * This validator demonstrates how to create custom validation logic when the automatic ++ * derivation cannot distinguish between different String fields that require different ++ * validation rules. The username field uses the username validator, while the optional email ++ * field uses the email validator. ++ */ ++ private given userProfileAsyncValidator: AsyncValidator[UserProfile] = new AsyncValidator[UserProfile] { ++ def validateAsync( ++ profile: UserProfile ++ )(using ec: concurrent.ExecutionContext): Future[ValidationResult[UserProfile]] = { ++ val usernameValidation = asyncUsernameValidator.validateAsync(profile.username) ++ val emailValidation = profile.email match { ++ case Some(email) => asyncEmailValidator.validateAsync(email).map(_.map(Some(_))) ++ case None => Future.successful(ValidationResult.Valid(None)) ++ } ++ ++ for { ++ nameResult <- usernameValidation ++ emailResult <- emailValidation ++ } yield { ++ nameResult.zip(emailResult).map { case (name, email) => ++ UserProfile(name, email) ++ } ++ } ++ } ++ } ++ ++ test("validateAsync should succeed for a valid object") { ++ val validUser = User("John", 30) ++ val futureResult = userAsyncValidator.validateAsync(validUser) ++ futureResult.map(result => assertEquals(result, ValidationResult.Valid(validUser))) ++ } ++ ++ test("validateAsync should handle synchronous validation failures") { ++ val invalidUser = User("John", -5) ++ val futureResult = userAsyncValidator.validateAsync(invalidUser) ++ futureResult.map { ++ case ValidationResult.Invalid(errors) => ++ assertEquals(errors.size, 1) ++ assert(errors.head.message.contains("Sync: Age must be non-negative")) ++ case _ => fail("Expected Invalid result") ++ } ++ } ++ ++ test("validateAsync should handle asynchronous validation failures") { ++ val invalidUser = User("admin", 30) ++ val futureResult = userAsyncValidator.validateAsync(invalidUser) ++ futureResult.map { ++ case ValidationResult.Invalid(errors) => ++ assertEquals(errors.size, 1) ++ assert(errors.head.message.contains("Async: Username 'admin' is reserved.")) ++ case _ => fail("Expected Invalid result") ++ } ++ } ++ ++ test("validateAsync should accumulate errors from both sync and async validators") { ++ val invalidUser = User("root", -10) ++ val futureResult = userAsyncValidator.validateAsync(invalidUser) ++ futureResult.map { ++ case ValidationResult.Invalid(errors) => ++ assertEquals(errors.size, 2) ++ assert(errors.exists(_.message.contains("Async: Username 'root' is reserved."))) ++ assert(errors.exists(_.message.contains("Sync: Age must be non-negative"))) ++ case _ => fail("Expected Invalid result") ++ } ++ } ++ ++ test("validateAsync should handle nested case classes and annotate error paths correctly") { ++ val invalidCompany = Company("BadCorp", User("", -1)) ++ val futureResult = companyAsyncValidator.validateAsync(invalidCompany) ++ futureResult.map { ++ case ValidationResult.Invalid(errors) => ++ assertEquals(errors.size, 2) ++ val nameError = errors.find(_.fieldPath.contains("name")).get ++ val ageError = errors.find(_.fieldPath.contains("age")).get ++ assertEquals(nameError.fieldPath, List("owner", "name")) ++ assertEquals(ageError.fieldPath, List("owner", "age")) ++ case _ => fail("Expected Invalid result") ++ } ++ } ++ ++ test("validateAsync should fail if a non-optional field is null") { ++ @SuppressWarnings(Array("scalafix:DisableSyntax.null")) ++ val invalidTeam = Team(null, "The A-Team") ++ val result = Await.result(teamAsyncValidator.validateAsync(invalidTeam), 1.second) ++ result match { ++ case ValidationResult.Invalid(errors) => ++ assertEquals(errors.size, 1) ++ assert(errors.head.message.contains("Field 'lead' must not be null.")) ++ case _ => fail("Expected Invalid result for null field") ++ } ++ } ++ ++ test("validateAsync should recover from a failed Future in a validator") { ++ val failingValidator: AsyncValidator[String] = new AsyncValidator[String] { ++ def validateAsync(a: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = ++ Future.failed(new RuntimeException("DB error")) ++ } ++ case class Service(endpoint: String) ++ given serviceValidator: AsyncValidator[Service] = { ++ given AsyncValidator[String] = failingValidator ++ AsyncValidator.derive ++ } ++ val service = Service("https://example.com") ++ val futureResult = serviceValidator.validateAsync(service) ++ futureResult.map { ++ case ValidationResult.Invalid(errors) => ++ assertEquals(errors.size, 1) ++ assert(errors.head.message.contains("Asynchronous validation failed unexpectedly")) ++ case _ => fail("Expected Invalid result from a failed future") ++ } ++ } ++ ++ test("validateAsync should handle collections with async validators") { ++ val post = Post( ++ "My Thoughts", ++ List(Comment("Alice", "Great post!"), Comment("Bob", "What the heck?"), Comment("Charlie", "")) ++ ) ++ val futureResult = postAsyncValidator.validateAsync(post) ++ futureResult.map { ++ case ValidationResult.Invalid(errors) => ++ assertEquals(errors.size, 2) ++ assert(errors.exists(e => e.message.contains("profanity") && e.fieldPath == List("comments", "text"))) ++ assert(errors.exists(e => e.message.contains("empty") && e.fieldPath.contains("comments"))) ++ case _ => fail("Expected Invalid result for collection validation") ++ } ++ } ++ ++ test("validateAsync should handle optional fields with async validators") { ++ val invalidProfile = UserProfile("testuser", Some("not-an-email")) ++ val validProfileNoEmail = UserProfile("testuser", None) ++ ++ val invalidResultF = userProfileAsyncValidator.validateAsync(invalidProfile) ++ val validResultF = userProfileAsyncValidator.validateAsync(validProfileNoEmail) ++ ++ for { ++ invalidResult <- invalidResultF ++ validResult <- validResultF ++ } yield { ++ invalidResult match { ++ case ValidationResult.Invalid(errors) => ++ assertEquals(errors.size, 1) ++ assert(errors.head.message.contains("Email format is invalid")) ++ case _ => fail("Expected Invalid result for bad email") ++ } ++ ++ validResult match { ++ case ValidationResult.Valid(_) => () ++ case _ => fail("Expected Valid result for None email") ++ } ++ } ++ } ++} +diff --git a/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala +index e897041..93563e5 100644 +--- a/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala ++++ b/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala +@@ -25,11 +25,11 @@ class TupleValidatorSpec extends FunSuite { + + /** Tuple validator for regular tuples. */ + private given tupleValidator[A, B](using va: Validator[A], vb: Validator[B]): Validator[(A, B)] = +- Validator.deriveValidatorMacro ++ Validator.derive + + /** Named tuple validator using automatic derivation. */ + private given namedTupleValidator: Validator[(name: String, age: Int)] = +- Validator.deriveValidatorMacro ++ Validator.derive + + test("Regular tuples should be validated with default validators") { + val validTuple = ("hello", 42) +diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala +new file mode 100644 +index 0000000..2cdf1e1 +--- /dev/null ++++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala +@@ -0,0 +1,61 @@ ++package net.ghoula.valar ++ ++import munit.FunSuite ++ ++import scala.collection.mutable.ListBuffer ++ ++import net.ghoula.valar.ValidationErrors.ValidationError ++ ++/** Verifies the behavior of the `ValidationObserver` typeclass and its `observe` extension method. ++ * ++ * This spec ensures that: ++ * - The default `noOpObserver` is a transparent, zero-cost operation when no custom observer is ++ * in scope. ++ * - A custom `given` `ValidationObserver` is correctly invoked for both `Valid` and `Invalid` ++ * results. ++ * - The `observe` method faithfully returns the original `ValidationResult` to preserve method ++ * chaining. ++ */ ++class ValidationObserverSpec extends FunSuite { ++ ++ /** A mock observer that records any results passed to its `onResult` method. */ ++ private class TestObserver extends ValidationObserver { ++ val observedResults: ListBuffer[ValidationResult[?]] = ListBuffer() ++ override def onResult[A](result: ValidationResult[A]): Unit = { ++ observedResults += result ++ } ++ } ++ ++ test("observe should be transparent when using the default no-op observer") { ++ val validResult = ValidationResult.Valid(42) ++ assertEquals(validResult.observe(), validResult) ++ ++ val invalidResult = ValidationResult.invalid(ValidationError("An error")) ++ assertEquals(invalidResult.observe(), invalidResult) ++ } ++ ++ test("observe should invoke a custom observer for a Valid result") { ++ val testObserver = new TestObserver ++ given customObserver: ValidationObserver = testObserver ++ ++ val validResult = ValidationResult.Valid("success") ++ val returnedResult = validResult.observe() ++ ++ assertEquals(testObserver.observedResults.size, 1) ++ assertEquals(testObserver.observedResults.head, validResult) ++ assertEquals(returnedResult, validResult) ++ } ++ ++ test("observe should invoke a custom observer for an Invalid result") { ++ val testObserver = new TestObserver ++ given customObserver: ValidationObserver = testObserver ++ ++ val error = ValidationError("A critical failure") ++ val invalidResult = ValidationResult.invalid(error) ++ val returnedResult = invalidResult.observe() ++ ++ assertEquals(testObserver.observedResults.size, 1) ++ assertEquals(testObserver.observedResults.head, invalidResult) ++ assertEquals(returnedResult, invalidResult) ++ } ++} +diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala +index c9e1d96..392fe6b 100644 +--- a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala ++++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala +@@ -7,7 +7,7 @@ import scala.collection.immutable.ArraySeq + import net.ghoula.valar.ErrorAccumulator + import net.ghoula.valar.ValidationErrors.{ValidationError, ValidationException} + import net.ghoula.valar.ValidationHelpers.* +-import net.ghoula.valar.Validator.deriveValidatorMacro ++import net.ghoula.valar.Validator.derive + + /** Comprehensive test suite for Valar's validation system. + * +@@ -50,16 +50,16 @@ class ValidationSpec extends FunSuite { + /** Test case classes for macro derivation testing. */ + + private case class User(name: String, age: Option[Int]) +- private given Validator[User] = deriveValidatorMacro ++ private given Validator[User] = derive + + private case class Address(street: String, city: String, zip: Int) +- private given Validator[Address] = deriveValidatorMacro ++ private given Validator[Address] = derive + + private case class Company(name: String, address: Address, ceo: Option[User]) +- private given Validator[Company] = deriveValidatorMacro ++ private given Validator[Company] = derive + + private case class NullFieldTest(name: String, age: Int) +- private given Validator[NullFieldTest] = deriveValidatorMacro ++ private given Validator[NullFieldTest] = derive + + /** Tests for collection type validators. */ + +diff --git a/valar-munit/README.md b/valar-munit/README.md +new file mode 100644 +index 0000000..67756cb +--- /dev/null ++++ b/valar-munit/README.md +@@ -0,0 +1,132 @@ ++# valar-munit ++ ++[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) ++[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) ++[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) ++ ++The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It ++introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. ++ ++## Installation ++ ++Add the valar-munit dependency to your build.sbt: ++ ++```scala ++libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ++``` ++ ++## Usage ++ ++Extend the ValarSuite trait in your test classes to get access to the assertion helpers. ++ ++```scala ++import net.ghoula.valar.munit.ValarSuite ++ ++class MyValidatorSpec extends ValarSuite { ++ test("valid data passes validation") { ++ val result = MyValidator.validate(validData) ++ val value = assertValid(result) ++ ++ // You can make additional assertions on the validated value ++ assertEquals(value.name, "Expected Name") ++ } ++} ++``` ++ ++## Assertion Helpers ++ ++The ValarSuite trait provides several assertion helpers for different validation testing scenarios. ++ ++### 1. assertValid ++ ++Asserts that a ValidationResult is Valid and returns the validated value for further assertions. ++ ++```scala ++test("valid data passes validation") { ++ val result = MyValidator.validate(validData) ++ val value = assertValid(result) ++ ++ // Additional assertions on the validated value ++ assertEquals(value.id, 123) ++} ++``` ++ ++### 2. assertHasOneError ++ ++Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual ++validation rules. ++ ++```scala ++test("empty name is rejected") { ++ val result = User.validate(User("", 25)) ++ ++ assertHasOneError(result) { error => ++ assertEquals(error.fieldPath, List("name")) ++ assert(error.message.contains("empty")) ++ } ++} ++``` ++ ++### 3. assertHasNErrors ++ ++Asserts that a ValidationResult is Invalid and contains exactly N errors. ++ ++```scala ++test("multiple specific errors are reported") { ++ val result = User.validate(User("", -5)) ++ ++ assertHasNErrors(result, 2) { errors => ++ // Assert on the collection of exactly 2 errors ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++### 4. assertInvalid ++ ++Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, ++accumulated errors are expected. ++ ++```scala ++test("multiple validation errors are accumulated") { ++ val result = User.validate(User("", -5)) ++ ++ assertInvalid(result) { ++ case errors if errors.size == 2 => ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++### 5. assertInvalidWith ++ ++Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular ++function. This is a simpler alternative to assertInvalid. ++ ++```scala ++test("validation fails with expected errors") { ++ val result = User.validate(User("", -5)) ++ ++ assertInvalidWith(result) { errors => ++ assertEquals(errors.size, 2) ++ assert(errors.exists(_.fieldPath.contains("name"))) ++ assert(errors.exists(_.fieldPath.contains("age"))) ++ } ++} ++``` ++ ++## Benefits ++ ++- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. ++ ++- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. ++ ++- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. ++ ++- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated ++ value. ++ ++- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match ++ your testing preferences. +diff --git a/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala b/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala +index cb7f8de..fab210b 100644 +--- a/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala ++++ b/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala +@@ -5,53 +5,71 @@ import munit.{FunSuite, Location} + import net.ghoula.valar.ValidationErrors.ValidationError + import net.ghoula.valar.ValidationResult + +-/** A base suite for MUnit tests that provides validation-specific assertion helpers. +- * +- * This suite provides a complete toolbox for testing Valar's validation logic: +- * - `assertValid` for success cases. +- * - `assertHasOneError` for testing single validation rules. +- * - `assertInvalid` for testing complex error accumulation. ++/** A base trait for test suites that use Valar, providing convenient assertion helpers for working ++ * with ValidationResult. + */ + trait ValarSuite extends FunSuite { + +- /** Asserts that a `ValidationResult` is `Valid`. ++ /** Asserts that a ValidationResult is Valid and returns the validated value for further ++ * assertions. ++ * ++ * @param result ++ * The ValidationResult to inspect. ++ * @param clue ++ * A clue to provide if the assertion fails. + * @return +- * The validated value `A` on success, allowing for chained assertions. ++ * The validated value if the result is Valid. + */ +- def assertValid[A](result: ValidationResult[A], clue: => Any = "Expected Valid, but got Invalid")(using +- loc: Location +- ): A = { ++ def assertValid[A](result: ValidationResult[A], clue: Any = "Expected Valid result")(using loc: Location): A = { + result match { + case ValidationResult.Valid(value) => value + case ValidationResult.Invalid(errors) => + val errorReport = errors.map(e => s" - ${e.prettyPrint(2)}").mkString("\n") +- fail(s"$clue. Errors:\n$errorReport") ++ fail(s"$clue, but got Invalid with errors:\n$errorReport") + } + } + +- /** Asserts that a `ValidationResult` is `Invalid` and contains exactly one error. This is the +- * ideal helper for testing individual validation rules. ++ /** Asserts that a ValidationResult is Invalid and contains exactly one error, then allows further ++ * assertions on that single error. + * + * @param result +- * The `ValidationResult` to check. +- * @param pf +- * A partial function to run assertions on the single `ValidationError`. +- * @return +- * The single `ValidationError` on success. ++ * The ValidationResult to inspect. ++ * @param clue ++ * A clue to provide if the assertion fails. ++ * @param body ++ * A function that takes the single ValidationError and performs further checks. + */ +- def assertHasOneError( +- result: ValidationResult[?] +- )(pf: PartialFunction[ValidationError, Unit])(using loc: Location): ValidationError = { +- val errors = assertInvalid(result) { +- case allErrors if allErrors.size == 1 => +- case allErrors => fail(s"Expected a single validation error, but found ${allErrors.size}.") +- } +- val singleError = errors.head +- if (!pf.isDefinedAt(singleError)) { +- fail(s"Partial function was not defined for the validation error:\n - ${singleError.prettyPrint(2)}") ++ def assertHasOneError[A](result: ValidationResult[A], clue: Any = "Expected exactly one validation error")( ++ body: ValidationError => Unit ++ )(using loc: Location): Unit = { ++ assertHasNErrors(result, 1, clue) { errors => body(errors.head) } ++ } ++ ++ /** Asserts that a ValidationResult is Invalid and contains a specific number of errors, then ++ * allows further assertions on the collection of errors. ++ * ++ * @param result ++ * The ValidationResult to inspect. ++ * @param expectedSize ++ * The expected number of errors. ++ * @param clue ++ * A clue to provide if the assertion fails. ++ * @param body ++ * A function that takes the Vector of ValidationErrors and performs further checks. ++ */ ++ def assertHasNErrors[A](result: ValidationResult[A], expectedSize: Int, clue: Any = "Mismatched number of errors")( ++ body: Vector[ValidationError] => Unit ++ )(using loc: Location): Unit = { ++ result match { ++ case ValidationResult.Valid(value) => ++ fail(s"Expected $expectedSize validation errors, but the result was Valid($value).") ++ case ValidationResult.Invalid(errors) => ++ if (errors.size == expectedSize) { ++ body(errors) ++ } else { ++ fail(s"$clue. Expected $expectedSize errors, but found ${errors.size}.") ++ } + } +- pf(singleError) +- singleError + } + + /** Asserts that a `ValidationResult` is `Invalid`. Use this for complex cases where multiple, +@@ -64,8 +82,8 @@ trait ValarSuite extends FunSuite { + * @return + * The `Vector[ValidationError]` on success. + */ +- def assertInvalid( +- result: ValidationResult[?] ++ def assertInvalid[A]( ++ result: ValidationResult[A] + )(pf: PartialFunction[Vector[ValidationError], Unit])(using loc: Location): Vector[ValidationError] = { + result match { + case ValidationResult.Valid(value) => +@@ -79,4 +97,31 @@ trait ValarSuite extends FunSuite { + errors + } + } ++ ++ /** Asserts that a ValidationResult is Invalid and allows flexible assertions on the error ++ * collection. This is a simpler alternative to `assertInvalid` that works with regular ++ * functions. ++ * ++ * @param result ++ * The ValidationResult to inspect. ++ * @param clue ++ * A clue to provide if the assertion fails. ++ * @param body ++ * A function that takes the Vector of ValidationErrors and performs further checks. ++ * @return ++ * The Vector of ValidationErrors on success. ++ */ ++ def assertInvalidWith[A]( ++ result: ValidationResult[A], ++ clue: Any = "Expected Invalid result" ++ )(body: Vector[ValidationError] => Unit)(using loc: Location): Vector[ValidationError] = { ++ result match { ++ case ValidationResult.Valid(value) => ++ fail(s"$clue, but got Valid($value)") ++ case ValidationResult.Invalid(errors) => ++ body(errors) ++ errors ++ } ++ } ++ + } +diff --git a/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala b/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala +new file mode 100644 +index 0000000..caea088 +--- /dev/null ++++ b/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala +@@ -0,0 +1,134 @@ ++package net.ghoula.valar.munit ++ ++import net.ghoula.valar.ValidationErrors.ValidationError ++import net.ghoula.valar.ValidationResult ++ ++/** Tests the `ValarSuite` trait to ensure its assertion helpers are correct and reliable. ++ */ ++class ValarSuiteSpec extends ValarSuite { ++ ++ private val validResult = ValidationResult.Valid("success") ++ private val singleErrorResult = ValidationResult.invalid(ValidationError("single error")) ++ private val multipleErrorsResult = ValidationResult.invalid( ++ Vector( ++ ValidationError("first error", fieldPath = List("field1")), ++ ValidationError("second error", fieldPath = List("field2")) ++ ) ++ ) ++ ++ test("assertValid should return value when result is Valid") { ++ val value = assertValid(validResult) ++ assertEquals(value, "success") ++ } ++ ++ test("assertValid should fail when result is Invalid") { ++ intercept[munit.FailException] { ++ assertValid(singleErrorResult) ++ } ++ } ++ ++ test("assertHasOneError should succeed when result has exactly one error") { ++ assertHasOneError(singleErrorResult) { error => ++ assertEquals(error.message, "single error") ++ } ++ } ++ ++ test("assertHasOneError should fail when result is Valid") { ++ intercept[munit.FailException] { ++ assertHasOneError(validResult)(_ => ()) ++ } ++ } ++ ++ test("assertHasOneError should fail when result has multiple errors") { ++ intercept[munit.FailException] { ++ assertHasOneError(multipleErrorsResult)(_ => ()) ++ } ++ } ++ ++ test("assertHasNErrors should succeed when result has exactly N errors") { ++ assertHasNErrors(multipleErrorsResult, 2) { errors => ++ assertEquals(errors.size, 2) ++ assertEquals(errors.head.message, "first error") ++ assertEquals(errors.last.message, "second error") ++ } ++ } ++ ++ test("assertHasNErrors should fail when result is Valid") { ++ intercept[munit.FailException] { ++ assertHasNErrors(validResult, 1)(_ => ()) ++ } ++ } ++ ++ test("assertHasNErrors should fail when error count doesn't match") { ++ intercept[munit.FailException] { ++ assertHasNErrors(singleErrorResult, 2)(_ => ()) ++ } ++ } ++ ++ test("assertInvalid should succeed when result is Invalid and partial function matches") { ++ val errors = assertInvalid(multipleErrorsResult) { ++ case vector if vector.size == 2 => ++ assert(vector.exists(_.fieldPath == List("field1"))) ++ assert(vector.exists(_.fieldPath == List("field2"))) ++ } ++ assertEquals(errors.size, 2) ++ } ++ ++ test("assertInvalid should fail when result is Valid") { ++ intercept[munit.FailException] { ++ assertInvalid(validResult) { case _ => () } ++ } ++ } ++ ++ test("assertInvalid should fail when partial function doesn't match") { ++ intercept[munit.FailException] { ++ assertInvalid(singleErrorResult) { ++ case errors if errors.size == 2 => ++ () ++ } ++ } ++ } ++ ++ test("assertInvalidWith should succeed when result is Invalid") { ++ val errors = assertInvalidWith(singleErrorResult) { errors => ++ assertEquals(errors.size, 1) ++ assertEquals(errors.head.message, "single error") ++ } ++ assertEquals(errors.size, 1) ++ } ++ ++ test("assertInvalidWith should fail when result is Valid") { ++ intercept[munit.FailException] { ++ assertInvalidWith(validResult)(_ => ()) ++ } ++ } ++ ++ test("assertion failures should provide meaningful error messages") { ++ val exception = intercept[munit.FailException] { ++ assertValid(singleErrorResult, "Should be valid") ++ } ++ assert(exception.getMessage.contains("Should be valid")) ++ assert(exception.getMessage.contains("single error")) ++ } ++ ++ test("assertions should work with all ValidationError features") { ++ val complexError = ValidationError( ++ message = "Complex validation error", ++ fieldPath = List("user", "profile", "email"), ++ code = Some("EMAIL_INVALID"), ++ severity = Some("ERROR"), ++ expected = Some("valid email format"), ++ actual = Some("invalid@") ++ ) ++ val complexResult = ValidationResult.invalid(complexError) ++ ++ assertHasOneError(complexResult) { error => ++ assertEquals(error.message, "Complex validation error") ++ assertEquals(error.fieldPath, List("user", "profile", "email")) ++ assertEquals(error.code, Some("EMAIL_INVALID")) ++ assertEquals(error.severity, Some("ERROR")) ++ assertEquals(error.expected, Some("valid email format")) ++ assertEquals(error.actual, Some("invalid@")) ++ } ++ } ++} +diff --git a/valar-translator/README.md b/valar-translator/README.md +new file mode 100644 +index 0000000..f1401bb +--- /dev/null ++++ b/valar-translator/README.md +@@ -0,0 +1,98 @@ ++# valar-translator ++ ++[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) ++[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) ++[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) ++ ++The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. ++ ++## Installation ++ ++Add the valar-translator dependency to your build.sbt: ++ ++```scala ++libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" ++``` ++ ++## Usage ++ ++The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. ++ ++### 1. Implement the `Translator` Trait ++ ++Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. ++ ++```scala ++import net.ghoula.valar.translator.Translator ++import net.ghoula.valar.ValidationErrors.ValidationError ++ ++// --- Example Setup --- ++// In a real application, this would come from a properties file or other i18n system. ++val translations: Map[String, String] = Map( ++ "error.string.nonEmpty" -> "The field must not be empty.", ++ "error.int.nonNegative" -> "The value cannot be negative.", ++ "error.unknown" -> "An unexpected validation error occurred." ++) ++ ++// --- Implementation of the Translator trait --- ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Logic to look up the error's key in your translation map. ++ // The `.getOrElse` provides a safe fallback. ++ translations.getOrElse( ++ error.key.getOrElse("error.unknown"), ++ error.message // Fall back to the original message if the key is not found ++ ) ++ } ++} ++``` ++ ++### 2. Call `translateErrors()` ++ ++Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. ++ ++```scala ++val result = User.validate(someData) // An Invalid ValidationResult ++val translatedResult = result.translateErrors() ++ ++// translatedResult now contains errors with localized messages ++``` ++ ++## Integration with the ValidationObserver Extensibility Pattern ++ ++The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. ++ ++This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: ++ ++* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result ++* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization ++ ++While these serve different purposes, they're designed to work together in a clean, composable way: ++ ++A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. ++ ++```scala ++// Given a defined extension using the ValidationObserver pattern ++given metricsObserver: ValidationObserver with { ++ def onResult[A](result: ValidationResult[A]): Unit = { ++ // Record validation metrics to your monitoring system ++ } ++} ++ ++// And a translator implementation for localization ++given myTranslator: Translator with { ++ def translate(error: ValidationError): String = { ++ // Translate errors using your i18n system ++ } ++} ++ ++// Both extensions work together through the same pattern ++val result = User.validate(invalidUser) ++ // First, observe the raw result using the core ValidationObserver pattern ++ .observe() ++ // Then, translate the errors for presentation (also built on the same pattern) ++ .translateErrors() ++ ++// This demonstrates how all Valar extensions follow the same architectural pattern, ++// allowing them to compose together seamlessly ++``` +diff --git a/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala b/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala +new file mode 100644 +index 0000000..d470b19 +--- /dev/null ++++ b/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala +@@ -0,0 +1,48 @@ ++package net.ghoula.valar.translator ++ ++import net.ghoula.valar.ValidationErrors.ValidationError ++import net.ghoula.valar.ValidationResult ++ ++/** A typeclass that defines how to translate a ValidationError into a human-readable string. ++ * Implement this to integrate with i18n libraries. ++ */ ++trait Translator { ++ ++ /** Translates a single validation error. ++ * @param error ++ * The structured ValidationError containing the key, args, and default message. ++ * @return ++ * A localized string message. ++ */ ++ def translate(error: ValidationError): String ++} ++ ++extension [A](vr: ValidationResult[A]) { ++ ++ /** Translates all errors within an Invalid result using the in-scope Translator. If the result is ++ * Valid, it is returned unchanged. ++ * ++ * @param translator ++ * The given Translator instance. ++ * @return ++ * A new ValidationResult with translated error messages. ++ */ ++ def translateErrors()(using translator: Translator): ValidationResult[A] = { ++ vr match { ++ case ValidationResult.Valid(a) => ValidationResult.Valid(a) ++ case ValidationResult.Invalid(errors) => ++ val translatedErrors = errors.map { err => ++ ValidationError( ++ message = translator.translate(err), ++ fieldPath = err.fieldPath, ++ children = err.children, ++ code = err.code, ++ severity = err.severity, ++ expected = err.expected, ++ actual = err.actual ++ ) ++ } ++ ValidationResult.Invalid(translatedErrors) ++ } ++ } ++} +diff --git a/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala b/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala +new file mode 100644 +index 0000000..acc3369 +--- /dev/null ++++ b/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala +@@ -0,0 +1,85 @@ ++package net.ghoula.valar.translator ++ ++import net.ghoula.valar.ValidationErrors.ValidationError ++import net.ghoula.valar.ValidationResult ++import net.ghoula.valar.munit.ValarSuite ++ ++/** Provides a comprehensive test suite for the [[Translator]] typeclass and its associated ++ * `translateErrors` extension method. ++ * ++ * This specification validates the core functionalities of the translation mechanism. It ensures ++ * that `Valid` instances are returned without modification and that `Invalid` instances have their ++ * error messages properly translated by the in-scope `Translator`. ++ * ++ * The suite also confirms the integrity of `ValidationError` objects post-translation, verifying ++ * that all properties, such as `fieldPath`, `code`, and `severity`, are preserved. Finally, it ++ * guarantees that the translation is not applied recursively to nested child errors, maintaining ++ * the original state of the error hierarchy. ++ */ ++class TranslatorSpec extends ValarSuite { ++ ++ test("translateErrors on a Valid result should return the instance unchanged") { ++ given Translator = error => fail(s"Translator should not be invoked, but was called for: ${error.message}") ++ ++ val validResult = ValidationResult.Valid("all good") ++ val result = validResult.translateErrors() ++ ++ assertEquals(result, validResult) ++ } ++ ++ test("translateErrors on an Invalid result should translate messages and preserve all other properties") { ++ given Translator = error => s"translated: ${error.message}" ++ ++ val originalError = ValidationError( ++ message = "A test error", ++ fieldPath = List("user", "email"), ++ children = Vector(ValidationError("A nested error")), ++ code = Some("E-101"), ++ severity = Some("Warning"), ++ expected = Some("a valid email"), ++ actual = Some("not-an-email") ++ ) ++ val invalidResult = ValidationResult.invalid(originalError) ++ ++ val translatedResult = invalidResult.translateErrors() ++ ++ assertHasOneError(translatedResult) { translatedError => ++ assertEquals(translatedError.message, "translated: A test error") ++ assertEquals(translatedError.fieldPath, originalError.fieldPath) ++ assertEquals(translatedError.children, originalError.children) ++ assertEquals(translatedError.code, originalError.code) ++ assertEquals(translatedError.severity, originalError.severity) ++ assertEquals(translatedError.expected, originalError.expected) ++ assertEquals(translatedError.actual, originalError.actual) ++ } ++ } ++ ++ test("translateErrors should correctly translate multiple errors in an Invalid result") { ++ given Translator = error => s"translated: ${error.message}" ++ ++ val error1 = ValidationError("First error") ++ val error2 = ValidationError("Second error") ++ val invalidResult = ValidationResult.Invalid(Vector(error1, error2)) ++ ++ val translatedResult = invalidResult.translateErrors() ++ ++ assertHasNErrors(translatedResult, 2)(translatedErrors => ++ assertEquals(translatedErrors.map(_.message), Vector("translated: First error", "translated: Second error")) ++ ) ++ } ++ ++ test("translateErrors should not apply translation recursively to nested child errors") { ++ given Translator = error => s"translated: ${error.message}" ++ ++ val childError = ValidationError("This is a child error") ++ val parentError = ValidationError("This is a parent error", children = Vector(childError)) ++ val invalidResult = ValidationResult.invalid(parentError) ++ val translatedResult = invalidResult.translateErrors() ++ ++ assertHasOneError(translatedResult) { translatedParent => ++ assertEquals(translatedParent.message, "translated: This is a parent error") ++ assertEquals(translatedParent.children.headOption, Some(childError)) ++ assertEquals(translatedParent.children.head.message, "This is a child error") ++ } ++ } ++} diff --git a/docs-src/README.md b/docs-src/README.md index 2ee98a1..5cd73d1 100644 --- a/docs-src/README.md +++ b/docs-src/README.md @@ -279,9 +279,9 @@ given loggingObserver: ValidationObserver with { } // Use the observer in your validation flow -val result = User.validate(user) +val result = Validator[User].validate(user) .observe() // The observer's onResult is called here - .map(_.toUpperCase) + .map(validatedUser => validatedUser.copy(name = validatedUser.name.trim)) ``` ### Building Custom Extensions @@ -297,7 +297,7 @@ trait MyCustomExtension extends ValidationObserver { } // Usage remains clean and composable -val result = User.validate(user) +val result = Validator[User].validate(user) .observe() // Uses your custom extension .map(processUser) ``` @@ -328,17 +328,17 @@ val translations: Map[String, String] = Map( // --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key in your translation map. - // The `.getOrElse` provides a safe fallback. + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") translations.getOrElse( - error.key.getOrElse("error.unknown"), - error.message // Fall back to the original message if the key is not found + translationKey, + error.message // Fall back to the original message if no translation is found ) } } // Use the translator in your validation flow -val result = User.validate(user) +val result = Validator[User].validate(user) .observe() // Optional: observe the raw result first .translateErrors() // Translate errors for user presentation ``` @@ -399,4 +399,4 @@ libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ## **License** Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) -file for details. \ No newline at end of file +file for details. diff --git a/docs-src/translator/README.md b/docs-src/translator/README.md index f1401bb..61f9ffb 100644 --- a/docs-src/translator/README.md +++ b/docs-src/translator/README.md @@ -37,11 +37,11 @@ val translations: Map[String, String] = Map( // --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key in your translation map. - // The `.getOrElse` provides a safe fallback. + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") translations.getOrElse( - error.key.getOrElse("error.unknown"), - error.message // Fall back to the original message if the key is not found + translationKey, + error.message // Fall back to the original message if no translation is found ) } } @@ -52,7 +52,7 @@ given myTranslator: Translator with { Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. ```scala -val result = User.validate(someData) // An Invalid ValidationResult +val result = Validator[User].validate(someData) // An Invalid ValidationResult val translatedResult = result.translateErrors() // translatedResult now contains errors with localized messages @@ -87,7 +87,7 @@ given myTranslator: Translator with { } // Both extensions work together through the same pattern -val result = User.validate(invalidUser) +val result = Validator[User].validate(invalidUser) // First, observe the raw result using the core ValidationObserver pattern .observe() // Then, translate the errors for presentation (also built on the same pattern) diff --git a/translator/README.md b/translator/README.md index f1401bb..61f9ffb 100644 --- a/translator/README.md +++ b/translator/README.md @@ -37,11 +37,11 @@ val translations: Map[String, String] = Map( // --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key in your translation map. - // The `.getOrElse` provides a safe fallback. + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") translations.getOrElse( - error.key.getOrElse("error.unknown"), - error.message // Fall back to the original message if the key is not found + translationKey, + error.message // Fall back to the original message if no translation is found ) } } @@ -52,7 +52,7 @@ given myTranslator: Translator with { Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. ```scala -val result = User.validate(someData) // An Invalid ValidationResult +val result = Validator[User].validate(someData) // An Invalid ValidationResult val translatedResult = result.translateErrors() // translatedResult now contains errors with localized messages @@ -87,7 +87,7 @@ given myTranslator: Translator with { } // Both extensions work together through the same pattern -val result = User.validate(invalidUser) +val result = Validator[User].validate(invalidUser) // First, observe the raw result using the core ValidationObserver pattern .observe() // Then, translate the errors for presentation (also built on the same pattern) diff --git a/valar-core/README.md b/valar-core/README.md deleted file mode 100644 index fcd70e0..0000000 --- a/valar-core/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# valar-core - -[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) -[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) - -The `valar-core` module provides the core validation functionality for Valar, a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages. - -## Key Components - -### ValidationResult - -Represents the outcome of validation as either Valid(value) or Invalid(errors): - -```scala -import net.ghoula.valar.ValidationErrors.ValidationError - -enum ValidationResult[+A] { - case Valid(value: A) - case Invalid(errors: Vector[ValidationError]) -} -``` - -### ValidationError - -Opaque type providing rich context for validation errors, including: - -* **message**: Human-readable description of the error. -* **fieldPath**: Path to the field causing the error (e.g., user.address.street). -* **code**: Optional application-specific error codes. -* **severity**: Optional severity indicator (Error, Warning). -* **expected/actual**: Information about expected and actual values. -* **children**: Nested errors for structured reporting. - -### Validator[A] - -A typeclass defining validation logic for a given type: - -```scala -import net.ghoula.valar.ValidationResult - -trait Validator[A] { - def validate(a: A): ValidationResult[A] -} -``` - -Validators can be automatically derived for case classes using deriveValidatorMacro. - -### ValidationObserver - -The `ValidationObserver` trait provides a mechanism to decouple validation logic from cross-cutting concerns such as logging, metrics collection, or auditing: - -```scala -import net.ghoula.valar.* -import org.slf4j.LoggerFactory - -// Define a custom observer that logs validation results -given loggingObserver: ValidationObserver with { - private val logger = LoggerFactory.getLogger("ValidationAnalytics") - - def onResult[A](result: ValidationResult[A]): Unit = result match { - case ValidationResult.Valid(_) => - logger.info("Validation succeeded") - case ValidationResult.Invalid(errors) => - logger.warn(s"Validation failed with ${errors.size} errors") - } -} - -// Use the observer in your validation flow -val result = User.validate(user).observe() -``` - -Key features of ValidationObserver: -* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -* **Non-Intrusive**: Observes validation results without altering the validation flow -* **Chainable**: Works seamlessly with other operations in the validation pipeline -* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect - -## Built-in Validators - -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This includes: - -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, Short, Char, Unit. -* **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their element/key/value types). -* **Tuple Types:** Named tuples and regular tuples. -* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. - -Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. - -## Usage - -For detailed usage examples and more information, please refer to the [main Valar documentation](https://github.com/hakimjonas/valar). \ No newline at end of file diff --git a/valar-translator/README.md b/valar-translator/README.md index f1401bb..61f9ffb 100644 --- a/valar-translator/README.md +++ b/valar-translator/README.md @@ -37,11 +37,11 @@ val translations: Map[String, String] = Map( // --- Implementation of the Translator trait --- given myTranslator: Translator with { def translate(error: ValidationError): String = { - // Logic to look up the error's key in your translation map. - // The `.getOrElse` provides a safe fallback. + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") translations.getOrElse( - error.key.getOrElse("error.unknown"), - error.message // Fall back to the original message if the key is not found + translationKey, + error.message // Fall back to the original message if no translation is found ) } } @@ -52,7 +52,7 @@ given myTranslator: Translator with { Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. ```scala -val result = User.validate(someData) // An Invalid ValidationResult +val result = Validator[User].validate(someData) // An Invalid ValidationResult val translatedResult = result.translateErrors() // translatedResult now contains errors with localized messages @@ -87,7 +87,7 @@ given myTranslator: Translator with { } // Both extensions work together through the same pattern -val result = User.validate(invalidUser) +val result = Validator[User].validate(invalidUser) // First, observe the raw result using the core ValidationObserver pattern .observe() // Then, translate the errors for presentation (also built on the same pattern) From da2f39cad8f9cfbaf192b6f57bd516b1a8443271 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Wed, 9 Jul 2025 11:19:49 +0200 Subject: [PATCH 19/19] not that --- diff.txt | 4514 ------------------------------------------------------ 1 file changed, 4514 deletions(-) delete mode 100644 diff.txt diff --git a/diff.txt b/diff.txt deleted file mode 100644 index c498423..0000000 --- a/diff.txt +++ /dev/null @@ -1,4514 +0,0 @@ -diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml -index f3e77df..c259d3b 100644 ---- a/.github/workflows/scala.yml -+++ b/.github/workflows/scala.yml -@@ -14,17 +14,17 @@ jobs: - - name: Checkout repository - uses: actions/checkout@v4 - -- - name: Set up JDK 17 -+ - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - distribution: 'temurin' -- java-version: '17' -+ java-version: '21' - cache: 'sbt' - - - name: Set up sbt - uses: sbt/setup-sbt@v1 - -- - name: Check formatting and code style -+ - name: Run all checks (style, formatting, API compatibility) - run: sbt check - - - name: Run all tests on JVM -@@ -51,11 +51,11 @@ jobs: - with: - fetch-depth: 0 # Fetch full history for dynver/release notes - -- - name: Set up JDK 17 -+ - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - distribution: 'temurin' -- java-version: '17' -+ java-version: '21' - cache: 'sbt' - - - name: Set up sbt launcher -diff --git a/MIGRATION.md b/MIGRATION.md -index 80d3b0b..43faf88 100644 ---- a/MIGRATION.md -+++ b/MIGRATION.md -@@ -1,9 +1,116 @@ - # Migration Guide - -+## Migrating from v0.4.8 to v0.5.0 -+ -+Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: -+ -+1. **New ValidationObserver trait** for observing validation outcomes without altering the flow -+2. **New valar-translator module** for internationalization support of validation error messages -+3. **Enhanced ValarSuite** with improved testing utilities -+4. **Reworked macros** for better performance and modern Scala 3 features -+5. **MiMa checks** to ensure binary compatibility between versions -+ -+### Update build.sbt: -+ -+```scala -+// Update core library -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" -+ -+// Add the optional translator module (if needed) -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" -+ -+// Update testing utilities (if used) -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -+``` -+ -+Your existing validation code will continue to work without any changes. -+ -+### Using the New Features -+ -+#### Core Extensibility Pattern (ValidationObserver) -+ -+The ValidationObserver pattern has been added to valar-core as the **standard way to extend Valar**. This pattern provides: -+ -+* A consistent API for integrating with external systems -+* Zero-cost abstractions when extensions aren't used -+* Type-safe composition with other Valar features -+ -+Future Valar modules (like valar-cats-effect and valar-zio) will build upon this pattern, making it the **recommended approach** for anyone building custom Valar extensions. -+ -+The ValidationObserver trait allows you to observe validation results without altering the flow: -+ -+```scala -+import net.ghoula.valar.* -+import org.slf4j.LoggerFactory -+ -+// Define a custom observer that logs validation results -+given loggingObserver: ValidationObserver with { -+ private val logger = LoggerFactory.getLogger("ValidationAnalytics") -+ -+ def onResult[A](result: ValidationResult[A]): Unit = result match { -+ case ValidationResult.Valid(_) => -+ logger.info("Validation succeeded") -+ case ValidationResult.Invalid(errors) => -+ logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") -+ } -+} -+ -+// Use the observer in your validation flow -+val result = User.validate(user) -+ .observe() // The observer's onResult is called here -+ .map(_.toUpperCase) -+``` -+ -+Key features of ValidationObserver: -+ -+* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -+* **Non-Intrusive**: Observes validation results without altering the validation flow -+* **Chainable**: Works seamlessly with other operations in the validation pipeline -+* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect -+ -+#### valar-translator -+ -+The valar-translator module provides internationalization support: -+ -+```scala -+import net.ghoula.valar.* -+import net.ghoula.valar.translator.Translator -+ -+// --- Example Setup --- -+// In a real application, this would come from a properties file or other i18n system. -+val translations: Map[String, String] = Map( -+ "error.string.nonEmpty" -> "The field must not be empty.", -+ "error.int.nonNegative" -> "The value cannot be negative.", -+ "error.unknown" -> "An unexpected validation error occurred." -+) -+ -+// --- Implementation of the Translator trait --- -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Logic to look up the error's key in your translation map. -+ // The `.getOrElse` provides a safe fallback. -+ translations.getOrElse( -+ error.key.getOrElse("error.unknown"), -+ error.message // Fall back to the original message if the key is not found -+ ) -+ } -+} -+ -+// Use the translator in your validation flow -+val result = User.validate(user) -+ .observe() // Optional: observe the raw result first -+ .translateErrors() // Translate errors for user presentation -+``` -+ -+The `valar-translator` module is designed to: -+ -+* Integrate with any i18n library through the `Translator` typeclass -+* Compose cleanly with other Valar features like ValidationObserver -+* Provide a clear separation between validation logic and presentation concerns -+ - ## Migrating from v0.3.0 to v0.4.8 - --The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular --architecture. -+The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the new modular architecture. - - ### Update build.sbt: - -@@ -12,26 +119,24 @@ architecture. - libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" - - // With this (note the triple %%% for cross-platform support): --libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - - // Add optional testing utilities (if desired): --libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test -- --// Alternatively, use bundle versions with all dependencies included: --libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test - ``` - --### Available Artifacts -+> **Note:** v0.4.8 used bundle versions (`-bundle` suffix) that included all dependencies. Starting from v0.5.0, we've moved to the standard approach without bundle versions for simpler dependency management. -+ -+### Available Artifacts for v0.4.8 - --The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). If you need to reference a specific artifact directly, here are all the available options: -+The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). For v0.4.8, only bundle versions are available: - --| Module | Platform | Artifact ID | Standard Version | Bundle Version | --|--------|----------|-------------------------|------------------------------------------------------|-------------------------------------------------------------| --| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8"` | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | --| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | --| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8"` | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | --| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | -+| Module | Platform | Artifact ID | Bundle Version | -+|--------|----------|-------------------------|-------------------------------------------------------------| -+| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | -+| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | -+| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | -+| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | - - Your existing validation code will continue to work without any changes. - -@@ -71,7 +176,7 @@ val result = summon[Validator[Email]].validate(email) - given stringValidator: Validator[String] with { ... } - given emailValidator: Validator[Email] with { ... } - } -- -+ - // Be explicit about which one to use - import validators.emailValidator - ``` -@@ -81,7 +186,7 @@ val result = summon[Validator[Email]].validate(email) - ```scala - given generalStringValidator: Validator[String] with { ... } - given specificEmailValidator: Validator[Email] with { ... } -- -+ - // Use the specific one explicitly - val result = specificEmailValidator.validate(email) - ``` -diff --git a/README.md b/README.md -index 5e8b14a..2ee98a1 100644 ---- a/README.md -+++ b/README.md -@@ -8,19 +8,24 @@ Valar is a validation library for Scala 3 designed for clarity and ease of use. - metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, - detailed error messages useful for debugging or user feedback. - --## **✨ What's New in 0.4.8** -- --* **🚀 Bundle Mode**: All modules now offer a `-bundle` version that includes all dependencies, making it easier to use -- in projects. --* **🎯 Platform-Specific Artifacts**: Valar provides dedicated artifacts for both JVM (`valar-core_3`, `valar-munit_3`) -- and Scala Native (`valar-core_native0.5_3`, `valar-munit_native0.5_3`) platforms. --* **📦 Modular Architecture**: The library is split into focused modules: `valar-core` for core validation functionality -- and the optional `valar-munit` for enhanced testing utilities. -+## **✨ What's New in 0.5.X** -+ -+* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, -+ perfect for logging, metrics collection, or auditing with zero overhead when not used. -+* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the -+ `Translator` typeclass. -+* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust -+ validation testing. -+* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time -+ validation. -+* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -+* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer -+ experience. - - ## **Key Features** - - * **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using -- ValidationResult\[A\]. Eliminate runtime errors caused by unexpected validation states. -+ ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. - * **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. - * **Flexible Error Handling:** Choose the strategy that fits your use case: -@@ -34,41 +39,81 @@ detailed error messages useful for debugging or user feedback. - * **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. - -+## **Extensibility Pattern** -+ -+Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to -+integrate with external systems without modifying the core validation logic. -+ -+### The ValidationObserver Pattern -+ -+The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: -+ -+```scala -+trait ValidationObserver { -+ def onResult[A](result: ValidationResult[A]): Unit -+} -+``` -+ -+This pattern offers several advantages: -+ -+* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -+* **Non-Intrusive**: Observes validation results without altering the validation flow -+* **Composable**: Works seamlessly with other Valar features and can be chained -+* **Type-Safe**: Leverages Scala's type system for compile-time safety -+ -+### Examples of Extensions Using This Pattern -+ -+Current implementations are following this pattern: -+ -+- **Logging**: Log validation outcomes for debugging and monitoring -+- **Metrics**: Collect validation statistics for performance analysis -+- **Auditing**: Track validation events for compliance and security -+ -+Future extensions planned: -+ -+- **valar-cats-effect**: Async validation with IO-based observers -+- **valar-zio**: ZIO-based validation with resource management -+- **Context-aware validation**: Observers that can access request-scoped data -+ - ## **Available Artifacts** - - Valar provides artifacts for both JVM and Scala Native platforms: - --| Module | Platform | Artifact ID | Standard Version | Bundle Version | --|-----------|----------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| --| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | --| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | --| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | --| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | -- --The **bundle versions** (with `-bundle` suffix) include all dependencies, making them easier to use in projects that --don't need fine-grained dependency control. -+| Module | Platform | Artifact ID | Maven Central | -+|----------------|----------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -+| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | -+| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | -+| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | -+| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | -+| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | -+| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | - - > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. - -+## **Additional Resources** -+ -+- 📊 **[Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md)**: Detailed JMH benchmark results and analysis -+- 🧪 **[Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md)**: Enhanced testing utilities with ValarSuite -+- 🌐 **[Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md)**: i18n support for validation error messages - ## **Installation** - - Add the following to your build.sbt: - - ```scala - // The core validation library (JVM & Scala Native) --libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" - --// Optional: For enhanced testing with MUnit --libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test -+// Optional: For internationalization (i18n) support -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" - --// Alternatively, use bundle versions with all dependencies included --libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" --libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test -+// Optional: For enhanced testing with MUnit -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test - ``` - - ## **Basic Usage Example** - --Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int (non-negative). -+Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int ( -+non-negative). - - ```scala - import net.ghoula.valar.* -@@ -114,7 +159,6 @@ import net.ghoula.valar.* - import net.ghoula.valar.munit.ValarSuite - - class UserValidationSuite extends ValarSuite { -- - // A given Validator for User must be in scope - given Validator[User] = Validator.deriveValidatorMacro - -@@ -126,7 +170,6 @@ class UserValidationSuite extends ValarSuite { - - test("a single validation error should be reported correctly") { - val result = Validator[User].validate(User("", Some(25))) -- - // Use assertHasOneError for the common case of a single error - assertHasOneError(result) { error => - assertEquals(error.fieldPath, List("name")) -@@ -136,7 +179,6 @@ class UserValidationSuite extends ValarSuite { - - test("multiple validation errors should be accumulated") { - val result = Validator[User].validate(User("", Some(-10))) -- - // Use assertInvalid for testing error accumulation - assertInvalid(result) { errors => - assertEquals(errors.size, 2) -@@ -188,9 +230,9 @@ trait Validator[A] { - Validators can be automatically derived for case classes using deriveValidatorMacro. - - **Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances --to be available in scope for **all** field types within the case class. If a validator for any field type is missing, --**compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the --"Built-in Validators" section for types supported out-of-the-box. -+to be available in scope for **all** field types within the case class. If a validator for any field type is missing, * -+*compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the " -+Built-in Validators" section for types supported out-of-the-box. - - ## **Built-in Validators** - -@@ -210,34 +252,143 @@ includes: - Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are - **pass-through** validators. You should define custom validators if you need specific constraints for these types. - --## **Migration Guide from v0.3.0** -+## **ValidationObserver, The Core Extensibility Pattern** - --The main breaking change since v0.4.0 is the **artifact name has changed** from valar to valar-core to support the new --modular architecture. -+The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending -+Valar with custom functionality. This pattern allows you to: - --1. **Update build.sbt**: -- ```scala -- // Replace this: -- libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" -- -- // With this (note the triple %%% for cross-platform support): -- libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" -- ``` -- --2. **Add optional testing utilities** (if desired): -- ```scala -- libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test -- ``` -- --3. **For simplified dependency management** (optional): -- ```scala -- // Use bundle versions with all dependencies included -- libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" -- libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test -- ``` -+- **Integrate with external systems** (logging, metrics, monitoring) -+- **Add side effects** without modifying validation logic -+- **Build composable extensions** that work together seamlessly -+- **Maintain zero overhead** when extensions aren't needed -+ -+```scala -+import net.ghoula.valar.* -+import org.slf4j.LoggerFactory -+ -+// Define a custom observer that logs validation results -+given loggingObserver: ValidationObserver with { -+ private val logger = LoggerFactory.getLogger("ValidationAnalytics") -+ -+ def onResult[A](result: ValidationResult[A]): Unit = result match { -+ case ValidationResult.Valid(_) => -+ logger.info("Validation succeeded") -+ case ValidationResult.Invalid(errors) => -+ logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") -+ } -+} -+ -+// Use the observer in your validation flow -+val result = User.validate(user) -+ .observe() // The observer's onResult is called here -+ .map(_.toUpperCase) -+``` -+ -+### Building Custom Extensions -+ -+When building extensions for Valar, follow the ValidationObserver pattern: -+ -+```scala -+// Your custom extension trait -+trait MyCustomExtension extends ValidationObserver { -+ def onResult[A](result: ValidationResult[A]): Unit = { -+ // Your custom logic here -+ } -+} -+ -+// Usage remains clean and composable -+val result = User.validate(user) -+ .observe() // Uses your custom extension -+ .map(processUser) -+``` -+ -+Key features of ValidationObserver: -+ -+* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -+* **Non-Intrusive**: Observes validation results without altering the validation flow -+* **Chainable**: Works seamlessly with other operations in the validation pipeline -+* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect -+ -+## **Internationalization with valar-translator** -+ -+The `valar-translator` module provides internationalization (i18n) support for validation error messages: -+ -+```scala -+import net.ghoula.valar.* -+import net.ghoula.valar.translator.Translator -+ -+// --- Example Setup --- -+// In a real application, this would come from a properties file or other i18n system. -+val translations: Map[String, String] = Map( -+ "error.string.nonEmpty" -> "The field must not be empty.", -+ "error.int.nonNegative" -> "The value cannot be negative.", -+ "error.unknown" -> "An unexpected validation error occurred." -+) -+ -+// --- Implementation of the Translator trait --- -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Logic to look up the error's key in your translation map. -+ // The `.getOrElse` provides a safe fallback. -+ translations.getOrElse( -+ error.key.getOrElse("error.unknown"), -+ error.message // Fall back to the original message if the key is not found -+ ) -+ } -+} -+ -+// Use the translator in your validation flow -+val result = User.validate(user) -+ .observe() // Optional: observe the raw result first -+ .translateErrors() // Translate errors for user presentation -+``` -+ -+The `valar-translator` module is designed to: -+ -+* Integrate with any i18n library through the `Translator` typeclass -+* Compose cleanly with other Valar features like ValidationObserver -+* Provide a clear separation between validation logic and presentation concerns -+ -+## **Migration Guide from v0.4.8 to v0.5.0** -+ -+Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: -+ -+1. **New ValidationObserver trait** for observing validation outcomes -+2. **New valar-translator module** for internationalization support -+3. **Enhanced ValarSuite** with improved testing utilities -+4. **Reworked macros** for better performance and modern Scala 3 features -+5. **MiMa checks** to ensure binary compatibility -+ -+To upgrade to v0.5.0, update your build.sbt: -+ -+```scala -+// Update core library -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" -+ -+// Add the optional translator module (if needed) -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" -+ -+// Update testing utilities (if used) -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -+``` - - Your existing validation code will continue to work without any changes. - -+## **Migration Guide from v0.3.0 to v0.4.8** -+ -+The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular -+architecture. -+ -+1. **Update build.sbt**: -+ -+```scala -+// Replace this: -+libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" -+ -+// With this (note the triple %%% for cross-platform support): -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" -+``` -+ - ## **Compatibility** - - * **Scala:** 3.7+ -@@ -248,4 +399,4 @@ Your existing validation code will continue to work without any changes. - ## **License** - - Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) --file for details. -+file for details. -\ No newline at end of file -diff --git a/build.sbt b/build.sbt -index 99de259..275fb37 100644 ---- a/build.sbt -+++ b/build.sbt -@@ -45,7 +45,10 @@ lazy val root = (project in file(".")) - valarCoreJVM, - valarCoreNative, - valarMunitJVM, -- valarMunitNative -+ valarMunitNative, -+ valarTranslatorJVM, -+ valarTranslatorNative, -+ valarBenchmarks - ) - .settings( - name := "valar-root", -@@ -61,8 +64,9 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) - usePgpKeyHex("9614A0CE1CE76975"), - useGpgAgent := true, - // --- MiMa & TASTy-MiMa Configuration --- -- mimaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8"), -- tastyMiMaPreviousArtifacts := Set(organization.value %% name.value % "0.4.8"), -+ mimaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release -+ tastyMiMaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release -+ mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set - // --- Library Dependencies --- - libraryDependencies ++= Seq( - "io.github.cquiroz" %%% "scala-java-time" % "2.6.0", -@@ -73,11 +77,10 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) - .jvmSettings( - mdocIn := file("docs-src"), - mdocOut := file("."), -- // --- Updated Check Command --- - addCommandAlias("prepare", "scalafixAll; scalafmtAll; scalafmtSbt"), - addCommandAlias( - "check", -- "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck; mimaReportBinaryIssues; tastyMiMaReportIssues" -+ "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck" - ) - ) - .jvmConfigure(_.enablePlugins(MdocPlugin)) -@@ -97,16 +100,68 @@ lazy val valarMunit = crossProject(JVMPlatform, NativePlatform) - name := "valar-munit", - usePgpKeyHex("9614A0CE1CE76975"), - useGpgAgent := true, -+ mimaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release -+ tastyMiMaPreviousArtifacts := Set.empty, // Will start enforcing binary compatibility after the 0.5.0 release -+ mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set -+ libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" -+ ) -+ .jvmSettings( -+ mdocIn := file("docs-src/munit"), -+ mdocOut := file("valar-munit"), -+ mdocVariables := Map( -+ "VERSION" -> version.value, -+ "SCALA_VERSION" -> scalaVersion.value -+ ) -+ ) -+ .jvmConfigure(_.enablePlugins(MdocPlugin)) -+ .nativeSettings( -+ testFrameworks += new TestFramework("munit.Framework") -+ ) -+ -+lazy val valarTranslator = crossProject(JVMPlatform, NativePlatform) -+ .crossType(CrossType.Pure) -+ .in(file("valar-translator")) -+ .dependsOn(valarCore, valarMunit % Test) -+ .settings(sonatypeSettings *) -+ .settings( -+ name := "valar-translator", -+ usePgpKeyHex("9614A0CE1CE76975"), -+ useGpgAgent := true, - mimaPreviousArtifacts := Set.empty, - tastyMiMaPreviousArtifacts := Set.empty, -- libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" -+ mimaFailOnNoPrevious := false, // Prevents MiMa from failing when no previous artifacts are set, -+ libraryDependencies += "org.scalameta" %%% "munit" % "1.1.1" % Test -+ ) -+ .jvmSettings( -+ mdocIn := file("docs-src/translator"), -+ mdocOut := file("valar-translator"), -+ mdocVariables := Map( -+ "VERSION" -> version.value, -+ "SCALA_VERSION" -> scalaVersion.value -+ ) - ) -+ .jvmConfigure(_.enablePlugins(MdocPlugin)) - .nativeSettings( - testFrameworks += new TestFramework("munit.Framework") - ) -+// ===== Benchmarks Module ===== -+lazy val valarBenchmarks = project -+ .in(file("valar-benchmarks")) -+ .dependsOn(valarCoreJVM) -+ .enablePlugins(JmhPlugin) -+ .settings( -+ name := "valar-benchmarks", -+ publish / skip := true, -+ libraryDependencies ++= Seq( -+ "org.openjdk.jmh" % "jmh-core" % "1.37", -+ "org.openjdk.jmh" % "jmh-generator-annprocess" % "1.37" -+ ) -+ ) - - // ===== Convenience Aliases ===== - lazy val valarCoreJVM = valarCore.jvm - lazy val valarCoreNative = valarCore.native - lazy val valarMunitJVM = valarMunit.jvm - lazy val valarMunitNative = valarMunit.native -+lazy val valarTranslatorJVM = valarTranslator.jvm -+lazy val valarTranslatorNative = valarTranslator.native -diff --git a/docs-src/MIGRATION.md b/docs-src/MIGRATION.md -index 80d3b0b..43faf88 100644 ---- a/docs-src/MIGRATION.md -+++ b/docs-src/MIGRATION.md -@@ -1,9 +1,116 @@ - # Migration Guide - -+## Migrating from v0.4.8 to v0.5.0 -+ -+Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: -+ -+1. **New ValidationObserver trait** for observing validation outcomes without altering the flow -+2. **New valar-translator module** for internationalization support of validation error messages -+3. **Enhanced ValarSuite** with improved testing utilities -+4. **Reworked macros** for better performance and modern Scala 3 features -+5. **MiMa checks** to ensure binary compatibility between versions -+ -+### Update build.sbt: -+ -+```scala -+// Update core library -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" -+ -+// Add the optional translator module (if needed) -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" -+ -+// Update testing utilities (if used) -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -+``` -+ -+Your existing validation code will continue to work without any changes. -+ -+### Using the New Features -+ -+#### Core Extensibility Pattern (ValidationObserver) -+ -+The ValidationObserver pattern has been added to valar-core as the **standard way to extend Valar**. This pattern provides: -+ -+* A consistent API for integrating with external systems -+* Zero-cost abstractions when extensions aren't used -+* Type-safe composition with other Valar features -+ -+Future Valar modules (like valar-cats-effect and valar-zio) will build upon this pattern, making it the **recommended approach** for anyone building custom Valar extensions. -+ -+The ValidationObserver trait allows you to observe validation results without altering the flow: -+ -+```scala -+import net.ghoula.valar.* -+import org.slf4j.LoggerFactory -+ -+// Define a custom observer that logs validation results -+given loggingObserver: ValidationObserver with { -+ private val logger = LoggerFactory.getLogger("ValidationAnalytics") -+ -+ def onResult[A](result: ValidationResult[A]): Unit = result match { -+ case ValidationResult.Valid(_) => -+ logger.info("Validation succeeded") -+ case ValidationResult.Invalid(errors) => -+ logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") -+ } -+} -+ -+// Use the observer in your validation flow -+val result = User.validate(user) -+ .observe() // The observer's onResult is called here -+ .map(_.toUpperCase) -+``` -+ -+Key features of ValidationObserver: -+ -+* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -+* **Non-Intrusive**: Observes validation results without altering the validation flow -+* **Chainable**: Works seamlessly with other operations in the validation pipeline -+* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect -+ -+#### valar-translator -+ -+The valar-translator module provides internationalization support: -+ -+```scala -+import net.ghoula.valar.* -+import net.ghoula.valar.translator.Translator -+ -+// --- Example Setup --- -+// In a real application, this would come from a properties file or other i18n system. -+val translations: Map[String, String] = Map( -+ "error.string.nonEmpty" -> "The field must not be empty.", -+ "error.int.nonNegative" -> "The value cannot be negative.", -+ "error.unknown" -> "An unexpected validation error occurred." -+) -+ -+// --- Implementation of the Translator trait --- -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Logic to look up the error's key in your translation map. -+ // The `.getOrElse` provides a safe fallback. -+ translations.getOrElse( -+ error.key.getOrElse("error.unknown"), -+ error.message // Fall back to the original message if the key is not found -+ ) -+ } -+} -+ -+// Use the translator in your validation flow -+val result = User.validate(user) -+ .observe() // Optional: observe the raw result first -+ .translateErrors() // Translate errors for user presentation -+``` -+ -+The `valar-translator` module is designed to: -+ -+* Integrate with any i18n library through the `Translator` typeclass -+* Compose cleanly with other Valar features like ValidationObserver -+* Provide a clear separation between validation logic and presentation concerns -+ - ## Migrating from v0.3.0 to v0.4.8 - --The main breaking change since v0.4.0 is the artifact name has changed from valar to valar-core to support the new modular --architecture. -+The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the new modular architecture. - - ### Update build.sbt: - -@@ -12,26 +119,24 @@ architecture. - libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" - - // With this (note the triple %%% for cross-platform support): --libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - - // Add optional testing utilities (if desired): --libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test -- --// Alternatively, use bundle versions with all dependencies included: --libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" - libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test - ``` - --### Available Artifacts -+> **Note:** v0.4.8 used bundle versions (`-bundle` suffix) that included all dependencies. Starting from v0.5.0, we've moved to the standard approach without bundle versions for simpler dependency management. -+ -+### Available Artifacts for v0.4.8 - --The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). If you need to reference a specific artifact directly, here are all the available options: -+The `%%%` operator in sbt will automatically select the appropriate artifact for your platform (JVM or Native). For v0.4.8, only bundle versions are available: - --| Module | Platform | Artifact ID | Standard Version | Bundle Version | --|--------|----------|-------------------------|------------------------------------------------------|-------------------------------------------------------------| --| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8"` | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | --| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | --| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8"` | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | --| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8"` | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | -+| Module | Platform | Artifact ID | Bundle Version | -+|--------|----------|-------------------------|-------------------------------------------------------------| -+| Core | JVM | valar-core_3 | `"net.ghoula" %% "valar-core" % "0.4.8-bundle"` | -+| Core | Native | valar-core_native0.5_3 | `"net.ghoula" % "valar-core_native0.5_3" % "0.4.8-bundle"` | -+| MUnit | JVM | valar-munit_3 | `"net.ghoula" %% "valar-munit" % "0.4.8-bundle"` | -+| MUnit | Native | valar-munit_native0.5_3 | `"net.ghoula" % "valar-munit_native0.5_3" % "0.4.8-bundle"` | - - Your existing validation code will continue to work without any changes. - -@@ -71,7 +176,7 @@ val result = summon[Validator[Email]].validate(email) - given stringValidator: Validator[String] with { ... } - given emailValidator: Validator[Email] with { ... } - } -- -+ - // Be explicit about which one to use - import validators.emailValidator - ``` -@@ -81,7 +186,7 @@ val result = summon[Validator[Email]].validate(email) - ```scala - given generalStringValidator: Validator[String] with { ... } - given specificEmailValidator: Validator[Email] with { ... } -- -+ - // Use the specific one explicitly - val result = specificEmailValidator.validate(email) - ``` -diff --git a/docs-src/README.md b/docs-src/README.md -index 5e8b14a..2ee98a1 100644 ---- a/docs-src/README.md -+++ b/docs-src/README.md -@@ -8,19 +8,24 @@ Valar is a validation library for Scala 3 designed for clarity and ease of use. - metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, - detailed error messages useful for debugging or user feedback. - --## **✨ What's New in 0.4.8** -- --* **🚀 Bundle Mode**: All modules now offer a `-bundle` version that includes all dependencies, making it easier to use -- in projects. --* **🎯 Platform-Specific Artifacts**: Valar provides dedicated artifacts for both JVM (`valar-core_3`, `valar-munit_3`) -- and Scala Native (`valar-core_native0.5_3`, `valar-munit_native0.5_3`) platforms. --* **📦 Modular Architecture**: The library is split into focused modules: `valar-core` for core validation functionality -- and the optional `valar-munit` for enhanced testing utilities. -+## **✨ What's New in 0.5.X** -+ -+* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, -+ perfect for logging, metrics collection, or auditing with zero overhead when not used. -+* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the -+ `Translator` typeclass. -+* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust -+ validation testing. -+* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time -+ validation. -+* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -+* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer -+ experience. - - ## **Key Features** - - * **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using -- ValidationResult\[A\]. Eliminate runtime errors caused by unexpected validation states. -+ ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. - * **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. - * **Flexible Error Handling:** Choose the strategy that fits your use case: -@@ -34,41 +39,81 @@ detailed error messages useful for debugging or user feedback. - * **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. - -+## **Extensibility Pattern** -+ -+Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to -+integrate with external systems without modifying the core validation logic. -+ -+### The ValidationObserver Pattern -+ -+The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: -+ -+```scala -+trait ValidationObserver { -+ def onResult[A](result: ValidationResult[A]): Unit -+} -+``` -+ -+This pattern offers several advantages: -+ -+* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -+* **Non-Intrusive**: Observes validation results without altering the validation flow -+* **Composable**: Works seamlessly with other Valar features and can be chained -+* **Type-Safe**: Leverages Scala's type system for compile-time safety -+ -+### Examples of Extensions Using This Pattern -+ -+Current implementations are following this pattern: -+ -+- **Logging**: Log validation outcomes for debugging and monitoring -+- **Metrics**: Collect validation statistics for performance analysis -+- **Auditing**: Track validation events for compliance and security -+ -+Future extensions planned: -+ -+- **valar-cats-effect**: Async validation with IO-based observers -+- **valar-zio**: ZIO-based validation with resource management -+- **Context-aware validation**: Observers that can access request-scoped data -+ - ## **Available Artifacts** - - Valar provides artifacts for both JVM and Scala Native platforms: - --| Module | Platform | Artifact ID | Standard Version | Bundle Version | --|-----------|----------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| --| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | --| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | --| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | --| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=bundle&style=flat-square&classifier=bundle)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | -- --The **bundle versions** (with `-bundle` suffix) include all dependencies, making them easier to use in projects that --don't need fine-grained dependency control. -+| Module | Platform | Artifact ID | Maven Central | -+|----------------|----------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -+| **Core** | JVM | valar-core_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) | -+| **Core** | Native | valar-core_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_native0.5_3) | -+| **MUnit** | JVM | valar-munit_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) | -+| **MUnit** | Native | valar-munit_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_native0.5_3) | -+| **Translator** | JVM | valar-translator_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) | -+| **Translator** | Native | valar-translator_native0.5_3 | [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_native0.5_3?label=latest&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_native0.5_3) | - - > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. - -+## **Additional Resources** -+ -+- 📊 **[Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md)**: Detailed JMH benchmark results and analysis -+- 🧪 **[Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md)**: Enhanced testing utilities with ValarSuite -+- 🌐 **[Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md)**: i18n support for validation error messages - ## **Installation** - - Add the following to your build.sbt: - - ```scala - // The core validation library (JVM & Scala Native) --libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" - --// Optional: For enhanced testing with MUnit --libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test -+// Optional: For internationalization (i18n) support -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" - --// Alternatively, use bundle versions with all dependencies included --libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" --libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test -+// Optional: For enhanced testing with MUnit -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test - ``` - - ## **Basic Usage Example** - --Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int (non-negative). -+Here's a basic example of validating a case class. Valar provides default validators for String (non-empty) and Int ( -+non-negative). - - ```scala - import net.ghoula.valar.* -@@ -114,7 +159,6 @@ import net.ghoula.valar.* - import net.ghoula.valar.munit.ValarSuite - - class UserValidationSuite extends ValarSuite { -- - // A given Validator for User must be in scope - given Validator[User] = Validator.deriveValidatorMacro - -@@ -126,7 +170,6 @@ class UserValidationSuite extends ValarSuite { - - test("a single validation error should be reported correctly") { - val result = Validator[User].validate(User("", Some(25))) -- - // Use assertHasOneError for the common case of a single error - assertHasOneError(result) { error => - assertEquals(error.fieldPath, List("name")) -@@ -136,7 +179,6 @@ class UserValidationSuite extends ValarSuite { - - test("multiple validation errors should be accumulated") { - val result = Validator[User].validate(User("", Some(-10))) -- - // Use assertInvalid for testing error accumulation - assertInvalid(result) { errors => - assertEquals(errors.size, 2) -@@ -188,9 +230,9 @@ trait Validator[A] { - Validators can be automatically derived for case classes using deriveValidatorMacro. - - **Important Note on Derivation:** Automatic derivation with deriveValidatorMacro requires implicit Validator instances --to be available in scope for **all** field types within the case class. If a validator for any field type is missing, --**compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the --"Built-in Validators" section for types supported out-of-the-box. -+to be available in scope for **all** field types within the case class. If a validator for any field type is missing, * -+*compilation will fail**. This strictness ensures that all fields are explicitly considered during validation. See the " -+Built-in Validators" section for types supported out-of-the-box. - - ## **Built-in Validators** - -@@ -210,34 +252,143 @@ includes: - Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are - **pass-through** validators. You should define custom validators if you need specific constraints for these types. - --## **Migration Guide from v0.3.0** -+## **ValidationObserver, The Core Extensibility Pattern** - --The main breaking change since v0.4.0 is the **artifact name has changed** from valar to valar-core to support the new --modular architecture. -+The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending -+Valar with custom functionality. This pattern allows you to: - --1. **Update build.sbt**: -- ```scala -- // Replace this: -- libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" -- -- // With this (note the triple %%% for cross-platform support): -- libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8" -- ``` -- --2. **Add optional testing utilities** (if desired): -- ```scala -- libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8" % Test -- ``` -- --3. **For simplified dependency management** (optional): -- ```scala -- // Use bundle versions with all dependencies included -- libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" -- libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.4.8-bundle" % Test -- ``` -+- **Integrate with external systems** (logging, metrics, monitoring) -+- **Add side effects** without modifying validation logic -+- **Build composable extensions** that work together seamlessly -+- **Maintain zero overhead** when extensions aren't needed -+ -+```scala -+import net.ghoula.valar.* -+import org.slf4j.LoggerFactory -+ -+// Define a custom observer that logs validation results -+given loggingObserver: ValidationObserver with { -+ private val logger = LoggerFactory.getLogger("ValidationAnalytics") -+ -+ def onResult[A](result: ValidationResult[A]): Unit = result match { -+ case ValidationResult.Valid(_) => -+ logger.info("Validation succeeded") -+ case ValidationResult.Invalid(errors) => -+ logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") -+ } -+} -+ -+// Use the observer in your validation flow -+val result = User.validate(user) -+ .observe() // The observer's onResult is called here -+ .map(_.toUpperCase) -+``` -+ -+### Building Custom Extensions -+ -+When building extensions for Valar, follow the ValidationObserver pattern: -+ -+```scala -+// Your custom extension trait -+trait MyCustomExtension extends ValidationObserver { -+ def onResult[A](result: ValidationResult[A]): Unit = { -+ // Your custom logic here -+ } -+} -+ -+// Usage remains clean and composable -+val result = User.validate(user) -+ .observe() // Uses your custom extension -+ .map(processUser) -+``` -+ -+Key features of ValidationObserver: -+ -+* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -+* **Non-Intrusive**: Observes validation results without altering the validation flow -+* **Chainable**: Works seamlessly with other operations in the validation pipeline -+* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect -+ -+## **Internationalization with valar-translator** -+ -+The `valar-translator` module provides internationalization (i18n) support for validation error messages: -+ -+```scala -+import net.ghoula.valar.* -+import net.ghoula.valar.translator.Translator -+ -+// --- Example Setup --- -+// In a real application, this would come from a properties file or other i18n system. -+val translations: Map[String, String] = Map( -+ "error.string.nonEmpty" -> "The field must not be empty.", -+ "error.int.nonNegative" -> "The value cannot be negative.", -+ "error.unknown" -> "An unexpected validation error occurred." -+) -+ -+// --- Implementation of the Translator trait --- -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Logic to look up the error's key in your translation map. -+ // The `.getOrElse` provides a safe fallback. -+ translations.getOrElse( -+ error.key.getOrElse("error.unknown"), -+ error.message // Fall back to the original message if the key is not found -+ ) -+ } -+} -+ -+// Use the translator in your validation flow -+val result = User.validate(user) -+ .observe() // Optional: observe the raw result first -+ .translateErrors() // Translate errors for user presentation -+``` -+ -+The `valar-translator` module is designed to: -+ -+* Integrate with any i18n library through the `Translator` typeclass -+* Compose cleanly with other Valar features like ValidationObserver -+* Provide a clear separation between validation logic and presentation concerns -+ -+## **Migration Guide from v0.4.8 to v0.5.0** -+ -+Version 0.5.0 introduces several new features while maintaining backward compatibility with v0.4.8: -+ -+1. **New ValidationObserver trait** for observing validation outcomes -+2. **New valar-translator module** for internationalization support -+3. **Enhanced ValarSuite** with improved testing utilities -+4. **Reworked macros** for better performance and modern Scala 3 features -+5. **MiMa checks** to ensure binary compatibility -+ -+To upgrade to v0.5.0, update your build.sbt: -+ -+```scala -+// Update core library -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.5.0" -+ -+// Add the optional translator module (if needed) -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" -+ -+// Update testing utilities (if used) -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -+``` - - Your existing validation code will continue to work without any changes. - -+## **Migration Guide from v0.3.0 to v0.4.8** -+ -+The main breaking change in v0.4.0 was the **artifact name change** from valar to valar-core to support the modular -+architecture. -+ -+1. **Update build.sbt**: -+ -+```scala -+// Replace this: -+libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" -+ -+// With this (note the triple %%% for cross-platform support): -+libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" -+``` -+ - ## **Compatibility** - - * **Scala:** 3.7+ -@@ -248,4 +399,4 @@ Your existing validation code will continue to work without any changes. - ## **License** - - Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) --file for details. -+file for details. -\ No newline at end of file -diff --git a/docs-src/munit/README.md b/docs-src/munit/README.md -new file mode 100644 -index 0000000..67756cb ---- /dev/null -+++ b/docs-src/munit/README.md -@@ -0,0 +1,132 @@ -+# valar-munit -+ -+[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) -+[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -+ -+The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It -+introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. -+ -+## Installation -+ -+Add the valar-munit dependency to your build.sbt: -+ -+```scala -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -+``` -+ -+## Usage -+ -+Extend the ValarSuite trait in your test classes to get access to the assertion helpers. -+ -+```scala -+import net.ghoula.valar.munit.ValarSuite -+ -+class MyValidatorSpec extends ValarSuite { -+ test("valid data passes validation") { -+ val result = MyValidator.validate(validData) -+ val value = assertValid(result) -+ -+ // You can make additional assertions on the validated value -+ assertEquals(value.name, "Expected Name") -+ } -+} -+``` -+ -+## Assertion Helpers -+ -+The ValarSuite trait provides several assertion helpers for different validation testing scenarios. -+ -+### 1. assertValid -+ -+Asserts that a ValidationResult is Valid and returns the validated value for further assertions. -+ -+```scala -+test("valid data passes validation") { -+ val result = MyValidator.validate(validData) -+ val value = assertValid(result) -+ -+ // Additional assertions on the validated value -+ assertEquals(value.id, 123) -+} -+``` -+ -+### 2. assertHasOneError -+ -+Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual -+validation rules. -+ -+```scala -+test("empty name is rejected") { -+ val result = User.validate(User("", 25)) -+ -+ assertHasOneError(result) { error => -+ assertEquals(error.fieldPath, List("name")) -+ assert(error.message.contains("empty")) -+ } -+} -+``` -+ -+### 3. assertHasNErrors -+ -+Asserts that a ValidationResult is Invalid and contains exactly N errors. -+ -+```scala -+test("multiple specific errors are reported") { -+ val result = User.validate(User("", -5)) -+ -+ assertHasNErrors(result, 2) { errors => -+ // Assert on the collection of exactly 2 errors -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+### 4. assertInvalid -+ -+Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, -+accumulated errors are expected. -+ -+```scala -+test("multiple validation errors are accumulated") { -+ val result = User.validate(User("", -5)) -+ -+ assertInvalid(result) { -+ case errors if errors.size == 2 => -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+### 5. assertInvalidWith -+ -+Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular -+function. This is a simpler alternative to assertInvalid. -+ -+```scala -+test("validation fails with expected errors") { -+ val result = User.validate(User("", -5)) -+ -+ assertInvalidWith(result) { errors => -+ assertEquals(errors.size, 2) -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+## Benefits -+ -+- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. -+ -+- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. -+ -+- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. -+ -+- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated -+ value. -+ -+- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match -+ your testing preferences. -diff --git a/docs-src/translator/README.md b/docs-src/translator/README.md -new file mode 100644 -index 0000000..f1401bb ---- /dev/null -+++ b/docs-src/translator/README.md -@@ -0,0 +1,98 @@ -+# valar-translator -+ -+[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) -+[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -+ -+The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. -+ -+## Installation -+ -+Add the valar-translator dependency to your build.sbt: -+ -+```scala -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" -+``` -+ -+## Usage -+ -+The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. -+ -+### 1. Implement the `Translator` Trait -+ -+Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. -+ -+```scala -+import net.ghoula.valar.translator.Translator -+import net.ghoula.valar.ValidationErrors.ValidationError -+ -+// --- Example Setup --- -+// In a real application, this would come from a properties file or other i18n system. -+val translations: Map[String, String] = Map( -+ "error.string.nonEmpty" -> "The field must not be empty.", -+ "error.int.nonNegative" -> "The value cannot be negative.", -+ "error.unknown" -> "An unexpected validation error occurred." -+) -+ -+// --- Implementation of the Translator trait --- -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Logic to look up the error's key in your translation map. -+ // The `.getOrElse` provides a safe fallback. -+ translations.getOrElse( -+ error.key.getOrElse("error.unknown"), -+ error.message // Fall back to the original message if the key is not found -+ ) -+ } -+} -+``` -+ -+### 2. Call `translateErrors()` -+ -+Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. -+ -+```scala -+val result = User.validate(someData) // An Invalid ValidationResult -+val translatedResult = result.translateErrors() -+ -+// translatedResult now contains errors with localized messages -+``` -+ -+## Integration with the ValidationObserver Extensibility Pattern -+ -+The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. -+ -+This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: -+ -+* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result -+* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization -+ -+While these serve different purposes, they're designed to work together in a clean, composable way: -+ -+A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. -+ -+```scala -+// Given a defined extension using the ValidationObserver pattern -+given metricsObserver: ValidationObserver with { -+ def onResult[A](result: ValidationResult[A]): Unit = { -+ // Record validation metrics to your monitoring system -+ } -+} -+ -+// And a translator implementation for localization -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Translate errors using your i18n system -+ } -+} -+ -+// Both extensions work together through the same pattern -+val result = User.validate(invalidUser) -+ // First, observe the raw result using the core ValidationObserver pattern -+ .observe() -+ // Then, translate the errors for presentation (also built on the same pattern) -+ .translateErrors() -+ -+// This demonstrates how all Valar extensions follow the same architectural pattern, -+// allowing them to compose together seamlessly -+``` -diff --git a/munit/README.md b/munit/README.md -new file mode 100644 -index 0000000..67756cb ---- /dev/null -+++ b/munit/README.md -@@ -0,0 +1,132 @@ -+# valar-munit -+ -+[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) -+[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -+ -+The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It -+introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. -+ -+## Installation -+ -+Add the valar-munit dependency to your build.sbt: -+ -+```scala -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -+``` -+ -+## Usage -+ -+Extend the ValarSuite trait in your test classes to get access to the assertion helpers. -+ -+```scala -+import net.ghoula.valar.munit.ValarSuite -+ -+class MyValidatorSpec extends ValarSuite { -+ test("valid data passes validation") { -+ val result = MyValidator.validate(validData) -+ val value = assertValid(result) -+ -+ // You can make additional assertions on the validated value -+ assertEquals(value.name, "Expected Name") -+ } -+} -+``` -+ -+## Assertion Helpers -+ -+The ValarSuite trait provides several assertion helpers for different validation testing scenarios. -+ -+### 1. assertValid -+ -+Asserts that a ValidationResult is Valid and returns the validated value for further assertions. -+ -+```scala -+test("valid data passes validation") { -+ val result = MyValidator.validate(validData) -+ val value = assertValid(result) -+ -+ // Additional assertions on the validated value -+ assertEquals(value.id, 123) -+} -+``` -+ -+### 2. assertHasOneError -+ -+Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual -+validation rules. -+ -+```scala -+test("empty name is rejected") { -+ val result = User.validate(User("", 25)) -+ -+ assertHasOneError(result) { error => -+ assertEquals(error.fieldPath, List("name")) -+ assert(error.message.contains("empty")) -+ } -+} -+``` -+ -+### 3. assertHasNErrors -+ -+Asserts that a ValidationResult is Invalid and contains exactly N errors. -+ -+```scala -+test("multiple specific errors are reported") { -+ val result = User.validate(User("", -5)) -+ -+ assertHasNErrors(result, 2) { errors => -+ // Assert on the collection of exactly 2 errors -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+### 4. assertInvalid -+ -+Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, -+accumulated errors are expected. -+ -+```scala -+test("multiple validation errors are accumulated") { -+ val result = User.validate(User("", -5)) -+ -+ assertInvalid(result) { -+ case errors if errors.size == 2 => -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+### 5. assertInvalidWith -+ -+Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular -+function. This is a simpler alternative to assertInvalid. -+ -+```scala -+test("validation fails with expected errors") { -+ val result = User.validate(User("", -5)) -+ -+ assertInvalidWith(result) { errors => -+ assertEquals(errors.size, 2) -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+## Benefits -+ -+- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. -+ -+- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. -+ -+- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. -+ -+- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated -+ value. -+ -+- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match -+ your testing preferences. -diff --git a/project/plugins.sbt b/project/plugins.sbt -index 2b223de..7f55807 100644 ---- a/project/plugins.sbt -+++ b/project/plugins.sbt -@@ -22,3 +22,6 @@ addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.8") - addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") - // For TASTy compatibility checking (for Scala 3 inlines/macros) - addSbtPlugin("ch.epfl.scala" % "sbt-tasty-mima" % "1.3.0") -+ -+// Benchmarking -+addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7") -diff --git a/translator/README.md b/translator/README.md -new file mode 100644 -index 0000000..f1401bb ---- /dev/null -+++ b/translator/README.md -@@ -0,0 +1,98 @@ -+# valar-translator -+ -+[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) -+[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -+ -+The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. -+ -+## Installation -+ -+Add the valar-translator dependency to your build.sbt: -+ -+```scala -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" -+``` -+ -+## Usage -+ -+The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. -+ -+### 1. Implement the `Translator` Trait -+ -+Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. -+ -+```scala -+import net.ghoula.valar.translator.Translator -+import net.ghoula.valar.ValidationErrors.ValidationError -+ -+// --- Example Setup --- -+// In a real application, this would come from a properties file or other i18n system. -+val translations: Map[String, String] = Map( -+ "error.string.nonEmpty" -> "The field must not be empty.", -+ "error.int.nonNegative" -> "The value cannot be negative.", -+ "error.unknown" -> "An unexpected validation error occurred." -+) -+ -+// --- Implementation of the Translator trait --- -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Logic to look up the error's key in your translation map. -+ // The `.getOrElse` provides a safe fallback. -+ translations.getOrElse( -+ error.key.getOrElse("error.unknown"), -+ error.message // Fall back to the original message if the key is not found -+ ) -+ } -+} -+``` -+ -+### 2. Call `translateErrors()` -+ -+Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. -+ -+```scala -+val result = User.validate(someData) // An Invalid ValidationResult -+val translatedResult = result.translateErrors() -+ -+// translatedResult now contains errors with localized messages -+``` -+ -+## Integration with the ValidationObserver Extensibility Pattern -+ -+The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. -+ -+This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: -+ -+* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result -+* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization -+ -+While these serve different purposes, they're designed to work together in a clean, composable way: -+ -+A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. -+ -+```scala -+// Given a defined extension using the ValidationObserver pattern -+given metricsObserver: ValidationObserver with { -+ def onResult[A](result: ValidationResult[A]): Unit = { -+ // Record validation metrics to your monitoring system -+ } -+} -+ -+// And a translator implementation for localization -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Translate errors using your i18n system -+ } -+} -+ -+// Both extensions work together through the same pattern -+val result = User.validate(invalidUser) -+ // First, observe the raw result using the core ValidationObserver pattern -+ .observe() -+ // Then, translate the errors for presentation (also built on the same pattern) -+ .translateErrors() -+ -+// This demonstrates how all Valar extensions follow the same architectural pattern, -+// allowing them to compose together seamlessly -+``` -diff --git a/valar-benchmarks/README.md b/valar-benchmarks/README.md -new file mode 100644 -index 0000000..f70ce8a ---- /dev/null -+++ b/valar-benchmarks/README.md -@@ -0,0 +1,139 @@ -+# Valar Benchmarks -+ -+This module contains JMH (Java Microbenchmark Harness) benchmarks for the Valar validation library. The benchmarks measure the performance of critical validation paths to help identify performance characteristics and potential optimizations. -+ -+## Overview -+ -+The benchmark suite covers: -+- **Synchronous validation** of simple and nested case classes -+- **Asynchronous validation** with a mix of sync and async rules -+- **Valid and invalid data paths** to understand performance differences -+ -+## Benchmark Results -+ -+Based on the latest run (JDK 21.0.7, OpenJDK 64-Bit Server VM): -+ -+| Benchmark | Mode | Score | Error | Units | -+|----------------------|------|------------|-------------|-------| -+| `syncSimpleValid` | avgt | 44.628 | ± 6.746 | ns/op | -+| `syncSimpleInvalid` | avgt | 149.155 | ± 7.124 | ns/op | -+| `syncNestedValid` | avgt | 108.968 | ± 7.300 | ns/op | -+| `syncNestedInvalid` | avgt | 449.783 | ± 18.373 | ns/op | -+| `asyncSimpleValid` | avgt | 13,212.036 | ± 1,114.597 | ns/op | -+| `asyncSimpleInvalid` | avgt | 13,465.022 | ± 214.379 | ns/op | -+| `asyncNestedValid` | avgt | 14,513.056 | ± 1,023.942 | ns/op | -+| `asyncNestedInvalid` | avgt | 15,432.503 | ± 2,592.103 | ns/op | -+ -+## Performance Analysis -+ -+### 🚀 Synchronous Performance is Excellent -+ -+The validation for simple, valid objects completes in **~45 nanoseconds**. This is incredibly fast and proves that for the "happy path," the library adds negligible overhead. The slightly higher numbers for invalid and nested cases (~150–450 ns) are also excellent and are expected, as they account for: -+ -+- Creation of `ValidationError` objects for invalid cases -+- Recursive validation calls for nested structures -+- Error accumulation logic -+ -+**Key takeaway**: Synchronous validation is extremely fast with minimal overhead. -+ -+### ⚡ Asynchronous Performance is As Expected -+ -+The async benchmarks show results in the **~13–16 microsecond range** (13,00016,000 ns). This is excellent and exactly what we should expect. The "cost" here is not from our validation logic but from the inherent overhead of: -+ -+- Creating `Future` instances -+- Managing the `ExecutionContext` -+- The `Await.result` call in the benchmark (blocking on async results) -+ -+**Key takeaway**: Our async logic is efficient and correctly builds on Scala's non-blocking primitives without introducing performance bottlenecks. -+ -+### Summary -+ -+- **Sync validation**: Negligible overhead, perfect for high-throughput scenarios -+- **Async validation**: Adds only the expected Future abstraction overhead -+- **Valid vs Invalid**: Invalid cases show expected slight overhead due to error object creation -+- **Simple vs Nested**: Nested validation scales linearly with complexity -+ -+The results confirm that Valar introduces no significant performance penalties beyond what's inherent to the chosen execution model (sync vs. async). -+ -+## Running Benchmarks -+ -+### Run All Benchmarks -+```bash -+sbt "valarBenchmarks / Jmh / run" -+``` -+``` -+### Run Specific Benchmarks -+``` bash -+# Run only sync benchmarks -+sbt "valarBenchmarks / Jmh / run .*sync.*" -+ -+# Run only async benchmarks -+sbt "valarBenchmarks / Jmh / run .*async.*" -+ -+# Run only valid cases -+sbt "valarBenchmarks / Jmh / run .*Valid.*" -+``` -+### Customize Benchmark Parameters -+``` bash -+# Run with custom iterations and warmup -+sbt "valarBenchmarks / Jmh / run -i 10 -wi 5 -f 2" -+ -+# Run with different output format -+sbt "valarBenchmarks / Jmh / run -rf json" -+``` -+### List Available Benchmarks -+``` bash -+sbt "valarBenchmarks / Jmh / run -l" -+``` -+## Benchmark Configuration -+The benchmarks are configured with: -+- : five iterations, 1 second each **Warmup** -+- : five iterations, 1 second each **Measurement** -+- : 1 fork **Fork** -+- : Average time (ns/op) **Mode** -+- **Threads**: 1 thread -+ -+## Test Data -+The benchmarks use the following test models: -+``` scala -+case class SimpleUser(name: String, age: Int) -+case class NestedCompany(name: String, owner: SimpleUser) -+``` -+With validation rules: -+- must be non-empty `name` -+- must be non-negative `age` -+ -+## Understanding Results -+- **ns/op**: Nanoseconds per operation (lower is better) -+- **Error**: 99.9% confidence interval -+- **Mode avgt**: Average time across all iterations -+ -+## Profiling -+For deeper performance analysis, you can use JMH's built-in profilers: -+``` bash -+# CPU profiling -+sbt "valarBenchmarks / Jmh / run -prof comp" -+ -+# Memory allocation profiling -+sbt "valarBenchmarks / Jmh / run -prof gc" -+ -+# Stack profiling -+sbt "valarBenchmarks / Jmh / run -prof stack" -+``` -+## Adding New Benchmarks -+To add new benchmarks: -+1. Add your benchmark method to `ValarBenchmark.scala` -+2. Annotate it with `@Benchmark` -+3. Ensure it returns a meaningful value to prevent dead code elimination -+4. Follow the existing naming conventions (`sync`/`async` + `Simple`/`Nested` + /`Invalid`) `Valid` -+ -+## Dependencies -+- JMH 1.37 -+- Scala 3.7.1 -+- OpenJDK 21+ -+ -+## Notes -+- Results may vary based on JVM version, hardware, and system load -+- Always run benchmarks multiple times to ensure consistency -+- Consider JVM warm-up effects when interpreting results -+- The async benchmarks include overhead, which inflates the numbers compared to pure async execution `Await.result` -diff --git a/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala b/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala -new file mode 100644 -index 0000000..0b81978 ---- /dev/null -+++ b/valar-benchmarks/src/main/scala/net/ghoula/valar/benchmarks/ValarBenchmark.scala -@@ -0,0 +1,108 @@ -+package net.ghoula.valar.benchmarks -+ -+import org.openjdk.jmh.annotations.* -+ -+import java.util.concurrent.TimeUnit -+import scala.concurrent.Await -+import scala.concurrent.ExecutionContext.Implicits.global -+import scala.concurrent.Future -+import scala.concurrent.duration.* -+ -+import net.ghoula.valar.* -+import net.ghoula.valar.ValidationErrors.ValidationError -+ -+/** Defines the JMH benchmark suite for Valar. -+ * -+ * This suite measures the performance of critical validation paths, including -+ * - Synchronous validation of simple and nested case classes. -+ * - Asynchronous validation with a mix of sync and async rules. -+ * -+ * To run these benchmarks, use the sbt command: `valarBenchmarks / Jmh / run` -+ */ -+@State(Scope.Thread) -+@BenchmarkMode(Array(Mode.AverageTime)) -+@OutputTimeUnit(TimeUnit.NANOSECONDS) -+@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) -+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) -+@Fork(1) -+class ValarBenchmark { -+ -+ // --- Test Data and Models --- -+ -+ case class SimpleUser(name: String, age: Int) -+ case class NestedCompany(name: String, owner: SimpleUser) -+ -+ private val validUser: SimpleUser = SimpleUser("John Doe", 30) -+ private val invalidUser: SimpleUser = SimpleUser("", -1) -+ private val validCompany: NestedCompany = NestedCompany("Valid Corp", validUser) -+ private val invalidCompany: NestedCompany = NestedCompany("", invalidUser) -+ -+ // --- Synchronous Validators --- -+ -+ given syncStringValidator: Validator[String] with { -+ def validate(value: String): ValidationResult[String] = -+ if (value.nonEmpty) ValidationResult.Valid(value) -+ else ValidationResult.invalid(ValidationError("String is empty")) -+ } -+ -+ given syncIntValidator: Validator[Int] with { -+ def validate(value: Int): ValidationResult[Int] = -+ if (value >= 0) ValidationResult.Valid(value) -+ else ValidationResult.invalid(ValidationError("Int is negative")) -+ } -+ -+ given syncUserValidator: Validator[SimpleUser] = Validator.derive -+ given syncCompanyValidator: Validator[NestedCompany] = Validator.derive -+ -+ // --- Asynchronous Validators --- -+ -+ given asyncStringValidator: AsyncValidator[String] with { -+ def validateAsync(name: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = -+ Future.successful(syncStringValidator.validate(name)) -+ } -+ -+ given asyncUserValidator: AsyncValidator[SimpleUser] = AsyncValidator.derive -+ given asyncCompanyValidator: AsyncValidator[NestedCompany] = AsyncValidator.derive -+ -+ // --- Benchmarks --- -+ -+ @Benchmark -+ def syncSimpleValid(): ValidationResult[SimpleUser] = { -+ syncUserValidator.validate(validUser) -+ } -+ -+ @Benchmark -+ def syncSimpleInvalid(): ValidationResult[SimpleUser] = { -+ syncUserValidator.validate(invalidUser) -+ } -+ -+ @Benchmark -+ def syncNestedValid(): ValidationResult[NestedCompany] = { -+ syncCompanyValidator.validate(validCompany) -+ } -+ -+ @Benchmark -+ def syncNestedInvalid(): ValidationResult[NestedCompany] = { -+ syncCompanyValidator.validate(invalidCompany) -+ } -+ -+ @Benchmark -+ def asyncSimpleValid(): ValidationResult[SimpleUser] = { -+ Await.result(asyncUserValidator.validateAsync(validUser), 1.second) -+ } -+ -+ @Benchmark -+ def asyncSimpleInvalid(): ValidationResult[SimpleUser] = { -+ Await.result(asyncUserValidator.validateAsync(invalidUser), 1.second) -+ } -+ -+ @Benchmark -+ def asyncNestedValid(): ValidationResult[NestedCompany] = { -+ Await.result(asyncCompanyValidator.validateAsync(validCompany), 1.second) -+ } -+ -+ @Benchmark -+ def asyncNestedInvalid(): ValidationResult[NestedCompany] = { -+ Await.result(asyncCompanyValidator.validateAsync(invalidCompany), 1.second) -+ } -+} -diff --git a/valar-core/README.md b/valar-core/README.md -new file mode 100644 -index 0000000..fcd70e0 ---- /dev/null -+++ b/valar-core/README.md -@@ -0,0 +1,94 @@ -+# valar-core -+ -+[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) -+[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -+ -+The `valar-core` module provides the core validation functionality for Valar, a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, detailed error messages. -+ -+## Key Components -+ -+### ValidationResult -+ -+Represents the outcome of validation as either Valid(value) or Invalid(errors): -+ -+```scala -+import net.ghoula.valar.ValidationErrors.ValidationError -+ -+enum ValidationResult[+A] { -+ case Valid(value: A) -+ case Invalid(errors: Vector[ValidationError]) -+} -+``` -+ -+### ValidationError -+ -+Opaque type providing rich context for validation errors, including: -+ -+* **message**: Human-readable description of the error. -+* **fieldPath**: Path to the field causing the error (e.g., user.address.street). -+* **code**: Optional application-specific error codes. -+* **severity**: Optional severity indicator (Error, Warning). -+* **expected/actual**: Information about expected and actual values. -+* **children**: Nested errors for structured reporting. -+ -+### Validator[A] -+ -+A typeclass defining validation logic for a given type: -+ -+```scala -+import net.ghoula.valar.ValidationResult -+ -+trait Validator[A] { -+ def validate(a: A): ValidationResult[A] -+} -+``` -+ -+Validators can be automatically derived for case classes using deriveValidatorMacro. -+ -+### ValidationObserver -+ -+The `ValidationObserver` trait provides a mechanism to decouple validation logic from cross-cutting concerns such as logging, metrics collection, or auditing: -+ -+```scala -+import net.ghoula.valar.* -+import org.slf4j.LoggerFactory -+ -+// Define a custom observer that logs validation results -+given loggingObserver: ValidationObserver with { -+ private val logger = LoggerFactory.getLogger("ValidationAnalytics") -+ -+ def onResult[A](result: ValidationResult[A]): Unit = result match { -+ case ValidationResult.Valid(_) => -+ logger.info("Validation succeeded") -+ case ValidationResult.Invalid(errors) => -+ logger.warn(s"Validation failed with ${errors.size} errors") -+ } -+} -+ -+// Use the observer in your validation flow -+val result = User.validate(user).observe() -+``` -+ -+Key features of ValidationObserver: -+* **Zero Overhead**: When using the default no-op observer, the compiler eliminates all observer-related code -+* **Non-Intrusive**: Observes validation results without altering the validation flow -+* **Chainable**: Works seamlessly with other operations in the validation pipeline -+* **Flexible**: Can be used for logging, metrics, alerting, or any other side effect -+ -+## Built-in Validators -+ -+Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This includes: -+ -+* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, Short, Char, Unit. -+* **Other Scala Types:** BigInt, BigDecimal, Symbol. -+* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -+* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their element/key/value types). -+* **Tuple Types:** Named tuples and regular tuples. -+* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. -+ -+Most built-in validators for scalar types (excluding those with obvious constraints like Int, String, Float, Double) are **pass-through** validators. You should define custom validators if you need specific constraints for these types. -+ -+## Usage -+ -+For detailed usage examples and more information, please refer to the [main Valar documentation](https://github.com/hakimjonas/valar). -\ No newline at end of file -diff --git a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala -new file mode 100644 -index 0000000..0d60ba4 ---- /dev/null -+++ b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala -@@ -0,0 +1,415 @@ -+package net.ghoula.valar -+ -+import java.time.* -+import java.util.UUID -+import scala.concurrent.{ExecutionContext, Future} -+import scala.deriving.Mirror -+import scala.quoted.{Expr, Quotes, Type} -+ -+import net.ghoula.valar.ValidationErrors.ValidationError -+import net.ghoula.valar.internal.Derivation -+ -+/** A typeclass for defining custom asynchronous validation logic for type `A`. -+ * -+ * This is used for validations that involve non-blocking I/O, such as checking for uniqueness in a -+ * database or calling an external service. -+ * -+ * @tparam A -+ * the type to be validated -+ */ -+trait AsyncValidator[A] { -+ -+ /** Asynchronously validate an instance of type `A`. -+ * -+ * @param a -+ * the instance to validate -+ * @param ec -+ * the execution context for the Future -+ * @return -+ * a `Future` containing the `ValidationResult[A]` -+ */ -+ def validateAsync(a: A)(using ec: ExecutionContext): Future[ValidationResult[A]] -+} -+ -+/** Companion object for the [[AsyncValidator]] typeclass. */ -+object AsyncValidator { -+ -+ /** Summons an implicit [[AsyncValidator]] instance for type `A`. */ -+ def apply[A](using v: AsyncValidator[A]): AsyncValidator[A] = v -+ -+ /** Lifts a synchronous `Validator` into an `AsyncValidator`. -+ * -+ * This allows synchronous validators to be used seamlessly in an asynchronous validation chain. -+ * -+ * @param v -+ * the synchronous validator to lift -+ * @return -+ * an `AsyncValidator` that wraps the result in a `Future.successful`. -+ */ -+ def fromSync[A](v: Validator[A]): AsyncValidator[A] = new AsyncValidator[A] { -+ def validateAsync(a: A)(using ec: ExecutionContext): Future[ValidationResult[A]] = -+ Future.successful(v.validate(a)) -+ } -+ -+ /** Generic helper method for folding validation results into errors and valid values. -+ * -+ * @param results -+ * the sequence of validation results to fold -+ * @param emptyAcc -+ * the empty accumulator for valid values -+ * @param addToAcc -+ * function to add a valid value to the accumulator -+ * @return -+ * a tuple containing accumulated errors and valid values -+ */ -+ private def foldValidationResults[A, B]( -+ results: Iterable[ValidationResult[A]], -+ emptyAcc: B, -+ addToAcc: (B, A) => B -+ ): (Vector[ValidationError], B) = { -+ results.foldLeft((Vector.empty[ValidationError], emptyAcc)) { -+ case ((errs, acc), ValidationResult.Valid(value)) => (errs, addToAcc(acc, value)) -+ case ((errs, acc), ValidationResult.Invalid(e)) => (errs ++ e, acc) -+ } -+ } -+ -+ /** Generic helper method for validating collections asynchronously. -+ * -+ * This method eliminates code duplication by providing a common validation pattern for different -+ * collection types. It validates each element in the collection asynchronously and accumulates -+ * both errors and valid results. -+ * -+ * @param items -+ * the collection of items to validate -+ * @param validator -+ * the validator for individual items -+ * @param buildResult -+ * function to construct the final collection from valid items -+ * @param ec -+ * execution context for async operations -+ * @return -+ * a Future containing the validation result -+ */ -+ private def validateCollection[A, C[_]]( -+ items: Iterable[A], -+ validator: AsyncValidator[A], -+ buildResult: Iterable[A] => C[A] -+ )(using ec: ExecutionContext): Future[ValidationResult[C[A]]] = { -+ val futureResults = items.map { item => -+ validator.validateAsync(item).map { -+ case ValidationResult.Valid(a) => ValidationResult.Valid(a) -+ case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) -+ } -+ } -+ -+ Future.sequence(futureResults).map { results => -+ val (errors, validValues) = foldValidationResults(results, Vector.empty[A], _ :+ _) -+ if (errors.isEmpty) ValidationResult.Valid(buildResult(validValues)) -+ else ValidationResult.Invalid(errors) -+ } -+ } -+ -+ /** Asynchronous validator for optional values. -+ * -+ * Validates an `Option[A]` by delegating to the underlying validator only when the value is -+ * present. Empty options are considered valid by default. -+ * -+ * @param v -+ * the validator for the wrapped type A -+ * @return -+ * an AsyncValidator that handles optional values -+ */ -+ given optionAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Option[A]] with { -+ def validateAsync(opt: Option[A])(using ec: ExecutionContext): Future[ValidationResult[Option[A]]] = -+ opt match { -+ case None => Future.successful(ValidationResult.Valid(None)) -+ case Some(value) => -+ v.validateAsync(value).map { -+ case ValidationResult.Valid(a) => ValidationResult.Valid(Some(a)) -+ case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) -+ } -+ } -+ } -+ -+ /** Asynchronous validator for lists. -+ * -+ * Validates a `List[A]` by applying the element validator to each item in the list -+ * asynchronously. All validation futures are executed concurrently, and their results are -+ * collected. Errors from individual elements are accumulated while preserving the order of valid -+ * elements. -+ * -+ * @param v -+ * the validator for list elements -+ * @return -+ * an AsyncValidator that handles lists -+ */ -+ given listAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[List[A]] with { -+ def validateAsync(xs: List[A])(using ec: ExecutionContext): Future[ValidationResult[List[A]]] = -+ validateCollection(xs, v, _.toList) -+ } -+ -+ /** Asynchronous validator for sequences. -+ * -+ * Validates a `Seq[A]` by applying the element validator to each item in the sequence -+ * asynchronously. All validation futures are executed concurrently, and their results are -+ * collected. Errors from individual elements are accumulated while preserving the order of valid -+ * elements. -+ * -+ * @param v -+ * the validator for sequence elements -+ * @return -+ * an AsyncValidator that handles sequences -+ */ -+ given seqAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Seq[A]] with { -+ def validateAsync(xs: Seq[A])(using ec: ExecutionContext): Future[ValidationResult[Seq[A]]] = -+ validateCollection(xs, v, _.toSeq) -+ } -+ -+ /** Asynchronous validator for vectors. -+ * -+ * Validates a `Vector[A]` by applying the element validator to each item in the vector -+ * asynchronously. All validation futures are executed concurrently, and their results are -+ * collected. Errors from individual elements are accumulated while preserving the order of valid -+ * elements. -+ * -+ * @param v -+ * the validator for vector elements -+ * @return -+ * an AsyncValidator that handles vectors -+ */ -+ given vectorAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Vector[A]] with { -+ def validateAsync(xs: Vector[A])(using ec: ExecutionContext): Future[ValidationResult[Vector[A]]] = -+ validateCollection(xs, v, _.toVector) -+ } -+ -+ /** Asynchronous validator for sets. -+ * -+ * Validates a `Set[A]` by applying the element validator to each item in the set asynchronously. -+ * All validation futures are executed concurrently, and their results are collected. Errors from -+ * individual elements are accumulated while preserving the valid elements in the resulting set. -+ * -+ * @param v -+ * the validator for set elements -+ * @return -+ * an AsyncValidator that handles sets -+ */ -+ given setAsyncValidator[A](using v: AsyncValidator[A]): AsyncValidator[Set[A]] with { -+ def validateAsync(xs: Set[A])(using ec: ExecutionContext): Future[ValidationResult[Set[A]]] = -+ validateCollection(xs, v, _.toSet) -+ } -+ -+ /** Asynchronous validator for maps. -+ * -+ * Validates a `Map[K, V]` by applying the key validator to each key and the value validator to -+ * each value asynchronously. All validation futures are executed concurrently, and their results -+ * are collected. Errors from individual keys and values are accumulated with proper field path -+ * annotation, while valid key-value pairs are preserved in the resulting map. -+ * -+ * @param vk -+ * the validator for map keys -+ * @param vv -+ * the validator for map values -+ * @return -+ * an AsyncValidator that handles maps -+ */ -+ given mapAsyncValidator[K, V](using vk: AsyncValidator[K], vv: AsyncValidator[V]): AsyncValidator[Map[K, V]] with { -+ def validateAsync(m: Map[K, V])(using ec: ExecutionContext): Future[ValidationResult[Map[K, V]]] = { -+ val futureResults = m.map { case (k, v) => -+ val futureKey = vk.validateAsync(k).map { -+ case ValidationResult.Valid(kk) => ValidationResult.Valid(kk) -+ case ValidationResult.Invalid(es) => -+ ValidationResult.Invalid(es.map(_.annotateField("key", k.getClass.getSimpleName))) -+ } -+ val futureValue = vv.validateAsync(v).map { -+ case ValidationResult.Valid(vv) => ValidationResult.Valid(vv) -+ case ValidationResult.Invalid(es) => -+ ValidationResult.Invalid(es.map(_.annotateField("value", v.getClass.getSimpleName))) -+ } -+ -+ for { -+ keyResult <- futureKey -+ valueResult <- futureValue -+ } yield keyResult.zip(valueResult) -+ } -+ -+ Future.sequence(futureResults).map { results => -+ val (errors, validPairs) = foldValidationResults(results, Map.empty[K, V], _ + _) -+ if (errors.isEmpty) ValidationResult.Valid(validPairs) else ValidationResult.Invalid(errors) -+ } -+ } -+ } -+ -+ /** Asynchronous validator for non-negative integers. -+ * -+ * Validates that an integer value is non-negative (>= 0). This validator is lifted from the -+ * corresponding synchronous validator and is used as a fallback when no custom integer validator -+ * is provided. -+ */ -+ given nonNegativeIntAsyncValidator: AsyncValidator[Int] = fromSync(Validator.nonNegativeIntValidator) -+ -+ /** Asynchronous validator for finite floating-point numbers. -+ * -+ * Validates that a float value is finite (not NaN or infinite). This validator is lifted from -+ * the corresponding synchronous validator and is used as a fallback when no custom float -+ * validator is provided. -+ */ -+ given finiteFloatAsyncValidator: AsyncValidator[Float] = fromSync(Validator.finiteFloatValidator) -+ -+ /** Asynchronous validator for finite double-precision numbers. -+ * -+ * Validates that a double value is finite (not NaN or infinite). This validator is lifted from -+ * the corresponding synchronous validator and is used as a fallback when no custom double -+ * validator is provided. -+ */ -+ given finiteDoubleAsyncValidator: AsyncValidator[Double] = fromSync(Validator.finiteDoubleValidator) -+ -+ /** Asynchronous validator for non-empty strings. -+ * -+ * Validates that a string value is not empty. This validator is lifted from the corresponding -+ * synchronous validator and is used as a fallback when no custom string validator is provided. -+ */ -+ given nonEmptyStringAsyncValidator: AsyncValidator[String] = fromSync(Validator.nonEmptyStringValidator) -+ -+ /** Asynchronous validator for boolean values. -+ * -+ * Pass-through validator for boolean values that always succeeds. This validator is lifted from -+ * the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given booleanAsyncValidator: AsyncValidator[Boolean] = fromSync(Validator.booleanValidator) -+ -+ /** Asynchronous validator for byte values. -+ * -+ * Pass-through validator for byte values that always succeeds. This validator is lifted from the -+ * corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given byteAsyncValidator: AsyncValidator[Byte] = fromSync(Validator.byteValidator) -+ -+ /** Asynchronous validator for short values. -+ * -+ * Pass-through validator for short values that always succeeds. This validator is lifted from -+ * the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given shortAsyncValidator: AsyncValidator[Short] = fromSync(Validator.shortValidator) -+ -+ /** Asynchronous validator for long values. -+ * -+ * Pass-through validator for long values that always succeeds. This validator is lifted from the -+ * corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given longAsyncValidator: AsyncValidator[Long] = fromSync(Validator.longValidator) -+ -+ /** Asynchronous validator for character values. -+ * -+ * Pass-through validator for character values that always succeeds. This validator is lifted -+ * from the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given charAsyncValidator: AsyncValidator[Char] = fromSync(Validator.charValidator) -+ -+ /** Asynchronous validator for unit values. -+ * -+ * Pass-through validator for unit values that always succeeds. This validator is lifted from the -+ * corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given unitAsyncValidator: AsyncValidator[Unit] = fromSync(Validator.unitValidator) -+ -+ /** Asynchronous validator for arbitrary precision integers. -+ * -+ * Pass-through validator for BigInt values that always succeeds. This validator is lifted from -+ * the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given bigIntAsyncValidator: AsyncValidator[BigInt] = fromSync(Validator.bigIntValidator) -+ -+ /** Asynchronous validator for arbitrary precision decimal numbers. -+ * -+ * Pass-through validator for BigDecimal values that always succeeds. This validator is lifted -+ * from the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given bigDecimalAsyncValidator: AsyncValidator[BigDecimal] = fromSync(Validator.bigDecimalValidator) -+ -+ /** Asynchronous validator for symbol values. -+ * -+ * Pass-through validator for symbol values that always succeeds. This validator is lifted from -+ * the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given symbolAsyncValidator: AsyncValidator[Symbol] = fromSync(Validator.symbolValidator) -+ -+ /** Asynchronous validator for UUID values. -+ * -+ * Pass-through validator for UUID values that always succeeds. This validator is lifted from the -+ * corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given uuidAsyncValidator: AsyncValidator[UUID] = fromSync(Validator.uuidValidator) -+ -+ /** Asynchronous validator for instant values. -+ * -+ * Pass-through validator for Instant values that always succeeds. This validator is lifted from -+ * the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given instantAsyncValidator: AsyncValidator[Instant] = fromSync(Validator.instantValidator) -+ -+ /** Asynchronous validator for local date values. -+ * -+ * Pass-through validator for LocalDate values that always succeeds. This validator is lifted -+ * from the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given localDateAsyncValidator: AsyncValidator[LocalDate] = fromSync(Validator.localDateValidator) -+ -+ /** Asynchronous validator for local time values. -+ * -+ * Pass-through validator for LocalTime values that always succeeds. This validator is lifted -+ * from the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given localTimeAsyncValidator: AsyncValidator[LocalTime] = fromSync(Validator.localTimeValidator) -+ -+ /** Asynchronous validator for local date-time values. -+ * -+ * Pass-through validator for LocalDateTime values that always succeeds. This validator is lifted -+ * from the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given localDateTimeAsyncValidator: AsyncValidator[LocalDateTime] = fromSync(Validator.localDateTimeValidator) -+ -+ /** Asynchronous validator for zoned date-time values. -+ * -+ * Pass-through validator for ZonedDateTime values that always succeeds. This validator is lifted -+ * from the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given zonedDateTimeAsyncValidator: AsyncValidator[ZonedDateTime] = fromSync(Validator.zonedDateTimeValidator) -+ -+ /** Asynchronous validator for duration values. -+ * -+ * Pass-through validator for Duration values that always succeeds. This validator is lifted from -+ * the corresponding synchronous validator and provides consistent async behavior. -+ */ -+ given javaDurationAsyncValidator: AsyncValidator[Duration] = fromSync(Validator.durationValidator) -+ -+ /** Automatically derives an `AsyncValidator` for case classes using Scala 3 macros. -+ * -+ * This method provides compile-time derivation of async validators for product types by -+ * analyzing the case class structure and generating appropriate validation logic that validates -+ * each field using the corresponding validator in scope. -+ * -+ * @param m -+ * the Mirror.ProductOf evidence for the type T -+ * @return -+ * a derived AsyncValidator instance for type T -+ */ -+ inline def derive[T](using m: Mirror.ProductOf[T]): AsyncValidator[T] = -+ ${ deriveImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } -+ -+ /** Macro implementation for deriving an `AsyncValidator`. -+ * -+ * This method implements the actual macro logic for generating async validator instances at -+ * compile time. It delegates to the internal Derivation utility with the async flag set to true -+ * to generate appropriate asynchronous validation code. -+ * -+ * @param m -+ * the Mirror.ProductOf expression -+ * @return -+ * an expression representing the derived AsyncValidator -+ */ -+ private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( -+ m: Expr[Mirror.ProductOf[T]] -+ )(using q: Quotes): Expr[AsyncValidator[T]] = { -+ Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = true).asExprOf[AsyncValidator[T]] -+ } -+} -diff --git a/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala -new file mode 100644 -index 0000000..1b673a1 ---- /dev/null -+++ b/valar-core/src/main/scala/net/ghoula/valar/ValidationObserver.scala -@@ -0,0 +1,150 @@ -+package net.ghoula.valar -+import net.ghoula.valar.ValidationResult -+ -+/** Defines the foundational extensibility pattern for Valar. -+ * -+ * This typeclass represents Valar's canonical pattern for extension development. It's designed to -+ * be the standard way to build integrations and extensions for the validation library. By -+ * implementing this trait and providing it as a `given` instance, developers can: -+ * -+ * - Extend Valar with cross-cutting concerns (logging, metrics, auditing) -+ * - Build composable extensions that work together seamlessly -+ * - Integrate with external monitoring and diagnostic systems -+ * - Create specialized behaviors without modifying validation logic -+ * -+ * @see -+ * [[ValidationObserver.noOpObserver]] for the default, zero-overhead implementation used when no -+ * custom observer is provided. -+ * -+ * ==Architectural Pattern== -+ * -+ * The `ValidationObserver` pattern is the recommended approach for extending Valar's capabilities. -+ * By using this pattern, you benefit from: -+ * -+ * - A standardized, type-safe interface for integrating with Valar -+ * - Zero-cost abstractions through the inline implementation when not used -+ * - Clean composition with other features (like the translator module) -+ * - Future compatibility with upcoming Valar modules (planned: valar-cats-effect, valar-zio) -+ * -+ * When implementing extensions to Valar, prefer extending this trait over creating alternative -+ * patterns. -+ * -+ * @example -+ * Building a simple extension for validation logging: -+ * {{{ -+ * import org.slf4j.LoggerFactory -+ * -+ * // 1. Define your extension by implementing ValidationObserver -+ * given loggingObserver: ValidationObserver with { -+ * private val logger = LoggerFactory.getLogger("ValidationAnalytics") -+ * -+ * def onResult[A](result: ValidationResult[A]): Unit = result match { -+ * case ValidationResult.Valid(_) => -+ * logger.info("Validation succeeded.") -+ * case ValidationResult.Invalid(errors) => -+ * logger.warn(s"Validation failed with ${errors.size} errors: ${errors.map(_.message).mkString(", ")}") -+ * } -+ * } -+ * -+ * // 2. Use your extension with the standard observe() pattern -+ * val result = someValidation().observe() // The observer is automatically used -+ * -+ * // 3. Extensions compose cleanly with other Valar features -+ * val processedResult = someValidation() -+ * .observe() // Trigger logging/metrics through your observer -+ * .map(transform) -+ * // Can be chained with other extensions like translator -+ * }}} -+ * -+ * Creating a reusable extension module: -+ * {{{ -+ * // Define a specialized observer for metrics collection -+ * trait MetricsObserver extends ValidationObserver { -+ * def recordMetric(name: String, value: Double): Unit -+ * -+ * def onResult[A](result: ValidationResult[A]): Unit = result match { -+ * case ValidationResult.Valid(_) => -+ * recordMetric("validation.success", 1.0) -+ * case ValidationResult.Invalid(errors) => -+ * recordMetric("validation.failure", 1.0) -+ * recordMetric("validation.error.count", errors.size.toDouble) -+ * } -+ * } -+ * -+ * // Concrete implementation for a specific metrics library -+ * given PrometheusMetricsObserver: MetricsObserver with { -+ * def recordMetric(name: String, value: Double): Unit = { -+ * // Implementation using Prometheus client -+ * } -+ * } -+ * }}} -+ */ -+trait ValidationObserver { -+ -+ /** A callback executed for each `ValidationResult` passed to the `observe` method. -+ * -+ * Implementations of this method can inspect the result and trigger side effects, such as -+ * writing to a log, incrementing a metrics counter, or sending an alert. This method should not -+ * throw exceptions. -+ * -+ * @tparam A -+ * The type of the value within the ValidationResult. -+ * @param result -+ * The `ValidationResult` to be observed. -+ */ -+ def onResult[A](result: ValidationResult[A]): Unit -+} -+ -+object ValidationObserver { -+ -+ /** The default, "no-op" `ValidationObserver` that performs no action. -+ * -+ * This instance is provided as an `inline given`. This is a critical optimization feature. When -+ * this default observer is in scope, the Scala compiler, in conjunction with the `inline` -+ * `observe()` extension method, will perform full dead-code elimination. -+ * -+ * This ensures that the observability feature is truly zero-cost and has no performance overhead -+ * unless a custom `ValidationObserver` is explicitly provided. -+ */ -+ inline given noOpObserver: ValidationObserver with { -+ def onResult[A](result: ValidationResult[A]): Unit = () // No operation -+ } -+} -+ -+extension [A](vr: ValidationResult[A]) { -+ -+ /** Applies the in-scope `ValidationObserver` to this `ValidationResult`. -+ * -+ * This extension method is the primary interface for the ValidationObserver extension pattern. -+ * It enables side-effecting operations and extensions to be applied to a validation result -+ * without altering the validation logic or flow. It returns the original result unchanged, -+ * allowing for seamless method chaining with other operations. -+ * -+ * ===Extension Pattern Entry Point=== -+ * -+ * This method serves as the standardized entry point for all extensions built on the -+ * ValidationObserver pattern. Current and future Valar modules that follow this pattern will be -+ * usable through this consistent interface. -+ * -+ * This method is declared `inline` to facilitate powerful compile-time optimizations. If the -+ * default [[ValidationObserver.noOpObserver]] is in scope, the compiler will eliminate this -+ * entire method call from the generated bytecode, ensuring zero runtime overhead. -+ * -+ * @param observer -+ * The `ValidationObserver` instance provided by the implicit context. -+ * @return -+ * The original, unmodified `ValidationResult`, to allow for method chaining. -+ * @example -+ * {{{ import net.ghoula.valar.Validator import net.ghoula.valar.ValidationResult -+ * -+ * def validateUsername(name: String): ValidationResult[String] = ??? -+ * -+ * // Assuming a `given ValidationObserver` is in scope val result = -+ * validateUsername("test-user") .observe() // The observer's onResult is called here -+ * .map(_.toUpperCase) }}} -+ */ -+ inline def observe()(using observer: ValidationObserver): ValidationResult[A] = { -+ observer.onResult(vr) -+ vr -+ } -+} -diff --git a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala -index 89a1d2e..e6069fe 100644 ---- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala -+++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala -@@ -11,7 +11,7 @@ import scala.reflect.ClassTag - import net.ghoula.valar.ValidationErrors.ValidationError - import net.ghoula.valar.ValidationHelpers.* - import net.ghoula.valar.ValidationResult.{validateUnion, given} --import net.ghoula.valar.internal.MacroHelper -+import net.ghoula.valar.internal.Derivation - - /** A typeclass for defining custom validation logic for type `A`. - * -@@ -39,6 +39,8 @@ object Validator { - /** Summons an implicit [[Validator]] instance for type `A`. */ - def apply[A](using v: Validator[A]): Validator[A] = v - -+ // ... keep all the existing given instances exactly as they are ... -+ - /** Validates that an Int is non-negative (>= 0). Uses [[ValidationHelpers.nonNegativeInt]]. */ - given nonNegativeIntValidator: Validator[Int] with { - def validate(i: Int): ValidationResult[Int] = nonNegativeInt(i) -@@ -63,31 +65,13 @@ object Validator { - def validate(s: String): ValidationResult[String] = nonEmpty(s) - } - -- /** Default validator for `Option[A]`. If the option is `Some(a)`, it validates the inner `a` -- * using the implicit `Validator[A]`. If the option is `None`, it is considered `Valid`. -- * Accumulates errors from the inner validation if `Some`. -- * -- * @tparam A -- * the inner type of the Option. -- * @param v -- * the implicit validator for the inner type `A`. -- * @return -- * A `Validator[Option[A]]`. -- */ -+ /** Default validator for `Option[A]`. */ - given optionValidator[A](using v: Validator[A]): Validator[Option[A]] with { - def validate(opt: Option[A]): ValidationResult[Option[A]] = - optional(opt)(using v) - } - -- /** Validates a `List[A]` by validating each element using the implicit `Validator[A]`. -- * Accumulates all errors found in invalid elements. -- * @tparam A -- * the element type. -- * @param v -- * the implicit validator for the element type `A`. -- * @return -- * A `Validator[List[A]]`. -- */ -+ /** Validates a `List[A]` by validating each element. */ - given listValidator[A](using v: Validator[A]): Validator[List[A]] with { - def validate(xs: List[A]): ValidationResult[List[A]] = { - val results = xs.map(v.validate) -@@ -99,16 +83,7 @@ object Validator { - } - } - -- /** Validates a `Seq[A]` by validating each element using the implicit `Validator[A]`. Accumulates -- * all errors found in invalid elements. -- * -- * @tparam A -- * the element type. -- * @param v -- * the implicit validator for the element type `A`. -- * @return -- * A `Validator[Seq[A]]`. -- */ -+ /** Validates a `Seq[A]` by validating each element. */ - given seqValidator[A](using v: Validator[A]): Validator[Seq[A]] with { - def validate(xs: Seq[A]): ValidationResult[Seq[A]] = { - val results = xs.map(v.validate) -@@ -120,16 +95,7 @@ object Validator { - } - } - -- /** Validates a `Vector[A]` by validating each element using the implicit `Validator[A]`. -- * Accumulates all errors found in invalid elements. -- * -- * @tparam A -- * the element type. -- * @param v -- * the implicit validator for the element type `A`. -- * @return -- * A `Validator[Vector[A]]`. -- */ -+ /** Validates a `Vector[A]` by validating each element. */ - given vectorValidator[A](using v: Validator[A]): Validator[Vector[A]] with { - def validate(xs: Vector[A]): ValidationResult[Vector[A]] = { - val results = xs.map(v.validate) -@@ -141,18 +107,7 @@ object Validator { - } - } - -- /** Validates a `Set[A]` by validating each element using the implicit `Validator[A]`. Accumulates -- * all errors found in invalid elements. -- * -- * @note -- * The order of accumulated errors from a Set is not guaranteed due to its unordered nature. -- * @tparam A -- * the element type. -- * @param v -- * the implicit validator for the element type `A`. -- * @return -- * A `Validator[Set[A]]`. -- */ -+ /** Validates a `Set[A]` by validating each element. */ - given setValidator[A](using v: Validator[A]): Validator[Set[A]] with { - def validate(xs: Set[A]): ValidationResult[Set[A]] = { - val results = xs.map(v.validate) -@@ -164,21 +119,7 @@ object Validator { - } - } - -- /** Validates a `Map[K, V]` by validating each key with `Validator[K]` and each value with -- * `Validator[V]`. Accumulates all errors from invalid keys and values. Errors are annotated with -- * context indicating whether they originated from a 'key' or a 'value'. -- * -- * @tparam K -- * the key type. -- * @tparam V -- * the value type. -- * @param vk -- * the implicit validator for the key type `K`. -- * @param vv -- * the implicit validator for the value type `V`. -- * @return -- * A `Validator[Map[K, V]]`. -- */ -+ /** Validates a `Map[K, V]` by validating each key and value. */ - given mapValidator[K, V](using vk: Validator[K], vv: Validator[V]): Validator[Map[K, V]] with { - def validate(m: Map[K, V]): ValidationResult[Map[K, V]] = { - val results = m.map { case (k, v) => -@@ -206,8 +147,7 @@ object Validator { - } - } - -- /** Helper method for validating iterable collections and building results. -- */ -+ /** Helper for validating iterable collections. */ - private def validateIterable[A, C[_]]( - xs: Iterable[A], - builder: Vector[A] => C[A] -@@ -225,262 +165,92 @@ object Validator { - else ValidationResult.Invalid(errors) - } - -- /** Validates an `Array[A]` by validating each element using the implicit `Validator[A]`. -- * Accumulates all errors found in invalid elements. -- * -- * @tparam A -- * the element type. -- * @param v -- * the implicit validator for the element type `A`. -- * @param ct -- * implicit ClassTag required for creating the resulting Array. -- * @return -- * A `Validator[Array[A]]`. -- */ -+ /** Validates an `Array[A]`. */ - given arrayValidator[A](using v: Validator[A], ct: ClassTag[A]): Validator[Array[A]] with { - def validate(xs: Array[A]): ValidationResult[Array[A]] = - validateIterable(xs, (validValues: Vector[A]) => validValues.toArray) - } - -- /** Validates an `ArraySeq[A]` by validating each element using the implicit `Validator[A]`. -- * Accumulates all errors found in invalid elements. -- * -- * @tparam A -- * the element type. -- * @param v -- * the implicit validator for the element type `A`. -- * @param ct -- * implicit ClassTag required for the underlying Array. -- * @return -- * A `Validator[ArraySeq[A]]`. -- */ -+ /** Validates an `ArraySeq[A]`. */ - given arraySeqValidator[A](using v: Validator[A], ct: ClassTag[A]): Validator[ArraySeq[A]] with { - def validate(xs: ArraySeq[A]): ValidationResult[ArraySeq[A]] = - validateIterable(xs, (validValues: Vector[A]) => ArraySeq.unsafeWrapArray(validValues.toArray)) - } - -- /** Validates an intersection type `A & B` by applying both `Validator[A]` and `Validator[B]`. The -- * result is `Valid` only if *both* underlying validators succeed. If either or both fail, their -- * errors are accumulated using `zip`. -- * -- * @tparam A -- * the first type in the intersection. -- * @tparam B -- * the second type in the intersection. -- * @param va -- * the implicit validator for type `A`. -- * @param vb -- * the implicit validator for type `B`. -- * @return -- * A `Validator[A & B]`. -- */ -+ /** Validates an intersection type `A & B`. */ - given intersectionValidator[A, B](using va: Validator[A], vb: Validator[B]): Validator[A & B] with { - def validate(ab: A & B): ValidationResult[A & B] = - va.validate(ab).zip(vb.validate(ab)).map(_ => ab) - } - -- /** Validates a union type `A | B`. It attempts to validate the input value first as type `A` and -- * then as type `B`. The result is `Valid` if *either* validation succeeds (preferring the result -- * for `A` if both succeed). If both underlying validations fail, it returns an `Invalid` result -- * containing a summary error wrapping the errors from both attempts. Delegates to -- * [[ValidationResult.validateUnion]]. -- * -- * @tparam A -- * the first type in the union. -- * @tparam B -- * the second type in the union. -- * @param va -- * the implicit validator for type `A`. -- * @param vb -- * the implicit validator for type `B`. -- * @param ctA -- * implicit ClassTag required for runtime type checking for `A`. -- * @param ctB -- * implicit ClassTag required for runtime type checking for `B`. -- * @return -- * A `Validator[A | B]`. -- */ -- given unionValidator[A, B](using va: Validator[A], vb: Validator[B], ctA: ClassTag[A], ctB: ClassTag[B]): Validator[ -- A | B -- ] with { -+ /** Validates a union type `A | B`. */ -+ given unionValidator[A, B](using -+ va: Validator[A], -+ vb: Validator[B], -+ ctA: ClassTag[A], -+ ctB: ClassTag[B] -+ ): Validator[A | B] with { - def validate(value: A | B): ValidationResult[A | B] = validateUnion[A, B](value)(using va, vb, ctA, ctB) - } - -- /** This section provides "pass-through" `given` instances that always return `Valid`. They are -- * marked as `inline` to allow the compiler to eliminate the validation overhead, making them -- * zero-cost abstractions when used by the `deriveValidatorMacro`. -- */ -+ /** This section provides "pass-through" `given` instances that always return `Valid`. */ - inline given booleanValidator: Validator[Boolean] with { - def validate(b: Boolean): ValidationResult[Boolean] = ValidationResult.Valid(b) - } -- - inline given byteValidator: Validator[Byte] with { - def validate(b: Byte): ValidationResult[Byte] = ValidationResult.Valid(b) - } -- - inline given shortValidator: Validator[Short] with { - def validate(s: Short): ValidationResult[Short] = ValidationResult.Valid(s) - } -- - inline given longValidator: Validator[Long] with { - def validate(l: Long): ValidationResult[Long] = ValidationResult.Valid(l) - } -- - inline given charValidator: Validator[Char] with { - def validate(c: Char): ValidationResult[Char] = ValidationResult.Valid(c) - } -- - inline given unitValidator: Validator[Unit] with { - def validate(u: Unit): ValidationResult[Unit] = ValidationResult.Valid(u) - } -- - inline given bigIntValidator: Validator[BigInt] with { - def validate(bi: BigInt): ValidationResult[BigInt] = ValidationResult.Valid(bi) - } -- - inline given bigDecimalValidator: Validator[BigDecimal] with { - def validate(bd: BigDecimal): ValidationResult[BigDecimal] = ValidationResult.Valid(bd) - } -- - inline given symbolValidator: Validator[Symbol] with { - def validate(s: Symbol): ValidationResult[Symbol] = ValidationResult.Valid(s) - } -- - inline given uuidValidator: Validator[UUID] with { - def validate(v: UUID): ValidationResult[UUID] = ValidationResult.Valid(v) - } -- - inline given instantValidator: Validator[Instant] with { - def validate(v: Instant): ValidationResult[Instant] = ValidationResult.Valid(v) - } -- - inline given localDateValidator: Validator[LocalDate] with { - def validate(v: LocalDate): ValidationResult[LocalDate] = ValidationResult.Valid(v) - } -- - inline given localTimeValidator: Validator[LocalTime] with { - def validate(v: LocalTime): ValidationResult[LocalTime] = ValidationResult.Valid(v) - } -- - inline given localDateTimeValidator: Validator[LocalDateTime] with { - def validate(v: LocalDateTime): ValidationResult[LocalDateTime] = ValidationResult.Valid(v) - } -- - inline given zonedDateTimeValidator: Validator[ZonedDateTime] with { - def validate(v: ZonedDateTime): ValidationResult[ZonedDateTime] = ValidationResult.Valid(v) - } -- - inline given durationValidator: Validator[Duration] with { - def validate(v: Duration): ValidationResult[Duration] = ValidationResult.Valid(v) - } - -- /** Automatically derives a `Validator` for case classes using Scala 3 macros. -- * -- * Derivation is recursive, validating each field using implicitly available validators. Errors -- * from nested fields are aggregated and annotated with clear field context. -- * -- * @tparam T -- * case class type to derive validator for -- * @param m -- * implicit Scala 3 Mirror for reflection -- * @return -- * Validator[T] automatically derived validator instance -- */ -- inline def deriveValidatorMacro[T](using m: Mirror.ProductOf[T]): Validator[T] = -- ${ deriveValidatorMacroImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } -+ /** Automatically derives a `Validator` for case classes using Scala 3 macros. */ -+ inline def derive[T](using m: Mirror.ProductOf[T]): Validator[T] = -+ ${ deriveImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } - -- private def deriveValidatorMacroImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( -+ /** Macro implementation for deriving a `Validator`. */ -+ private def deriveImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( - m: Expr[Mirror.ProductOf[T]] - )(using q: Quotes): Expr[Validator[T]] = { -- import q.reflect.* -- -- val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] -- val fieldLabels: List[String] = getLabels[Labels] -- val isOptionList: List[Boolean] = getIsOptionFlags[Elems] -- -- val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) -- val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) -- val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) -- -- '{ -- new Validator[T] { -- def validate(a: T): ValidationResult[T] = { -- a match { -- case product: Product => -- val validators = ${ validatorsExpr } -- val labels = ${ fieldLabelsExpr } -- val isOptionFlags = ${ isOptionListExpr } -- -- val results = product.productIterator.zipWithIndex.map { case (fieldValue, i) => -- val label = labels(i) -- val isOption = isOptionFlags(i) -- -- if (Option(fieldValue).isEmpty && !isOption) { -- ValidationResult.invalid( -- ValidationError( -- message = s"Field '$label' must not be null.", -- fieldPath = List(label), -- expected = Some("non-null value"), -- actual = Some("null") -- ) -- ) -- } else { -- val validator = validators(i) -- validator.validate(fieldValue) match { -- case ValidationResult.Valid(v) => ValidationResult.Valid(v) -- case ValidationResult.Invalid(errs) => -- val fieldTypeName = Option(fieldValue).map(_.getClass.getSimpleName).getOrElse("null") -- ValidationResult.Invalid(errs.map(_.annotateField(label, fieldTypeName))) -- } -- } -- }.toList -- -- val allErrors = results.collect { case ValidationResult.Invalid(e) => e }.flatten.toVector -- if (allErrors.isEmpty) { -- val validValues = results.collect { case ValidationResult.Valid(v) => v } -- ValidationResult.Valid($m.fromProduct(Tuple.fromArray(validValues.toArray))) -- } else { -- ValidationResult.Invalid(allErrors) -- } -- } -- } -- } -- } -- } -- -- private def summonValidators[Elems <: Tuple: Type](using q: Quotes): List[Expr[Validator[Any]]] = { -- import q.reflect.* -- Type.of[Elems] match { -- case '[EmptyTuple] => Nil -- case '[h *: t] => -- val validatorExpr = Expr.summon[Validator[h]].getOrElse { -- report.errorAndAbort(s"Could not find a given Validator for type ${Type.show[h]}") -- } -- '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] -- } -- } -- -- private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { -- import q.reflect.* -- def loop(tpe: TypeRepr): List[String] = tpe.dealias match { -- case AppliedType(_, List(head, tail)) => -- head match { -- case ConstantType(StringConstant(label)) => label :: loop(tail) -- case _ => report.errorAndAbort(s"Macro error: Expected a literal string for a label, but got ${head.show}") -- } -- case t if t =:= TypeRepr.of[EmptyTuple] => Nil -- case _ => report.errorAndAbort(s"Macro error: The labels tuple was not structured as expected: ${tpe.show}") -- } -- -- loop(TypeRepr.of[Labels]) -- } -- -- private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { -- import q.reflect.* -- Type.of[Elems] match { -- case '[EmptyTuple] => Nil -- case '[h *: t] => -- (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] -- } -+ Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = false).asExprOf[Validator[T]] - } - } -diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala -new file mode 100644 -index 0000000..3941d64 ---- /dev/null -+++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala -@@ -0,0 +1,343 @@ -+package net.ghoula.valar.internal -+ -+import scala.concurrent.{ExecutionContext, Future} -+import scala.deriving.Mirror -+import scala.quoted.{Expr, Quotes, Type} -+ -+import net.ghoula.valar.ValidationErrors.ValidationError -+import net.ghoula.valar.{AsyncValidator, ValidationResult, Validator} -+ -+/** Internal derivation engine for automatically generating validator instances. -+ * -+ * This object provides the core macro infrastructure for deriving both synchronous and -+ * asynchronous validators for product types (case classes). It handles the compile-time generation -+ * of validation logic, field introspection, and error annotation. -+ * -+ * @note -+ * This object is strictly for internal use by Valar's macro system and is not part of the public -+ * API. All methods, signatures, and behavior are subject to change without notice in future -+ * versions. -+ * -+ * @since 0.5.0 -+ */ -+object Derivation { -+ -+ /** Processes validation results from multiple fields into a single consolidated result. -+ * -+ * This method aggregates validation outcomes from all fields of a product type. If any field -+ * validation fails, all errors are collected and returned as an `Invalid` result. If all -+ * validations succeed, the validated values are used to reconstruct the original product type -+ * using the provided `Mirror`. -+ * -+ * @param results -+ * The validation results from each field of the product type. -+ * @param mirror -+ * The mirror instance used to reconstruct the product type from validated field values. -+ * @tparam T -+ * The product type being validated. -+ * @return -+ * A `ValidationResult[T]` containing either the reconstructed valid product or accumulated -+ * errors. -+ */ -+ private def processResults[T]( -+ results: List[ValidationResult[Any]], -+ mirror: Mirror.ProductOf[T] -+ ): ValidationResult[T] = { -+ val allErrors = results.collect { case ValidationResult.Invalid(e) => e }.flatten.toVector -+ if (allErrors.isEmpty) { -+ val validValues = results.collect { case ValidationResult.Valid(v) => v } -+ ValidationResult.Valid(mirror.fromProduct(Tuple.fromArray(validValues.toArray))) -+ } else { -+ ValidationResult.Invalid(allErrors) -+ } -+ } -+ -+ /** Enhances validation results with field-specific context information. -+ * -+ * This method annotates validation errors with the field name and type information, providing -+ * better debugging and error reporting capabilities. Valid results are passed through unchanged. -+ * -+ * @param result -+ * The validation result to annotate. -+ * @param label -+ * The field name for error context. -+ * @param fieldValue -+ * The field value used to extract type information. -+ * @return -+ * The validation result with enhanced error context if invalid, or unchanged if valid. -+ */ -+ private def annotateErrors( -+ result: ValidationResult[Any], -+ label: String, -+ fieldValue: Any -+ ): ValidationResult[Any] = { -+ result match { -+ case ValidationResult.Valid(v) => ValidationResult.Valid(v) -+ case ValidationResult.Invalid(errs) => -+ val fieldTypeName = Option(fieldValue).map(_.getClass.getSimpleName).getOrElse("null") -+ ValidationResult.Invalid(errs.map(_.annotateField(label, fieldTypeName))) -+ } -+ } -+ -+ /** Applies validation logic to each field of a product type with null-safety handling. -+ * -+ * This method iterates through the fields of a product type, applying the appropriate validation -+ * logic to each field. It handles null values appropriately based on whether the field is -+ * optional, and provides consistent error handling for both synchronous and asynchronous -+ * validation scenarios. -+ * -+ * @param product -+ * The product instance whose fields are being validated. -+ * @param validators -+ * The sequence of validators corresponding to each field. -+ * @param labels -+ * The field names for error reporting. -+ * @param isOptionFlags -+ * Flags indicating which fields are optional (Option types). -+ * @param validateAndAnnotate -+ * Function to apply validation and annotation to a field. -+ * @param handleNull -+ * Function to handle null values in non-optional fields. -+ * @tparam V -+ * The validator type (either `Validator` or `AsyncValidator`). -+ * @tparam R -+ * The result type (either `ValidationResult` or `Future[ValidationResult]`). -+ * @return -+ * A list of validation results for each field. -+ */ -+ private def validateProduct[V, R]( -+ product: Product, -+ validators: Seq[V], -+ labels: List[String], -+ isOptionFlags: List[Boolean], -+ validateAndAnnotate: (V, Any, String) => R, -+ handleNull: String => R -+ ): List[R] = { -+ product.productIterator.zipWithIndex.map { case (fieldValue, i) => -+ val label = labels(i) -+ if (Option(fieldValue).isEmpty && !isOptionFlags(i)) { -+ handleNull(label) -+ } else { -+ val validator = validators(i) -+ validateAndAnnotate(validator, fieldValue, label) -+ } -+ }.toList -+ } -+ -+ /** Extracts field names from a compile-time tuple of string literal types. -+ * -+ * This method recursively processes a tuple type containing string literals (typically from -+ * `Mirror.MirroredElemLabels`) to extract the actual field names as a runtime `List[String]`. It -+ * performs compile-time validation to ensure all labels are string literals. -+ * -+ * @param q -+ * The quotes context for macro operations. -+ * @tparam Labels -+ * The tuple type containing string literal types for field names. -+ * @return -+ * A list of field names extracted from the tuple type. -+ * @throws Compilation -+ * error if any label is not a string literal. -+ */ -+ private def getLabels[Labels <: Tuple: Type](using q: Quotes): List[String] = { -+ import q.reflect.* -+ def loop(tpe: TypeRepr): List[String] = tpe.dealias match { -+ case AppliedType(_, List(head, tail)) => -+ head match { -+ case ConstantType(StringConstant(label)) => label :: loop(tail) -+ case _ => -+ report.errorAndAbort( -+ s"Invalid field label type: expected string literal, found ${head.show}. " + -+ "This typically indicates a structural issue with the case class definition." -+ ) -+ } -+ case t if t =:= TypeRepr.of[EmptyTuple] => Nil -+ case _ => -+ report.errorAndAbort( -+ s"Invalid label tuple structure: ${tpe.show}. " + -+ "This may indicate an incompatible case class or tuple definition." -+ ) -+ } -+ loop(TypeRepr.of[Labels]) -+ } -+ -+ /** Analyzes field types to identify which fields are optional (`Option[T]`). -+ * -+ * This method examines each field type in a product type to determine if it's an `Option` type. -+ * This information is used during validation to handle null values appropriately - null values -+ * are acceptable for optional fields but trigger validation errors for required fields. -+ * -+ * @param q -+ * The quotes context for macro operations. -+ * @tparam Elems -+ * The tuple type containing all field types. -+ * @return -+ * A list of boolean flags indicating which fields are optional. -+ */ -+ private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { -+ import q.reflect.* -+ Type.of[Elems] match { -+ case '[EmptyTuple] => Nil -+ case '[h *: t] => -+ (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] -+ } -+ } -+ -+ /** Generates validator instances for product types using compile-time reflection. -+ * -+ * This is the core derivation method that generates either synchronous or asynchronous -+ * validators based on the `isAsync` parameter. It performs compile-time introspection of the -+ * product type, extracts field information, summons appropriate validators for each field, and -+ * generates optimized validation logic. -+ * -+ * The generated validators handle: -+ * - Field-by-field validation using appropriate validator instances -+ * - Error accumulation and proper error context annotation -+ * - Null-safety for optional vs required fields -+ * - Automatic lifting of synchronous validators in async contexts -+ * - Exception handling for asynchronous operations -+ * -+ * @param m -+ * The mirror instance for the product type being validated. -+ * @param isAsync -+ * Flag indicating whether to generate an `AsyncValidator` (true) or `Validator` (false). -+ * @param q -+ * The quotes context for macro operations. -+ * @tparam T -+ * The product type for which to generate a validator. -+ * @tparam Elems -+ * The tuple type containing all field types. -+ * @tparam Labels -+ * The tuple type containing all field names as string literals. -+ * @return -+ * An expression representing the generated validator instance. -+ * @throws Compilation -+ * error if required validator instances cannot be found for any field type. -+ */ -+ def deriveValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( -+ m: Expr[Mirror.ProductOf[T]], -+ isAsync: Boolean -+ )(using q: Quotes): Expr[Any] = { -+ import q.reflect.* -+ -+ val fieldLabels: List[String] = getLabels[Labels] -+ val isOptionList: List[Boolean] = getIsOptionFlags[Elems] -+ val fieldLabelsExpr: Expr[List[String]] = Expr(fieldLabels) -+ val isOptionListExpr: Expr[List[Boolean]] = Expr(isOptionList) -+ -+ if (isAsync) { -+ def summonAsyncOrSync[E <: Tuple: Type]: List[Expr[AsyncValidator[Any]]] = -+ Type.of[E] match { -+ case '[EmptyTuple] => Nil -+ case '[h *: t] => -+ val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).getOrElse { -+ report.errorAndAbort( -+ s"Cannot derive AsyncValidator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + -+ "Please provide a given instance of either Validator[${Type.show[h]}] or AsyncValidator[${Type.show[h]}]." -+ ) -+ } -+ -+ val finalExpr = validatorExpr.asTerm.tpe.asType match { -+ case '[AsyncValidator[h]] => validatorExpr -+ case '[Validator[h]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } -+ } -+ -+ '{ MacroHelper.upcastTo[AsyncValidator[Any]](${ finalExpr }) } :: summonAsyncOrSync[t] -+ } -+ -+ val fieldValidators: List[Expr[AsyncValidator[Any]]] = summonAsyncOrSync[Elems] -+ val validatorsExpr: Expr[Seq[AsyncValidator[Any]]] = Expr.ofSeq(fieldValidators) -+ -+ '{ -+ new AsyncValidator[T] { -+ def validateAsync(a: T)(using ec: ExecutionContext): Future[ValidationResult[T]] = { -+ a match { -+ case product: Product => -+ val validators = ${ validatorsExpr } -+ val labels = ${ fieldLabelsExpr } -+ val isOptionFlags = ${ isOptionListExpr } -+ -+ val fieldResultsF = validateProduct( -+ product, -+ validators, -+ labels, -+ isOptionFlags, -+ validateAndAnnotate = (v, fv, l) => v.validateAsync(fv).map(annotateErrors(_, l, fv)), -+ handleNull = l => -+ Future.successful( -+ ValidationResult.invalid( -+ ValidationError( -+ s"Field '$l' must not be null.", -+ List(l), -+ expected = Some("non-null value"), -+ actual = Some("null") -+ ) -+ ) -+ ) -+ ) -+ -+ val allResultsF: Future[List[ValidationResult[Any]]] = -+ Future.sequence(fieldResultsF.map { f => -+ f.recover { case scala.util.control.NonFatal(ex) => -+ ValidationResult.invalid( -+ ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") -+ ) -+ } -+ }) -+ -+ allResultsF.map(processResults(_, ${ m })) -+ } -+ } -+ } -+ }.asExprOf[Any] -+ } else { -+ def summonValidators[E <: Tuple: Type]: List[Expr[Validator[Any]]] = -+ Type.of[E] match { -+ case '[EmptyTuple] => Nil -+ case '[h *: t] => -+ val validatorExpr = Expr.summon[Validator[h]].getOrElse { -+ report.errorAndAbort( -+ s"Cannot derive Validator for ${Type.show[T]}: missing validator for field type ${Type.show[h]}. " + -+ "Please provide a given instance of Validator[${Type.show[h]}]." -+ ) -+ } -+ '{ MacroHelper.upcastTo[Validator[Any]](${ validatorExpr }) } :: summonValidators[t] -+ } -+ -+ val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] -+ val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) -+ -+ '{ -+ new Validator[T] { -+ def validate(a: T): ValidationResult[T] = { -+ a match { -+ case product: Product => -+ val validators = ${ validatorsExpr } -+ val labels = ${ fieldLabelsExpr } -+ val isOptionFlags = ${ isOptionListExpr } -+ -+ val results = validateProduct( -+ product, -+ validators, -+ labels, -+ isOptionFlags, -+ validateAndAnnotate = (v, fv, l) => annotateErrors(v.validate(fv), l, fv), -+ handleNull = l => -+ ValidationResult.invalid( -+ ValidationError( -+ s"Field '$l' must not be null.", -+ List(l), -+ expected = Some("non-null value"), -+ actual = Some("null") -+ ) -+ ) -+ ) -+ -+ processResults(results, ${ m }) -+ } -+ } -+ } -+ }.asExprOf[Any] -+ } -+ } -+} -diff --git a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala -new file mode 100644 -index 0000000..c9515ac ---- /dev/null -+++ b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala -@@ -0,0 +1,304 @@ -+package net.ghoula.valar -+ -+import munit.FunSuite -+ -+import scala.concurrent.ExecutionContext.Implicits.global -+import scala.concurrent.duration.* -+import scala.concurrent.{Await, Future} -+ -+import net.ghoula.valar.ValidationErrors.ValidationError -+ -+/** Provides a comprehensive test suite for the [[AsyncValidator]] typeclass and its derivation. -+ * -+ * This spec verifies all core functionalities of the asynchronous validation mechanism: -+ * - Successful validation of valid objects. -+ * - Correct handling of failures from synchronous validators within an async context. -+ * - Correct handling of failures from native asynchronous validators. -+ * - Proper accumulation of errors from both sync and async sources. -+ * - Correct validation of nested case classes with proper error path annotation. -+ * - Robustness against null values, optional fields, collections, and exceptions within Futures. -+ */ -+class AsyncValidatorSpec extends FunSuite { -+ -+ /** A simple case class for basic validation tests. */ -+ private case class User(name: String, age: Int) -+ -+ /** A nested case class for testing recursive derivation. */ -+ private case class Company(name: String, owner: User) -+ -+ /** A case class to test null handling. */ -+ private case class Team(lead: User, name: String) -+ -+ /** A case class for testing collection validation. */ -+ private case class Post(title: String, comments: List[Comment]) -+ -+ /** A simple model for items within a collection. */ -+ private case class Comment(author: String, text: String) -+ -+ /** A case class for testing optional field validation. */ -+ private case class UserProfile(username: String, email: Option[String]) -+ -+ /** A standard synchronous validator for non-empty strings. */ -+ private given syncStringValidator: Validator[String] with { -+ def validate(value: String): ValidationResult[String] = -+ if (value.nonEmpty) ValidationResult.Valid(value) -+ else ValidationResult.invalid(ValidationError("Sync: String must not be empty")) -+ } -+ -+ /** A standard synchronous validator for non-negative integers. */ -+ private given syncIntValidator: Validator[Int] with { -+ def validate(value: Int): ValidationResult[Int] = -+ if (value >= 0) ValidationResult.Valid(value) -+ else ValidationResult.invalid(ValidationError("Sync: Age must be non-negative")) -+ } -+ -+ /** A native asynchronous validator that simulates a database check for usernames. -+ * -+ * This validator checks if a username is reserved (e.g., "admin", "root") by simulating an -+ * asynchronous database lookup. If the username is not reserved, it delegates to the synchronous -+ * string validator for basic validation. -+ */ -+ private given asyncUsernameValidator: AsyncValidator[String] with { -+ def validateAsync(name: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = -+ Future { -+ if (name.toLowerCase == "admin" || name.toLowerCase == "root") { -+ ValidationResult.invalid(ValidationError(s"Async: Username '$name' is reserved.")) -+ } else { -+ syncStringValidator.validate(name) -+ } -+ } -+ } -+ -+ /** A native asynchronous validator that simulates a profanity filter. -+ * -+ * This validator checks if a text contains profanity by simulating an asynchronous profanity -+ * checking service. If no profanity is detected, it delegates to the synchronous string -+ * validator for basic validation. -+ */ -+ private given asyncCommentTextValidator: AsyncValidator[String] with { -+ def validateAsync(text: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = -+ Future { -+ if (text.toLowerCase.contains("heck")) { -+ ValidationResult.invalid(ValidationError("Async: Comment contains profanity.")) -+ } else { -+ syncStringValidator.validate(text) -+ } -+ } -+ } -+ -+ /** A native asynchronous validator for email formats. -+ * -+ * This validator performs basic email format validation by checking for the presence of an '@' -+ * symbol. In a real application, this would typically involve more sophisticated email -+ * validation logic or external service calls. -+ */ -+ private given asyncEmailValidator: AsyncValidator[String] with { -+ def validateAsync(email: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = -+ Future { -+ if (email.contains("@")) ValidationResult.Valid(email) -+ else ValidationResult.invalid(ValidationError("Async: Email format is invalid.")) -+ } -+ } -+ -+ /** User validator using custom validators for both name and age fields. -+ * -+ * This validator demonstrates how to set up specific validators for different field types within -+ * a case class. The username field uses the asynchronous username validator, while the age field -+ * uses a synchronous validator lifted to async. -+ */ -+ private given userAsyncValidator: AsyncValidator[User] = { -+ given AsyncValidator[String] = asyncUsernameValidator -+ given AsyncValidator[Int] = AsyncValidator.fromSync(syncIntValidator) -+ AsyncValidator.derive -+ } -+ -+ /** Company validator that reuses the user validation logic. -+ * -+ * This validator demonstrates automatic derivation where the existing user validator is used for -+ * the nested User field, and the string validator is used for the company name. -+ */ -+ private given companyAsyncValidator: AsyncValidator[Company] = AsyncValidator.derive -+ -+ /** Team validator that reuses the user validation logic. -+ * -+ * This validator demonstrates automatic derivation where the existing user validator is used for -+ * the nested User field, and the string validator is used for the team name. -+ */ -+ private given teamAsyncValidator: AsyncValidator[Team] = AsyncValidator.derive -+ -+ /** A derived validator for Comment that uses the async profanity filter for the text field. -+ * -+ * This validator demonstrates how to use a specific validator for text content that requires -+ * asynchronous profanity checking while using the standard validator for the author field. -+ */ -+ private given commentAsyncValidator: AsyncValidator[Comment] = { -+ given AsyncValidator[String] = asyncCommentTextValidator -+ AsyncValidator.derive -+ } -+ -+ /** A derived validator for Post that uses the async Comment validator for the comments-field. -+ * -+ * This validator demonstrates validation of collections where each item in the collection -+ * requires asynchronous validation. The title field uses a synchronous validator, while the -+ * comments-field uses the async comment validator. -+ */ -+ private given postAsyncValidator: AsyncValidator[Post] = { -+ given AsyncValidator[String] = AsyncValidator.fromSync(syncStringValidator) -+ AsyncValidator.derive -+ } -+ -+ /** A custom validator for UserProfile that handles different validation logic for username and -+ * email fields. -+ * -+ * This validator demonstrates how to create custom validation logic when the automatic -+ * derivation cannot distinguish between different String fields that require different -+ * validation rules. The username field uses the username validator, while the optional email -+ * field uses the email validator. -+ */ -+ private given userProfileAsyncValidator: AsyncValidator[UserProfile] = new AsyncValidator[UserProfile] { -+ def validateAsync( -+ profile: UserProfile -+ )(using ec: concurrent.ExecutionContext): Future[ValidationResult[UserProfile]] = { -+ val usernameValidation = asyncUsernameValidator.validateAsync(profile.username) -+ val emailValidation = profile.email match { -+ case Some(email) => asyncEmailValidator.validateAsync(email).map(_.map(Some(_))) -+ case None => Future.successful(ValidationResult.Valid(None)) -+ } -+ -+ for { -+ nameResult <- usernameValidation -+ emailResult <- emailValidation -+ } yield { -+ nameResult.zip(emailResult).map { case (name, email) => -+ UserProfile(name, email) -+ } -+ } -+ } -+ } -+ -+ test("validateAsync should succeed for a valid object") { -+ val validUser = User("John", 30) -+ val futureResult = userAsyncValidator.validateAsync(validUser) -+ futureResult.map(result => assertEquals(result, ValidationResult.Valid(validUser))) -+ } -+ -+ test("validateAsync should handle synchronous validation failures") { -+ val invalidUser = User("John", -5) -+ val futureResult = userAsyncValidator.validateAsync(invalidUser) -+ futureResult.map { -+ case ValidationResult.Invalid(errors) => -+ assertEquals(errors.size, 1) -+ assert(errors.head.message.contains("Sync: Age must be non-negative")) -+ case _ => fail("Expected Invalid result") -+ } -+ } -+ -+ test("validateAsync should handle asynchronous validation failures") { -+ val invalidUser = User("admin", 30) -+ val futureResult = userAsyncValidator.validateAsync(invalidUser) -+ futureResult.map { -+ case ValidationResult.Invalid(errors) => -+ assertEquals(errors.size, 1) -+ assert(errors.head.message.contains("Async: Username 'admin' is reserved.")) -+ case _ => fail("Expected Invalid result") -+ } -+ } -+ -+ test("validateAsync should accumulate errors from both sync and async validators") { -+ val invalidUser = User("root", -10) -+ val futureResult = userAsyncValidator.validateAsync(invalidUser) -+ futureResult.map { -+ case ValidationResult.Invalid(errors) => -+ assertEquals(errors.size, 2) -+ assert(errors.exists(_.message.contains("Async: Username 'root' is reserved."))) -+ assert(errors.exists(_.message.contains("Sync: Age must be non-negative"))) -+ case _ => fail("Expected Invalid result") -+ } -+ } -+ -+ test("validateAsync should handle nested case classes and annotate error paths correctly") { -+ val invalidCompany = Company("BadCorp", User("", -1)) -+ val futureResult = companyAsyncValidator.validateAsync(invalidCompany) -+ futureResult.map { -+ case ValidationResult.Invalid(errors) => -+ assertEquals(errors.size, 2) -+ val nameError = errors.find(_.fieldPath.contains("name")).get -+ val ageError = errors.find(_.fieldPath.contains("age")).get -+ assertEquals(nameError.fieldPath, List("owner", "name")) -+ assertEquals(ageError.fieldPath, List("owner", "age")) -+ case _ => fail("Expected Invalid result") -+ } -+ } -+ -+ test("validateAsync should fail if a non-optional field is null") { -+ @SuppressWarnings(Array("scalafix:DisableSyntax.null")) -+ val invalidTeam = Team(null, "The A-Team") -+ val result = Await.result(teamAsyncValidator.validateAsync(invalidTeam), 1.second) -+ result match { -+ case ValidationResult.Invalid(errors) => -+ assertEquals(errors.size, 1) -+ assert(errors.head.message.contains("Field 'lead' must not be null.")) -+ case _ => fail("Expected Invalid result for null field") -+ } -+ } -+ -+ test("validateAsync should recover from a failed Future in a validator") { -+ val failingValidator: AsyncValidator[String] = new AsyncValidator[String] { -+ def validateAsync(a: String)(using ec: concurrent.ExecutionContext): Future[ValidationResult[String]] = -+ Future.failed(new RuntimeException("DB error")) -+ } -+ case class Service(endpoint: String) -+ given serviceValidator: AsyncValidator[Service] = { -+ given AsyncValidator[String] = failingValidator -+ AsyncValidator.derive -+ } -+ val service = Service("https://example.com") -+ val futureResult = serviceValidator.validateAsync(service) -+ futureResult.map { -+ case ValidationResult.Invalid(errors) => -+ assertEquals(errors.size, 1) -+ assert(errors.head.message.contains("Asynchronous validation failed unexpectedly")) -+ case _ => fail("Expected Invalid result from a failed future") -+ } -+ } -+ -+ test("validateAsync should handle collections with async validators") { -+ val post = Post( -+ "My Thoughts", -+ List(Comment("Alice", "Great post!"), Comment("Bob", "What the heck?"), Comment("Charlie", "")) -+ ) -+ val futureResult = postAsyncValidator.validateAsync(post) -+ futureResult.map { -+ case ValidationResult.Invalid(errors) => -+ assertEquals(errors.size, 2) -+ assert(errors.exists(e => e.message.contains("profanity") && e.fieldPath == List("comments", "text"))) -+ assert(errors.exists(e => e.message.contains("empty") && e.fieldPath.contains("comments"))) -+ case _ => fail("Expected Invalid result for collection validation") -+ } -+ } -+ -+ test("validateAsync should handle optional fields with async validators") { -+ val invalidProfile = UserProfile("testuser", Some("not-an-email")) -+ val validProfileNoEmail = UserProfile("testuser", None) -+ -+ val invalidResultF = userProfileAsyncValidator.validateAsync(invalidProfile) -+ val validResultF = userProfileAsyncValidator.validateAsync(validProfileNoEmail) -+ -+ for { -+ invalidResult <- invalidResultF -+ validResult <- validResultF -+ } yield { -+ invalidResult match { -+ case ValidationResult.Invalid(errors) => -+ assertEquals(errors.size, 1) -+ assert(errors.head.message.contains("Email format is invalid")) -+ case _ => fail("Expected Invalid result for bad email") -+ } -+ -+ validResult match { -+ case ValidationResult.Valid(_) => () -+ case _ => fail("Expected Valid result for None email") -+ } -+ } -+ } -+} -diff --git a/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala -index e897041..93563e5 100644 ---- a/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala -+++ b/valar-core/src/test/scala/net/ghoula/valar/TupleValidatorSpec.scala -@@ -25,11 +25,11 @@ class TupleValidatorSpec extends FunSuite { - - /** Tuple validator for regular tuples. */ - private given tupleValidator[A, B](using va: Validator[A], vb: Validator[B]): Validator[(A, B)] = -- Validator.deriveValidatorMacro -+ Validator.derive - - /** Named tuple validator using automatic derivation. */ - private given namedTupleValidator: Validator[(name: String, age: Int)] = -- Validator.deriveValidatorMacro -+ Validator.derive - - test("Regular tuples should be validated with default validators") { - val validTuple = ("hello", 42) -diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala -new file mode 100644 -index 0000000..2cdf1e1 ---- /dev/null -+++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationObserverSpec.scala -@@ -0,0 +1,61 @@ -+package net.ghoula.valar -+ -+import munit.FunSuite -+ -+import scala.collection.mutable.ListBuffer -+ -+import net.ghoula.valar.ValidationErrors.ValidationError -+ -+/** Verifies the behavior of the `ValidationObserver` typeclass and its `observe` extension method. -+ * -+ * This spec ensures that: -+ * - The default `noOpObserver` is a transparent, zero-cost operation when no custom observer is -+ * in scope. -+ * - A custom `given` `ValidationObserver` is correctly invoked for both `Valid` and `Invalid` -+ * results. -+ * - The `observe` method faithfully returns the original `ValidationResult` to preserve method -+ * chaining. -+ */ -+class ValidationObserverSpec extends FunSuite { -+ -+ /** A mock observer that records any results passed to its `onResult` method. */ -+ private class TestObserver extends ValidationObserver { -+ val observedResults: ListBuffer[ValidationResult[?]] = ListBuffer() -+ override def onResult[A](result: ValidationResult[A]): Unit = { -+ observedResults += result -+ } -+ } -+ -+ test("observe should be transparent when using the default no-op observer") { -+ val validResult = ValidationResult.Valid(42) -+ assertEquals(validResult.observe(), validResult) -+ -+ val invalidResult = ValidationResult.invalid(ValidationError("An error")) -+ assertEquals(invalidResult.observe(), invalidResult) -+ } -+ -+ test("observe should invoke a custom observer for a Valid result") { -+ val testObserver = new TestObserver -+ given customObserver: ValidationObserver = testObserver -+ -+ val validResult = ValidationResult.Valid("success") -+ val returnedResult = validResult.observe() -+ -+ assertEquals(testObserver.observedResults.size, 1) -+ assertEquals(testObserver.observedResults.head, validResult) -+ assertEquals(returnedResult, validResult) -+ } -+ -+ test("observe should invoke a custom observer for an Invalid result") { -+ val testObserver = new TestObserver -+ given customObserver: ValidationObserver = testObserver -+ -+ val error = ValidationError("A critical failure") -+ val invalidResult = ValidationResult.invalid(error) -+ val returnedResult = invalidResult.observe() -+ -+ assertEquals(testObserver.observedResults.size, 1) -+ assertEquals(testObserver.observedResults.head, invalidResult) -+ assertEquals(returnedResult, invalidResult) -+ } -+} -diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala -index c9e1d96..392fe6b 100644 ---- a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala -+++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala -@@ -7,7 +7,7 @@ import scala.collection.immutable.ArraySeq - import net.ghoula.valar.ErrorAccumulator - import net.ghoula.valar.ValidationErrors.{ValidationError, ValidationException} - import net.ghoula.valar.ValidationHelpers.* --import net.ghoula.valar.Validator.deriveValidatorMacro -+import net.ghoula.valar.Validator.derive - - /** Comprehensive test suite for Valar's validation system. - * -@@ -50,16 +50,16 @@ class ValidationSpec extends FunSuite { - /** Test case classes for macro derivation testing. */ - - private case class User(name: String, age: Option[Int]) -- private given Validator[User] = deriveValidatorMacro -+ private given Validator[User] = derive - - private case class Address(street: String, city: String, zip: Int) -- private given Validator[Address] = deriveValidatorMacro -+ private given Validator[Address] = derive - - private case class Company(name: String, address: Address, ceo: Option[User]) -- private given Validator[Company] = deriveValidatorMacro -+ private given Validator[Company] = derive - - private case class NullFieldTest(name: String, age: Int) -- private given Validator[NullFieldTest] = deriveValidatorMacro -+ private given Validator[NullFieldTest] = derive - - /** Tests for collection type validators. */ - -diff --git a/valar-munit/README.md b/valar-munit/README.md -new file mode 100644 -index 0000000..67756cb ---- /dev/null -+++ b/valar-munit/README.md -@@ -0,0 +1,132 @@ -+# valar-munit -+ -+[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-munit_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-munit_3) -+[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -+ -+The valar-munit module provides testing utilities for Valar validation logic using the MUnit testing framework. It -+introduces the ValarSuite trait that extends MUnit's FunSuite with specialized assertion helpers for ValidationResult. -+ -+## Installation -+ -+Add the valar-munit dependency to your build.sbt: -+ -+```scala -+libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test -+``` -+ -+## Usage -+ -+Extend the ValarSuite trait in your test classes to get access to the assertion helpers. -+ -+```scala -+import net.ghoula.valar.munit.ValarSuite -+ -+class MyValidatorSpec extends ValarSuite { -+ test("valid data passes validation") { -+ val result = MyValidator.validate(validData) -+ val value = assertValid(result) -+ -+ // You can make additional assertions on the validated value -+ assertEquals(value.name, "Expected Name") -+ } -+} -+``` -+ -+## Assertion Helpers -+ -+The ValarSuite trait provides several assertion helpers for different validation testing scenarios. -+ -+### 1. assertValid -+ -+Asserts that a ValidationResult is Valid and returns the validated value for further assertions. -+ -+```scala -+test("valid data passes validation") { -+ val result = MyValidator.validate(validData) -+ val value = assertValid(result) -+ -+ // Additional assertions on the validated value -+ assertEquals(value.id, 123) -+} -+``` -+ -+### 2. assertHasOneError -+ -+Asserts that a ValidationResult is Invalid and contains exactly one error. This is ideal for testing individual -+validation rules. -+ -+```scala -+test("empty name is rejected") { -+ val result = User.validate(User("", 25)) -+ -+ assertHasOneError(result) { error => -+ assertEquals(error.fieldPath, List("name")) -+ assert(error.message.contains("empty")) -+ } -+} -+``` -+ -+### 3. assertHasNErrors -+ -+Asserts that a ValidationResult is Invalid and contains exactly N errors. -+ -+```scala -+test("multiple specific errors are reported") { -+ val result = User.validate(User("", -5)) -+ -+ assertHasNErrors(result, 2) { errors => -+ // Assert on the collection of exactly 2 errors -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+### 4. assertInvalid -+ -+Asserts that a ValidationResult is Invalid using a partial function. Use this for complex cases where multiple, -+accumulated errors are expected. -+ -+```scala -+test("multiple validation errors are accumulated") { -+ val result = User.validate(User("", -5)) -+ -+ assertInvalid(result) { -+ case errors if errors.size == 2 => -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+### 5. assertInvalidWith -+ -+Asserts that a ValidationResult is Invalid and allows flexible assertions on the error collection using a regular -+function. This is a simpler alternative to assertInvalid. -+ -+```scala -+test("validation fails with expected errors") { -+ val result = User.validate(User("", -5)) -+ -+ assertInvalidWith(result) { errors => -+ assertEquals(errors.size, 2) -+ assert(errors.exists(_.fieldPath.contains("name"))) -+ assert(errors.exists(_.fieldPath.contains("age"))) -+ } -+} -+``` -+ -+## Benefits -+ -+- **Comprehensive Coverage**: Assertion helpers cover all common validation testing scenarios. -+ -+- **Cleaner Tests**: Specialized assertions make validation tests more concise and readable. -+ -+- **Better Error Messages**: Failed assertions provide detailed error reports with pretty-printed validation errors. -+ -+- **Type Safety**: The assertion helpers maintain type information, allowing for chained assertions on the validated -+ value. -+ -+- **Flexible API**: Multiple assertion styles (partial functions, regular functions, specific error counts) to match -+ your testing preferences. -diff --git a/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala b/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala -index cb7f8de..fab210b 100644 ---- a/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala -+++ b/valar-munit/src/main/scala/net/ghoula/valar/munit/ValarSuite.scala -@@ -5,53 +5,71 @@ import munit.{FunSuite, Location} - import net.ghoula.valar.ValidationErrors.ValidationError - import net.ghoula.valar.ValidationResult - --/** A base suite for MUnit tests that provides validation-specific assertion helpers. -- * -- * This suite provides a complete toolbox for testing Valar's validation logic: -- * - `assertValid` for success cases. -- * - `assertHasOneError` for testing single validation rules. -- * - `assertInvalid` for testing complex error accumulation. -+/** A base trait for test suites that use Valar, providing convenient assertion helpers for working -+ * with ValidationResult. - */ - trait ValarSuite extends FunSuite { - -- /** Asserts that a `ValidationResult` is `Valid`. -+ /** Asserts that a ValidationResult is Valid and returns the validated value for further -+ * assertions. -+ * -+ * @param result -+ * The ValidationResult to inspect. -+ * @param clue -+ * A clue to provide if the assertion fails. - * @return -- * The validated value `A` on success, allowing for chained assertions. -+ * The validated value if the result is Valid. - */ -- def assertValid[A](result: ValidationResult[A], clue: => Any = "Expected Valid, but got Invalid")(using -- loc: Location -- ): A = { -+ def assertValid[A](result: ValidationResult[A], clue: Any = "Expected Valid result")(using loc: Location): A = { - result match { - case ValidationResult.Valid(value) => value - case ValidationResult.Invalid(errors) => - val errorReport = errors.map(e => s" - ${e.prettyPrint(2)}").mkString("\n") -- fail(s"$clue. Errors:\n$errorReport") -+ fail(s"$clue, but got Invalid with errors:\n$errorReport") - } - } - -- /** Asserts that a `ValidationResult` is `Invalid` and contains exactly one error. This is the -- * ideal helper for testing individual validation rules. -+ /** Asserts that a ValidationResult is Invalid and contains exactly one error, then allows further -+ * assertions on that single error. - * - * @param result -- * The `ValidationResult` to check. -- * @param pf -- * A partial function to run assertions on the single `ValidationError`. -- * @return -- * The single `ValidationError` on success. -+ * The ValidationResult to inspect. -+ * @param clue -+ * A clue to provide if the assertion fails. -+ * @param body -+ * A function that takes the single ValidationError and performs further checks. - */ -- def assertHasOneError( -- result: ValidationResult[?] -- )(pf: PartialFunction[ValidationError, Unit])(using loc: Location): ValidationError = { -- val errors = assertInvalid(result) { -- case allErrors if allErrors.size == 1 => -- case allErrors => fail(s"Expected a single validation error, but found ${allErrors.size}.") -- } -- val singleError = errors.head -- if (!pf.isDefinedAt(singleError)) { -- fail(s"Partial function was not defined for the validation error:\n - ${singleError.prettyPrint(2)}") -+ def assertHasOneError[A](result: ValidationResult[A], clue: Any = "Expected exactly one validation error")( -+ body: ValidationError => Unit -+ )(using loc: Location): Unit = { -+ assertHasNErrors(result, 1, clue) { errors => body(errors.head) } -+ } -+ -+ /** Asserts that a ValidationResult is Invalid and contains a specific number of errors, then -+ * allows further assertions on the collection of errors. -+ * -+ * @param result -+ * The ValidationResult to inspect. -+ * @param expectedSize -+ * The expected number of errors. -+ * @param clue -+ * A clue to provide if the assertion fails. -+ * @param body -+ * A function that takes the Vector of ValidationErrors and performs further checks. -+ */ -+ def assertHasNErrors[A](result: ValidationResult[A], expectedSize: Int, clue: Any = "Mismatched number of errors")( -+ body: Vector[ValidationError] => Unit -+ )(using loc: Location): Unit = { -+ result match { -+ case ValidationResult.Valid(value) => -+ fail(s"Expected $expectedSize validation errors, but the result was Valid($value).") -+ case ValidationResult.Invalid(errors) => -+ if (errors.size == expectedSize) { -+ body(errors) -+ } else { -+ fail(s"$clue. Expected $expectedSize errors, but found ${errors.size}.") -+ } - } -- pf(singleError) -- singleError - } - - /** Asserts that a `ValidationResult` is `Invalid`. Use this for complex cases where multiple, -@@ -64,8 +82,8 @@ trait ValarSuite extends FunSuite { - * @return - * The `Vector[ValidationError]` on success. - */ -- def assertInvalid( -- result: ValidationResult[?] -+ def assertInvalid[A]( -+ result: ValidationResult[A] - )(pf: PartialFunction[Vector[ValidationError], Unit])(using loc: Location): Vector[ValidationError] = { - result match { - case ValidationResult.Valid(value) => -@@ -79,4 +97,31 @@ trait ValarSuite extends FunSuite { - errors - } - } -+ -+ /** Asserts that a ValidationResult is Invalid and allows flexible assertions on the error -+ * collection. This is a simpler alternative to `assertInvalid` that works with regular -+ * functions. -+ * -+ * @param result -+ * The ValidationResult to inspect. -+ * @param clue -+ * A clue to provide if the assertion fails. -+ * @param body -+ * A function that takes the Vector of ValidationErrors and performs further checks. -+ * @return -+ * The Vector of ValidationErrors on success. -+ */ -+ def assertInvalidWith[A]( -+ result: ValidationResult[A], -+ clue: Any = "Expected Invalid result" -+ )(body: Vector[ValidationError] => Unit)(using loc: Location): Vector[ValidationError] = { -+ result match { -+ case ValidationResult.Valid(value) => -+ fail(s"$clue, but got Valid($value)") -+ case ValidationResult.Invalid(errors) => -+ body(errors) -+ errors -+ } -+ } -+ - } -diff --git a/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala b/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala -new file mode 100644 -index 0000000..caea088 ---- /dev/null -+++ b/valar-munit/src/test/scala/net/ghoula/valar/munit/ValarSuiteSpec.scala -@@ -0,0 +1,134 @@ -+package net.ghoula.valar.munit -+ -+import net.ghoula.valar.ValidationErrors.ValidationError -+import net.ghoula.valar.ValidationResult -+ -+/** Tests the `ValarSuite` trait to ensure its assertion helpers are correct and reliable. -+ */ -+class ValarSuiteSpec extends ValarSuite { -+ -+ private val validResult = ValidationResult.Valid("success") -+ private val singleErrorResult = ValidationResult.invalid(ValidationError("single error")) -+ private val multipleErrorsResult = ValidationResult.invalid( -+ Vector( -+ ValidationError("first error", fieldPath = List("field1")), -+ ValidationError("second error", fieldPath = List("field2")) -+ ) -+ ) -+ -+ test("assertValid should return value when result is Valid") { -+ val value = assertValid(validResult) -+ assertEquals(value, "success") -+ } -+ -+ test("assertValid should fail when result is Invalid") { -+ intercept[munit.FailException] { -+ assertValid(singleErrorResult) -+ } -+ } -+ -+ test("assertHasOneError should succeed when result has exactly one error") { -+ assertHasOneError(singleErrorResult) { error => -+ assertEquals(error.message, "single error") -+ } -+ } -+ -+ test("assertHasOneError should fail when result is Valid") { -+ intercept[munit.FailException] { -+ assertHasOneError(validResult)(_ => ()) -+ } -+ } -+ -+ test("assertHasOneError should fail when result has multiple errors") { -+ intercept[munit.FailException] { -+ assertHasOneError(multipleErrorsResult)(_ => ()) -+ } -+ } -+ -+ test("assertHasNErrors should succeed when result has exactly N errors") { -+ assertHasNErrors(multipleErrorsResult, 2) { errors => -+ assertEquals(errors.size, 2) -+ assertEquals(errors.head.message, "first error") -+ assertEquals(errors.last.message, "second error") -+ } -+ } -+ -+ test("assertHasNErrors should fail when result is Valid") { -+ intercept[munit.FailException] { -+ assertHasNErrors(validResult, 1)(_ => ()) -+ } -+ } -+ -+ test("assertHasNErrors should fail when error count doesn't match") { -+ intercept[munit.FailException] { -+ assertHasNErrors(singleErrorResult, 2)(_ => ()) -+ } -+ } -+ -+ test("assertInvalid should succeed when result is Invalid and partial function matches") { -+ val errors = assertInvalid(multipleErrorsResult) { -+ case vector if vector.size == 2 => -+ assert(vector.exists(_.fieldPath == List("field1"))) -+ assert(vector.exists(_.fieldPath == List("field2"))) -+ } -+ assertEquals(errors.size, 2) -+ } -+ -+ test("assertInvalid should fail when result is Valid") { -+ intercept[munit.FailException] { -+ assertInvalid(validResult) { case _ => () } -+ } -+ } -+ -+ test("assertInvalid should fail when partial function doesn't match") { -+ intercept[munit.FailException] { -+ assertInvalid(singleErrorResult) { -+ case errors if errors.size == 2 => -+ () -+ } -+ } -+ } -+ -+ test("assertInvalidWith should succeed when result is Invalid") { -+ val errors = assertInvalidWith(singleErrorResult) { errors => -+ assertEquals(errors.size, 1) -+ assertEquals(errors.head.message, "single error") -+ } -+ assertEquals(errors.size, 1) -+ } -+ -+ test("assertInvalidWith should fail when result is Valid") { -+ intercept[munit.FailException] { -+ assertInvalidWith(validResult)(_ => ()) -+ } -+ } -+ -+ test("assertion failures should provide meaningful error messages") { -+ val exception = intercept[munit.FailException] { -+ assertValid(singleErrorResult, "Should be valid") -+ } -+ assert(exception.getMessage.contains("Should be valid")) -+ assert(exception.getMessage.contains("single error")) -+ } -+ -+ test("assertions should work with all ValidationError features") { -+ val complexError = ValidationError( -+ message = "Complex validation error", -+ fieldPath = List("user", "profile", "email"), -+ code = Some("EMAIL_INVALID"), -+ severity = Some("ERROR"), -+ expected = Some("valid email format"), -+ actual = Some("invalid@") -+ ) -+ val complexResult = ValidationResult.invalid(complexError) -+ -+ assertHasOneError(complexResult) { error => -+ assertEquals(error.message, "Complex validation error") -+ assertEquals(error.fieldPath, List("user", "profile", "email")) -+ assertEquals(error.code, Some("EMAIL_INVALID")) -+ assertEquals(error.severity, Some("ERROR")) -+ assertEquals(error.expected, Some("valid email format")) -+ assertEquals(error.actual, Some("invalid@")) -+ } -+ } -+} -diff --git a/valar-translator/README.md b/valar-translator/README.md -new file mode 100644 -index 0000000..f1401bb ---- /dev/null -+++ b/valar-translator/README.md -@@ -0,0 +1,98 @@ -+# valar-translator -+ -+[![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-translator_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-translator_3) -+[![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) -+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -+ -+The `valar-translator` module provides internationalization (i18n) support for Valar's validation error messages. It introduces a `Translator` typeclass that allows you to integrate with any i18n library to convert structured validation errors into localized, human-readable strings. -+ -+## Installation -+ -+Add the valar-translator dependency to your build.sbt: -+ -+```scala -+libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" -+``` -+ -+## Usage -+ -+The module provides a `Translator` trait and an extension method, `translateErrors()`, on `ValidationResult`. -+ -+### 1. Implement the `Translator` Trait -+ -+Create a `given` instance of `Translator` that contains your localization logic. This typically involves looking up a key from a resource bundle. -+ -+```scala -+import net.ghoula.valar.translator.Translator -+import net.ghoula.valar.ValidationErrors.ValidationError -+ -+// --- Example Setup --- -+// In a real application, this would come from a properties file or other i18n system. -+val translations: Map[String, String] = Map( -+ "error.string.nonEmpty" -> "The field must not be empty.", -+ "error.int.nonNegative" -> "The value cannot be negative.", -+ "error.unknown" -> "An unexpected validation error occurred." -+) -+ -+// --- Implementation of the Translator trait --- -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Logic to look up the error's key in your translation map. -+ // The `.getOrElse` provides a safe fallback. -+ translations.getOrElse( -+ error.key.getOrElse("error.unknown"), -+ error.message // Fall back to the original message if the key is not found -+ ) -+ } -+} -+``` -+ -+### 2. Call `translateErrors()` -+ -+Chain the `.translateErrors()` method to your validation call. It will use the in-scope `given Translator` to transform the error messages. -+ -+```scala -+val result = User.validate(someData) // An Invalid ValidationResult -+val translatedResult = result.translateErrors() -+ -+// translatedResult now contains errors with localized messages -+``` -+ -+## Integration with the ValidationObserver Extensibility Pattern -+ -+The `valar-translator` module is built to work seamlessly with Valar's extensibility system, specifically the **ValidationObserver pattern** that forms the foundation for all Valar extensions. -+ -+This architectural alignment means that the translator module integrates naturally with other extensions that follow the same pattern: -+ -+* **ValidationObserver Pattern (from `valar-core`)**: The foundation for all extensions, enabling side effects without changing the validation result -+* **Translator (from `valar-translator`)**: Built on top of the core pattern, transforming validation errors for localization -+ -+While these serve different purposes, they're designed to work together in a clean, composable way: -+ -+A common workflow is to first use the `ValidationObserver` to log or collect metrics on the raw, untranslated error, and then use the `Translator` to prepare the error for user presentation. -+ -+```scala -+// Given a defined extension using the ValidationObserver pattern -+given metricsObserver: ValidationObserver with { -+ def onResult[A](result: ValidationResult[A]): Unit = { -+ // Record validation metrics to your monitoring system -+ } -+} -+ -+// And a translator implementation for localization -+given myTranslator: Translator with { -+ def translate(error: ValidationError): String = { -+ // Translate errors using your i18n system -+ } -+} -+ -+// Both extensions work together through the same pattern -+val result = User.validate(invalidUser) -+ // First, observe the raw result using the core ValidationObserver pattern -+ .observe() -+ // Then, translate the errors for presentation (also built on the same pattern) -+ .translateErrors() -+ -+// This demonstrates how all Valar extensions follow the same architectural pattern, -+// allowing them to compose together seamlessly -+``` -diff --git a/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala b/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala -new file mode 100644 -index 0000000..d470b19 ---- /dev/null -+++ b/valar-translator/src/main/scala/net/ghoula/valar/translator/Translator.scala -@@ -0,0 +1,48 @@ -+package net.ghoula.valar.translator -+ -+import net.ghoula.valar.ValidationErrors.ValidationError -+import net.ghoula.valar.ValidationResult -+ -+/** A typeclass that defines how to translate a ValidationError into a human-readable string. -+ * Implement this to integrate with i18n libraries. -+ */ -+trait Translator { -+ -+ /** Translates a single validation error. -+ * @param error -+ * The structured ValidationError containing the key, args, and default message. -+ * @return -+ * A localized string message. -+ */ -+ def translate(error: ValidationError): String -+} -+ -+extension [A](vr: ValidationResult[A]) { -+ -+ /** Translates all errors within an Invalid result using the in-scope Translator. If the result is -+ * Valid, it is returned unchanged. -+ * -+ * @param translator -+ * The given Translator instance. -+ * @return -+ * A new ValidationResult with translated error messages. -+ */ -+ def translateErrors()(using translator: Translator): ValidationResult[A] = { -+ vr match { -+ case ValidationResult.Valid(a) => ValidationResult.Valid(a) -+ case ValidationResult.Invalid(errors) => -+ val translatedErrors = errors.map { err => -+ ValidationError( -+ message = translator.translate(err), -+ fieldPath = err.fieldPath, -+ children = err.children, -+ code = err.code, -+ severity = err.severity, -+ expected = err.expected, -+ actual = err.actual -+ ) -+ } -+ ValidationResult.Invalid(translatedErrors) -+ } -+ } -+} -diff --git a/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala b/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala -new file mode 100644 -index 0000000..acc3369 ---- /dev/null -+++ b/valar-translator/src/test/scala/net/ghoula/valar/translator/TranslatorSpec.scala -@@ -0,0 +1,85 @@ -+package net.ghoula.valar.translator -+ -+import net.ghoula.valar.ValidationErrors.ValidationError -+import net.ghoula.valar.ValidationResult -+import net.ghoula.valar.munit.ValarSuite -+ -+/** Provides a comprehensive test suite for the [[Translator]] typeclass and its associated -+ * `translateErrors` extension method. -+ * -+ * This specification validates the core functionalities of the translation mechanism. It ensures -+ * that `Valid` instances are returned without modification and that `Invalid` instances have their -+ * error messages properly translated by the in-scope `Translator`. -+ * -+ * The suite also confirms the integrity of `ValidationError` objects post-translation, verifying -+ * that all properties, such as `fieldPath`, `code`, and `severity`, are preserved. Finally, it -+ * guarantees that the translation is not applied recursively to nested child errors, maintaining -+ * the original state of the error hierarchy. -+ */ -+class TranslatorSpec extends ValarSuite { -+ -+ test("translateErrors on a Valid result should return the instance unchanged") { -+ given Translator = error => fail(s"Translator should not be invoked, but was called for: ${error.message}") -+ -+ val validResult = ValidationResult.Valid("all good") -+ val result = validResult.translateErrors() -+ -+ assertEquals(result, validResult) -+ } -+ -+ test("translateErrors on an Invalid result should translate messages and preserve all other properties") { -+ given Translator = error => s"translated: ${error.message}" -+ -+ val originalError = ValidationError( -+ message = "A test error", -+ fieldPath = List("user", "email"), -+ children = Vector(ValidationError("A nested error")), -+ code = Some("E-101"), -+ severity = Some("Warning"), -+ expected = Some("a valid email"), -+ actual = Some("not-an-email") -+ ) -+ val invalidResult = ValidationResult.invalid(originalError) -+ -+ val translatedResult = invalidResult.translateErrors() -+ -+ assertHasOneError(translatedResult) { translatedError => -+ assertEquals(translatedError.message, "translated: A test error") -+ assertEquals(translatedError.fieldPath, originalError.fieldPath) -+ assertEquals(translatedError.children, originalError.children) -+ assertEquals(translatedError.code, originalError.code) -+ assertEquals(translatedError.severity, originalError.severity) -+ assertEquals(translatedError.expected, originalError.expected) -+ assertEquals(translatedError.actual, originalError.actual) -+ } -+ } -+ -+ test("translateErrors should correctly translate multiple errors in an Invalid result") { -+ given Translator = error => s"translated: ${error.message}" -+ -+ val error1 = ValidationError("First error") -+ val error2 = ValidationError("Second error") -+ val invalidResult = ValidationResult.Invalid(Vector(error1, error2)) -+ -+ val translatedResult = invalidResult.translateErrors() -+ -+ assertHasNErrors(translatedResult, 2)(translatedErrors => -+ assertEquals(translatedErrors.map(_.message), Vector("translated: First error", "translated: Second error")) -+ ) -+ } -+ -+ test("translateErrors should not apply translation recursively to nested child errors") { -+ given Translator = error => s"translated: ${error.message}" -+ -+ val childError = ValidationError("This is a child error") -+ val parentError = ValidationError("This is a parent error", children = Vector(childError)) -+ val invalidResult = ValidationResult.invalid(parentError) -+ val translatedResult = invalidResult.translateErrors() -+ -+ assertHasOneError(translatedResult) { translatedParent => -+ assertEquals(translatedParent.message, "translated: This is a parent error") -+ assertEquals(translatedParent.children.headOption, Some(childError)) -+ assertEquals(translatedParent.children.head.message, "This is a child error") -+ } -+ } -+}