validrb 0.5.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.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ # Represents a successful validation result
5
+ class Success
6
+ attr_reader :data
7
+
8
+ def initialize(data)
9
+ @data = data.freeze
10
+ freeze
11
+ end
12
+
13
+ def success?
14
+ true
15
+ end
16
+
17
+ def failure?
18
+ false
19
+ end
20
+
21
+ def errors
22
+ ErrorCollection.new
23
+ end
24
+
25
+ def value_or(_default = nil)
26
+ @data
27
+ end
28
+
29
+ def map
30
+ Success.new(yield(@data))
31
+ end
32
+
33
+ def flat_map
34
+ yield(@data)
35
+ end
36
+
37
+ def ==(other)
38
+ other.is_a?(Success) && data == other.data
39
+ end
40
+ alias eql? ==
41
+
42
+ def hash
43
+ [self.class, data].hash
44
+ end
45
+ end
46
+
47
+ # Represents a failed validation result
48
+ class Failure
49
+ attr_reader :errors
50
+
51
+ def initialize(errors)
52
+ @errors = errors.is_a?(ErrorCollection) ? errors : ErrorCollection.new(Array(errors))
53
+ freeze
54
+ end
55
+
56
+ def success?
57
+ false
58
+ end
59
+
60
+ def failure?
61
+ true
62
+ end
63
+
64
+ def data
65
+ nil
66
+ end
67
+
68
+ def value_or(default = nil)
69
+ block_given? ? yield(@errors) : default
70
+ end
71
+
72
+ def map
73
+ self
74
+ end
75
+
76
+ def flat_map
77
+ self
78
+ end
79
+
80
+ def ==(other)
81
+ other.is_a?(Failure) && errors.to_a == other.errors.to_a
82
+ end
83
+ alias eql? ==
84
+
85
+ def hash
86
+ [self.class, errors.to_a].hash
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ # Schema class with DSL for defining fields
5
+ class Schema
6
+ attr_reader :fields, :validators, :options
7
+
8
+ # Options:
9
+ # strict: true - raise error on unknown keys
10
+ # strip: true - remove unknown keys (default behavior)
11
+ # passthrough: true - keep unknown keys in output
12
+ def initialize(**options, &block)
13
+ @fields = {}
14
+ @validators = []
15
+ @options = normalize_options(options)
16
+ @builder = Builder.new(self)
17
+ @builder.instance_eval(&block) if block_given?
18
+ @fields.freeze
19
+ @validators.freeze
20
+ freeze
21
+ end
22
+
23
+ # Parse data and raise ValidationError on failure
24
+ # @param data [Hash] The data to validate
25
+ # @param path_prefix [Array] Path prefix for error messages
26
+ # @param context [Context, Hash, nil] Optional validation context
27
+ def parse(data, path_prefix: [], context: nil)
28
+ result = safe_parse(data, path_prefix: path_prefix, context: context)
29
+
30
+ raise ValidationError, result.errors if result.failure?
31
+
32
+ result.data
33
+ end
34
+
35
+ # Parse data and return Result (Success or Failure)
36
+ # @param data [Hash] The data to validate
37
+ # @param path_prefix [Array] Path prefix for error messages
38
+ # @param context [Context, Hash, nil] Optional validation context
39
+ def safe_parse(data, path_prefix: [], context: nil)
40
+ normalized = normalize_input(data)
41
+ ctx = normalize_context(context)
42
+ errors = []
43
+ result_data = {}
44
+
45
+ # Check for unknown keys if strict mode
46
+ if @options[:strict]
47
+ unknown_keys = normalized.keys - @fields.keys
48
+ unknown_keys.each do |key|
49
+ errors << Error.new(path: path_prefix + [key], message: "is not allowed", code: :unknown_key)
50
+ end
51
+ end
52
+
53
+ # Validate each field
54
+ @fields.each do |name, field|
55
+ value = fetch_value(normalized, name)
56
+ # Pass full data and context for conditional validation (when:/unless:)
57
+ coerced, field_errors = field.call(value, path: path_prefix, data: normalized, context: ctx)
58
+
59
+ if field_errors.empty?
60
+ # Only include in result if value is not nil or field has a value
61
+ # Also skip if field was conditionally skipped (conditional? && value is nil)
62
+ should_include = !(coerced.nil? && field.optional? && !field.has_default?)
63
+ should_include &&= !(coerced.nil? && field.conditional?)
64
+ result_data[name] = coerced if should_include
65
+ else
66
+ errors.concat(field_errors)
67
+ end
68
+ end
69
+
70
+ # Passthrough unknown keys
71
+ if @options[:passthrough]
72
+ unknown_keys = normalized.keys - @fields.keys
73
+ unknown_keys.each do |key|
74
+ result_data[key] = normalized[key]
75
+ end
76
+ end
77
+
78
+ # Run custom validators only if no field errors
79
+ if errors.empty?
80
+ validator_errors = run_validators(result_data, path_prefix, ctx)
81
+ errors.concat(validator_errors)
82
+ end
83
+
84
+ if errors.empty?
85
+ Success.new(result_data)
86
+ else
87
+ Failure.new(errors)
88
+ end
89
+ end
90
+
91
+ # Add a field to the schema (used by Builder)
92
+ def add_field(field)
93
+ raise ArgumentError, "Field #{field.name} already defined" if @fields.key?(field.name)
94
+
95
+ @fields[field.name] = field
96
+ end
97
+
98
+ # Add a custom validator (used by Builder)
99
+ def add_validator(validator)
100
+ @validators << validator
101
+ end
102
+
103
+ # Schema composition methods
104
+
105
+ # Create a new schema extending this one with additional fields
106
+ def extend(**options, &block)
107
+ parent_fields = @fields
108
+ parent_validators = @validators
109
+ parent_options = @options
110
+
111
+ Schema.new(**parent_options.merge(options)) do
112
+ # Copy parent fields
113
+ parent_fields.each_value do |f|
114
+ @schema.add_field(f)
115
+ end
116
+ # Copy parent validators
117
+ parent_validators.each do |v|
118
+ @schema.add_validator(v)
119
+ end
120
+ # Add new fields/validators from block
121
+ instance_eval(&block) if block
122
+ end
123
+ end
124
+
125
+ # Create a new schema with only specified fields
126
+ def pick(*field_names, **options)
127
+ field_names = field_names.map(&:to_sym)
128
+ selected_fields = @fields.slice(*field_names)
129
+ parent_options = @options
130
+
131
+ Schema.new(**parent_options.merge(options)) do
132
+ selected_fields.each_value do |f|
133
+ @schema.add_field(f)
134
+ end
135
+ end
136
+ end
137
+
138
+ # Create a new schema without specified fields
139
+ def omit(*field_names, **options)
140
+ field_names = field_names.map(&:to_sym)
141
+ remaining_fields = @fields.reject { |k, _| field_names.include?(k) }
142
+ parent_options = @options
143
+
144
+ Schema.new(**parent_options.merge(options)) do
145
+ remaining_fields.each_value do |f|
146
+ @schema.add_field(f)
147
+ end
148
+ end
149
+ end
150
+
151
+ # Merge another schema into this one (other schema's fields take precedence)
152
+ def merge(other, **options)
153
+ raise ArgumentError, "Expected Schema, got #{other.class}" unless other.is_a?(Schema)
154
+
155
+ parent_fields = @fields
156
+ parent_validators = @validators
157
+ other_fields = other.fields
158
+ other_validators = other.validators
159
+ parent_options = @options
160
+
161
+ Schema.new(**parent_options.merge(options)) do
162
+ parent_fields.each_value do |f|
163
+ @schema.add_field(f) unless other_fields.key?(f.name)
164
+ end
165
+ other_fields.each_value do |f|
166
+ @schema.add_field(f)
167
+ end
168
+ parent_validators.each { |v| @schema.add_validator(v) }
169
+ other_validators.each { |v| @schema.add_validator(v) }
170
+ end
171
+ end
172
+
173
+ # Create a new schema with all fields optional
174
+ def partial(**options)
175
+ parent_fields = @fields
176
+ parent_options = @options
177
+
178
+ Schema.new(**parent_options.merge(options)) do
179
+ parent_fields.each do |name, f|
180
+ # Rebuild field as optional
181
+ field = Field.new(
182
+ name,
183
+ f.type,
184
+ optional: true,
185
+ **f.options.reject { |k, _| k == :optional }
186
+ )
187
+ @schema.add_field(field)
188
+ end
189
+ end
190
+ end
191
+
192
+ private
193
+
194
+ def normalize_options(options)
195
+ {
196
+ strict: options[:strict] || false,
197
+ passthrough: options[:passthrough] || false
198
+ }
199
+ end
200
+
201
+ def normalize_context(context)
202
+ case context
203
+ when Context
204
+ context
205
+ when Hash
206
+ Context.new(**context)
207
+ when nil
208
+ Context.empty
209
+ else
210
+ raise ArgumentError, "Expected Context or Hash, got #{context.class}"
211
+ end
212
+ end
213
+
214
+ def normalize_input(data)
215
+ return {} if data.nil?
216
+
217
+ raise ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(Hash)
218
+
219
+ # Convert string keys to symbols
220
+ data.transform_keys(&:to_sym)
221
+ end
222
+
223
+ def fetch_value(data, name)
224
+ return data[name] if data.key?(name)
225
+
226
+ # Also check string key
227
+ string_key = name.to_s
228
+ return data[string_key] if data.key?(string_key)
229
+
230
+ Field::MISSING
231
+ end
232
+
233
+ def run_validators(data, path_prefix, context = nil)
234
+ errors = []
235
+ validator_ctx = ValidatorContext.new(data, path_prefix, context)
236
+
237
+ @validators.each do |validator|
238
+ if validator.arity <= 1
239
+ validator_ctx.instance_exec(data, &validator)
240
+ else
241
+ validator_ctx.instance_exec(data, context, &validator)
242
+ end
243
+ end
244
+
245
+ validator_ctx.errors
246
+ end
247
+
248
+ # Context object for custom validators
249
+ class ValidatorContext
250
+ attr_reader :errors, :context
251
+
252
+ def initialize(data, path_prefix, context = nil)
253
+ @data = data
254
+ @path_prefix = path_prefix
255
+ @context = context
256
+ @errors = []
257
+ end
258
+
259
+ # Add an error for a specific field
260
+ def error(field, message, code: :custom)
261
+ path = @path_prefix + [field.to_sym]
262
+ @errors << Error.new(path: path, message: message, code: code)
263
+ end
264
+
265
+ # Add a base-level error (not tied to a specific field)
266
+ def base_error(message, code: :custom)
267
+ @errors << Error.new(path: @path_prefix, message: message, code: code)
268
+ end
269
+
270
+ # Access field values
271
+ def [](field)
272
+ @data[field.to_sym]
273
+ end
274
+ end
275
+
276
+ # DSL Builder for defining fields
277
+ class Builder
278
+ def initialize(schema)
279
+ @schema = schema
280
+ end
281
+
282
+ def field(name, type, **options)
283
+ field = Field.new(name, type, **options)
284
+ @schema.add_field(field)
285
+ end
286
+
287
+ # Shorthand for optional field
288
+ def optional(name, type, **options)
289
+ field(name, type, **options.merge(optional: true))
290
+ end
291
+
292
+ # Shorthand for required field (explicit)
293
+ def required(name, type, **options)
294
+ field(name, type, **options.merge(optional: false))
295
+ end
296
+
297
+ # Add a custom validator block
298
+ def validate(&block)
299
+ @schema.add_validator(block)
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Validrb
6
+ # Serializer for converting validated data back to primitives
7
+ # Useful for JSON serialization, database storage, etc.
8
+ class Serializer
9
+ # Serialize a value to a primitive representation
10
+ def self.dump(value, format: :hash)
11
+ serialized = serialize_value(value)
12
+
13
+ case format
14
+ when :hash
15
+ serialized
16
+ when :json
17
+ JSON.generate(serialized)
18
+ else
19
+ raise ArgumentError, "Unknown format: #{format}"
20
+ end
21
+ end
22
+
23
+ # Serialize a value recursively
24
+ def self.serialize_value(value)
25
+ case value
26
+ when nil, true, false, ::Integer, ::Float, ::String
27
+ value
28
+ when ::Symbol
29
+ value.to_s
30
+ when ::BigDecimal
31
+ value.to_s("F")
32
+ when ::Date
33
+ value.iso8601
34
+ when ::DateTime
35
+ value.iso8601
36
+ when ::Time
37
+ value.iso8601
38
+ when ::Array
39
+ value.map { |v| serialize_value(v) }
40
+ when ::Hash
41
+ value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
42
+ else
43
+ # Try to_h, to_s as fallbacks
44
+ if value.respond_to?(:to_h)
45
+ serialize_value(value.to_h)
46
+ else
47
+ value.to_s
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ # Add serialization to Result
54
+ class Success
55
+ def dump(format: :hash)
56
+ Serializer.dump(@data, format: format)
57
+ end
58
+
59
+ def to_json(*args)
60
+ dump(format: :json)
61
+ end
62
+ end
63
+
64
+ class Failure
65
+ def dump(format: :hash)
66
+ serialized_errors = @errors.map do |e|
67
+ {
68
+ "path" => e.path.map(&:to_s),
69
+ "message" => e.message,
70
+ "code" => e.code.to_s
71
+ }
72
+ end
73
+
74
+ case format
75
+ when :hash
76
+ { "errors" => serialized_errors }
77
+ when :json
78
+ JSON.generate({ "errors" => serialized_errors })
79
+ else
80
+ raise ArgumentError, "Unknown format: #{format}"
81
+ end
82
+ end
83
+
84
+ def to_json(*args)
85
+ dump(format: :json)
86
+ end
87
+ end
88
+
89
+ # Add serialization to Schema
90
+ class Schema
91
+ # Serialize validated data
92
+ def dump(data, format: :hash)
93
+ result = safe_parse(data)
94
+
95
+ if result.success?
96
+ result.dump(format: format)
97
+ else
98
+ raise ValidationError, result.errors
99
+ end
100
+ end
101
+
102
+ # Parse and serialize in one step (returns Result with serialized data)
103
+ def safe_dump(data, format: :hash)
104
+ result = safe_parse(data)
105
+
106
+ if result.success?
107
+ Success.new(Serializer.dump(result.data, format: :hash))
108
+ else
109
+ result
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Types
5
+ # Array type with optional item type validation
6
+ class Array < Base
7
+ attr_reader :item_type
8
+
9
+ def initialize(of: nil, **options)
10
+ @item_type = of
11
+ super(**options)
12
+ end
13
+
14
+ def coerce(value)
15
+ return COERCION_FAILED unless value.is_a?(::Array)
16
+
17
+ value
18
+ end
19
+
20
+ def valid?(value)
21
+ value.is_a?(::Array)
22
+ end
23
+
24
+ # Override call to handle item validation
25
+ def call(value, path: [])
26
+ coerced = coerce(value)
27
+
28
+ if coerced.equal?(COERCION_FAILED)
29
+ return [nil, [Error.new(path: path, message: coercion_error_message(value), code: :type_error)]]
30
+ end
31
+
32
+ return [coerced, []] unless @item_type
33
+
34
+ validate_items(coerced, path)
35
+ end
36
+
37
+ def type_name
38
+ @item_type ? "array<#{item_type_name}>" : "array"
39
+ end
40
+
41
+ private
42
+
43
+ def validate_items(array, path)
44
+ type_instance = resolve_item_type
45
+ result_array = []
46
+ errors = []
47
+
48
+ array.each_with_index do |item, index|
49
+ item_path = path + [index]
50
+ coerced_item, item_errors = type_instance.call(item, path: item_path)
51
+
52
+ if item_errors.empty?
53
+ result_array << coerced_item
54
+ else
55
+ errors.concat(item_errors)
56
+ end
57
+ end
58
+
59
+ errors.empty? ? [result_array, []] : [nil, errors]
60
+ end
61
+
62
+ def resolve_item_type
63
+ case @item_type
64
+ when Symbol
65
+ Types.build(@item_type)
66
+ when Types::Base
67
+ @item_type
68
+ when Class
69
+ @item_type.new
70
+ else
71
+ raise ArgumentError, "Invalid item type: #{@item_type.inspect}"
72
+ end
73
+ end
74
+
75
+ def item_type_name
76
+ case @item_type
77
+ when Symbol
78
+ @item_type.to_s
79
+ when Types::Base
80
+ @item_type.type_name
81
+ when Class
82
+ @item_type.name.split("::").last.downcase
83
+ else
84
+ "unknown"
85
+ end
86
+ end
87
+ end
88
+
89
+ register(:array, Array)
90
+ end
91
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Types
5
+ # Sentinel object for failed coercion (distinguishes from nil)
6
+ COERCION_FAILED = Object.new.tap do |obj|
7
+ def obj.inspect
8
+ "COERCION_FAILED"
9
+ end
10
+
11
+ def obj.to_s
12
+ "COERCION_FAILED"
13
+ end
14
+ end.freeze
15
+
16
+ # Registry for types
17
+ @registry = {}
18
+
19
+ class << self
20
+ attr_reader :registry
21
+
22
+ def register(name, klass)
23
+ @registry[name.to_sym] = klass
24
+ end
25
+
26
+ def lookup(name)
27
+ @registry[name.to_sym]
28
+ end
29
+
30
+ def build(name, **options)
31
+ klass = lookup(name)
32
+ raise ArgumentError, "Unknown type: #{name}" unless klass
33
+
34
+ klass.new(**options)
35
+ end
36
+ end
37
+
38
+ # Base class for all types
39
+ class Base
40
+ attr_reader :options
41
+
42
+ def initialize(**options)
43
+ @options = options.freeze
44
+ freeze
45
+ end
46
+
47
+ # Main entry point: coerce and validate a value
48
+ # Returns [coerced_value, errors_array]
49
+ def call(value, path: [])
50
+ coerced = coerce(value)
51
+
52
+ if coerced.equal?(COERCION_FAILED)
53
+ return [nil, [Error.new(path: path, message: coercion_error_message(value), code: :type_error)]]
54
+ end
55
+
56
+ unless valid?(coerced)
57
+ return [nil, [Error.new(path: path, message: validation_error_message(coerced), code: :type_error)]]
58
+ end
59
+
60
+ [coerced, []]
61
+ end
62
+
63
+ # Override in subclasses: attempt to coerce value to target type
64
+ # Return COERCION_FAILED if coercion is not possible
65
+ def coerce(value)
66
+ value
67
+ end
68
+
69
+ # Override in subclasses: validate that value is correct type
70
+ def valid?(_value)
71
+ true
72
+ end
73
+
74
+ # Override in subclasses: type name for error messages
75
+ def type_name
76
+ self.class.name.split("::").last.downcase
77
+ end
78
+
79
+ # Override in subclasses: error message for failed coercion
80
+ def coercion_error_message(value)
81
+ "cannot coerce #{value.class} to #{type_name}"
82
+ end
83
+
84
+ # Override in subclasses: error message for failed validation
85
+ def validation_error_message(_value)
86
+ "must be a #{type_name}"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ module Types
5
+ # Boolean type with coercion from String/Integer
6
+ class Boolean < Base
7
+ TRUTHY_VALUES = [true, 1, "1", "true", "yes", "on", "t", "y"].freeze
8
+ FALSY_VALUES = [false, 0, "0", "false", "no", "off", "f", "n"].freeze
9
+
10
+ def coerce(value)
11
+ return true if TRUTHY_VALUES.include?(normalize(value))
12
+ return false if FALSY_VALUES.include?(normalize(value))
13
+
14
+ COERCION_FAILED
15
+ end
16
+
17
+ def valid?(value)
18
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
19
+ end
20
+
21
+ def type_name
22
+ "boolean"
23
+ end
24
+
25
+ private
26
+
27
+ def normalize(value)
28
+ return value.downcase if value.is_a?(::String)
29
+
30
+ value
31
+ end
32
+ end
33
+
34
+ register(:boolean, Boolean)
35
+ register(:bool, Boolean)
36
+ end
37
+ end