diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ec89c..6c5a85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Content validators: `unique`, `contains` / `includes` - Element validation: `of(validator)` for nested validation - Transformations: `compact`, `flatten` +- **ObjectValidator** (closes #9): + - Schema-based validation via `ValidatorRb.object(field: sub_validator, ...)` + - Symbol and string keys are interchangeable between schema and input + - Nested errors carry a composed key path (e.g. `[:address, :zip]`), enabling arbitrary nesting of objects inside objects and objects inside arrays + - Schema modifiers: `strict` (rejects undeclared keys with `:unknown_key`), `partial` (treats every key as optional), `pick(keys)` / `omit(keys)` (derive a narrower schema) — `partial` / `pick` / `omit` return fresh instances and preserve flags - **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 d87dbc2..fe776ac 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,70 @@ result.success? # => true result.value # => "hello" ``` +## Object Validators + +Validate hashes against a schema of per-field sub-validators. Schema keys and input keys can be either symbols or strings — they are matched interchangeably. + +### Basic Schema + +```ruby +user_validator = ValidatorRb.object( + name: ValidatorRb.string.min(1).required, + email: ValidatorRb.string.email.required, + age: ValidatorRb.integer.min(0).optional +) + +result = user_validator.validate( + name: "Alice", + email: "alice@example.com", + age: 30 +) +result.success? # => true +``` + +### Nested Objects and Error Paths + +Errors from nested fields carry a composed `path` so you can tell where each failure happened. + +```ruby +validator = ValidatorRb.object( + name: ValidatorRb.string.required, + address: ValidatorRb.object( + street: ValidatorRb.string.required, + zip: ValidatorRb.string.regex(/\A\d{5}\z/).optional + ) +) + +result = validator.validate(name: "Alice", address: { street: "Main", zip: "abc" }) +result.errors.first.path # => [:address, :zip] +result.errors.first.code # => :invalid_format +``` + +Objects can be nested inside arrays too via `ValidatorRb.array.of(ValidatorRb.object(...))`. + +### Schema Modifiers + +```ruby +base = ValidatorRb.object( + name: ValidatorRb.string.required, + email: ValidatorRb.string.email.required, + password: ValidatorRb.string.min(8).required +) + +# Reject keys that are not declared in the schema +base.strict.validate(name: "Alice", email: "a@b.c", password: "secret123", extra: 1) +# => error with code :unknown_key, path: [:extra] + +# Treat every declared key as optional (missing keys are skipped) +base.partial.validate({}) # success + +# Narrow the schema to specific keys +login_validator = base.pick([:email, :password]) +public_validator = base.omit([:password]) +``` + +`partial`, `pick`, and `omit` return **new** validator instances, so a single base schema can be reused safely for login, update, and public-view variants without cross-contamination. `strict` mutates the receiver, matching the existing `required` / `optional` style. + ## Custom Error Messages Override default error messages for better user experience: @@ -420,7 +484,7 @@ Bug reports and pull requests are welcome on GitHub! ## Roadmap -- [ ] Additional validators (float, boolean, hash) +- [ ] Additional validators (float, boolean) - [ ] Async validation support - [ ] Conditional validations - [ ] Custom validator registration diff --git a/lib/validator_rb.rb b/lib/validator_rb.rb index d6259d1..a1df123 100644 --- a/lib/validator_rb.rb +++ b/lib/validator_rb.rb @@ -6,6 +6,7 @@ require_relative "validator_rb/string_validator" require_relative "validator_rb/integer_validator" require_relative "validator_rb/array_validator" +require_relative "validator_rb/object_validator" # Main ValidatorRb module # @@ -59,5 +60,19 @@ def integer def array ArrayValidator.new end + + # Creates a new ObjectValidator instance + # + # @param schema [Hash{Symbol,String=>BaseValidator}] field validators + # @return [ObjectValidator] + # + # @example + # validator = ValidatorRb.object( + # name: ValidatorRb.string.required, + # email: ValidatorRb.string.email.required + # ) + def object(schema = {}) + ObjectValidator.new(schema) + end end end diff --git a/lib/validator_rb/object_validator.rb b/lib/validator_rb/object_validator.rb new file mode 100644 index 0000000..d122466 --- /dev/null +++ b/lib/validator_rb/object_validator.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module ValidatorRb + # Validates Hash values against a schema of field-level sub-validators. + # + # Keys in the schema may be symbols or strings; the input hash is matched + # against both forms. Sub-validator errors are re-emitted with the schema + # key prepended to their +path+, producing errors like + # +path: [:address, :zip]+ for nested objects. + # + # @example + # validator = ValidatorRb.object( + # name: ValidatorRb.string.min(1).required, + # email: ValidatorRb.string.email.required + # ) + # result = validator.validate(name: "Alice", email: "alice@example.com") + # result.success? # => true + class ObjectValidator < BaseValidator + # @param schema [Hash{Symbol,String=>BaseValidator}] field validators + def initialize(schema = {}) + super() + @schema = schema + @strict = false + @partial = false + add_validation(code: :not_hash) { |v| v.is_a?(Hash) || "must be a hash" } + @validations << lambda do |v| + next true unless v.is_a?(Hash) + + errors = validate_schema(v) + errors.empty? || errors + end + end + + # Flattens nested errors produced by schema validation. + def validate(value) + result = super + result.errors.flatten! + result + end + + # Returns a new validator that treats every schema key as optional. + # Keys that are present in the input are still validated by their + # sub-validator; keys that are absent are skipped. + # + # @return [ObjectValidator] + def partial + derive(@schema, partial: true) + end + + # Returns a new validator whose schema only contains the given keys. + # + # @param keys [Array] + # @return [ObjectValidator] + def pick(keys) + derive(@schema.select { |k, _| keys.any? { |want| key_match?(k, want) } }) + end + + # Returns a new validator whose schema omits the given keys. + # + # @param keys [Array] + # @return [ObjectValidator] + def omit(keys) + derive(@schema.reject { |k, _| keys.any? { |want| key_match?(k, want) } }) + end + + # Rejects keys that are not declared in the schema with a +:unknown_key+ + # error. Mutates the receiver for chaining consistency with the other + # +BaseValidator+ modifiers. + # + # @return [self] + def strict + @strict = true + self + end + + private + + def derive(schema, partial: @partial) + copy = self.class.new(schema) + copy.instance_variable_set(:@strict, @strict) + copy.instance_variable_set(:@partial, partial) + copy + end + + def validate_schema(hash) + errors = [] + @schema.each do |key, sub_validator| + present, sub_value = fetch_key(hash, key) + next if @partial && !present + + result = sub_validator.validate(sub_value) + next if result.success? + + result.errors.each { |e| errors << nested_error(e, key) } + end + errors.concat(unknown_key_errors(hash)) if @strict + errors + end + + def fetch_key(hash, key) + return [true, hash[key]] if hash.key?(key) + + alt = key.is_a?(Symbol) ? key.to_s : key.to_sym + return [true, hash[alt]] if hash.key?(alt) + + [false, nil] + end + + def nested_error(error, key) + ValidationError.new( + error.message, + error.code, + path: [key] + error.path, + meta: error.meta + ) + end + + def unknown_key_errors(hash) + hash.keys.reject { |k| @schema.keys.any? { |sk| key_match?(sk, k) } }.map do |k| + ValidationError.new("unknown key #{k.inspect}", :unknown_key, path: [k]) + end + end + + def key_match?(lhs, rhs) + lhs == rhs || lhs.to_s == rhs.to_s + end + end +end diff --git a/spec/validator_rb/object_validator_spec.rb b/spec/validator_rb/object_validator_spec.rb new file mode 100644 index 0000000..7fb2395 --- /dev/null +++ b/spec/validator_rb/object_validator_spec.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ValidatorRb::ObjectValidator do + describe "basic validation" do + it "passes for a hash" do + validator = ValidatorRb.object + result = validator.validate({ name: "John" }) + + expect(result.success?).to be true + expect(result.value).to eq({ name: "John" }) + end + + it "fails for a non-hash value" do + validator = ValidatorRb.object + result = validator.validate("not a hash") + + expect(result.success?).to be false + expect(result.errors).to include(ValidatorRb::ValidationError.new("must be a hash", :not_hash)) + end + + it "does not run schema validations when value is not a hash" do + validator = ValidatorRb.object(name: ValidatorRb.string.required) + result = validator.validate("not a hash") + + expect(result.errors.map(&:code)).to eq([:not_hash]) + end + end + + describe "schema validation" do + it "passes when every field is valid" do + validator = ValidatorRb.object( + name: ValidatorRb.string.min(1).required, + age: ValidatorRb.integer.min(0).optional + ) + result = validator.validate({ name: "Alice", age: 30 }) + + expect(result.success?).to be true + end + + it "collects errors from every invalid field with a key path" do + validator = ValidatorRb.object( + name: ValidatorRb.string.min(3).required, + email: ValidatorRb.string.email.required + ) + result = validator.validate({ name: "Al", email: "not-an-email" }) + + expect(result.success?).to be false + expect(result.errors.length).to eq(2) + + by_path = result.errors.each_with_object({}) { |e, h| h[e.path] = e.code } + expect(by_path).to eq( + [:name] => :too_short, + [:email] => :invalid_email + ) + end + + it "reports :required for missing keys when the sub-validator is required" do + validator = ValidatorRb.object(name: ValidatorRb.string.required) + result = validator.validate({}) + + expect(result.success?).to be false + error = result.errors.first + expect(error.code).to eq(:required) + expect(error.path).to eq([:name]) + end + + it "treats missing keys as nil for optional sub-validators" do + validator = ValidatorRb.object(nickname: ValidatorRb.string.optional) + result = validator.validate({}) + + expect(result.success?).to be true + end + + it "accepts string keys in the input when the schema uses symbol keys" do + validator = ValidatorRb.object(name: ValidatorRb.string.required) + result = validator.validate({ "name" => "Alice" }) + + expect(result.success?).to be true + end + + it "accepts symbol keys in the input when the schema uses string keys" do + validator = ValidatorRb.object("name" => ValidatorRb.string.required) + result = validator.validate({ name: "Alice" }) + + expect(result.success?).to be true + end + end + + describe "nested objects" do + let(:validator) do + ValidatorRb.object( + name: ValidatorRb.string.required, + address: ValidatorRb.object( + street: ValidatorRb.string.required, + zip: ValidatorRb.string.regex(/\A\d{5}\z/).optional + ) + ) + end + + it "passes when nested schemas are satisfied" do + result = validator.validate( + name: "Alice", + address: { street: "Main", zip: "12345" } + ) + + expect(result.success?).to be true + end + + it "builds a path across nesting levels" do + result = validator.validate( + name: "Alice", + address: { street: "Main", zip: "abc" } + ) + + expect(result.success?).to be false + error = result.errors.first + expect(error.path).to eq(%i[address zip]) + expect(error.code).to eq(:invalid_format) + end + end + + describe "#strict" do + it "rejects keys that are not in the schema" do + validator = ValidatorRb.object(name: ValidatorRb.string.required).strict + result = validator.validate({ name: "Alice", extra: 1 }) + + expect(result.success?).to be false + error = result.errors.find { |e| e.code == :unknown_key } + expect(error).not_to be_nil + expect(error.path).to eq([:extra]) + end + + it "matches keys regardless of string/symbol form" do + validator = ValidatorRb.object(name: ValidatorRb.string.required).strict + result = validator.validate({ "name" => "Alice" }) + + expect(result.success?).to be true + end + + it "passes when no extra keys are present" do + validator = ValidatorRb.object(name: ValidatorRb.string.required).strict + result = validator.validate({ name: "Alice" }) + + expect(result.success?).to be true + end + end + + describe "#partial" do + let(:base) do + ValidatorRb.object( + name: ValidatorRb.string.required, + email: ValidatorRb.string.email.required + ) + end + + it "skips validation for missing keys" do + result = base.partial.validate({}) + + expect(result.success?).to be true + end + + it "still validates keys that are present" do + result = base.partial.validate({ email: "not-an-email" }) + + expect(result.success?).to be false + expect(result.errors.map(&:code)).to eq([:invalid_email]) + end + + it "returns a new validator without mutating the original" do + partial = base.partial + + expect(partial).not_to equal(base) + expect(base.validate({}).success?).to be false + end + + it "preserves the strict flag" do + result = base.strict.partial.validate({ extra: 1 }) + + expect(result.errors.map(&:code)).to include(:unknown_key) + end + end + + describe "#pick" do + let(:base) do + ValidatorRb.object( + name: ValidatorRb.string.required, + email: ValidatorRb.string.email.required, + age: ValidatorRb.integer.optional + ) + end + + it "keeps only the chosen keys" do + picked = base.pick(%i[name email]) + result = picked.validate({ name: "Alice", email: "alice@example.com" }) + + expect(result.success?).to be true + end + + it "ignores fields that were not picked" do + picked = base.pick([:name]) + result = picked.validate({ name: "Alice" }) + + expect(result.success?).to be true + end + + it "returns a new validator without mutating the original" do + picked = base.pick([:name]) + + expect(picked).not_to equal(base) + expect(base.validate({ name: "Alice" }).errors.map(&:code)).to include(:required) + end + + it "matches keys regardless of string/symbol form" do + picked = base.pick(["name"]) + result = picked.validate({ name: "Alice" }) + + expect(result.success?).to be true + end + end + + describe "#omit" do + let(:base) do + ValidatorRb.object( + name: ValidatorRb.string.required, + password: ValidatorRb.string.min(8).required + ) + end + + it "drops the excluded keys" do + public_view = base.omit([:password]) + result = public_view.validate({ name: "Alice" }) + + expect(result.success?).to be true + end + + it "still validates the remaining keys" do + public_view = base.omit([:password]) + result = public_view.validate({}) + + expect(result.errors.map(&:code)).to eq([:required]) + end + + it "returns a new validator without mutating the original" do + public_view = base.omit([:password]) + + expect(public_view).not_to equal(base) + expect(base.validate({ name: "Alice" }).errors.map(&:code)).to include(:required) + end + end + + describe "#required on the object itself" do + it "fails when the hash itself is nil" do + validator = ValidatorRb.object(name: ValidatorRb.string.required).required + result = validator.validate(nil) + + expect(result.success?).to be false + expect(result.errors.first.code).to eq(:required) + end + end +end