philiprehberger-schema_validator 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55525c7f988aea6bd1d007871569d66a11b8b79f328d85b33244b86f4e5b66c5
4
- data.tar.gz: 8d7c5f754c183e5b55c82d2560d2c1af39427ca20a73fd0d639ecb047166cedd
3
+ metadata.gz: d8df4265e7d600d1e257959c9f8241af55300b726ec88d640c5dced38bcf452f
4
+ data.tar.gz: 729098b84f4986fd5bd7a34aabe0dd1908a9174a11b1df7f21917ce9f80ecda9
5
5
  SHA512:
6
- metadata.gz: 21c96da7a71f69d774eafe5c3e6bfd05aec1303643ed7c615a5083bf1cd73067b82a484a4efa5b71b52c253e87e499fac6d3368c2268d93e885b8dec8cf59683
7
- data.tar.gz: 4e4f524265420757023b069ec79cfc091b71f6e097627e6905bfc003d19a74883051bb04330273818de3524986d8b52b09fde5a39b765ee6afd1efced00f1be4
6
+ metadata.gz: 808853b4f3a53335d6bb5dec753fed996e192eaac9a346dba9ddae78f8fe5b25d91f7a1e43144a5498b42a8a58d6c70ea478784427f5928e2ba22d3f2df429e7
7
+ data.tar.gz: 34a39d9763676a48031e9202a72e493d3862c2efb405884f9b2017e77364032f00ec1ba50035a84c852d0582ee5f9c00ec53db876ba325acac761ee758c8d79a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Added
6
+ - Nested schema validation via `nested` DSL method with recursive error prefixing
7
+ - Array element validation with `of:` option for type-checked elements
8
+ - Array of objects validation with `schema:` option
9
+ - Built-in format presets: `:email`, `:url`, `:uuid`, `:iso8601`, `:phone`
10
+ - Cross-field validation via `validate` block at schema level
11
+ - Schema composition via `merge` method to combine and extend schemas
12
+
3
13
  ## 0.2.2
4
14
 
5
15
  - Fix rubocop: string interpolation style, ParameterLists cop
data/README.md CHANGED
@@ -80,6 +80,32 @@ schema = Philiprehberger::SchemaValidator.define do
80
80
  end
81
81
  ```
82
82
 
83
+ #### Format Presets
84
+
85
+ Use built-in format presets instead of writing regex patterns:
86
+
87
+ ```ruby
88
+ schema = Philiprehberger::SchemaValidator.define do
89
+ string :email, format: :email
90
+ string :website, format: :url
91
+ string :id, format: :uuid
92
+ string :created_at, format: :iso8601
93
+ string :phone, format: :phone
94
+ end
95
+ ```
96
+
97
+ Available presets:
98
+
99
+ | Preset | Matches |
100
+ |------------|--------------------------------------------------|
101
+ | `:email` | Basic email format (`user@domain.tld`) |
102
+ | `:url` | HTTP/HTTPS URLs |
103
+ | `:uuid` | UUID v4 format |
104
+ | `:iso8601` | ISO 8601 date and datetime |
105
+ | `:phone` | International phone numbers |
106
+
107
+ Both symbol presets and `Regexp` values are supported for `format:`.
108
+
83
109
  #### `in:` — Allowlist Validation
84
110
 
85
111
  ```ruby
@@ -97,6 +123,104 @@ schema = Philiprehberger::SchemaValidator.define do
97
123
  end
98
124
  ```
99
125
 
126
+ ### Nested Schema Validation
127
+
128
+ Validate objects within objects using the `nested` DSL method:
129
+
130
+ ```ruby
131
+ schema = Philiprehberger::SchemaValidator.define do
132
+ string :name, required: true
133
+ nested :address, required: true do
134
+ string :city, required: true
135
+ string :zip, required: true
136
+ end
137
+ end
138
+
139
+ result = schema.validate({ name: "Alice", address: { city: "Vienna" } })
140
+ result.errors # => ["address.zip is required"]
141
+ ```
142
+
143
+ Nested schemas support all the same field types and options. Nesting can go arbitrarily deep:
144
+
145
+ ```ruby
146
+ schema = Philiprehberger::SchemaValidator.define do
147
+ nested :config, required: true do
148
+ nested :database, required: true do
149
+ string :host, required: true
150
+ integer :port, required: true
151
+ end
152
+ end
153
+ end
154
+ ```
155
+
156
+ ### Array Element Validation
157
+
158
+ Validate the type of each element in an array with `of:`:
159
+
160
+ ```ruby
161
+ schema = Philiprehberger::SchemaValidator.define do
162
+ array :tags, of: :string
163
+ array :scores, of: :integer
164
+ end
165
+
166
+ result = schema.validate({ tags: ["a", "b"], scores: [1, "bad", 3] })
167
+ result.errors # => ["scores[1] must be a integer"]
168
+ ```
169
+
170
+ Validate arrays of objects with `schema:`:
171
+
172
+ ```ruby
173
+ address_schema = Philiprehberger::SchemaValidator.define do
174
+ string :city, required: true
175
+ string :zip, required: true
176
+ end
177
+
178
+ schema = Philiprehberger::SchemaValidator.define do
179
+ array :addresses, schema: address_schema
180
+ end
181
+
182
+ result = schema.validate({ addresses: [{ city: "Vienna" }] })
183
+ result.errors # => ["addresses[0].zip is required"]
184
+ ```
185
+
186
+ ### Cross-Field Validation
187
+
188
+ Add schema-level validation blocks for relational checks between fields:
189
+
190
+ ```ruby
191
+ schema = Philiprehberger::SchemaValidator.define do
192
+ integer :min_age, required: false
193
+ integer :max_age, required: false
194
+
195
+ validate do |data, errors|
196
+ if data[:min_age] && data[:max_age] && data[:min_age] > data[:max_age]
197
+ errors << "min_age must be less than or equal to max_age"
198
+ end
199
+ end
200
+ end
201
+ ```
202
+
203
+ Multiple `validate` blocks can be defined on the same schema.
204
+
205
+ ### Schema Composition
206
+
207
+ Combine and extend schemas using `merge`:
208
+
209
+ ```ruby
210
+ base = Philiprehberger::SchemaValidator.define do
211
+ string :name, required: true
212
+ end
213
+
214
+ extended = base.merge do
215
+ integer :age, required: false
216
+ string :email, required: true
217
+ end
218
+
219
+ extended.fields # => [:name, :age, :email]
220
+ ```
221
+
222
+ The original schema is not modified. Nested schemas and cross-field validators are preserved in the merged schema.
223
+
100
224
  ### Schema Introspection
101
225
 
102
226
  ```ruby
@@ -148,6 +272,10 @@ Validates a hash against the schema. Returns a `Result` object.
148
272
 
149
273
  Validates and raises `ValidationError` if invalid.
150
274
 
275
+ ### `Schema#merge(&block)` -> `Schema`
276
+
277
+ Creates a new schema combining the current schema with additional definitions from the block.
278
+
151
279
  ### `Result#valid?` -> `Boolean`
152
280
 
153
281
  Returns `true` if there are no errors.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module SchemaValidator
5
+ module Constraints
6
+ private
7
+
8
+ def check_type(field, value, errors, field_label)
9
+ coerced = Coercer.coerce(value, field.type)
10
+ if coerced.nil?
11
+ errors << "#{field_label} must be #{field.type}"
12
+ else
13
+ check_constraints(field, coerced, errors, field_label)
14
+ end
15
+ end
16
+
17
+ def check_constraints(field, value, errors, field_label)
18
+ check_format(field, value, errors, field_label)
19
+ check_inclusion(field, value, errors, field_label)
20
+ check_range(field, value, errors, field_label)
21
+ check_array_elements(field, value, errors, field_label)
22
+ run_custom_validator(field, value, errors, field_label)
23
+ end
24
+
25
+ def check_format(field, value, errors, field_label)
26
+ return unless field.format
27
+
28
+ pattern = resolve_format(field.format)
29
+ errors << "#{field_label} does not match expected format" unless value.match?(pattern)
30
+ end
31
+
32
+ def resolve_format(fmt)
33
+ return fmt if fmt.is_a?(Regexp)
34
+
35
+ Formats::FORMATS.fetch(fmt) do
36
+ raise ArgumentError, "unknown format preset: #{fmt}"
37
+ end
38
+ end
39
+
40
+ def check_inclusion(field, value, errors, field_label)
41
+ return unless field.in
42
+
43
+ errors << "#{field_label} must be one of: #{field.in.join(', ')}" unless field.in.include?(value)
44
+ end
45
+
46
+ def check_range(field, value, errors, field_label)
47
+ errors << "#{field_label} must be >= #{field.min}" if field.min && value < field.min
48
+ errors << "#{field_label} must be <= #{field.max}" if field.max && value > field.max
49
+ end
50
+
51
+ def check_array_elements(field, value, errors, field_label)
52
+ return unless field.type == :array && value.is_a?(Array)
53
+
54
+ if field.of
55
+ validate_array_of_type(field, value, errors, field_label)
56
+ elsif field.schema
57
+ validate_array_of_schema(field, value, errors, field_label)
58
+ end
59
+ end
60
+
61
+ def validate_array_of_type(field, value, errors, field_label)
62
+ value.each_with_index do |elem, idx|
63
+ coerced = Coercer.coerce(elem, field.of)
64
+ errors << "#{field_label}[#{idx}] must be a #{field.of}" if coerced.nil?
65
+ end
66
+ end
67
+
68
+ def validate_array_of_schema(field, value, errors, field_label)
69
+ value.each_with_index do |elem, idx|
70
+ unless elem.is_a?(Hash)
71
+ errors << "#{field_label}[#{idx}] must be a hash"
72
+ next
73
+ end
74
+
75
+ field.schema.validate(elem).errors.each do |err|
76
+ errors << "#{field_label}[#{idx}].#{err}"
77
+ end
78
+ end
79
+ end
80
+
81
+ def run_custom_validator(field, value, errors, field_label)
82
+ return unless field.validator
83
+
84
+ msg = field.validator.call(value)
85
+ errors << "#{field_label} #{msg}" if msg.is_a?(String)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -3,22 +3,34 @@
3
3
  module Philiprehberger
4
4
  module SchemaValidator
5
5
  class Field
6
- attr_reader :name, :type, :default, :validator, :format, :in, :min, :max
6
+ attr_reader :name, :type, :default, :validator, :format, :in, :min, :max, :of, :schema
7
7
 
8
- def initialize(name, type, required: true, default: nil, format: nil, in: nil, min: nil, max: nil, &validator) # rubocop:disable Metrics/ParameterLists
8
+ def initialize(name, type, required: true, default: nil, format: nil, in: nil, min: nil, max: nil, of: nil, schema: nil, &validator) # rubocop:disable Metrics/ParameterLists,Layout/LineLength
9
+ assign_basic_attrs(name, type, required, default, format)
10
+ assign_constraint_attrs(binding.local_variable_get(:in), min, max, of, schema)
11
+ @validator = validator
12
+ end
13
+
14
+ def required?
15
+ @required
16
+ end
17
+
18
+ private
19
+
20
+ def assign_basic_attrs(name, type, required, default, format)
9
21
  @name = name
10
22
  @type = type
11
23
  @required = required
12
24
  @default = default
13
25
  @format = format
14
- @in = binding.local_variable_get(:in)
15
- @min = min
16
- @max = max
17
- @validator = validator
18
26
  end
19
27
 
20
- def required?
21
- @required
28
+ def assign_constraint_attrs(inclusion, min, max, of, schema)
29
+ @in = inclusion
30
+ @min = min
31
+ @max = max
32
+ @of = of
33
+ @schema = schema
22
34
  end
23
35
  end
24
36
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module SchemaValidator
5
+ module Formats
6
+ FORMATS = {
7
+ email: /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/,
8
+ url: %r{\Ahttps?://[^\s]+\z},
9
+ uuid: /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i,
10
+ iso8601: /\A\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(.\d+)?(Z|[+-]\d{2}:\d{2})?)?\z/,
11
+ phone: /\A\+?[0-9\s\-().]{7,}\z/
12
+ }.freeze
13
+ end
14
+ end
15
+ end
@@ -5,10 +5,14 @@ module Philiprehberger
5
5
  class ValidationError < StandardError; end
6
6
 
7
7
  class Schema
8
+ include Constraints
9
+
8
10
  TYPES = %i[string integer float boolean array hash].freeze
9
11
 
10
12
  def initialize(&block)
11
13
  @fields = {}
14
+ @nested_schemas = {}
15
+ @cross_validators = []
12
16
  instance_eval(&block) if block
13
17
  end
14
18
 
@@ -19,13 +23,21 @@ module Philiprehberger
19
23
  end
20
24
  end
21
25
 
22
- def fields
23
- @fields.keys
26
+ def nested(name, **opts, &)
27
+ sub_schema = Schema.new(&)
28
+ @nested_schemas[name] = { schema: sub_schema, required: opts.fetch(:required, false) }
24
29
  end
25
30
 
26
- def validate(data)
31
+ def validate(data = nil, &block)
32
+ if block
33
+ @cross_validators << block
34
+ return
35
+ end
36
+
27
37
  errors = []
28
38
  @fields.each_value { |field| validate_field(field, data, errors) }
39
+ @nested_schemas.each { |name, config| validate_nested(name, config, data, errors) }
40
+ @cross_validators.each { |cv| cv.call(data, errors) }
29
41
  Result.new(errors: errors)
30
42
  end
31
43
 
@@ -36,13 +48,31 @@ module Philiprehberger
36
48
  result
37
49
  end
38
50
 
51
+ def fields
52
+ @fields.keys
53
+ end
54
+
55
+ def merge(&block)
56
+ new_schema = Schema.new
57
+ new_schema.instance_variable_set(:@fields, @fields.dup)
58
+ new_schema.instance_variable_set(:@nested_schemas, @nested_schemas.dup)
59
+ new_schema.instance_variable_set(:@cross_validators, @cross_validators.dup)
60
+ new_schema.instance_eval(&block) if block
61
+ new_schema
62
+ end
63
+
39
64
  private
40
65
 
41
66
  def validate_field(field, data, errors)
42
67
  value = fetch_value(field, data)
43
- return check_required(field, errors) if value.nil?
68
+ field_label = field.name.to_s
44
69
 
45
- check_type(field, value, errors)
70
+ if value.nil?
71
+ errors << "#{field_label} is required" if field.required?
72
+ return
73
+ end
74
+
75
+ check_type(field, value, errors, field_label)
46
76
  end
47
77
 
48
78
  def fetch_value(field, data)
@@ -51,48 +81,21 @@ module Philiprehberger
51
81
  field.default
52
82
  end
53
83
 
54
- def check_required(field, errors)
55
- errors << "#{field.name} is required" if field.required?
56
- end
84
+ def validate_nested(name, config, data, errors)
85
+ value = data[name]
57
86
 
58
- def check_type(field, value, errors)
59
- coerced = Coercer.coerce(value, field.type)
60
- if coerced.nil?
61
- errors << "#{field.name} must be #{field.type}"
62
- else
63
- check_constraints(field, coerced, errors)
87
+ if value.nil?
88
+ errors << "#{name} is required" if config[:required]
89
+ return
64
90
  end
65
- end
66
91
 
67
- def check_constraints(field, value, errors)
68
- check_format(field, value, errors)
69
- check_inclusion(field, value, errors)
70
- check_range(field, value, errors)
71
- run_custom_validator(field, value, errors)
72
- end
73
-
74
- def check_format(field, value, errors)
75
- return unless field.format
92
+ return errors << "#{name} must be a hash" unless value.is_a?(Hash)
76
93
 
77
- errors << "#{field.name} does not match expected format" unless value.match?(field.format)
94
+ collect_nested_errors(name, config[:schema], value, errors)
78
95
  end
79
96
 
80
- def check_inclusion(field, value, errors)
81
- return unless field.in
82
-
83
- errors << "#{field.name} must be one of: #{field.in.join(', ')}" unless field.in.include?(value)
84
- end
85
-
86
- def check_range(field, value, errors)
87
- errors << "#{field.name} must be >= #{field.min}" if field.min && value < field.min
88
- errors << "#{field.name} must be <= #{field.max}" if field.max && value > field.max
89
- end
90
-
91
- def run_custom_validator(field, value, errors)
92
- return unless field.validator
93
-
94
- msg = field.validator.call(value)
95
- errors << "#{field.name} #{msg}" if msg.is_a?(String)
97
+ def collect_nested_errors(name, schema, value, errors)
98
+ schema.validate(value).errors.each { |err| errors << "#{name}.#{err}" }
96
99
  end
97
100
  end
98
101
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module SchemaValidator
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -4,6 +4,8 @@ require_relative "schema_validator/version"
4
4
  require_relative "schema_validator/field"
5
5
  require_relative "schema_validator/result"
6
6
  require_relative "schema_validator/coercer"
7
+ require_relative "schema_validator/formats"
8
+ require_relative "schema_validator/constraints"
7
9
  require_relative "schema_validator/schema"
8
10
 
9
11
  module Philiprehberger
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-schema_validator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-16 00:00:00.000000000 Z
11
+ date: 2026-03-17 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A zero-dependency Ruby gem for validating hash data against schemas with
14
14
  type checking, coercion, required/optional fields, and custom validators.
@@ -23,7 +23,9 @@ files:
23
23
  - README.md
24
24
  - lib/philiprehberger/schema_validator.rb
25
25
  - lib/philiprehberger/schema_validator/coercer.rb
26
+ - lib/philiprehberger/schema_validator/constraints.rb
26
27
  - lib/philiprehberger/schema_validator/field.rb
28
+ - lib/philiprehberger/schema_validator/formats.rb
27
29
  - lib/philiprehberger/schema_validator/result.rb
28
30
  - lib/philiprehberger/schema_validator/schema.rb
29
31
  - lib/philiprehberger/schema_validator/version.rb