From 4c39077441ffc73bb255facd41af6346e826e581 Mon Sep 17 00:00:00 2001 From: Daniel Nottingham Date: Mon, 15 Dec 2025 10:58:28 -0300 Subject: [PATCH 1/2] feat: Add array validator --- .gitignore | 1 + CHANGELOG.md | 5 + README.md | 61 +++++- examples/array_validation.rb | 68 +++++++ examples/structured_errors.rb | 2 + lib/validator_rb.rb | 12 ++ lib/validator_rb/array_validator.rb | 135 ++++++++++++++ spec/validator_rb/array_validator_spec.rb | 216 ++++++++++++++++++++++ 8 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 examples/array_validation.rb create mode 100644 lib/validator_rb/array_validator.rb create mode 100644 spec/validator_rb/array_validator_spec.rb diff --git a/.gitignore b/.gitignore index b04a8c8..a383698 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /pkg/ /spec/reports/ /tmp/ +.idea/ # rspec failure tracking .rspec_status diff --git a/CHANGELOG.md b/CHANGELOG.md index 480f19a..32ec89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `non_empty_string` - combines required, trim, and non_empty - `trimmed_email` - combines trim, lowercase, and email - `Result#value` attribute to access transformed values +- **ArrayValidator**: + - Size validators: `min_items`, `max_items`, `length`, `non_empty` + - Content validators: `unique`, `contains` / `includes` + - Element validation: `of(validator)` for nested validation + - Transformations: `compact`, `flatten` - **Structured Error Object**: - New `ValidationError` class with `message`, `code`, `path`, and `meta` attributes. - Specific error codes for all validators (e.g., `:too_short`, `:invalid_email`). diff --git a/README.md b/README.md index a8525f0..acaf18e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A fluent, type-safe schema validation library for Ruby inspired by Zod. Build complex validations with chainable methods, custom error messages, and built-in transformations. [![Ruby](https://img.shields.io/badge/ruby-3.2+-red.svg)](https://www.ruby-lang.org) -[![Tests](https://img.shields.io/badge/tests-143%20passing-success.svg)](spec/) +[![Tests](https://img.shields.io/badge/tests-166%20passing-success.svg)](spec/) [![Coverage](https://img.shields.io/badge/coverage-100%25-success.svg)](coverage/) ## Features @@ -207,6 +207,65 @@ discount_validator = ValidatorRb.integer .min(0) .max(100) .optional + +## Array Validators + +### Size Constraints + +```ruby +# Minimum items +ValidatorRb.array.min_items(1).validate([1, 2, 3]) + +# Maximum items +ValidatorRb.array.max_items(5).validate([1, 2, 3]) + +# Exact length +ValidatorRb.array.length(3).validate([1, 2, 3]) + +# Not empty +ValidatorRb.array.non_empty.validate([1]) +``` + +### Content Validation + +```ruby +# Unique elements +ValidatorRb.array.unique.validate([1, 2, 3]) + +# Contains specific element +ValidatorRb.array.contains("admin").validate(["user", "admin"]) +ValidatorRb.array.includes(1).validate([1, 2, 3]) +``` + +### Element Validation + +Validate each element in the array using another validator: + +```ruby +# Array of strings +ValidatorRb.array.of(ValidatorRb.string).validate(["a", "b"]) + +# Array of positive integers +ValidatorRb.array.of(ValidatorRb.integer.positive).validate([1, 2, 3]) + +# Nested arrays +inner = ValidatorRb.array.of(ValidatorRb.integer) +ValidatorRb.array.of(inner).validate([[1, 2], [3, 4]]) +``` + +### Transformations + +```ruby +# Compact (remove nil) +validator = ValidatorRb.array.compact +result = validator.validate([1, nil, 2]) +result.value # => [1, 2] + +# Flatten +validator = ValidatorRb.array.flatten +result = validator.validate([[1, 2], [3, 4]]) +result.value # => [1, 2, 3, 4] +``` ``` ## Transformations diff --git a/examples/array_validation.rb b/examples/array_validation.rb new file mode 100644 index 0000000..ee686c5 --- /dev/null +++ b/examples/array_validation.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "../lib/validator_rb" + +puts "=== Array Validation Example ===" + +# 1. Basic Array Validation +puts "\n1. Basic Array Validation:" +validator = ValidatorRb.array.min_items(2).max_items(5).unique +result = validator.validate([1, 2, 3]) +puts "Input: [1, 2, 3]" +puts "Valid? #{result.success?}" + +result = validator.validate([1]) +puts "Input: [1]" +puts "Valid? #{result.success?}" +puts "Errors: #{result.error_message}" + +result = validator.validate([1, 2, 2]) +puts "Input: [1, 2, 2]" +puts "Valid? #{result.success?}" +puts "Errors: #{result.error_message}" + +# 2. Element Validation (Array of Emails) +puts "\n2. Element Validation (Array of Emails):" +email_validator = ValidatorRb.string.email +array_validator = ValidatorRb.array.of(email_validator) + +emails = ["valid@example.com", "invalid-email", "another@example.com"] +result = array_validator.validate(emails) + +puts "Input: #{emails.inspect}" +puts "Valid? #{result.success?}" +if result.failure? + result.errors.each do |error| + puts "- Error at index #{error.path[0]}: #{error.message} (Code: #{error.code})" + end +end + +# 3. Nested Array Validation +puts "\n3. Nested Array Validation:" +# Matrix of positive integers +inner_validator = ValidatorRb.array.of(ValidatorRb.integer.positive) +matrix_validator = ValidatorRb.array.of(inner_validator) + +matrix = [ + [1, 2, 3], + [4, -5, 6], # Error here + [7, 8, 9] +] + +result = matrix_validator.validate(matrix) +puts "Input: #{matrix.inspect}" +puts "Valid? #{result.success?}" +if result.failure? + result.errors.each do |error| + puts "- Error at path #{error.path}: #{error.message}" + end +end + +# 4. Transformations +puts "\n4. Transformations (Compact & Flatten):" +validator = ValidatorRb.array.compact.flatten +input = [1, nil, [2, 3], nil, [4, [5]]] +result = validator.validate(input) + +puts "Input: #{input.inspect}" +puts "Output: #{result.value.inspect}" diff --git a/examples/structured_errors.rb b/examples/structured_errors.rb index 0659d83..61261f1 100644 --- a/examples/structured_errors.rb +++ b/examples/structured_errors.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative "../lib/validator_rb" puts "=== Structured Error Handling Example ===" diff --git a/lib/validator_rb.rb b/lib/validator_rb.rb index 237879a..d6259d1 100644 --- a/lib/validator_rb.rb +++ b/lib/validator_rb.rb @@ -5,6 +5,7 @@ require_relative "validator_rb/base_validator" require_relative "validator_rb/string_validator" require_relative "validator_rb/integer_validator" +require_relative "validator_rb/array_validator" # Main ValidatorRb module # @@ -47,5 +48,16 @@ def string def integer IntegerValidator.new end + + # Creates a new ArrayValidator instance + # + # @return [ArrayValidator] a new array validator instance + # + # @example + # validator = ValidatorRb.array.min_items(1) + # result = validator.validate([1, 2, 3]) + def array + ArrayValidator.new + end end end diff --git a/lib/validator_rb/array_validator.rb b/lib/validator_rb/array_validator.rb new file mode 100644 index 0000000..d701c4f --- /dev/null +++ b/lib/validator_rb/array_validator.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module ValidatorRb + # Validator for Array types + class ArrayValidator < BaseValidator + def initialize + super + add_validation(code: :not_array) { |value| value.is_a?(Array) || "must be an array" } + end + + # Overrides validate to flatten nested errors returned by array validations + def validate(value) + result = super + result.errors.flatten! + result + end + + # Validates that the array has at least the specified number of items + # + # @param count [Integer] minimum number of items + # @param message [String, nil] custom error message + # @return [self] + def min_items(count, message: nil) + add_validation(message: message, code: :min_items) do |value| + value.length >= count || "must have at least #{count} items" + end + end + + # Validates that the array has at most the specified number of items + # + # @param count [Integer] maximum number of items + # @param message [String, nil] custom error message + # @return [self] + def max_items(count, message: nil) + add_validation(message: message, code: :max_items) do |value| + value.length <= count || "must have at most #{count} items" + end + end + + # Validates that the array has exactly the specified number of items + # + # @param count [Integer] exact number of items + # @param message [String, nil] custom error message + # @return [self] + def length(count, message: nil) + add_validation(message: message, code: :length) do |value| + value.length == count || "must have exactly #{count} items" + end + end + + # Validates that the array is not empty + # + # @param message [String, nil] custom error message + # @return [self] + def non_empty(message: nil) + add_validation(message: message, code: :empty) do |value| + !value.empty? || "cannot be empty" + end + end + + # Validates that the array contains unique elements + # + # @param message [String, nil] custom error message + # @return [self] + def unique(message: nil) + add_validation(message: message, code: :unique) do |value| + value.uniq.length == value.length || "must contain unique elements" + end + end + + # Validates that the array contains a specific element + # + # @param element [Object] element that must be present + # @param message [String, nil] custom error message + # @return [self] + def contains(element, message: nil) + add_validation(message: message, code: :missing_element) do |value| + value.include?(element) || "must contain #{element.inspect}" + end + end + alias includes contains + + # Validates each element of the array using another validator + # + # @param validator [BaseValidator] validator to apply to each element + # @return [self] + def of(validator) + validation = lambda do |value| + errors = validate_elements(value, validator) + errors.empty? || errors + end + @validations << validation + self + end + + # Transformation: Removes nil values from the array + # + # @return [self] + def compact + add_transformation(&:compact) + end + + # Transformation: Flattens nested arrays + # + # @param level [Integer, nil] recursion level (default: nil for all levels) + # @return [self] + def flatten(level = nil) + add_transformation { |value| level ? value.flatten(level) : value.flatten } + end + + private + + def validate_elements(value, validator) + errors = [] + value.each_with_index do |item, index| + result = validator.validate(item) + next if result.success? + + result.errors.each do |error| + errors << create_nested_error(error, index) + end + end + errors + end + + def create_nested_error(error, index) + ValidationError.new( + error.message, + error.code, + path: [index] + error.path, + meta: error.meta + ) + end + end +end diff --git a/spec/validator_rb/array_validator_spec.rb b/spec/validator_rb/array_validator_spec.rb new file mode 100644 index 0000000..cec2eb4 --- /dev/null +++ b/spec/validator_rb/array_validator_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ValidatorRb::ArrayValidator do + describe "basic validation" do + it "passes for an array" do + validator = ValidatorRb.array + result = validator.validate([1, 2, 3]) + + expect(result.success?).to be true + expect(result.value).to eq([1, 2, 3]) + end + + it "fails for non-array" do + validator = ValidatorRb.array + result = validator.validate("not an array") + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new("must be an array", :not_array)) + end + end + + describe "#min_items" do + it "passes when array has enough items" do + validator = ValidatorRb.array.min_items(2) + result = validator.validate([1, 2]) + + expect(result.success?).to be true + end + + it "fails when array has too few items" do + validator = ValidatorRb.array.min_items(3) + result = validator.validate([1, 2]) + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new("must have at least 3 items", :min_items)) + end + + it "supports custom error message" do + validator = ValidatorRb.array.min_items(3, message: "Too few items") + result = validator.validate([1]) + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new("Too few items", :min_items)) + end + end + + describe "#max_items" do + it "passes when array has few enough items" do + validator = ValidatorRb.array.max_items(3) + result = validator.validate([1, 2, 3]) + + expect(result.success?).to be true + end + + it "fails when array has too many items" do + validator = ValidatorRb.array.max_items(2) + result = validator.validate([1, 2, 3]) + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new("must have at most 2 items", :max_items)) + end + end + + describe "#length" do + it "passes when array has exact number of items" do + validator = ValidatorRb.array.length(3) + result = validator.validate([1, 2, 3]) + + expect(result.success?).to be true + end + + it "fails when array has wrong number of items" do + validator = ValidatorRb.array.length(3) + result = validator.validate([1, 2]) + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new("must have exactly 3 items", :length)) + end + end + + describe "#non_empty" do + it "passes for non-empty array" do + validator = ValidatorRb.array.non_empty + result = validator.validate([1]) + + expect(result.success?).to be true + end + + it "fails for empty array" do + validator = ValidatorRb.array.non_empty + result = validator.validate([]) + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new("cannot be empty", :empty)) + end + end + + describe "#unique" do + it "passes for array with unique elements" do + validator = ValidatorRb.array.unique + result = validator.validate([1, 2, 3]) + + expect(result.success?).to be true + end + + it "fails for array with duplicate elements" do + validator = ValidatorRb.array.unique + result = validator.validate([1, 2, 2]) + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new("must contain unique elements", :unique)) + end + end + + describe "#contains" do + it "passes when element is present" do + validator = ValidatorRb.array.contains("admin") + result = validator.validate(%w[user admin]) + + expect(result.success?).to be true + end + + it "fails when element is missing" do + validator = ValidatorRb.array.contains("admin") + result = validator.validate(%w[user guest]) + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new('must contain "admin"', :missing_element)) + end + + it "is aliased as includes" do + validator = ValidatorRb.array.includes(1) + result = validator.validate([2, 3]) + + expect(result.success?).to be false + expect(result.errors.first.code).to eq(:missing_element) + end + end + + describe "#of" do + it "validates each element" do + validator = ValidatorRb.array.of(ValidatorRb.integer.positive) + result = validator.validate([1, 2, 3]) + + expect(result.success?).to be true + end + + it "fails when an element is invalid" do + validator = ValidatorRb.array.of(ValidatorRb.integer.positive) + result = validator.validate([1, -2, 3]) + + expect(result.success?).to be false + expect(result.errors.length).to eq(1) + + error = result.errors.first + expect(error.code).to eq(:not_positive) + expect(error.path).to eq([1]) + end + + it "collects multiple errors" do + validator = ValidatorRb.array.of(ValidatorRb.integer.positive) + result = validator.validate([-1, -2]) + + expect(result.success?).to be false + expect(result.errors.length).to eq(2) + + expect(result.errors[0].path).to eq([0]) + expect(result.errors[1].path).to eq([1]) + end + + it "supports nested arrays" do + # Array of arrays of positive integers + inner_validator = ValidatorRb.array.of(ValidatorRb.integer.positive) + validator = ValidatorRb.array.of(inner_validator) + + result = validator.validate([[1, 2], [3, -4]]) + + expect(result.success?).to be false + error = result.errors.first + expect(error.path).to eq([1, 1]) # Second array, second item + expect(error.code).to eq(:not_positive) + end + end + + describe "transformations" do + describe "#compact" do + it "removes nil values" do + validator = ValidatorRb.array.compact + result = validator.validate([1, nil, 2]) + + expect(result.success?).to be true + expect(result.value).to eq([1, 2]) + end + end + + describe "#flatten" do + it "flattens nested arrays" do + validator = ValidatorRb.array.flatten + result = validator.validate([[1, 2], [3, 4]]) + + expect(result.success?).to be true + expect(result.value).to eq([1, 2, 3, 4]) + end + + it "flattens to specified level" do + validator = ValidatorRb.array.flatten(1) + result = validator.validate([[1, [2]], [3]]) + + expect(result.success?).to be true + expect(result.value).to eq([1, [2], 3]) + end + end + end +end From 42a81b4a0b50e3e01a98c19b18b4a096a0e33481 Mon Sep 17 00:00:00 2001 From: Daniel Nottingham Date: Mon, 15 Dec 2025 11:18:51 -0300 Subject: [PATCH 2/2] fix: README --- README.md | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index acaf18e..d87dbc2 100644 --- a/README.md +++ b/README.md @@ -208,11 +208,6 @@ discount_validator = ValidatorRb.integer .max(100) .optional -## Array Validators - -### Size Constraints - -```ruby # Minimum items ValidatorRb.array.min_items(1).validate([1, 2, 3]) @@ -265,14 +260,7 @@ result.value # => [1, 2] validator = ValidatorRb.array.flatten result = validator.validate([[1, 2], [3, 4]]) result.value # => [1, 2, 3, 4] -``` -``` - -## Transformations - -Transformations modify the value **before** validation runs. The transformed value is available in `result.value`. -```ruby # Trim whitespace validator = ValidatorRb.string.trim result = validator.validate(" hello ") @@ -392,8 +380,6 @@ result.error_message # String: errors joined by ", " result.value # Object: transformed value (or original if no transformations) ### ValidationError Object - -```ruby error = result.errors.first error.message # String: Human-readable error message @@ -402,13 +388,13 @@ error.path # Array: Path to the invalid field (default: []) error.meta # Hash: Additional context (default: {}) error.to_h # Hash: Serialized error representation ``` -``` ## Development After checking out the repo, run: ```bash + bundle install bundle exec rspec ``` @@ -416,6 +402,7 @@ bundle exec rspec To run tests with coverage: ```bash + bundle exec rspec --format documentation ``` @@ -433,7 +420,7 @@ Bug reports and pull requests are welcome on GitHub! ## Roadmap -- [ ] Additional validators (float, boolean, array, hash) +- [ ] Additional validators (float, boolean, hash) - [ ] Async validation support - [ ] Conditional validations - [ ] Custom validator registration