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..5cd73d1 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 = Validator[User].validate(user) + .observe() // The observer's onResult is called here + .map(validatedUser => validatedUser.copy(name = validatedUser.name.trim)) +``` + +### 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 = Validator[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 = { + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") + translations.getOrElse( + translationKey, + error.message // Fall back to the original message if no translation is found + ) + } +} + +// Use the translator in your validation flow +val result = Validator[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+ diff --git a/build.sbt b/build.sbt index c77cad1..275fb37 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 ===== @@ -49,7 +45,10 @@ lazy val root = (project in file(".")) valarCoreJVM, valarCoreNative, valarMunitJVM, - valarMunitNative + valarMunitNative, + valarTranslatorJVM, + valarTranslatorNative, + valarBenchmarks ) .settings( name := "valar-root", @@ -64,6 +63,11 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) name := "valar-core", usePgpKeyHex("9614A0CE1CE76975"), useGpgAgent := true, + // --- 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", "io.github.cquiroz" %%% "scala-java-time-tzdb" % "2.6.0", @@ -74,7 +78,10 @@ lazy val valarCore = crossProject(JVMPlatform, NativePlatform) mdocIn := file("docs-src"), mdocOut := file("."), addCommandAlias("prepare", "scalafixAll; scalafmtAll; scalafmtSbt"), - addCommandAlias("check", "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck") + addCommandAlias( + "check", + "scalafixAll --check; scalafmtCheckAll; scalafmtSbtCheck" + ) ) .jvmConfigure(_.enablePlugins(MdocPlugin)) .nativeSettings( @@ -93,14 +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, + 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..5cd73d1 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 = Validator[User].validate(user) + .observe() // The observer's onResult is called here + .map(validatedUser => validatedUser.copy(name = validatedUser.name.trim)) +``` + +### 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 = Validator[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 = { + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") + translations.getOrElse( + translationKey, + error.message // Fall back to the original message if no translation is found + ) + } +} + +// Use the translator in your validation flow +val result = Validator[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+ 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..61f9ffb --- /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 = { + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") + translations.getOrElse( + translationKey, + error.message // Fall back to the original message if no translation is 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 = Validator[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 = 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) + .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 de0938b..7f55807 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -16,3 +16,12 @@ 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") + +// 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..61f9ffb --- /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 = { + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") + translations.getOrElse( + translationKey, + error.message // Fall back to the original message if no translation is 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 = Validator[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 = 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) + .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/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/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..e6069fe 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.Derivation /** A typeclass for defining custom validation logic for type `A`. * @@ -40,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) @@ -64,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) @@ -100,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) @@ -121,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) @@ -142,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) @@ -165,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) => @@ -207,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] @@ -226,306 +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) } - /** Pass-through validator for Boolean. Always returns Valid. */ - given booleanValidator: Validator[Boolean] with { + /** 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. 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) } - /** 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. - * - * Example usage: - * {{{ - * case class User(name: String, age: Int) - * given Validator[User] = deriveValidatorMacro - * }}} - * - * @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.* - if !(TypeRepr.of[Elems] <:< TypeRepr.of[Tuple]) then - report.errorAndAbort(s"deriveValidatorMacro: Expected Elems to be a Tuple type, but got ${Type.show[Elems]}") - - '{ - 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 }) - } - 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]] = { - 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...") - } - } + 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/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) -} 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..61f9ffb --- /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 = { + // Use the error's `code` to find the right translation key. + val translationKey = error.code.getOrElse("error.unknown") + translations.getOrElse( + translationKey, + error.message // Fall back to the original message if no translation is 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 = Validator[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 = 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) + .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") + } + } +}