diff --git a/.gitignore b/.gitignore index e75e032..973175b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,8 @@ build/ .DS_Store ### Scala ### -.bsp/ \ No newline at end of file +.bsp/ + +### JVM crash logs ### +hs_err_pid*.log +core.* \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e8b0efb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to Valar + +Thanks for your interest in contributing to Valar. + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/valar.git` +3. Create a branch: `git checkout -b your-feature-branch` + +## Development Setup + +Requirements: +- JDK 21+ +- sbt 1.10+ + +Build and test: +```bash +sbt compile +sbt test +``` + +Run tests for a specific module: +```bash +sbt valarCore/test +sbt valarMunit/test +sbt valarTranslator/test +``` + +Format code: +```bash +sbt scalafmtAll +``` + +Check formatting: +```bash +sbt scalafmtCheckAll +``` + +## Project Structure + +``` +valar/ + valar-core/ # Core validation library + valar-munit/ # MUnit testing utilities + valar-translator/ # i18n support + valar-benchmarks/ # JMH benchmarks +``` + +## Submitting Changes + +1. Ensure all tests pass: `sbt test` +2. Check formatting: `sbt scalafmtCheckAll` +3. Write clear commit messages +4. Open a pull request against `main` + +## Guidelines + +- Keep changes focused. One feature or fix per PR. +- Add tests for new functionality. +- Update documentation if needed. +- Follow existing code style. + +## Running Benchmarks + +```bash +sbt "valarBenchmarks / Jmh / run" +``` + +## Questions + +Open an issue for questions or discussion. diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index fecde22..0000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,290 +0,0 @@ -# Implementation Summary: Code Review Improvements - -This document summarizes all improvements implemented based on the comprehensive code review. - -## Branch: `code-review-improvements` - ---- - -## Changes Implemented - -### ✅ Priority 1A: Code Coverage Reporting - -**Status:** Completed - -**Changes:** -- Added `sbt-scoverage` 2.2.2 plugin to `project/plugins.sbt` -- Configured coverage thresholds in `build.sbt`: - - Minimum statement coverage: 80% - - Coverage highlighting enabled - - Fail on minimum: disabled (warning only) - -**Files Modified:** -- `project/plugins.sbt` -- `build.sbt` - -**Commit:** `71473fa` - Add scoverage plugin for code coverage reporting - ---- - -### ✅ Priority 1B.1: ReDoS Security Documentation - -**Status:** Completed - -**Changes:** -- Added comprehensive security warnings to `regexMatch` methods in `ValidationHelpers.scala` -- Documented safe vs. unsafe usage patterns with examples -- Added new "Security Considerations" section to `README.md` covering: - - Regular Expression Denial of Service (ReDoS) - - Input size limits - - Error information disclosure - -**Files Modified:** -- `valar-core/src/main/scala/net/ghoula/valar/ValidationHelpers.scala` -- `README.md` - -**Commit:** `995a9a5` - Add comprehensive ReDoS security warnings - -**Impact:** -- Users are now clearly warned about ReDoS vulnerabilities -- Documentation provides actionable guidance for secure usage - ---- - -### ✅ Priority 1B.2: Input Size Limits (ValidationConfig) - -**Status:** Completed - -**Changes:** -- Created new `ValidationConfig.scala` with configurable limits: - - `maxCollectionSize`: Limits elements in collections - - `maxNestingDepth`: Reserved for future use -- Provided three presets: - - `ValidationConfig.default`: No limits (trusted data) - - `ValidationConfig.strict`: 10,000 element limit (untrusted data) - - `ValidationConfig.permissive`: 1,000,000 element limit (internal data) -- Updated all collection validators to check size limits: - - `List`, `Seq`, `Vector`, `Set`, `Map`, `Array`, `ArraySeq` -- Size checks fail fast before processing elements for performance - -**Files Modified:** -- `valar-core/src/main/scala/net/ghoula/valar/ValidationConfig.scala` (new file) -- `valar-core/src/main/scala/net/ghoula/valar/Validator.scala` - -**Commit:** `618a4a6` - Implement ValidationConfig for input size limits - -**Impact:** -- Protection against memory exhaustion attacks -- Protection against CPU exhaustion from large collections -- Configurable security boundaries for different trust levels - ---- - -### ✅ Priority 3.2: Convenience Methods - -**Status:** Completed - -**Changes:** -- Added `ValidationResult.sequence[A]` method: - - Combines `List[ValidationResult[A]]` into `ValidationResult[List[A]]` - - Accumulates all errors from failed validations -- Added `ValidationResult.traverse[A, B]` method: - - Maps and sequences in one operation - - More efficient than separate map + sequence -- Both methods include comprehensive documentation and examples - -**Files Modified:** -- `valar-core/src/main/scala/net/ghoula/valar/ValidationResult.scala` - -**Commit:** `50490c8` - Add sequence and traverse convenience methods - -**Impact:** -- Improved developer experience for working with collections -- Common functional programming patterns now available -- Reduces boilerplate code - ---- - -### ✅ Priority 4.1: Troubleshooting Guide - -**Status:** Completed - -**Changes:** -- Created comprehensive `TROUBLESHOOTING.md` with: - - Compilation errors section (missing validators, ambiguous implicits, etc.) - - Runtime issues section (empty error vectors, size limits, etc.) - - Performance problems section (slow validation, stack overflow) - - Security concerns section (ReDoS, error disclosure) - - Best practices section (fail-fast vs accumulation, optional fields, composition) - - Quick reference table - -**Files Created:** -- `TROUBLESHOOTING.md` - -**Files Modified:** -- `README.md` (added link to troubleshooting guide) - -**Commit:** `ad36cb9` - Add comprehensive troubleshooting guide - -**Impact:** -- Reduced support burden with self-service troubleshooting -- Faster problem resolution for users -- Better understanding of common patterns and anti-patterns - ---- - -### ✅ Priority 4.2: Performance Documentation - -**Status:** Completed - -**Changes:** -- Added "Performance" section to `README.md` with: - - Complexity characteristics table (time/space complexity) - - Performance best practices - - Link to detailed benchmarks - - Key findings from JMH benchmarks -- Enhanced "Input Size Limits" section with ValidationConfig usage examples -- Added troubleshooting guide to Additional Resources - -**Files Modified:** -- `README.md` - -**Commit:** `7fa1727` - Add performance documentation and link to troubleshooting - -**Impact:** -- Users understand performance characteristics before using the library -- Clear guidance on optimizing validation performance -- Better security posture through documented ValidationConfig usage - ---- - -### ✅ Priority 5.1: CI/CD Enhancements - -**Status:** Completed - -**Changes:** -- Added code coverage reporting: - - Tests run with `sbt coverage ... coverageReport` - - Coverage uploaded to Codecov - - Tracks coverage across all modules (core, munit, translator) -- Added caching for Scala Native builds: - - Caches coursier, ivy2, and sbt directories - - Significantly speeds up CI builds -- Enhanced test coverage to include all modules - -**Files Modified:** -- `.github/workflows/scala.yml` - -**Commit:** `344908f` - Enhance CI/CD pipeline with coverage and caching - -**Impact:** -- Visibility into code coverage trends over time -- Faster CI builds with caching -- More comprehensive test execution - ---- - -## Summary Statistics - -**Total Commits:** 8 -**Files Modified:** 10 -**New Files Created:** 3 -**Lines Added:** ~800 -**Lines Modified:** ~100 - -### Files Changed: -1. `project/plugins.sbt` - Added scoverage plugin -2. `build.sbt` - Added coverage configuration -3. `valar-core/src/main/scala/net/ghoula/valar/ValidationHelpers.scala` - Security warnings -4. `valar-core/src/main/scala/net/ghoula/valar/ValidationConfig.scala` - **NEW** -5. `valar-core/src/main/scala/net/ghoula/valar/Validator.scala` - Size limit checks -6. `valar-core/src/main/scala/net/ghoula/valar/ValidationResult.scala` - Convenience methods -7. `README.md` - Performance docs, security section, troubleshooting link -8. `TROUBLESHOOTING.md` - **NEW** -9. `IMPLEMENTATION_SUMMARY.md` - **NEW** (this file) -10. `.github/workflows/scala.yml` - Coverage and caching - ---- - -## Items NOT Implemented (Lower Priority) - -The following items from the original plan were not implemented in this iteration: - -### Priority 2.1: Add Edge-Case Tests -- **Reason:** Requires running coverage report first to identify gaps -- **Recommendation:** Run `sbt coverage test coverageReport` and review gaps, then add targeted tests - -### Priority 3.1: Standardize Error Codes -- **Reason:** This is a breaking change that should be bundled into a 0.6.0 release -- **Recommendation:** Plan for 0.6.0 release with comprehensive migration guide - -### Priority 3.3: Improve Error Message Consistency -- **Reason:** Should be done together with error code standardization (3.1) -- **Recommendation:** Include in 0.6.0 release - ---- - -## Breaking Changes Analysis - -**Current Implementation:** No breaking changes introduced - -All changes are backward compatible: -- `ValidationConfig` is optional (default provides unlimited validation like before) -- New methods (`sequence`, `traverse`) are additions, not modifications -- Documentation changes have no API impact -- CI/CD changes are infrastructure only - ---- - -## Recommendations for Next Steps - -1. **Immediate (Before Merge):** - - Review all commits - - Run full test suite locally - - Generate and review coverage report - - Test ValidationConfig with various limits - -2. **Short Term (After Merge):** - - Monitor Codecov reports for coverage trends - - Add edge-case tests based on coverage gaps - - Gather user feedback on troubleshooting guide - -3. **Medium Term (0.6.0 Planning):** - - Standardize error codes across all validators - - Improve error message consistency - - Consider deprecating String-based `regexMatch` - - Add ScalaCheck property-based tests - -4. **Long Term:** - - Implement `valar-cats-effect` module - - Implement `valar-zio` module - - Add Scalafix migration rules for breaking changes - ---- - -## Testing Checklist - -Before merging, verify: - -- [ ] All tests pass: `sbt test` -- [ ] Code formatting: `sbt scalafmtCheckAll` -- [ ] Linting: `sbt scalafixAll --check` -- [ ] Coverage report generates: `sbt coverage test coverageReport` -- [ ] Documentation builds: `sbt mdoc` -- [ ] Native builds work: `sbt valarCoreNative/test` -- [ ] ValidationConfig works as expected with strict/permissive/custom limits -- [ ] Security warnings are visible in Scaladoc - ---- - -## Acknowledgments - -This implementation addresses the findings from the comprehensive code review conducted on 2025-01-17, which gave the project an A- (90/100) rating and identified these improvements as high-priority items for production readiness. - ---- - -**Implementation Date:** 2025-01-17 -**Branch:** `code-review-improvements` -**Implemented By:** Claude (Anthropic) -**Review Grade Before:** A- (90/100) -**Expected Grade After:** A (95/100) diff --git a/MIGRATION.md b/MIGRATION.md index 43faf88..015c2b3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,54 @@ # Migration Guide +## Migrating from v0.5.0 to v0.6.0 + +Version 0.6.0 includes a **breaking change** to built-in validators. + +### Breaking Change: Pass-Through Validators + +Built-in validators for `Int`, `String`, `Float`, and `Double` are now **pass-through** (accept all values). Previously +they enforced constraints: + +| Type | v0.5.0 (constrained) | v0.6.0 (pass-through) | +|--------|------------------------------|-----------------------| +| Int | Rejects negative values | Accepts all values | +| String | Rejects empty strings | Accepts all values | +| Float | Rejects NaN/Infinity | Accepts all values | +| Double | Rejects NaN/Infinity | Accepts all values | + +**Why this change?** The opinionated defaults limited Valar's use as a general-purpose foundation. Users validating +temperatures (negative values valid), legacy data (empty strings valid), or scientific data (NaN meaningful) had to +fight the library's defaults. + +### Migration Steps + +If you relied on the constrained defaults, define explicit validators using `ValidationHelpers`: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.ValidationHelpers.* + +// Restore v0.5.0 behavior for Int +given Validator[Int] with { + def validate(i: Int) = nonNegativeInt(i) +} + +// Restore v0.5.0 behavior for String +given Validator[String] with { + def validate(s: String) = nonEmpty(s) +} + +// Restore v0.5.0 behavior for Float +given Validator[Float] with { + def validate(f: Float) = finiteFloat(f) +} + +// Restore v0.5.0 behavior for Double +given Validator[Double] with { + def validate(d: Double) = finiteDouble(d) +} +``` + ## 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: @@ -7,7 +56,7 @@ Version 0.5.0 introduces several new features while maintaining backward compati 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 +4. **Reworked derivation** using modern Scala 3 inline metaprogramming 5. **MiMa checks** to ensure binary compatibility between versions ### Update build.sbt: diff --git a/README.md b/README.md index 3bfd6d3..05bbd5f 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,41 @@ -# **Valar – Type-Safe Validation for Scala 3** +# Valar - Type-Safe Validation for Scala 3 [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) [![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and -metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, -detailed error messages useful for debugging or user feedback. - -## **✨ What's New in 0.5.X** - -* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, - perfect for logging, metrics collection, or auditing with zero overhead when not used. -* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the - `Translator` typeclass. -* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust - validation testing. -* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time - validation. -* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer - experience. - -## **Key Features** - -* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using - ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. -* **Flexible Error Handling:** Choose the strategy that fits your use case: - * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in - UIs or API responses). - * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. -* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation - rule specifics (like expected vs. actual values), and optional codes/severity. -* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward - compatibility. -* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. - -## **Extensibility Pattern** - -Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to -integrate with external systems without modifying the core validation logic. +Valar is a validation library for Scala 3. It uses Scala 3's type system and inline metaprogramming to define validation +rules with minimal boilerplate, providing structured error messages for debugging or user feedback. -### The ValidationObserver Pattern +## What's New in 0.5.X + +* **ValidationObserver**: A trait for observing validation outcomes without altering the flow. Useful for logging, + metrics, or auditing. Zero overhead when not used. +* **valar-translator Module**: Internationalization (i18n) support for validation error messages via the `Translator` + typeclass. +* **Enhanced ValarSuite**: Updated testing utilities in `valar-munit`. +* **Reworked Derivation**: Uses modern Scala 3 inline metaprogramming for compile-time validation. +* **MiMa Checks**: Binary compatibility verification between versions. +* **Improved Documentation**: Updated scaladoc and module-level README files. + +## Key Features + +* **Type Safety:** Distinguish between valid results and accumulated errors at compile time using `ValidationResult[A]`. +* **Minimal Boilerplate:** Derive `Validator` instances automatically for case classes using compile-time derivation. +* **Flexible Error Handling:** + * **Error Accumulation** (default): Collect all validation failures for reporting multiple issues. + * **Fail-Fast**: Stop on the first failure for performance-sensitive pipelines. +* **Detailed Error Reports:** `ValidationError` objects with field paths, expected vs. actual values, and optional + codes/severity. +* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples. +* **Scala 3 Idiomatic:** Uses extension methods, given instances, opaque types, and inline metaprogramming. + +## Extensibility Pattern + +Valar is extensible through the `ValidationObserver` pattern, which integrates with external systems without modifying +core validation logic. -The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: +### The ValidationObserver Pattern ```scala trait ValidationObserver { @@ -54,28 +43,26 @@ trait ValidationObserver { } ``` -This pattern offers several advantages: +Properties: -* **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 +* **Zero Overhead**: Default no-op observer is eliminated by the compiler +* **Non-Intrusive**: Observes results without altering the validation flow +* **Composable**: Works with other Valar features and can be chained -### Examples of Extensions Using This Pattern +### Extension Examples -Current implementations are following this pattern: +Current uses: -- **Logging**: Log validation outcomes for debugging and monitoring -- **Metrics**: Collect validation statistics for performance analysis -- **Auditing**: Track validation events for compliance and security +- **Logging**: Log validation outcomes +- **Metrics**: Collect validation statistics +- **Auditing**: Track validation events -Future extensions planned: +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** +## Available Artifacts Valar provides artifacts for both JVM and Scala Native platforms: @@ -90,11 +77,9 @@ Valar provides artifacts for both JVM and Scala Native platforms: > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. -## **Performance** +## Performance -Valar is designed for high performance with minimal overhead: - -### **Complexity Characteristics** +### Complexity Characteristics | Operation | Time Complexity | Space Complexity | Notes | |-----------|----------------|------------------|-------| @@ -104,7 +89,7 @@ Valar is designed for high performance with minimal overhead: | Nested case class | O(fields) | O(errors) | Accumulates errors across all fields | | Union type validation | O(types) | O(errors) | Tries each type in the union | -### **Performance Best Practices** +### Best Practices 1. **Use ValidationConfig limits** for untrusted input to prevent DoS: ```scala @@ -127,23 +112,24 @@ Valar is designed for high performance with minimal overhead: if (collection.size > 10000) return BadRequest("Too large") ``` -### **Benchmark Results** +### Benchmark Results -Detailed performance benchmarks with JMH are available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). +Detailed benchmarks available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). -**Key findings:** +Key findings: - Simple validations: ~10-50 nanoseconds - Case class derivation: Zero runtime overhead (compile-time only) - Collection validation: Linear with collection size -- Zero-cost abstractions: `ValidationObserver` with no-op has no runtime impact +- `ValidationObserver` with no-op has no runtime impact + +## Additional Resources -## **Additional Resources** +- [Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md): JMH benchmark results +- [Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md): ValarSuite testing utilities +- [Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md): i18n support +- [Troubleshooting Guide](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md): Common issues and solutions -- 📊 **[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 -- 🔧 **[Troubleshooting Guide](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md)**: Common issues and solutions -## **Installation** +## Installation Add the following to your build.sbt: @@ -158,7 +144,7 @@ libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ``` -## **Basic Usage Example** +## 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). @@ -197,7 +183,7 @@ result match { } ``` -## **Testing with valar-munit** +## Testing with valar-munit The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to make your tests clean and expressive. @@ -237,9 +223,9 @@ class UserValidationSuite extends ValarSuite { } ``` -## **Core Components** +## Core Components -### **ValidationResult** +### ValidationResult Represents the outcome of validation as either Valid(value) or Invalid(errors): @@ -252,7 +238,7 @@ enum ValidationResult[+A] { } ``` -### **ValidationError** +### ValidationError Opaque type providing rich context for validation errors, including: @@ -263,7 +249,7 @@ Opaque type providing rich context for validation errors, including: * **expected/actual**: Information about expected and actual values. * **children**: Nested errors for structured reporting. -### **Validator[A]** +### Validator[A] A typeclass defining validation logic for a given type: @@ -282,25 +268,39 @@ to be available in scope for **all** field types within the case class. If a val *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** +## Built-in Validators + +Valar provides pass-through `Validator` instances for common types to enable derivation. All built-in validators accept +any value - constraints are opt-in via `ValidationHelpers`. -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This -includes: +**Supported types:** -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, - Short, Char, Unit. -* **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, - java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their - element/key/value types). -* **Tuple Types:** Named tuples and regular tuples. -* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. +* **Scala Primitives:** Int, String, Boolean, Long, Double, Float, Byte, Short, Char, Unit +* **Other Scala Types:** BigInt, BigDecimal, Symbol +* **Java Types:** UUID, Instant, LocalDate, LocalDateTime, ZonedDateTime, LocalTime, Duration +* **Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map +* **Tuple Types:** Named tuples and regular tuples +* **Composite Types:** Intersection (&) and Union (|) types + +**Opt-in constraints** (from `ValidationHelpers`): + +```scala +import net.ghoula.valar.ValidationHelpers.* + +// Define constrained validators when you need them +given Validator[Int] with { + def validate(i: Int) = nonNegativeInt(i) +} + +given Validator[String] with { + def validate(s: String) = nonEmpty(s) +} +``` -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. +Available constraint helpers: `nonNegativeInt`, `nonEmpty`, `finiteFloat`, `finiteDouble`, `minLength`, `maxLength`, +`regexMatch`, `inRange`, `oneOf`. -## **ValidationObserver, The Core Extensibility Pattern** +## ValidationObserver Pattern The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending Valar with custom functionality. This pattern allows you to: @@ -357,7 +357,7 @@ Key features of ValidationObserver: * **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** +## Internationalization with valar-translator The `valar-translator` module provides internationalization (i18n) support for validation error messages: @@ -397,14 +397,14 @@ The `valar-translator` module is designed to: * 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** +## 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 +4. **Reworked derivation** using modern Scala 3 inline metaprogramming 5. **MiMa checks** to ensure binary compatibility To upgrade to v0.5.0, update your build.sbt: @@ -422,7 +422,7 @@ 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** +## 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. @@ -437,29 +437,29 @@ libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ``` -## **Security Considerations** +## Security Considerations When using Valar with untrusted user input, please be aware of the following security considerations: -### **Regular Expression Denial of Service (ReDoS)** +### Regular Expression Denial of Service (ReDoS) -⚠️ **Warning:** The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. +**Warning:** The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. **Safe Practice:** ```scala -// ✅ SAFE - Use pre-compiled regex patterns +// SAFE - Use pre-compiled regex patterns val emailPattern = "^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$".r regexMatch(userInput, emailPattern)(_ => "Invalid email") ``` **Unsafe Practice:** ```scala -// ❌ UNSAFE - Never pass user-provided patterns! +// UNSAFE - Never pass user-provided patterns! val userPattern = request.getParameter("pattern") regexMatch(value, userPattern)(_ => "Invalid") // ReDoS vulnerability! ``` -### **Input Size Limits** +### Input Size Limits Valar provides built-in protection against resource exhaustion through `ValidationConfig`: @@ -484,21 +484,21 @@ When a collection exceeds the configured limit, validation fails immediately ''' **Important:** Always use `ValidationConfig.strict` or custom limits when validating untrusted user input. -### **Error Information Disclosure** +### Error Information Disclosure `ValidationError` objects include detailed information about what was expected vs. what was received. When exposing validation errors to end users: - Review error messages for sensitive information - Consider using the `valar-translator` module to provide user-friendly, sanitized messages - Be cautious about exposing internal field names or structure -## **Compatibility** +## Compatibility * **Scala:** 3.7+ * **Platforms:** JVM, Scala Native * **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, cross-platform support for the `java.time` API. -## **License** +## License Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) file for details. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 6c081f3..c77f75d 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -185,6 +185,35 @@ val result = Validator[List[Data]].validate(userList) --- +### Issue: NullPointerException during validation (Java interop / Spark) + +**Problem:** Validating objects with null fields throws NPE instead of returning Invalid. + +**Cause:** Valar is designed for idiomatic Scala 3 code where nulls are discouraged. By default, derived validators pass field values directly to the field's validator without null checks. + +**Solution:** Define null-aware validators for types that may contain null: + +```scala +// For Java interop or Spark DataFrames +given Validator[String] with { + def validate(s: String) = + if (s == null) ValidationResult.invalid(ValidationError("Value is null")) + else if (s.isEmpty) ValidationResult.invalid(ValidationError("Value is empty")) + else ValidationResult.Valid(s) +} + +// Now derivation will handle nulls gracefully +case class JavaUser(name: String, email: String) +given Validator[JavaUser] = Validator.derive +``` + +This approach: +- Keeps the core library simple and fast for pure Scala code +- Lets users opt-in to null handling where needed +- Allows custom null handling behavior (fail vs. treat as empty, etc.) + +--- + ## Performance Problems ### Issue: Validation of large collections is slow @@ -476,3 +505,4 @@ def validateUser(user: User): ValidationResult[User] = for { | Stack overflow | Limit nesting depth or use iterative validation | | ReDoS vulnerability | Never accept user regex patterns | | Sensitive data in errors | Use `valar-translator` to sanitize messages | +| NPE with null fields (Java/Spark) | Define null-aware validators for affected types | diff --git a/build.sbt b/build.sbt index 55daeb1..7740e88 100644 --- a/build.sbt +++ b/build.sbt @@ -9,7 +9,7 @@ import _root_.mdoc.MdocPlugin // ===== Build‑wide Settings ===== ThisBuild / organization := "net.ghoula" ThisBuild / versionScheme := Some("early-semver") -ThisBuild / scalaVersion := "3.7.1" +ThisBuild / scalaVersion := "3.7.4" ThisBuild / semanticdbEnabled := true ThisBuild / semanticdbVersion := scalafixSemanticdb.revision diff --git a/docs-src/MIGRATION.md b/docs-src/MIGRATION.md index 43faf88..015c2b3 100644 --- a/docs-src/MIGRATION.md +++ b/docs-src/MIGRATION.md @@ -1,5 +1,54 @@ # Migration Guide +## Migrating from v0.5.0 to v0.6.0 + +Version 0.6.0 includes a **breaking change** to built-in validators. + +### Breaking Change: Pass-Through Validators + +Built-in validators for `Int`, `String`, `Float`, and `Double` are now **pass-through** (accept all values). Previously +they enforced constraints: + +| Type | v0.5.0 (constrained) | v0.6.0 (pass-through) | +|--------|------------------------------|-----------------------| +| Int | Rejects negative values | Accepts all values | +| String | Rejects empty strings | Accepts all values | +| Float | Rejects NaN/Infinity | Accepts all values | +| Double | Rejects NaN/Infinity | Accepts all values | + +**Why this change?** The opinionated defaults limited Valar's use as a general-purpose foundation. Users validating +temperatures (negative values valid), legacy data (empty strings valid), or scientific data (NaN meaningful) had to +fight the library's defaults. + +### Migration Steps + +If you relied on the constrained defaults, define explicit validators using `ValidationHelpers`: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.ValidationHelpers.* + +// Restore v0.5.0 behavior for Int +given Validator[Int] with { + def validate(i: Int) = nonNegativeInt(i) +} + +// Restore v0.5.0 behavior for String +given Validator[String] with { + def validate(s: String) = nonEmpty(s) +} + +// Restore v0.5.0 behavior for Float +given Validator[Float] with { + def validate(f: Float) = finiteFloat(f) +} + +// Restore v0.5.0 behavior for Double +given Validator[Double] with { + def validate(d: Double) = finiteDouble(d) +} +``` + ## 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: @@ -7,7 +56,7 @@ Version 0.5.0 introduces several new features while maintaining backward compati 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 +4. **Reworked derivation** using modern Scala 3 inline metaprogramming 5. **MiMa checks** to ensure binary compatibility between versions ### Update build.sbt: diff --git a/docs-src/README.md b/docs-src/README.md index 3bfd6d3..05bbd5f 100644 --- a/docs-src/README.md +++ b/docs-src/README.md @@ -1,52 +1,41 @@ -# **Valar – Type-Safe Validation for Scala 3** +# Valar - Type-Safe Validation for Scala 3 [![Maven Central](https://img.shields.io/maven-central/v/net.ghoula/valar-core_3?label=maven-central&style=flat-square)](https://central.sonatype.com/artifact/net.ghoula/valar-core_3) [![Scala CI and GitHub Release](https://github.com/hakimjonas/valar/actions/workflows/scala.yml/badge.svg)](https://github.com/hakimjonas/valar/actions/workflows/scala.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -Valar is a validation library for Scala 3 designed for clarity and ease of use. It leverages Scala 3's type system and -metaprogramming (macros) to help you define complex validation rules with less boilerplate, while providing structured, -detailed error messages useful for debugging or user feedback. - -## **✨ What's New in 0.5.X** - -* **🔍 ValidationObserver**: A new trait in `valar-core` for observing validation outcomes without altering the flow, - perfect for logging, metrics collection, or auditing with zero overhead when not used. -* **🌐 valar-translator Module**: New internationalization (i18n) support for validation error messages through the - `Translator` typeclass. -* **🧪 Enhanced ValarSuite**: Updated testing utilities in `valar-munit` now used in `valar-translator` for more robust - validation testing. -* **⚡ Reworked Macros**: Simpler, more performant, and more modern macro implementations for better compile-time - validation. -* **🛡️ MiMa Checks**: Added binary compatibility verification to ensure smooth upgrades between versions. -* **📚 Improved Documentation**: Comprehensive updates to scaladoc and module-level README files for a better developer - experience. - -## **Key Features** - -* **Type Safety:** Clearly distinguish between valid results and accumulated errors at compile time using - ValidationResult[A]. Eliminate runtime errors caused by unexpected validation states. -* **Minimal Boilerplate:** Derive Validator instances automatically for case classes using compile-time macros, - significantly reducing repetitive validation logic. Focus on your rules, not the wiring. -* **Flexible Error Handling:** Choose the strategy that fits your use case: - * **Error Accumulation** (default): Collect all validation failures, ideal for reporting multiple issues (e.g., in - UIs or API responses). - * **Fail-Fast**: Stop validation immediately on the first failure, suitable for performance-sensitive pipelines. -* **Actionable Error Reports:** Generate detailed ValidationError objects containing precise field paths, validation - rule specifics (like expected vs. actual values), and optional codes/severity. -* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples, with preserved backward - compatibility. -* **Scala 3 Idiomatic:** Built specifically for Scala 3, embracing features like extension methods, given instances, - opaque types, and macros for a modern, expressive API. - -## **Extensibility Pattern** - -Valar is designed to be extensible through the **ValidationObserver pattern**, which provides a clean, type-safe way to -integrate with external systems without modifying the core validation logic. +Valar is a validation library for Scala 3. It uses Scala 3's type system and inline metaprogramming to define validation +rules with minimal boilerplate, providing structured error messages for debugging or user feedback. -### The ValidationObserver Pattern +## What's New in 0.5.X + +* **ValidationObserver**: A trait for observing validation outcomes without altering the flow. Useful for logging, + metrics, or auditing. Zero overhead when not used. +* **valar-translator Module**: Internationalization (i18n) support for validation error messages via the `Translator` + typeclass. +* **Enhanced ValarSuite**: Updated testing utilities in `valar-munit`. +* **Reworked Derivation**: Uses modern Scala 3 inline metaprogramming for compile-time validation. +* **MiMa Checks**: Binary compatibility verification between versions. +* **Improved Documentation**: Updated scaladoc and module-level README files. + +## Key Features + +* **Type Safety:** Distinguish between valid results and accumulated errors at compile time using `ValidationResult[A]`. +* **Minimal Boilerplate:** Derive `Validator` instances automatically for case classes using compile-time derivation. +* **Flexible Error Handling:** + * **Error Accumulation** (default): Collect all validation failures for reporting multiple issues. + * **Fail-Fast**: Stop on the first failure for performance-sensitive pipelines. +* **Detailed Error Reports:** `ValidationError` objects with field paths, expected vs. actual values, and optional + codes/severity. +* **Named Tuple Support:** Field-aware error messages for Scala 3.7's named tuples. +* **Scala 3 Idiomatic:** Uses extension methods, given instances, opaque types, and inline metaprogramming. + +## Extensibility Pattern + +Valar is extensible through the `ValidationObserver` pattern, which integrates with external systems without modifying +core validation logic. -The `ValidationObserver` trait serves as the foundational pattern for extending Valar with cross-cutting concerns: +### The ValidationObserver Pattern ```scala trait ValidationObserver { @@ -54,28 +43,26 @@ trait ValidationObserver { } ``` -This pattern offers several advantages: +Properties: -* **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 +* **Zero Overhead**: Default no-op observer is eliminated by the compiler +* **Non-Intrusive**: Observes results without altering the validation flow +* **Composable**: Works with other Valar features and can be chained -### Examples of Extensions Using This Pattern +### Extension Examples -Current implementations are following this pattern: +Current uses: -- **Logging**: Log validation outcomes for debugging and monitoring -- **Metrics**: Collect validation statistics for performance analysis -- **Auditing**: Track validation events for compliance and security +- **Logging**: Log validation outcomes +- **Metrics**: Collect validation statistics +- **Auditing**: Track validation events -Future extensions planned: +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** +## Available Artifacts Valar provides artifacts for both JVM and Scala Native platforms: @@ -90,11 +77,9 @@ Valar provides artifacts for both JVM and Scala Native platforms: > **Note:** When using the `%%%` operator in sbt, the correct platform-specific artifact will be selected automatically. -## **Performance** +## Performance -Valar is designed for high performance with minimal overhead: - -### **Complexity Characteristics** +### Complexity Characteristics | Operation | Time Complexity | Space Complexity | Notes | |-----------|----------------|------------------|-------| @@ -104,7 +89,7 @@ Valar is designed for high performance with minimal overhead: | Nested case class | O(fields) | O(errors) | Accumulates errors across all fields | | Union type validation | O(types) | O(errors) | Tries each type in the union | -### **Performance Best Practices** +### Best Practices 1. **Use ValidationConfig limits** for untrusted input to prevent DoS: ```scala @@ -127,23 +112,24 @@ Valar is designed for high performance with minimal overhead: if (collection.size > 10000) return BadRequest("Too large") ``` -### **Benchmark Results** +### Benchmark Results -Detailed performance benchmarks with JMH are available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). +Detailed benchmarks available in the [valar-benchmarks module](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md). -**Key findings:** +Key findings: - Simple validations: ~10-50 nanoseconds - Case class derivation: Zero runtime overhead (compile-time only) - Collection validation: Linear with collection size -- Zero-cost abstractions: `ValidationObserver` with no-op has no runtime impact +- `ValidationObserver` with no-op has no runtime impact + +## Additional Resources -## **Additional Resources** +- [Performance Benchmarks](https://github.com/hakimjonas/valar/blob/main/valar-benchmarks/README.md): JMH benchmark results +- [Testing Guide](https://github.com/hakimjonas/valar/blob/main/valar-munit/README.md): ValarSuite testing utilities +- [Internationalization](https://github.com/hakimjonas/valar/blob/main/valar-translator/README.md): i18n support +- [Troubleshooting Guide](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md): Common issues and solutions -- 📊 **[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 -- 🔧 **[Troubleshooting Guide](https://github.com/hakimjonas/valar/blob/main/TROUBLESHOOTING.md)**: Common issues and solutions -## **Installation** +## Installation Add the following to your build.sbt: @@ -158,7 +144,7 @@ libraryDependencies += "net.ghoula" %%% "valar-translator" % "0.5.0" libraryDependencies += "net.ghoula" %%% "valar-munit" % "0.5.0" % Test ``` -## **Basic Usage Example** +## 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). @@ -197,7 +183,7 @@ result match { } ``` -## **Testing with valar-munit** +## Testing with valar-munit The optional valar-munit module provides ValarSuite, a trait that offers powerful, validation-specific assertions to make your tests clean and expressive. @@ -237,9 +223,9 @@ class UserValidationSuite extends ValarSuite { } ``` -## **Core Components** +## Core Components -### **ValidationResult** +### ValidationResult Represents the outcome of validation as either Valid(value) or Invalid(errors): @@ -252,7 +238,7 @@ enum ValidationResult[+A] { } ``` -### **ValidationError** +### ValidationError Opaque type providing rich context for validation errors, including: @@ -263,7 +249,7 @@ Opaque type providing rich context for validation errors, including: * **expected/actual**: Information about expected and actual values. * **children**: Nested errors for structured reporting. -### **Validator[A]** +### Validator[A] A typeclass defining validation logic for a given type: @@ -282,25 +268,39 @@ to be available in scope for **all** field types within the case class. If a val *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** +## Built-in Validators + +Valar provides pass-through `Validator` instances for common types to enable derivation. All built-in validators accept +any value - constraints are opt-in via `ValidationHelpers`. -Valar provides given Validator instances out-of-the-box for many common types to ease setup and support derivation. This -includes: +**Supported types:** -* **Scala Primitives:** Int (non-negative), String (non-empty), Boolean, Long, Double (finite), Float (finite), Byte, - Short, Char, Unit. -* **Other Scala Types:** BigInt, BigDecimal, Symbol. -* **Common Java Types:** java.util.UUID, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, - java.time.ZonedDateTime, java.time.LocalTime, java.time.Duration. -* **Standard Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map (provided validators exist for their - element/key/value types). -* **Tuple Types:** Named tuples and regular tuples. -* **Intersection (&) and Union (|) Types:** Provided corresponding validators for the constituent types exist. +* **Scala Primitives:** Int, String, Boolean, Long, Double, Float, Byte, Short, Char, Unit +* **Other Scala Types:** BigInt, BigDecimal, Symbol +* **Java Types:** UUID, Instant, LocalDate, LocalDateTime, ZonedDateTime, LocalTime, Duration +* **Collections:** Option, List, Vector, Seq, Set, Array, ArraySeq, Map +* **Tuple Types:** Named tuples and regular tuples +* **Composite Types:** Intersection (&) and Union (|) types + +**Opt-in constraints** (from `ValidationHelpers`): + +```scala +import net.ghoula.valar.ValidationHelpers.* + +// Define constrained validators when you need them +given Validator[Int] with { + def validate(i: Int) = nonNegativeInt(i) +} + +given Validator[String] with { + def validate(s: String) = nonEmpty(s) +} +``` -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. +Available constraint helpers: `nonNegativeInt`, `nonEmpty`, `finiteFloat`, `finiteDouble`, `minLength`, `maxLength`, +`regexMatch`, `inRange`, `oneOf`. -## **ValidationObserver, The Core Extensibility Pattern** +## ValidationObserver Pattern The `ValidationObserver` trait is more than just a logging mechanism—it's the **foundational pattern** for extending Valar with custom functionality. This pattern allows you to: @@ -357,7 +357,7 @@ Key features of ValidationObserver: * **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** +## Internationalization with valar-translator The `valar-translator` module provides internationalization (i18n) support for validation error messages: @@ -397,14 +397,14 @@ The `valar-translator` module is designed to: * 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** +## 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 +4. **Reworked derivation** using modern Scala 3 inline metaprogramming 5. **MiMa checks** to ensure binary compatibility To upgrade to v0.5.0, update your build.sbt: @@ -422,7 +422,7 @@ 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** +## 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. @@ -437,29 +437,29 @@ libraryDependencies += "net.ghoula" %% "valar" % "0.3.0" libraryDependencies += "net.ghoula" %%% "valar-core" % "0.4.8-bundle" ``` -## **Security Considerations** +## Security Considerations When using Valar with untrusted user input, please be aware of the following security considerations: -### **Regular Expression Denial of Service (ReDoS)** +### Regular Expression Denial of Service (ReDoS) -⚠️ **Warning:** The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. +**Warning:** The `regexMatch` methods that accept `String` patterns are vulnerable to ReDoS attacks when used with untrusted input. **Safe Practice:** ```scala -// ✅ SAFE - Use pre-compiled regex patterns +// SAFE - Use pre-compiled regex patterns val emailPattern = "^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$".r regexMatch(userInput, emailPattern)(_ => "Invalid email") ``` **Unsafe Practice:** ```scala -// ❌ UNSAFE - Never pass user-provided patterns! +// UNSAFE - Never pass user-provided patterns! val userPattern = request.getParameter("pattern") regexMatch(value, userPattern)(_ => "Invalid") // ReDoS vulnerability! ``` -### **Input Size Limits** +### Input Size Limits Valar provides built-in protection against resource exhaustion through `ValidationConfig`: @@ -484,21 +484,21 @@ When a collection exceeds the configured limit, validation fails immediately ''' **Important:** Always use `ValidationConfig.strict` or custom limits when validating untrusted user input. -### **Error Information Disclosure** +### Error Information Disclosure `ValidationError` objects include detailed information about what was expected vs. what was received. When exposing validation errors to end users: - Review error messages for sensitive information - Consider using the `valar-translator` module to provide user-friendly, sanitized messages - Be cautious about exposing internal field names or structure -## **Compatibility** +## Compatibility * **Scala:** 3.7+ * **Platforms:** JVM, Scala Native * **Dependencies:** valar-core has a Compile dependency on `io.github.cquiroz:scala-java-time` to provide robust, cross-platform support for the `java.time` API. -## **License** +## License Valar is licensed under the **MIT License**. See the [LICENSE](https://github.com/hakimjonas/valar/blob/main/LICENSE) file for details. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..ae6ba25 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,164 @@ +# Valar Design + +This document explains the technical design choices in Valar and how Scala 3's type system enables its implementation. + +## Typeclass Derivation: Scala 2 vs Scala 3 + +### Scala 2 Approach + +In Scala 2, automatic typeclass derivation required external libraries: + +**Shapeless** - Generic programming via HLists: +```scala +import shapeless._ + +trait Validator[A] { + def validate(a: A): ValidationResult[A] +} + +object Validator { + // Derive using Generic and HList machinery + implicit def deriveHNil: Validator[HNil] = ... + implicit def deriveHCons[H, T <: HList]( + implicit hv: Validator[H], tv: Validator[T] + ): Validator[H :: T] = ... + implicit def deriveGeneric[A, R]( + implicit gen: Generic.Aux[A, R], rv: Validator[R] + ): Validator[A] = ... +} +``` + +Problems: +- Complex implicit resolution chains +- Slow compile times for large case classes +- Cryptic error messages ("could not find implicit value for...") +- Runtime overhead from HList conversions + +**Magnolia** - Macro-based derivation: +```scala +import magnolia1._ + +object Validator extends AutoDerivation[Validator] { + def join[A](ctx: CaseClass[Validator, A]): Validator[A] = new Validator[A] { + def validate(a: A) = { + ctx.parameters.foldLeft(ValidationResult.Valid(a)) { (acc, param) => + // validate each parameter + } + } + } +} +``` + +Better than Shapeless but still: +- Requires macro annotation or explicit derivation calls +- Limited compile-time introspection +- Error messages still opaque + +### Scala 3 Approach + +Scala 3 provides first-class support for typeclass derivation through: + +1. **Mirrors** - Compile-time type information +2. **Inline/Quotes** - Type-safe metaprogramming +3. **Match types** - Type-level computation + +Valar's implementation: + +```scala +import scala.deriving.Mirror +import scala.quoted.* + +object Validator { + // User-facing API + inline def derive[T](using m: Mirror.ProductOf[T]): Validator[T] = + ${ deriveSyncValidatorImpl[T, m.MirroredElemTypes, m.MirroredElemLabels]('m) } + + // Compile-time implementation + private def deriveSyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] + )(using q: Quotes): Expr[Validator[T]] = { + // Extract field names and types at compile time + // Generate validation code for each field + // Return fully inlined validator + } +} +``` + +Advantages: +- **Zero runtime overhead** - all derivation happens at compile time +- **Clear error messages** - Valar reports exactly which field validators are missing +- **Direct field access** - no HList conversion, uses `Select.unique` for zero-cast access +- **Type-safe** - the quotes API ensures generated code is well-typed + +### Compile-Time Validation + +Valar validates that all required validators exist before generating code: + +```scala +// If you write: +case class User(name: String, age: Int, data: MyCustomType) +given Validator[User] = Validator.derive + +// And MyCustomType has no Validator, you get: +// error: Cannot derive Validator for User: missing validators for 1 field(s). +// - Field 'data' of type MyCustomType +// Add: given Validator[MyCustomType] = ... +``` + +This is implemented by checking `Expr.summon[Validator[FieldType]]` for each field at compile time and collecting all failures before reporting. + +## Architecture + +### Core Types + +``` +ValidationResult[+A] +├── Valid(value: A) +└── Invalid(errors: Vector[ValidationError]) + +Validator[A] - Synchronous validation +AsyncValidator[A] - Asynchronous validation (Future-based) +ValidationObserver - Side-effect hook for logging/metrics +ValidationConfig - Runtime configuration (collection limits) +``` + +### Field Access Strategy + +Valar uses different strategies depending on the type being validated: + +| Type | Strategy | Cast Required | +|------|----------|---------------| +| Case class | `Select.unique(term, fieldName)` | No | +| Regular tuple | `Select.unique(term, "_N")` | No | +| Named tuple | `productElement(index)` | Yes (matches stdlib) | + +This was determined by examining how the Scala 3.7.4 standard library handles named tuples - they also use `productElement` with a cast. + +### Error Accumulation + +By default, Valar accumulates all errors rather than failing fast: + +```scala +// zip combines results, accumulating errors +val result = validateName(name).zip(validateAge(age)).zip(validateEmail(email)) + +// flatMap is fail-fast (stops on first error) +val result = for { + n <- validateName(name) + a <- validateAge(age) + e <- validateEmail(email) +} yield User(n, a, e) +``` + +The `Invalid` case uses `Vector[ValidationError]` for efficient concatenation during accumulation. + +## Why Inline Metaprogramming? + +Valar uses Scala 3's inline metaprogramming (quotes/reflect API) rather than runtime reflection because: + +1. **Performance** - No runtime type inspection or reflection calls +2. **Type safety** - Generated code is checked by the compiler +3. **Early errors** - Missing validators are caught at compile time +4. **Optimization** - The compiler can inline and optimize the generated code + +The term "inline metaprogramming" reflects modern Scala 3 terminology. The quotes/reflect API is the standard approach for compile-time code generation, replacing Scala 2's `scala.reflect` macros. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..3ca62c0 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,357 @@ +# Valar Examples + +Advanced usage patterns and examples. + +## Async Validation + +Use `AsyncValidator` when validation requires I/O operations like database lookups or API calls. + +```scala +import net.ghoula.valar.* +import scala.concurrent.{Future, ExecutionContext} + +case class User(email: String, username: String) + +// Simulate async checks +def emailExists(email: String)(using ec: ExecutionContext): Future[Boolean] = + Future { /* database lookup */ true } + +def usernameAvailable(username: String)(using ec: ExecutionContext): Future[Boolean] = + Future { /* database lookup */ true } + +// Define async validators +given ec: ExecutionContext = ExecutionContext.global + +given AsyncValidator[String] with { + def validateAsync(value: String): Future[ValidationResult[String]] = + Future.successful( + if (value.nonEmpty) ValidationResult.Valid(value) + else ValidationResult.invalid(ValidationError("Value must not be empty")) + ) +} + +// Custom async validator for User with database checks +given AsyncValidator[User] with { + def validateAsync(user: User): Future[ValidationResult[User]] = { + for { + emailCheck <- emailExists(user.email) + usernameCheck <- usernameAvailable(user.username) + } yield { + val errors = Vector.newBuilder[ValidationError] + + if (emailCheck) + errors += ValidationError("Email already registered") + .withFieldPath("email") + + if (!usernameCheck) + errors += ValidationError("Username not available") + .withFieldPath("username") + + val errs = errors.result() + if (errs.isEmpty) ValidationResult.Valid(user) + else ValidationResult.Invalid(errs) + } + } +} + +// Usage +val user = User("test@example.com", "newuser") +val resultFuture: Future[ValidationResult[User]] = + AsyncValidator[User].validateAsync(user) +``` + +### Mixing Sync and Async + +You can derive an `AsyncValidator` that uses synchronous validators for fields: + +```scala +case class Registration(user: User, acceptedTerms: Boolean) + +// Sync validator for Boolean +given Validator[Boolean] with { + def validate(value: Boolean): ValidationResult[Boolean] = + if (value) ValidationResult.Valid(value) + else ValidationResult.invalid(ValidationError("Must accept terms")) +} + +// Derive async validator - uses sync validators wrapped in Future.successful +given AsyncValidator[Registration] = AsyncValidator.derive +``` + +## Validator Composition + +### Sequential Composition (Fail-Fast) + +Use `flatMap` or for-comprehensions when later validations depend on earlier ones: + +```scala +import net.ghoula.valar.* +import net.ghoula.valar.ValidationHelpers.* + +def validatePassword(password: String): ValidationResult[String] = { + for { + p <- nonEmpty(password, _ => "Password required") + p <- minLength(p, 8, _ => "Password must be at least 8 characters") + p <- if (p.exists(_.isUpper)) ValidationResult.Valid(p) + else ValidationResult.invalid(ValidationError("Password must contain uppercase")) + p <- if (p.exists(_.isDigit)) ValidationResult.Valid(p) + else ValidationResult.invalid(ValidationError("Password must contain a digit")) + } yield p +} + +// Stops at first failure +validatePassword("short") // Invalid: "Password must be at least 8 characters" +validatePassword("longenoughbutnoupperodigit") // Invalid: "Password must contain uppercase" +``` + +### Parallel Composition (Error Accumulation) + +Use `zip` to validate independent fields and collect all errors: + +```scala +case class RegistrationForm( + username: String, + email: String, + password: String, + confirmPassword: String +) + +def validateForm(form: RegistrationForm): ValidationResult[RegistrationForm] = { + val usernameResult = nonEmpty(form.username, _ => "Username required") + .map(_ => form.username) + + val emailResult = nonEmpty(form.email, _ => "Email required") + .flatMap(e => regexMatch(e, "^[^@]+@[^@]+$".r)(_ => "Invalid email format")) + .map(_ => form.email) + + val passwordResult = validatePassword(form.password) + + val confirmResult = + if (form.password == form.confirmPassword) ValidationResult.Valid(form.confirmPassword) + else ValidationResult.invalid(ValidationError("Passwords do not match")) + + // Combine all validations - accumulates errors + usernameResult + .zip(emailResult) + .zip(passwordResult) + .zip(confirmResult) + .map { case (((_, _), _), _) => form } +} +``` + +### Reusable Validator Combinators + +Build validators from smaller pieces: + +```scala +object Validators { + def nonEmptyString: Validator[String] = new Validator[String] { + def validate(s: String) = + if (s.trim.nonEmpty) ValidationResult.Valid(s.trim) + else ValidationResult.invalid(ValidationError("Must not be empty")) + } + + def inRange(min: Int, max: Int): Validator[Int] = new Validator[Int] { + def validate(n: Int) = + if (n >= min && n <= max) ValidationResult.Valid(n) + else ValidationResult.invalid( + ValidationError(s"Must be between $min and $max") + .withExpected(s"$min-$max") + .withActual(n.toString) + ) + } + + def matchesRegex(pattern: scala.util.matching.Regex, message: String): Validator[String] = + new Validator[String] { + def validate(s: String) = + if (pattern.matches(s)) ValidationResult.Valid(s) + else ValidationResult.invalid(ValidationError(message)) + } +} + +// Compose into domain validators +object UserValidators { + val username: Validator[String] = new Validator[String] { + def validate(s: String) = + Validators.nonEmptyString.validate(s).flatMap { trimmed => + Validators.matchesRegex("^[a-zA-Z0-9_]+$".r, "Username can only contain letters, numbers, and underscores") + .validate(trimmed) + } + } + + val age: Validator[Int] = Validators.inRange(0, 150) +} +``` + +## Union Types + +Valar can validate union types when validators exist for each member: + +```scala +// Define validators for each type in the union +given Validator[String] with { + def validate(s: String) = + if (s.nonEmpty) ValidationResult.Valid(s) + else ValidationResult.invalid(ValidationError("String must not be empty")) +} + +given Validator[Int] with { + def validate(n: Int) = + if (n >= 0) ValidationResult.Valid(n) + else ValidationResult.invalid(ValidationError("Int must be non-negative")) +} + +// Union type validator is derived automatically +val unionValidator = summon[Validator[String | Int]] + +unionValidator.validate("hello") // Valid("hello") +unionValidator.validate(42) // Valid(42) +unionValidator.validate("") // Invalid: String must not be empty +unionValidator.validate(-1) // Invalid: Int must be non-negative +``` + +### Discriminated Unions with Enums + +```scala +enum PaymentMethod { + case CreditCard(number: String, expiry: String, cvv: String) + case BankTransfer(iban: String, bic: String) + case Crypto(walletAddress: String) +} + +// Validators for each case +given Validator[PaymentMethod.CreditCard] with { + def validate(cc: PaymentMethod.CreditCard) = { + val numberValid = cc.number.length == 16 && cc.number.forall(_.isDigit) + val cvvValid = cc.cvv.length == 3 && cc.cvv.forall(_.isDigit) + + if (numberValid && cvvValid) ValidationResult.Valid(cc) + else ValidationResult.invalid(ValidationError("Invalid credit card details")) + } +} + +given Validator[PaymentMethod.BankTransfer] with { + def validate(bt: PaymentMethod.BankTransfer) = { + if (bt.iban.nonEmpty && bt.bic.nonEmpty) ValidationResult.Valid(bt) + else ValidationResult.invalid(ValidationError("IBAN and BIC required")) + } +} + +given Validator[PaymentMethod.Crypto] with { + def validate(c: PaymentMethod.Crypto) = { + if (c.walletAddress.startsWith("0x")) ValidationResult.Valid(c) + else ValidationResult.invalid(ValidationError("Invalid wallet address")) + } +} + +// Validate any payment method +def validatePayment(pm: PaymentMethod): ValidationResult[PaymentMethod] = pm match { + case cc: PaymentMethod.CreditCard => Validator[PaymentMethod.CreditCard].validate(cc) + case bt: PaymentMethod.BankTransfer => Validator[PaymentMethod.BankTransfer].validate(bt) + case c: PaymentMethod.Crypto => Validator[PaymentMethod.Crypto].validate(c) +} +``` + +## Intersection Types + +Intersection types validate when the value satisfies all component validators: + +```scala +trait Named { def name: String } +trait Aged { def age: Int } + +case class Person(name: String, age: Int) extends Named with Aged + +given Validator[Named] with { + def validate(n: Named) = + if (n.name.nonEmpty) ValidationResult.Valid(n) + else ValidationResult.invalid(ValidationError("Name required")) +} + +given Validator[Aged] with { + def validate(a: Aged) = + if (a.age >= 0) ValidationResult.Valid(a) + else ValidationResult.invalid(ValidationError("Age must be non-negative")) +} + +// For intersection types, both validators must pass +val person = Person("Alice", 30) + +// Validate against both traits +val namedResult = Validator[Named].validate(person) +val agedResult = Validator[Aged].validate(person) + +// Combine results +val combined = namedResult.zip(agedResult).map { case (_, _) => person } +``` + +## Nested Case Classes + +Valar automatically validates nested structures: + +```scala +case class Address(street: String, city: String, postalCode: String) +case class Company(name: String, address: Address) +case class Employee(name: String, company: Company) + +// Define validators for leaf types +given Validator[String] with { + def validate(s: String) = + if (s.nonEmpty) ValidationResult.Valid(s) + else ValidationResult.invalid(ValidationError("Must not be empty")) +} + +// Derive validators bottom-up +given Validator[Address] = Validator.derive +given Validator[Company] = Validator.derive +given Validator[Employee] = Validator.derive + +// Validation errors include full field paths +val invalid = Employee("", Company("", Address("", "", ""))) +val result = Validator[Employee].validate(invalid) + +// Errors will have paths like: +// - "name" +// - "company.name" +// - "company.address.street" +// - "company.address.city" +// - "company.address.postalCode" +``` + +## Collection Validation + +Collections are validated element by element: + +```scala +case class Order(items: List[OrderItem]) +case class OrderItem(productId: String, quantity: Int) + +given Validator[OrderItem] with { + def validate(item: OrderItem) = { + val productValid = item.productId.nonEmpty + val quantityValid = item.quantity > 0 + + if (productValid && quantityValid) ValidationResult.Valid(item) + else { + val errors = Vector.newBuilder[ValidationError] + if (!productValid) errors += ValidationError("Product ID required") + if (!quantityValid) errors += ValidationError("Quantity must be positive") + ValidationResult.Invalid(errors.result()) + } + } +} + +given Validator[Order] = Validator.derive + +// With ValidationConfig to limit collection size +given ValidationConfig = ValidationConfig.strict // Max 10,000 items + +val order = Order(List( + OrderItem("", 1), // Invalid: no product ID + OrderItem("abc", 0), // Invalid: quantity not positive + OrderItem("xyz", 5) // Valid +)) + +val result = Validator[Order].validate(order) +// Accumulates errors from all invalid items +``` diff --git a/project/plugins.sbt b/project/plugins.sbt index 7e3b1a5..5540fd9 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -15,7 +15,7 @@ addSbtPlugin("org.portable-scala" % "sbt-crossproject" % "1.3.2") addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") // Scala Native -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.8") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.9") // --- Compatibility Tools --- // For binary compatibility checking diff --git a/valar-benchmarks/README.md b/valar-benchmarks/README.md index f70ce8a..d8007b2 100644 --- a/valar-benchmarks/README.md +++ b/valar-benchmarks/README.md @@ -129,7 +129,7 @@ To add new benchmarks: ## Dependencies - JMH 1.37 -- Scala 3.7.1 +- Scala 3.7.4 - OpenJDK 21+ ## Notes diff --git a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala index db83191..8c92e23 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/AsyncValidator.scala @@ -6,8 +6,7 @@ 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 +import net.ghoula.valar.internal.{Derivation, FutureEffect, ValidationLogic} /** A typeclass for defining custom asynchronous validation logic for type `A`. * @@ -51,76 +50,6 @@ object AsyncValidator { 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. - * - * '''Security Note:''' This method enforces size limits from `ValidationConfig` before - * processing elements to prevent memory exhaustion attacks from maliciously large collections. - * - * @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 collectionType - * description of the collection type for error messages - * @param config - * validation configuration for security limits - * @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], - collectionType: String - )(using config: ValidationConfig, ec: ExecutionContext): Future[ValidationResult[C[A]]] = { - config.checkCollectionSize(items.size, collectionType) match { - case ValidationResult.Invalid(errors) => Future.successful(ValidationResult.Invalid(errors)) - case ValidationResult.Valid(_) => - 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 @@ -143,177 +72,51 @@ object AsyncValidator { } } - /** 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. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large lists. - * - * @param v - * the validator for list elements - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles lists - */ + /** Asynchronous validator for lists. */ given listAsyncValidator[A](using v: AsyncValidator[A], config: ValidationConfig): AsyncValidator[List[A]] with { def validateAsync(xs: List[A])(using ec: ExecutionContext): Future[ValidationResult[List[A]]] = - validateCollection(xs, v, _.toList, "List") + ValidationLogic.validateCollection(xs, identity, "List")(v.validateAsync)(using FutureEffect(), config) } - /** 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. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large sequences. - * - * @param v - * the validator for sequence elements - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles sequences - */ + /** Asynchronous validator for sequences. */ given seqAsyncValidator[A](using v: AsyncValidator[A], config: ValidationConfig): AsyncValidator[Seq[A]] with { def validateAsync(xs: Seq[A])(using ec: ExecutionContext): Future[ValidationResult[Seq[A]]] = - validateCollection(xs, v, _.toSeq, "Seq") + ValidationLogic.validateCollection(xs, _.toSeq, "Seq")(v.validateAsync)(using FutureEffect(), config) } - /** 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. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large vectors. - * - * @param v - * the validator for vector elements - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles vectors - */ + /** Asynchronous validator for vectors. */ given vectorAsyncValidator[A](using v: AsyncValidator[A], config: ValidationConfig): AsyncValidator[Vector[A]] with { def validateAsync(xs: Vector[A])(using ec: ExecutionContext): Future[ValidationResult[Vector[A]]] = - validateCollection(xs, v, _.toVector, "Vector") + ValidationLogic.validateCollection(xs, _.toVector, "Vector")(v.validateAsync)(using FutureEffect(), config) } - /** 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. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large sets. - * - * @param v - * the validator for set elements - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles sets - */ + /** Asynchronous validator for sets. */ given setAsyncValidator[A](using v: AsyncValidator[A], config: ValidationConfig): AsyncValidator[Set[A]] with { def validateAsync(xs: Set[A])(using ec: ExecutionContext): Future[ValidationResult[Set[A]]] = - validateCollection(xs, v, _.toSet, "Set") + ValidationLogic.validateCollection(xs, _.toSet, "Set")(v.validateAsync)(using FutureEffect(), config) } - /** 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. - * - * '''Security Note:''' This validator enforces collection size limits from `ValidationConfig` to - * prevent DoS attacks via extremely large maps. - * - * @param vk - * the validator for map keys - * @param vv - * the validator for map values - * @param config - * validation configuration for security limits - * @return - * an AsyncValidator that handles maps - */ + /** Asynchronous validator for maps. */ given mapAsyncValidator[K, V](using vk: AsyncValidator[K], vv: AsyncValidator[V], config: ValidationConfig ): AsyncValidator[Map[K, V]] with { - def validateAsync(m: Map[K, V])(using ec: ExecutionContext): Future[ValidationResult[Map[K, V]]] = { - config.checkCollectionSize(m.size, "Map") match { - case ValidationResult.Invalid(errors) => Future.successful(ValidationResult.Invalid(errors)) - case ValidationResult.Valid(_) => - 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) - } - } - } + def validateAsync(m: Map[K, V])(using ec: ExecutionContext): Future[ValidationResult[Map[K, V]]] = + ValidationLogic.validateMap(m)(vk.validateAsync, vv.validateAsync)(using FutureEffect(), config) } - /** 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) + /** Pass-through async validator for Int. For constraints, define a custom AsyncValidator. */ + given intAsyncValidator: AsyncValidator[Int] = fromSync(Validator.intValidator) - /** 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) + /** Pass-through async validator for Float. For constraints, define a custom AsyncValidator. */ + given floatAsyncValidator: AsyncValidator[Float] = fromSync(Validator.floatValidator) - /** 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) + /** Pass-through async validator for Double. For constraints, define a custom AsyncValidator. */ + given doubleAsyncValidator: AsyncValidator[Double] = fromSync(Validator.doubleValidator) - /** Asynchronous validator for non-empty strings. - * - * Validates that a string value is not empty. This validator is lifted from the corresponding - * synchronous validator and is used as a fallback when no custom string validator is provided. - */ - given nonEmptyStringAsyncValidator: AsyncValidator[String] = fromSync(Validator.nonEmptyStringValidator) + /** Pass-through async validator for String. For constraints, define a custom AsyncValidator. */ + given stringAsyncValidator: AsyncValidator[String] = fromSync(Validator.stringValidator) /** Asynchronous validator for boolean values. * @@ -454,7 +257,6 @@ object 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]] - } + )(using q: Quotes): Expr[AsyncValidator[T]] = + Derivation.deriveAsyncValidatorImpl[T, Elems, Labels](m) } 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 1edf6cb..a74ea57 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/Validator.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/Validator.scala @@ -4,14 +4,12 @@ import java.time.{Duration, Instant, LocalDate, LocalDateTime, LocalTime, ZonedD import java.util.UUID import scala.collection.immutable.ArraySeq import scala.deriving.Mirror -import scala.language.reflectiveCalls import scala.quoted.{Expr, Quotes, Type} 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.Derivation +import net.ghoula.valar.internal.{Derivation, SyncEffect, ValidationLogic} /** A typeclass for defining custom validation logic for type `A`. * @@ -41,28 +39,24 @@ object Validator { // ... 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) + /** Pass-through validator for Int. For constraints, use [[ValidationHelpers.nonNegativeInt]]. */ + inline given intValidator: Validator[Int] with { + def validate(i: Int): ValidationResult[Int] = ValidationResult.Valid(i) } - /** Validates that a Float is finite (not NaN or infinite). Uses - * [[ValidationHelpers.finiteFloat]]. - */ - given finiteFloatValidator: Validator[Float] with { - def validate(f: Float): ValidationResult[Float] = finiteFloat(f) + /** Pass-through validator for Float. For constraints, use [[ValidationHelpers.finiteFloat]]. */ + inline given floatValidator: Validator[Float] with { + def validate(f: Float): ValidationResult[Float] = ValidationResult.Valid(f) } - /** Validates that a Double is finite (not NaN or infinite). Uses - * [[ValidationHelpers.finiteDouble]]. - */ - given finiteDoubleValidator: Validator[Double] with { - def validate(d: Double): ValidationResult[Double] = finiteDouble(d) + /** Pass-through validator for Double. For constraints, use [[ValidationHelpers.finiteDouble]]. */ + inline given doubleValidator: Validator[Double] with { + def validate(d: Double): ValidationResult[Double] = ValidationResult.Valid(d) } - /** Validates that a String is non-empty. Uses [[ValidationHelpers.nonEmpty]]. */ - given nonEmptyStringValidator: Validator[String] with { - def validate(s: String): ValidationResult[String] = nonEmpty(s) + /** Pass-through validator for String. For constraints, use [[ValidationHelpers.nonEmpty]]. */ + inline given stringValidator: Validator[String] with { + def validate(s: String): ValidationResult[String] = ValidationResult.Valid(s) } /** Default validator for `Option[A]`. */ @@ -71,154 +65,61 @@ object Validator { optional(opt)(using v) } - /** Validates a `List[A]` by validating each element. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates a `List[A]` by validating each element. */ given listValidator[A](using v: Validator[A], config: ValidationConfig): Validator[List[A]] with { - def validate(xs: List[A]): ValidationResult[List[A]] = { - config.checkCollectionSize(xs.size, "List").flatMap { _ => - val results = xs.map(v.validate) - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], List.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e2)) => (errs ++ e2, vals) - } - if errors.isEmpty then ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validate(xs: List[A]): ValidationResult[List[A]] = + ValidationLogic.validateCollection[[X] =>> X, A, List[A]](xs, identity, "List")(v.validate)(using + SyncEffect, + config + ) } - /** Validates a `Seq[A]` by validating each element. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates a `Seq[A]` by validating each element. */ given seqValidator[A](using v: Validator[A], config: ValidationConfig): Validator[Seq[A]] with { - def validate(xs: Seq[A]): ValidationResult[Seq[A]] = { - config.checkCollectionSize(xs.size, "Seq").flatMap { _ => - val results = xs.map(v.validate) - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Seq.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e2)) => (errs ++ e2, vals) - } - if errors.isEmpty then ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validate(xs: Seq[A]): ValidationResult[Seq[A]] = + ValidationLogic.validateCollection[[X] =>> X, A, Seq[A]](xs, _.toSeq, "Seq")(v.validate)(using SyncEffect, config) } - /** Validates a `Vector[A]` by validating each element. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates a `Vector[A]` by validating each element. */ given vectorValidator[A](using v: Validator[A], config: ValidationConfig): Validator[Vector[A]] with { - def validate(xs: Vector[A]): ValidationResult[Vector[A]] = { - config.checkCollectionSize(xs.size, "Vector").flatMap { _ => - val results = xs.map(v.validate) - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Vector.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) - case ((errs, vals), ValidationResult.Invalid(e2)) => (errs ++ e2, vals) - } - if errors.isEmpty then ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validate(xs: Vector[A]): ValidationResult[Vector[A]] = + ValidationLogic.validateCollection[[X] =>> X, A, Vector[A]](xs, _.toVector, "Vector")(v.validate)(using + SyncEffect, + config + ) } - /** Validates a `Set[A]` by validating each element. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates a `Set[A]` by validating each element. */ given setValidator[A](using v: Validator[A], config: ValidationConfig): Validator[Set[A]] with { - def validate(xs: Set[A]): ValidationResult[Set[A]] = { - config.checkCollectionSize(xs.size, "Set").flatMap { _ => - val results = xs.map(v.validate) - val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], Set.empty[A])) { - case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals + a) - case ((errs, vals), ValidationResult.Invalid(e2)) => (errs ++ e2, vals) - } - if errors.isEmpty then ValidationResult.Valid(validValues) else ValidationResult.Invalid(errors) - } - } + def validate(xs: Set[A]): ValidationResult[Set[A]] = + ValidationLogic.validateCollection[[X] =>> X, A, Set[A]](xs, _.toSet, "Set")(v.validate)(using SyncEffect, config) } - /** Validates a `Map[K, V]` by validating each key and value. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the map size before processing entries, failing fast if the limit is exceeded. - */ + /** Validates a `Map[K, V]` by validating each key and value. */ given mapValidator[K, V](using vk: Validator[K], vv: Validator[V], config: ValidationConfig): Validator[Map[K, V]] with { - def validate(m: Map[K, V]): ValidationResult[Map[K, V]] = { - config.checkCollectionSize(m.size, "Map").flatMap { _ => - val results = m.map { case (k, v) => - val validatedKey: ValidationResult[K] = vk.validate(k) match { - case ValidationResult.Valid(kk) => ValidationResult.Valid(kk) - case ValidationResult.Invalid(es) => - ValidationResult.Invalid( - es.map(e => e.annotateField("key", k.getClass.getSimpleName)) - ) - } - val validatedValue: ValidationResult[V] = vv.validate(v) match { - case ValidationResult.Valid(vv) => ValidationResult.Valid(vv) - case ValidationResult.Invalid(es) => - ValidationResult.Invalid( - es.map(e => e.annotateField("value", v.getClass.getSimpleName)) - ) - } - validatedKey.zip(validatedValue) - } - val (errors, validPairs) = results.foldLeft((Vector.empty[ValidationError], Map.empty[K, V])) { - case ((errs, acc), ValidationResult.Valid(pair)) => (errs, acc + pair) - case ((errs, acc), ValidationResult.Invalid(e2)) => (errs ++ e2, acc) - } - if errors.isEmpty then ValidationResult.Valid(validPairs) else ValidationResult.Invalid(errors) - } - } + def validate(m: Map[K, V]): ValidationResult[Map[K, V]] = + ValidationLogic.validateMap[[X] =>> X, K, V](m)(vk.validate, vv.validate)(using SyncEffect, config) } - /** Helper for validating iterable collections. */ - private def validateIterable[A, C[_]]( - xs: Iterable[A], - builder: Vector[A] => C[A] - )(using v: Validator[A]): ValidationResult[C[A]] = { - val resultsIterator = xs.iterator.map(v.validate) - val initial = (Vector.empty[ValidationError], Vector.empty[A]) - val (errors, validValues) = resultsIterator.foldLeft(initial) { - case ((currentErrors, currentValidValues), result) => - result match { - case ValidationResult.Valid(a) => (currentErrors, currentValidValues :+ a) - case ValidationResult.Invalid(e2) => (currentErrors ++ e2, currentValidValues) - } - } - if (errors.isEmpty) ValidationResult.Valid(builder(validValues)) - else ValidationResult.Invalid(errors) - } - - /** Validates an `Array[A]`. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the array size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates an `Array[A]` by validating each element. */ given arrayValidator[A](using v: Validator[A], ct: ClassTag[A], config: ValidationConfig): Validator[Array[A]] with { def validate(xs: Array[A]): ValidationResult[Array[A]] = - config.checkCollectionSize(xs.length, "Array").flatMap { _ => - validateIterable(xs, (validValues: Vector[A]) => validValues.toArray) - } + ValidationLogic.validateCollection[[X] =>> X, A, Array[A]](xs, _.toArray, "Array")(v.validate)(using + SyncEffect, + config + ) } - /** Validates an `ArraySeq[A]`. - * - * If a [[ValidationConfig]] is in scope with `maxCollectionSize` set, this validator will check - * the collection size before processing elements, failing fast if the limit is exceeded. - */ + /** Validates an `ArraySeq[A]` by validating each element. */ given arraySeqValidator[A](using v: Validator[A], ct: ClassTag[A], config: ValidationConfig): Validator[ArraySeq[A]] with { def validate(xs: ArraySeq[A]): ValidationResult[ArraySeq[A]] = - config.checkCollectionSize(xs.size, "ArraySeq").flatMap { _ => - validateIterable(xs, (validValues: Vector[A]) => ArraySeq.unsafeWrapArray(validValues.toArray)) - } + ValidationLogic.validateCollection[[X] =>> X, A, ArraySeq[A]]( + xs, + l => ArraySeq.unsafeWrapArray(l.toArray), + "ArraySeq" + )(v.validate)(using SyncEffect, config) } /** Validates an intersection type `A & B`. */ @@ -294,7 +195,6 @@ object Validator { /** 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]] = { - Derivation.deriveValidatorImpl[T, Elems, Labels](m, isAsync = false).asExprOf[Validator[T]] - } + )(using q: Quotes): Expr[Validator[T]] = + Derivation.deriveSyncValidatorImpl[T, Elems, Labels](m) } 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 index 3941d64..0906abc 100644 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/Derivation.scala @@ -7,6 +7,21 @@ import scala.quoted.{Expr, Quotes, Type} import net.ghoula.valar.ValidationErrors.ValidationError import net.ghoula.valar.{AsyncValidator, ValidationResult, Validator} +/** Represents a missing validator discovered during compile-time validation. + * + * @param fieldName + * The name of the field missing a validator. + * @param fieldType + * A human-readable representation of the field's type. + * @param suggestion + * A helpful suggestion for how to fix the issue. + */ +private[internal] case class MissingValidator( + fieldName: String, + fieldType: String, + suggestion: String +) + /** Internal derivation engine for automatically generating validator instances. * * This object provides the core macro infrastructure for deriving both synchronous and @@ -79,56 +94,11 @@ object Derivation { } } - /** 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. + * Uses Scala 3.7.4's type-level pattern matching to extract string literals from the + * `Mirror.MirroredElemLabels` tuple type. Each element is a singleton string type that gets + * extracted to a runtime value. * * @param q * The quotes context for macro operations. @@ -141,66 +111,116 @@ object Derivation { */ 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." - ) + + // Type-level extraction using pattern matching on quoted types + // More idiomatic Scala 3 approach than manual TypeRepr traversal + def extract[L <: Tuple: Type]: List[String] = Type.of[L] match { + case '[EmptyTuple] => Nil + case '[label *: rest] => + // Extract the singleton string type's value using constValue pattern + Type.of[label] match { + case '[l] => + TypeRepr.of[l] match { + case ConstantType(StringConstant(s)) => s :: extract[rest] + case other => + report.errorAndAbort( + s"Invalid field label type: expected string literal, found ${other.show}. " + + "This typically indicates a structural issue with the case class definition.", + Position.ofMacroExpansion + ) + } } - 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]) + + extract[Labels] } - /** Analyzes field types to identify which fields are optional (`Option[T]`). + /** Validates that all required validators are available at compile time. * - * 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. + * This method performs upfront validation of all field types before code generation, collecting + * ALL missing validators rather than failing on the first one. This provides better developer + * experience by showing all issues at once. * * @param q * The quotes context for macro operations. + * @tparam T + * The product type being validated. * @tparam Elems * The tuple type containing all field types. - * @return - * A list of boolean flags indicating which fields are optional. + * @param labels + * The list of field names. + * @param isAsync + * Whether this is for async validation (affects error message). */ - private def getIsOptionFlags[Elems <: Tuple: Type](using q: Quotes): List[Boolean] = { + private def validateAllFieldsHaveValidators[T: Type, Elems <: Tuple: Type]( + labels: List[String], + isAsync: Boolean + )(using q: Quotes): Unit = { import q.reflect.* - Type.of[Elems] match { - case '[EmptyTuple] => Nil - case '[h *: t] => - (TypeRepr.of[h] <:< TypeRepr.of[Option[Any]]) :: getIsOptionFlags[t] + + def collectMissing[E <: Tuple: Type]( + remainingLabels: List[String], + acc: List[MissingValidator] + ): List[MissingValidator] = + Type.of[E] match { + case '[EmptyTuple] => acc.reverse + case '[h *: t] => + val label = remainingLabels.head + val fieldTypeStr = Type.show[h] + + // For async, we accept either AsyncValidator or Validator + val hasValidator = if (isAsync) { + Expr.summon[AsyncValidator[h]].isDefined || Expr.summon[Validator[h]].isDefined + } else { + Expr.summon[Validator[h]].isDefined + } + + val newAcc = + if (hasValidator) acc + else { + val suggestion = if (isAsync) { + s"given Validator[$fieldTypeStr] = ... or given AsyncValidator[$fieldTypeStr] = ..." + } else { + s"given Validator[$fieldTypeStr] = ..." + } + MissingValidator(label, fieldTypeStr, suggestion) :: acc + } + + collectMissing[t](remainingLabels.tail, newAcc) + } + + val missing = collectMissing[Elems](labels, Nil) + + if (missing.nonEmpty) { + val validatorType = if (isAsync) "AsyncValidator" else "Validator" + val header = + s"Cannot derive $validatorType for ${Type.show[T]}: missing validators for ${missing.length} field(s).\n" + + val details = missing.zipWithIndex.map { case (m, i) => + s" ${i + 1}. Field '${m.fieldName}' of type ${m.fieldType}\n" + + s" Add: ${m.suggestion}" + }.mkString("\n\n") + + val footer = "\n\nHint: Valar provides built-in validators for common types (Int, String, Option, etc.).\n" + + "For custom types, either derive them with `Validator.derived` or provide explicit instances." + + report.errorAndAbort(header + "\n" + details + footer, Position.ofMacroExpansion) } } - /** Generates validator instances for product types using compile-time reflection. + /** Generates a synchronous validator 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. + * This method 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: + * The generated validator handles: * - 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 @@ -210,134 +230,227 @@ object Derivation { * @tparam Labels * The tuple type containing all field names as string literals. * @return - * An expression representing the generated validator instance. + * An expression representing the generated `Validator[T]` 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] = { + def deriveSyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] + )(using q: Quotes): Expr[Validator[T]] = { 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]] }) } - } + // Compile-time validation: check ALL fields have validators before generating code + // This provides better error messages by reporting all missing validators at once + validateAllFieldsHaveValidators[T, Elems](fieldLabels, isAsync = false) - '{ MacroHelper.upcastTo[AsyncValidator[Any]](${ finalExpr }) } :: summonAsyncOrSync[t] - } + // Type detection strategy: + // 1. Regular tuples (Tuple2, etc.): T <:< Tuple, use _1, _2 accessors - zero cast + // 2. Case classes: have actual field members, use field name accessor - zero cast + // 3. Named tuples: NOT <:< Tuple, no field members, use productElement - requires cast + // (This matches Scala 3.7.4 stdlib pattern: NamedTuple.apply uses asInstanceOf) + val isRegularTuple = TypeRepr.of[T] <:< TypeRepr.of[Tuple] - val fieldValidators: List[Expr[AsyncValidator[Any]]] = summonAsyncOrSync[Elems] - val validatorsExpr: Expr[Seq[AsyncValidator[Any]]] = Expr.ofSeq(fieldValidators) + // Generate validation expression for a single field + // Field access strategy depends on type: + // - Regular tuples: Select.unique(a, "_1") - zero cast + // - Case classes: Select.unique(a, "fieldName") - zero cast + // - Named tuples: productElement(index).asInstanceOf[H] - matches stdlib + def generateFieldValidation[H: Type]( + aExpr: Expr[T], + label: String, + index: Int, + validatorExpr: Expr[Validator[H]] + ): Expr[ValidationResult[Any]] = { + val labelExpr = Expr(label) + // Determine field access method based on type structure + val fieldAccess: Expr[H] = if (isRegularTuple) { + // Regular tuple: use _1, _2, etc. - typed accessors, zero cast + Select.unique(aExpr.asTerm, s"_${index + 1}").asExprOf[H] + } else { + // Check if type has actual field member (case classes do, named tuples don't) + val typeSymbol = TypeRepr.of[T].typeSymbol + val hasFieldMember = typeSymbol.fieldMember(label) != Symbol.noSymbol + + if (hasFieldMember) { + // Case class: direct field access - zero cast + Select.unique(aExpr.asTerm, label).asExprOf[H] + } else { + // Named tuple: use productElement (matches Scala 3.7.4 stdlib pattern) + // See: scala.NamedTuple.apply uses asInstanceOf for element access + val indexExpr = Expr(index) + '{ + $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] + } // scalafix:ok DisableSyntax.asInstanceOf + } + } + + // Validate field - null handling is delegated to the field's Validator + // For Java interop or Spark, users can define null-aware validators '{ - 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 fieldValue: H = $fieldAccess + val result = $validatorExpr.validate(fieldValue) + annotateErrors(result, $labelExpr, fieldValue) + } + } - 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}") - ) - } - }) + // Generate all field validations at compile time + def generateAllValidations[E <: Tuple: Type]( + aExpr: Expr[T], + index: Int, + labels: List[String] + ): List[Expr[ValidationResult[Any]]] = + Type.of[E] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + val label = labels.head + // Safe to use .get - upfront validation guarantees validators exist + val validatorExpr = Expr.summon[Validator[h]].get + val fieldValidation = generateFieldValidation[h](aExpr, label, index, validatorExpr) + fieldValidation :: generateAllValidations[t](aExpr, index + 1, labels.tail) + } - allResultsF.map(processResults(_, ${ m })) - } + '{ + new Validator[T] { + def validate(a: T): ValidationResult[T] = { + val results: List[ValidationResult[Any]] = ${ + val aExpr = 'a + Expr.ofList(generateAllValidations[Elems](aExpr, 0, fieldLabels)) } + processResults(results, $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] + } + } + } + + /** Generates an asynchronous validator for product types using compile-time reflection. + * + * This method performs compile-time introspection of the product type, extracts field + * information, summons appropriate validators for each field, and generates optimized async + * validation logic. + * + * The generated validator handles: + * - 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 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 `AsyncValidator[T]` instance. + * @throws Compilation + * error if required validator instances cannot be found for any field type. + */ + def deriveAsyncValidatorImpl[T: Type, Elems <: Tuple: Type, Labels <: Tuple: Type]( + m: Expr[Mirror.ProductOf[T]] + )(using q: Quotes): Expr[AsyncValidator[T]] = { + import q.reflect.* + + val fieldLabels: List[String] = getLabels[Labels] + + // Compile-time validation: check ALL fields have validators before generating code + validateAllFieldsHaveValidators[T, Elems](fieldLabels, isAsync = true) + + // Type detection strategy (same as sync version): + // 1. Regular tuples: T <:< Tuple, use _1, _2 accessors - zero cast + // 2. Case classes: have actual field members, use field name accessor - zero cast + // 3. Named tuples: NOT <:< Tuple, no field members, use productElement - requires cast + val isRegularTuple = TypeRepr.of[T] <:< TypeRepr.of[Tuple] + + // Generate async validation expression for a single field + // Field access strategy depends on type structure + def generateAsyncFieldValidation[H: Type]( + aExpr: Expr[T], + label: String, + index: Int, + asyncValidatorExpr: Expr[AsyncValidator[H]] + ): Expr[ExecutionContext => Future[ValidationResult[Any]]] = { + val labelExpr = Expr(label) + + // Determine field access method based on type structure + val fieldAccess: Expr[H] = if (isRegularTuple) { + // Regular tuple: use _1, _2, etc. - typed accessors, zero cast + Select.unique(aExpr.asTerm, s"_${index + 1}").asExprOf[H] + } else { + // Check if type has actual field member (case classes do, named tuples don't) + val typeSymbol = TypeRepr.of[T].typeSymbol + val hasFieldMember = typeSymbol.fieldMember(label) != Symbol.noSymbol + + if (hasFieldMember) { + // Case class: direct field access - zero cast + Select.unique(aExpr.asTerm, label).asExprOf[H] + } else { + // Named tuple: use productElement (matches Scala 3.7.4 stdlib pattern) + val indexExpr = Expr(index) + '{ + $aExpr.asInstanceOf[Product].productElement($indexExpr).asInstanceOf[H] + } // scalafix:ok DisableSyntax.asInstanceOf } + } - val fieldValidators: List[Expr[Validator[Any]]] = summonValidators[Elems] - val validatorsExpr: Expr[Seq[Validator[Any]]] = Expr.ofSeq(fieldValidators) + // Validate field - null handling is delegated to the field's AsyncValidator + // For Java interop or Spark, users can define null-aware validators + '{ (ec: ExecutionContext) => + given ExecutionContext = ec + val fieldValue: H = $fieldAccess + $asyncValidatorExpr + .validateAsync(fieldValue) + .map(result => annotateErrors(result, $labelExpr, fieldValue)) + .recover { case scala.util.control.NonFatal(ex) => + ValidationResult.invalid( + ValidationError(s"Asynchronous validation failed unexpectedly: ${ex.getMessage}") + ) + } + } + } - '{ - 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") - ) - ) - ) + // Generate all async field validations at compile time + def generateAllAsyncValidations[E <: Tuple: Type]( + aExpr: Expr[T], + index: Int, + labels: List[String] + ): List[Expr[ExecutionContext => Future[ValidationResult[Any]]]] = + Type.of[E] match { + case '[EmptyTuple] => Nil + case '[h *: t] => + val label = labels.head + // Safe to use .get - upfront validation guarantees validators exist + // Try AsyncValidator first, fall back to Validator + val validatorExpr = Expr.summon[AsyncValidator[h]].orElse(Expr.summon[Validator[h]]).get - processResults(results, ${ m }) - } + // Convert sync validator to async if needed (compile-time type witness, no runtime cast) + val asyncValidatorExpr: Expr[AsyncValidator[h]] = validatorExpr.asTerm.tpe.asType match { + case '[AsyncValidator[`h`]] => validatorExpr.asExprOf[AsyncValidator[h]] + case '[Validator[`h`]] => '{ AsyncValidator.fromSync(${ validatorExpr.asExprOf[Validator[h]] }) } + } + + val fieldValidation = generateAsyncFieldValidation[h](aExpr, label, index, asyncValidatorExpr) + fieldValidation :: generateAllAsyncValidations[t](aExpr, index + 1, labels.tail) + } + + '{ + new AsyncValidator[T] { + def validateAsync(a: T)(using ec: ExecutionContext): Future[ValidationResult[T]] = { + val validations: List[ExecutionContext => Future[ValidationResult[Any]]] = ${ + val aExpr = 'a + Expr.ofList(generateAllAsyncValidations[Elems](aExpr, 0, fieldLabels)) } + val futureResults = validations.map(_(ec)) + Future.sequence(futureResults).map(processResults(_, $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 deleted file mode 100644 index 01c1b8f..0000000 --- a/valar-core/src/main/scala/net/ghoula/valar/internal/MacroHelper.scala +++ /dev/null @@ -1,25 +0,0 @@ -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/ValidationEffect.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationEffect.scala new file mode 100644 index 0000000..29f7ec8 --- /dev/null +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationEffect.scala @@ -0,0 +1,37 @@ +package net.ghoula.valar.internal + +import scala.concurrent.{ExecutionContext, Future} + +/** Internal abstraction for effect types used in validation. + * + * This is a minimal monad-like interface that allows collection validation logic to be written + * once and reused for both synchronous (Id) and asynchronous (Future) validation. + * + * This trait is intentionally private to valar - it's an implementation detail, not a public API. + */ +private[valar] trait ValidationEffect[F[_]] { + def pure[A](a: A): F[A] + def map[A, B](fa: F[A])(f: A => B): F[B] + def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] + def traverse[A, B](as: List[A])(f: A => F[B]): F[List[B]] +} + +/** Synchronous effect (identity). Operations execute immediately. */ +private[valar] object SyncEffect extends ValidationEffect[[X] =>> X] { + def pure[A](a: A): A = a + def map[A, B](fa: A)(f: A => B): B = f(fa) + def flatMap[A, B](fa: A)(f: A => B): B = f(fa) + def traverse[A, B](as: List[A])(f: A => B): List[B] = as.map(f) +} + +/** Asynchronous effect using Future. Requires an ExecutionContext. */ +private[valar] class FutureEffect(using ec: ExecutionContext) extends ValidationEffect[Future] { + def pure[A](a: A): Future[A] = Future.successful(a) + def map[A, B](fa: Future[A])(f: A => B): Future[B] = fa.map(f) + def flatMap[A, B](fa: Future[A])(f: A => Future[B]): Future[B] = fa.flatMap(f) + def traverse[A, B](as: List[A])(f: A => Future[B]): Future[List[B]] = Future.traverse(as)(f) +} + +private[valar] object FutureEffect { + def apply()(using ec: ExecutionContext): FutureEffect = new FutureEffect() +} diff --git a/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationLogic.scala b/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationLogic.scala new file mode 100644 index 0000000..0a7aa8f --- /dev/null +++ b/valar-core/src/main/scala/net/ghoula/valar/internal/ValidationLogic.scala @@ -0,0 +1,139 @@ +package net.ghoula.valar.internal + +import net.ghoula.valar.ValidationErrors.ValidationError +import net.ghoula.valar.{ValidationConfig, ValidationResult} + +/** Shared validation logic for collections. + * + * This eliminates duplication between Validator and AsyncValidator by abstracting over the effect + * type F[_]. The collection validation algorithm is written once here. + */ +private[valar] object ValidationLogic { + + /** Validates a collection of items, checking size limits and accumulating errors. + * + * @tparam F + * The effect type (Id for sync, Future for async) + * @tparam A + * The element type + * @tparam C + * The collection type + * @param items + * The collection to validate + * @param buildResult + * Function to reconstruct the collection from validated elements + * @param collectionType + * Name of collection type for error messages (e.g., "List", "Vector") + * @param validateItem + * Function to validate a single item + * @param F + * The effect instance + * @param config + * Validation configuration with size limits + * @return + * Validation result wrapped in effect F + */ + def validateCollection[F[_], A, C]( + items: Iterable[A], + buildResult: List[A] => C, + collectionType: String + )( + validateItem: A => F[ValidationResult[A]] + )(using + F: ValidationEffect[F], + config: ValidationConfig + ): F[ValidationResult[C]] = { + + // 1. Check size limit (pure/sync operation) + val sizeCheck: ValidationResult[Unit] = config.checkCollectionSize(items.size, collectionType) match { + case ValidationResult.Valid(_) => ValidationResult.Valid(()) + case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) + } + + // 2. If size check fails, return early + sizeCheck match { + case ValidationResult.Invalid(errors) => + F.pure(ValidationResult.Invalid(errors)) + + case ValidationResult.Valid(_) => + // 3. Validate all items + val itemResults: F[List[ValidationResult[A]]] = F.traverse(items.toList)(validateItem) + + // 4. Aggregate results + F.map(itemResults) { results => + val (errors, validValues) = results.foldLeft((Vector.empty[ValidationError], List.empty[A])) { + case ((errs, vals), ValidationResult.Valid(a)) => (errs, vals :+ a) + case ((errs, vals), ValidationResult.Invalid(e)) => (errs ++ e, vals) + } + + if (errors.isEmpty) ValidationResult.Valid(buildResult(validValues)) + else ValidationResult.Invalid(errors) + } + } + } + + /** Validates a Map by validating each key and value. + * + * @tparam F + * The effect type + * @tparam K + * The key type + * @tparam V + * The value type + */ + def validateMap[F[_], K, V]( + m: Map[K, V] + )( + validateKey: K => F[ValidationResult[K]], + validateValue: V => F[ValidationResult[V]] + )(using + F: ValidationEffect[F], + config: ValidationConfig + ): F[ValidationResult[Map[K, V]]] = { + + // 1. Check size limit + val sizeCheck = config.checkCollectionSize(m.size, "Map") match { + case ValidationResult.Valid(_) => ValidationResult.Valid(()) + case ValidationResult.Invalid(errors) => ValidationResult.Invalid(errors) + } + + sizeCheck match { + case ValidationResult.Invalid(errors) => + F.pure(ValidationResult.Invalid(errors)) + + case ValidationResult.Valid(_) => + // 2. Validate all entries + val entryResults: F[List[ValidationResult[(K, V)]]] = F.traverse(m.toList) { case (k, v) => + val keyResult: F[ValidationResult[K]] = F.map(validateKey(k)) { + case ValidationResult.Valid(kk) => ValidationResult.Valid(kk) + case ValidationResult.Invalid(es) => + ValidationResult.Invalid(es.map(e => e.annotateField("key", k.getClass.getSimpleName))) + } + + val valueResult: F[ValidationResult[V]] = F.map(validateValue(v)) { + case ValidationResult.Valid(vv) => ValidationResult.Valid(vv) + case ValidationResult.Invalid(es) => + ValidationResult.Invalid(es.map(e => e.annotateField("value", v.getClass.getSimpleName))) + } + + // Combine key and value results + F.flatMap(keyResult) { kr => + F.map(valueResult) { vr => + kr.zip(vr) + } + } + } + + // 3. Aggregate results + F.map(entryResults) { results => + val (errors, validPairs) = results.foldLeft((Vector.empty[ValidationError], Map.empty[K, V])) { + case ((errs, acc), ValidationResult.Valid(pair)) => (errs, acc + pair) + case ((errs, acc), ValidationResult.Invalid(e)) => (errs ++ e, acc) + } + + if (errors.isEmpty) ValidationResult.Valid(validPairs) + else ValidationResult.Invalid(errors) + } + } + } +} diff --git a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala index c9515ac..7673f4c 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/AsyncValidatorSpec.scala @@ -3,20 +3,18 @@ package net.ghoula.valar import munit.FunSuite import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.duration.* -import scala.concurrent.{Await, Future} +import scala.concurrent.Future import net.ghoula.valar.ValidationErrors.ValidationError -/** Provides a comprehensive test suite for the [[AsyncValidator]] typeclass and its derivation. +/** Tests 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. + * Covers: + * - Successful validation of valid objects + * - Handling of failures from sync and async validators + * - Error accumulation from both sources + * - Nested case class validation with proper error paths + * - Optional fields, collections, and Future exception recovery */ class AsyncValidatorSpec extends FunSuite { @@ -26,9 +24,6 @@ class AsyncValidatorSpec extends FunSuite { /** 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]) @@ -119,13 +114,6 @@ class AsyncValidatorSpec extends FunSuite { */ 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 @@ -230,18 +218,6 @@ class AsyncValidatorSpec extends FunSuite { } } - 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]] = diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala new file mode 100644 index 0000000..77f750d --- /dev/null +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationConfigSpec.scala @@ -0,0 +1,66 @@ +package net.ghoula.valar + +import munit.FunSuite + +import java.util.concurrent.atomic.AtomicInteger + +/** Tests ValidationConfig collection size limits. + */ +class ValidationConfigSpec extends FunSuite { + + private given Validator[Int] with { + def validate(value: Int): ValidationResult[Int] = ValidationResult.Valid(value) + } + + test("default config should allow unlimited collection sizes") { + given ValidationConfig = ValidationConfig.default + val largeList = List.fill(50000)(1) + val validator = summon[Validator[List[Int]]] + assert(validator.validate(largeList).isValid) + } + + test("strict config should reject collections exceeding limit") { + given ValidationConfig = ValidationConfig.strict // 10,000 limit + val oversizedList = List.fill(10001)(1) + val validator = summon[Validator[List[Int]]] + validator.validate(oversizedList) match { + case ValidationResult.Invalid(errors) => + assertEquals(errors.size, 1) + assert(errors.head.message.contains("exceeds maximum allowed size")) + assertEquals(errors.head.code, Some("validation.security.collection_too_large")) + case _ => fail("Expected Invalid for oversized collection") + } + } + + test("strict config should allow collections within limit") { + given ValidationConfig = ValidationConfig.strict + val validList = List.fill(10000)(1) + val validator = summon[Validator[List[Int]]] + assert(validator.validate(validList).isValid) + } + + test("custom config should enforce custom limit") { + given ValidationConfig = ValidationConfig(maxCollectionSize = Some(5)) + val oversized = List(1, 2, 3, 4, 5, 6) + val validator = summon[Validator[List[Int]]] + assert(validator.validate(oversized).isInvalid) + assert(validator.validate(List(1, 2, 3, 4, 5)).isValid) + } + + test("size check should fail fast before validating elements") { + val elementsValidated = new AtomicInteger(0) + given Validator[String] with { + def validate(s: String): ValidationResult[String] = { + elementsValidated.incrementAndGet() + ValidationResult.Valid(s) + } + } + given ValidationConfig = ValidationConfig(maxCollectionSize = Some(2)) + + val oversized = List("a", "b", "c", "d") + val validator = summon[Validator[List[String]]] + validator.validate(oversized) + + assertEquals(elementsValidated.get(), 0, "Should not validate any elements when size limit exceeded") + } +} 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 392fe6b..54c8240 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidationSpec.scala @@ -58,9 +58,6 @@ class ValidationSpec extends FunSuite { private case class Company(name: String, address: Address, ceo: Option[User]) private given Validator[Company] = derive - private case class NullFieldTest(name: String, age: Int) - private given Validator[NullFieldTest] = derive - /** Tests for collection type validators. */ test("Collection Validators - listValidator") { @@ -310,10 +307,6 @@ class ValidationSpec extends FunSuite { } } - test("Macro Derivation - non-optional null field") { - assert(summon[Validator[NullFieldTest]].validate(NullFieldTest(Option.empty[String].orNull, 30)).isInvalid) - } - /** Tests for fail-fast validation operations. */ test("Fail-Fast - zipFailFast") { @@ -354,9 +347,10 @@ class ValidationSpec extends FunSuite { } /** Extension methods for ValidationResult to support test assertions. */ -private implicit class ValidationResultTestOps[A](vr: ValidationResult[A]) { - def isInvalid: Boolean = vr match { - case _: ValidationResult.Invalid => true +private[valar] implicit class ValidationResultTestOps[A](vr: ValidationResult[A]) { + def isValid: Boolean = vr match { + case _: ValidationResult.Valid[?] => true case _ => false } + def isInvalid: Boolean = !isValid } diff --git a/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala b/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala index 188f202..4636755 100644 --- a/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala +++ b/valar-core/src/test/scala/net/ghoula/valar/ValidatorSpec.scala @@ -2,112 +2,78 @@ package net.ghoula.valar import munit.FunSuite -import java.time.* -import java.util.UUID -import scala.Symbol -import scala.math.{BigDecimal, BigInt} +import net.ghoula.valar.ValidationHelpers.* -/** Tests the built-in validators for standard library and Java types provided in the `Validator` - * companion object. - * - * This spec ensures that Valar provides sensible default instances for common types. It verifies - * both the simple "pass-through" validators (for types like `Long`, `Boolean`, `UUID`, etc.) and - * the more opinionated default validators that enforce constraints (e.g., non-negative `Int`, - * non-empty `String`). +/** Tests the built-in pass-through validators and opt-in constraint validators from + * ValidationHelpers. */ class ValidatorSpec extends FunSuite { - /** Helper to test simple pass-through validators for a given value. */ - private def checkValidator[T](value: T)(using validator: Validator[T]): Unit = { - assertEquals(validator.validate(value), ValidationResult.Valid(value)) - } - - /** Tests the opinionated standard validators that enforce constraints. */ - - test("Provided validator for Int should validate non-negative numbers") { + test("Built-in Int validator should be pass-through") { val validator = summon[Validator[Int]] assertEquals(validator.validate(10), ValidationResult.Valid(10)) assertEquals(validator.validate(0), ValidationResult.Valid(0)) - validator.validate(-1) match { - case _: ValidationResult.Invalid => - /** Expected Invalid result */ - case v => fail(s"Expected Invalid, but got $v") - } + assertEquals(validator.validate(-1), ValidationResult.Valid(-1)) + assertEquals(validator.validate(Int.MinValue), ValidationResult.Valid(Int.MinValue)) } - test("Provided validator for Float should validate finite numbers") { + test("Built-in Float validator should be pass-through") { val validator = summon[Validator[Float]] assertEquals(validator.validate(3.14f), ValidationResult.Valid(3.14f)) + assertEquals(validator.validate(Float.PositiveInfinity), ValidationResult.Valid(Float.PositiveInfinity)) + // NaN requires special handling since NaN != NaN validator.validate(Float.NaN) match { - case _: ValidationResult.Invalid => - /** Expected Invalid result */ - case v => fail(s"Expected Invalid, but got $v") + case ValidationResult.Valid(v) => assert(v.isNaN) + case _ => fail("Expected Valid result for NaN") } } - test("Provided validator for Double should validate finite numbers") { + test("Built-in Double validator should be pass-through") { val validator = summon[Validator[Double]] assertEquals(validator.validate(3.14d), ValidationResult.Valid(3.14d)) - validator.validate(Double.PositiveInfinity) match { - case _: ValidationResult.Invalid => - /** Expected Invalid result */ - case v => fail(s"Expected Invalid, but got $v") + assertEquals(validator.validate(Double.PositiveInfinity), ValidationResult.Valid(Double.PositiveInfinity)) + // NaN requires special handling since NaN != NaN + validator.validate(Double.NaN) match { + case ValidationResult.Valid(v) => assert(v.isNaN) + case _ => fail("Expected Valid result for NaN") } } - test("Provided validator for String should validate non-empty strings") { + test("Built-in String validator should be pass-through") { val validator = summon[Validator[String]] assertEquals(validator.validate("hello"), ValidationResult.Valid("hello")) - validator.validate("") match { - case _: ValidationResult.Invalid => - /** Expected Invalid result */ - case v => fail(s"Expected Invalid, but got $v") - } + assertEquals(validator.validate(""), ValidationResult.Valid("")) } - test("Provided validator for Option[Int] should validate the inner value") { + test("Built-in Option validator should be pass-through for inner value") { val validator = summon[Validator[Option[Int]]] assertEquals(validator.validate(Some(42)), ValidationResult.Valid(Some(42))) assertEquals(validator.validate(None), ValidationResult.Valid(None)) + assertEquals(validator.validate(Some(-5)), ValidationResult.Valid(Some(-5))) + } - validator.validate(Some(-5)) match { + test("Opt-in nonNegativeInt constraint should reject negative values") { + val result = nonNegativeInt(-5) + result match { case ValidationResult.Invalid(errors) => assertEquals(errors.size, 1) - assert(errors.head.message.contains("Int must be non-negative")) - case _ => fail("Expected Invalid result but got Valid") + assert(errors.head.message.contains("non-negative")) + case _ => fail("Expected Invalid result") } } - /** Tests the pass-through validators that accept all values of their type. */ - - test("Pass-through validator for Boolean") { checkValidator(true) } - test("Pass-through validator for Byte") { checkValidator(1.toByte) } - test("Pass-through validator for Short") { checkValidator(1.toShort) } - test("Pass-through validator for Long") { checkValidator(1L) } - test("Pass-through validator for Char") { checkValidator('a') } - test("Pass-through validator for Unit") { checkValidator(()) } - test("Pass-through validator for BigInt") { checkValidator(BigInt(123)) } - test("Pass-through validator for BigDecimal") { checkValidator(BigDecimal(123.45)) } - test("Pass-through validator for Symbol") { checkValidator(Symbol("abc")) } - test("Pass-through validator for UUID") { - checkValidator(UUID.fromString("123e4567-e89b-12d3-a456-426614174000")) + test("Opt-in finiteFloat constraint should reject NaN") { + val result = finiteFloat(Float.NaN) + assert(result.isInvalid) } - test("Pass-through validator for Instant") { - checkValidator(Instant.ofEpochSecond(1672531200)) - } - test("Pass-through validator for LocalDate") { - checkValidator(LocalDate.of(2025, 7, 5)) - } - test("Pass-through validator for LocalTime") { - checkValidator(LocalTime.of(10, 30, 0)) - } - test("Pass-through validator for LocalDateTime") { - checkValidator(LocalDateTime.of(2025, 7, 5, 10, 30, 0)) - } - test("Pass-through validator for ZonedDateTime") { - checkValidator(ZonedDateTime.of(2025, 7, 5, 10, 30, 0, 0, ZoneId.of("UTC"))) + + test("Opt-in finiteDouble constraint should reject Infinity") { + val result = finiteDouble(Double.PositiveInfinity) + assert(result.isInvalid) } - test("Pass-through validator for Duration") { - checkValidator(Duration.ofHours(5)) + + test("Opt-in nonEmpty constraint should reject empty strings") { + val result = nonEmpty("") + assert(result.isInvalid) } }