Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions lib/validator_rb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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
128 changes: 128 additions & 0 deletions lib/validator_rb/object_validator.rb
Original file line number Diff line number Diff line change
@@ -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<Symbol,String>]
# @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<Symbol,String>]
# @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
Loading
Loading