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,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ # Represents a field definition with type, constraints, and options
5
+ class Field
6
+ # Sentinel for missing values (distinguishes from nil)
7
+ MISSING = Object.new.tap do |obj|
8
+ def obj.inspect
9
+ "MISSING"
10
+ end
11
+
12
+ def obj.to_s
13
+ "MISSING"
14
+ end
15
+ end.freeze
16
+
17
+ attr_reader :name, :type, :constraints, :options, :refinements
18
+
19
+ def initialize(name, type, **options)
20
+ @name = name.to_sym
21
+ @type = resolve_type(type, options)
22
+ @constraints = build_constraints(options)
23
+ @refinements = build_refinements(options)
24
+ @options = extract_options(options)
25
+ @transform = options[:transform]
26
+ @preprocess = options[:preprocess]
27
+ @message = options[:message]
28
+ @coerce = options.fetch(:coerce, true)
29
+ @when_condition = options[:when]
30
+ @unless_condition = options[:unless]
31
+ freeze
32
+ end
33
+
34
+ def optional?
35
+ @options[:optional] == true
36
+ end
37
+
38
+ def required?
39
+ !optional?
40
+ end
41
+
42
+ def nullable?
43
+ @options[:nullable] == true
44
+ end
45
+
46
+ def has_default?
47
+ @options.key?(:default)
48
+ end
49
+
50
+ def default_value
51
+ value = @options[:default]
52
+ value.respond_to?(:call) ? value.call : value
53
+ end
54
+
55
+ def conditional?
56
+ !@when_condition.nil? || !@unless_condition.nil?
57
+ end
58
+
59
+ # Validate a value for this field
60
+ # Returns [coerced_value, errors_array]
61
+ # @param value - the value to validate
62
+ # @param path - the path prefix for error messages
63
+ # @param data - the full input data (for conditional validation)
64
+ # @param context - optional validation context
65
+ def call(value, path: [], data: nil, context: nil)
66
+ field_path = path + [@name]
67
+
68
+ # Check conditional validation (when:/unless:)
69
+ if conditional? && !should_validate?(data, context)
70
+ # Skip validation - treat as optional
71
+ return [nil, []] if value.equal?(MISSING) || value.nil?
72
+
73
+ # Still process the value if present (preprocess + transform)
74
+ value = apply_preprocess(value, context) unless value.equal?(MISSING)
75
+ return [apply_transform(value, context), []]
76
+ end
77
+
78
+ # Handle missing values
79
+ if value.equal?(MISSING)
80
+ return handle_missing_value(field_path, context)
81
+ end
82
+
83
+ # Apply preprocessing BEFORE type coercion
84
+ value = apply_preprocess(value, context)
85
+
86
+ # Handle nil values - nullable fields accept nil
87
+ if value.nil?
88
+ return [nil, []] if nullable?
89
+
90
+ return handle_missing_value(field_path, context)
91
+ end
92
+
93
+ # Type coercion and validation
94
+ coerced, type_errors = coerce_value(value, field_path)
95
+ return [nil, apply_custom_message(type_errors)] unless type_errors.empty?
96
+
97
+ # Constraint validation
98
+ constraint_errors = validate_constraints(coerced, field_path)
99
+ return [nil, apply_custom_message(constraint_errors)] unless constraint_errors.empty?
100
+
101
+ # Refinement validation
102
+ refinement_errors = validate_refinements(coerced, field_path, context)
103
+ return [nil, apply_custom_message(refinement_errors)] unless refinement_errors.empty?
104
+
105
+ # Apply transform if present
106
+ coerced = apply_transform(coerced, context)
107
+
108
+ [coerced, []]
109
+ end
110
+
111
+ private
112
+
113
+ def resolve_type(type, options)
114
+ # Handle literal types
115
+ if options[:literal]
116
+ return Types::Literal.new(values: options[:literal])
117
+ end
118
+
119
+ # Handle union types
120
+ if options[:union]
121
+ return Types::Union.new(types: options[:union])
122
+ end
123
+
124
+ case type
125
+ when Symbol
126
+ type_options = extract_type_options(type, options)
127
+ Types.build(type, **type_options)
128
+ when Types::Base
129
+ type
130
+ when Class
131
+ type.new
132
+ else
133
+ raise ArgumentError, "Invalid type: #{type.inspect}"
134
+ end
135
+ end
136
+
137
+ def extract_type_options(type, options)
138
+ case type
139
+ when :array
140
+ { of: options[:of] }.compact
141
+ when :object, :hash
142
+ { schema: options[:schema] }.compact
143
+ when :discriminated_union
144
+ {
145
+ discriminator: options[:discriminator],
146
+ mapping: options[:mapping]
147
+ }.compact
148
+ else
149
+ {}
150
+ end
151
+ end
152
+
153
+ def build_constraints(options)
154
+ constraints = []
155
+
156
+ # Min constraint
157
+ constraints << Constraints::Min.new(options[:min]) if options.key?(:min)
158
+
159
+ # Max constraint
160
+ constraints << Constraints::Max.new(options[:max]) if options.key?(:max)
161
+
162
+ # Length constraint
163
+ if options.key?(:length)
164
+ length_opts = options[:length]
165
+ case length_opts
166
+ when Integer
167
+ constraints << Constraints::Length.new(exact: length_opts)
168
+ when Range
169
+ constraints << Constraints::Length.new(range: length_opts)
170
+ when Hash
171
+ constraints << Constraints::Length.new(**length_opts)
172
+ end
173
+ end
174
+
175
+ # Format constraint
176
+ constraints << Constraints::Format.new(options[:format]) if options.key?(:format)
177
+
178
+ # Enum constraint
179
+ constraints << Constraints::Enum.new(options[:enum]) if options.key?(:enum)
180
+
181
+ constraints.freeze
182
+ end
183
+
184
+ def build_refinements(options)
185
+ refinements = []
186
+
187
+ # Handle refine: option (single or array of procs/hashes)
188
+ if options.key?(:refine)
189
+ refine_opts = options[:refine]
190
+ # Normalize to array - be careful with Hash (don't use Array())
191
+ refine_opts = [refine_opts] unless refine_opts.is_a?(::Array)
192
+
193
+ refine_opts.each do |refine_opt|
194
+ case refine_opt
195
+ when Proc
196
+ refinements << { check: refine_opt, message: "failed refinement" }
197
+ when Hash
198
+ refinements << {
199
+ check: refine_opt[:check] || refine_opt[:if],
200
+ message: refine_opt[:message] || "failed refinement"
201
+ }
202
+ end
203
+ end
204
+ end
205
+
206
+ refinements.freeze
207
+ end
208
+
209
+ def extract_options(options)
210
+ {
211
+ optional: options[:optional] || false,
212
+ nullable: options[:nullable] || false,
213
+ default: options[:default]
214
+ }.tap { |h| h.delete(:default) unless options.key?(:default) }.freeze
215
+ end
216
+
217
+ def handle_missing_value(path, context = nil)
218
+ # Apply default if present
219
+ if has_default?
220
+ value = default_value
221
+ value = apply_preprocess(value, context) if @preprocess
222
+ value = apply_transform(value, context) if @transform
223
+ return [value, []]
224
+ end
225
+
226
+ # Optional fields can be missing
227
+ if optional?
228
+ return [nil, []]
229
+ end
230
+
231
+ # Required field is missing
232
+ message = @message || I18n.t(:required)
233
+ error = Error.new(path: path, message: message, code: :required)
234
+ [nil, [error]]
235
+ end
236
+
237
+ def validate_constraints(value, path)
238
+ errors = []
239
+ @constraints.each do |constraint|
240
+ constraint_errors = constraint.call(value, path: path)
241
+ errors.concat(constraint_errors)
242
+ end
243
+ errors
244
+ end
245
+
246
+ def validate_refinements(value, path, context = nil)
247
+ errors = []
248
+ @refinements.each do |refinement|
249
+ check = refinement[:check]
250
+ # Support context-aware refinements (2 or 3 args)
251
+ result = if check.arity == 1
252
+ check.call(value)
253
+ elsif check.arity == 2
254
+ check.call(value, context)
255
+ else
256
+ check.call(value, context)
257
+ end
258
+
259
+ unless result
260
+ message = refinement[:message]
261
+ message = message.call(value) if message.respond_to?(:call)
262
+ errors << Error.new(path: path, message: message, code: :refinement)
263
+ end
264
+ end
265
+ errors
266
+ end
267
+
268
+ def apply_custom_message(errors)
269
+ return errors unless @message
270
+
271
+ errors.map do |error|
272
+ Error.new(path: error.path, message: @message, code: error.code)
273
+ end
274
+ end
275
+
276
+ def apply_transform(value, context = nil)
277
+ return value unless @transform
278
+
279
+ # Support context-aware transforms
280
+ if @transform.arity == 1
281
+ @transform.call(value)
282
+ else
283
+ @transform.call(value, context)
284
+ end
285
+ end
286
+
287
+ def apply_preprocess(value, context = nil)
288
+ return value unless @preprocess
289
+
290
+ # Support context-aware preprocessing
291
+ if @preprocess.arity == 1
292
+ @preprocess.call(value)
293
+ else
294
+ @preprocess.call(value, context)
295
+ end
296
+ end
297
+
298
+ def coerce_value(value, path)
299
+ if @coerce
300
+ @type.call(value, path: path)
301
+ else
302
+ # No coercion - just validate the type
303
+ if @type.valid?(value)
304
+ [value, []]
305
+ else
306
+ [nil, [Error.new(path: path, message: @type.validation_error_message(value), code: :type_error)]]
307
+ end
308
+ end
309
+ end
310
+
311
+ def should_validate?(data, context = nil)
312
+ return true if data.nil?
313
+
314
+ # Check when: condition
315
+ if @when_condition
316
+ result = evaluate_condition(@when_condition, data, context)
317
+ return false unless result
318
+ end
319
+
320
+ # Check unless: condition
321
+ if @unless_condition
322
+ result = evaluate_condition(@unless_condition, data, context)
323
+ return false if result
324
+ end
325
+
326
+ true
327
+ end
328
+
329
+ def evaluate_condition(condition, data, context = nil)
330
+ case condition
331
+ when Proc
332
+ # Support context-aware conditions
333
+ if condition.arity == 1
334
+ condition.call(data)
335
+ else
336
+ condition.call(data, context)
337
+ end
338
+ when Symbol
339
+ # Symbol refers to a field value being truthy
340
+ !!data[condition]
341
+ else
342
+ !!condition
343
+ end
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ # Simple I18n module for error message translations
5
+ # Can be configured to use Rails I18n or custom translations
6
+ module I18n
7
+ class << self
8
+ attr_accessor :backend
9
+
10
+ # Default English translations
11
+ DEFAULT_TRANSLATIONS = {
12
+ en: {
13
+ required: "is required",
14
+ type_error: "is invalid",
15
+ min: "must be at least %{value}",
16
+ min_length: "length must be at least %{value} (got %{actual})",
17
+ max: "must be at most %{value}",
18
+ max_length: "length must be at most %{value} (got %{actual})",
19
+ length_exact: "length must be exactly %{value} (got %{actual})",
20
+ length_range: "length must be between %{min} and %{max} (got %{actual})",
21
+ format: "must match format %{pattern}",
22
+ format_named: "must be a valid %{name}",
23
+ enum: "must be one of: %{values}",
24
+ unknown_key: "is not allowed",
25
+ union_type_error: "must be one of the allowed types"
26
+ }
27
+ }.freeze
28
+
29
+ # Current locale
30
+ def locale
31
+ @locale ||= :en
32
+ end
33
+
34
+ def locale=(loc)
35
+ @locale = loc.to_sym
36
+ end
37
+
38
+ # Custom translations storage
39
+ def translations
40
+ @translations ||= deep_dup(DEFAULT_TRANSLATIONS)
41
+ end
42
+
43
+ # Add or override translations for a locale
44
+ def add_translations(locale, trans)
45
+ translations[locale.to_sym] ||= {}
46
+ translations[locale.to_sym].merge!(trans)
47
+ end
48
+
49
+ # Translate a key with optional interpolations
50
+ def t(key, **options)
51
+ # If using Rails I18n backend
52
+ if backend == :rails && defined?(::I18n)
53
+ return ::I18n.t("validrb.errors.#{key}", **options, default: key.to_s)
54
+ end
55
+
56
+ # Use built-in translations
57
+ message = translations.dig(locale, key) || translations.dig(:en, key) || key.to_s
58
+
59
+ # Interpolate values
60
+ options.each do |k, v|
61
+ message = message.gsub("%{#{k}}", v.to_s)
62
+ end
63
+
64
+ message
65
+ end
66
+
67
+ # Reset to defaults
68
+ def reset!
69
+ @locale = :en
70
+ @translations = deep_dup(DEFAULT_TRANSLATIONS)
71
+ @backend = nil
72
+ end
73
+
74
+ # Configure the I18n module
75
+ def configure
76
+ yield self
77
+ end
78
+
79
+ private
80
+
81
+ def deep_dup(hash)
82
+ hash.transform_values do |v|
83
+ v.is_a?(Hash) ? deep_dup(v) : v.dup
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Validrb
4
+ # Schema introspection methods
5
+ class Schema
6
+ # Get field names
7
+ def field_names
8
+ @fields.keys
9
+ end
10
+
11
+ # Get a field by name
12
+ def field(name)
13
+ @fields[name.to_sym]
14
+ end
15
+
16
+ # Check if field exists
17
+ def field?(name)
18
+ @fields.key?(name.to_sym)
19
+ end
20
+
21
+ # Get required field names (excludes fields with defaults since they won't fail validation)
22
+ def required_fields
23
+ @fields.select { |_, f| f.required? && !f.conditional? && !f.has_default? }.keys
24
+ end
25
+
26
+ # Get optional field names
27
+ def optional_fields
28
+ @fields.select { |_, f| f.optional? }.keys
29
+ end
30
+
31
+ # Get conditional field names
32
+ def conditional_fields
33
+ @fields.select { |_, f| f.conditional? }.keys
34
+ end
35
+
36
+ # Get fields with defaults
37
+ def fields_with_defaults
38
+ @fields.select { |_, f| f.has_default? }.keys
39
+ end
40
+
41
+ # Get schema structure as a hash (for documentation/debugging)
42
+ def to_schema_hash
43
+ {
44
+ fields: @fields.transform_values { |f| field_to_hash(f) },
45
+ options: @options,
46
+ validators_count: @validators.size
47
+ }
48
+ end
49
+
50
+ # Generate JSON Schema (subset of JSON Schema Draft-07)
51
+ def to_json_schema
52
+ {
53
+ "$schema" => "https://json-schema.org/draft-07/schema#",
54
+ "type" => "object",
55
+ "properties" => @fields.transform_values { |f| field_to_json_schema(f) }.transform_keys(&:to_s),
56
+ "required" => required_fields.map(&:to_s),
57
+ "additionalProperties" => @options[:passthrough] || !@options[:strict]
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def field_to_hash(field)
64
+ {
65
+ type: field.type.type_name,
66
+ optional: field.optional?,
67
+ nullable: field.nullable?,
68
+ has_default: field.has_default?,
69
+ conditional: field.conditional?,
70
+ constraints: field.constraints.map { |c| constraint_to_hash(c) }
71
+ }
72
+ end
73
+
74
+ def constraint_to_hash(constraint)
75
+ case constraint
76
+ when Constraints::Min
77
+ { type: :min, value: constraint.value }
78
+ when Constraints::Max
79
+ { type: :max, value: constraint.value }
80
+ when Constraints::Length
81
+ { type: :length, options: constraint.options }
82
+ when Constraints::Format
83
+ { type: :format, pattern: constraint.pattern.to_s }
84
+ when Constraints::Enum
85
+ { type: :enum, values: constraint.values }
86
+ else
87
+ { type: constraint.class.name }
88
+ end
89
+ end
90
+
91
+ def field_to_json_schema(field)
92
+ schema = {}
93
+
94
+ # Map type to JSON Schema type
95
+ type_mapping = type_to_json_schema(field.type)
96
+ schema.merge!(type_mapping)
97
+
98
+ # Handle nullable
99
+ if field.nullable?
100
+ if schema["type"].is_a?(::Array)
101
+ schema["type"] << "null" unless schema["type"].include?("null")
102
+ elsif schema["type"]
103
+ schema["type"] = [schema["type"], "null"]
104
+ end
105
+ end
106
+
107
+ # Handle default
108
+ schema["default"] = field.default_value if field.has_default?
109
+
110
+ # Handle constraints
111
+ field.constraints.each do |constraint|
112
+ case constraint
113
+ when Constraints::Min
114
+ if %w[integer number].include?(schema["type"]) || (schema["type"].is_a?(::Array) && (schema["type"] & %w[integer number]).any?)
115
+ schema["minimum"] = constraint.value
116
+ else
117
+ schema["minLength"] = constraint.value
118
+ end
119
+ when Constraints::Max
120
+ if %w[integer number].include?(schema["type"]) || (schema["type"].is_a?(::Array) && (schema["type"] & %w[integer number]).any?)
121
+ schema["maximum"] = constraint.value
122
+ else
123
+ schema["maxLength"] = constraint.value
124
+ end
125
+ when Constraints::Length
126
+ opts = constraint.options
127
+ schema["minLength"] = opts[:min] if opts[:min]
128
+ schema["maxLength"] = opts[:max] if opts[:max]
129
+ if opts[:exact]
130
+ schema["minLength"] = opts[:exact]
131
+ schema["maxLength"] = opts[:exact]
132
+ end
133
+ when Constraints::Format
134
+ # Only add pattern for regex formats
135
+ schema["pattern"] = constraint.pattern.source if constraint.pattern.is_a?(Regexp)
136
+ when Constraints::Enum
137
+ schema["enum"] = constraint.values
138
+ end
139
+ end
140
+
141
+ schema
142
+ end
143
+
144
+ def type_to_json_schema(type)
145
+ case type
146
+ when Types::String
147
+ { "type" => "string" }
148
+ when Types::Integer
149
+ { "type" => "integer" }
150
+ when Types::Float, Types::Decimal
151
+ { "type" => "number" }
152
+ when Types::Boolean
153
+ { "type" => "boolean" }
154
+ when Types::Array
155
+ schema = { "type" => "array" }
156
+ schema["items"] = type_to_json_schema(type.item_type) if type.respond_to?(:item_type) && type.item_type
157
+ schema
158
+ when Types::Object
159
+ if type.respond_to?(:schema) && type.schema
160
+ type.schema.to_json_schema.tap { |s| s.delete("$schema") }
161
+ else
162
+ { "type" => "object" }
163
+ end
164
+ when Types::Date, Types::DateTime, Types::Time
165
+ { "type" => "string", "format" => "date-time" }
166
+ when Types::Union
167
+ { "oneOf" => type.types.map { |t| type_to_json_schema(t) } }
168
+ when Types::Literal
169
+ { "enum" => type.values }
170
+ else
171
+ { "type" => "string" }
172
+ end
173
+ end
174
+ end
175
+
176
+ # Field introspection
177
+ class Field
178
+ # Get constraint by type
179
+ def constraint(type)
180
+ @constraints.find { |c| c.is_a?(type) }
181
+ end
182
+
183
+ # Check if field has a specific constraint type
184
+ def has_constraint?(type)
185
+ @constraints.any? { |c| c.is_a?(type) }
186
+ end
187
+
188
+ # Get all constraint values as a hash
189
+ def constraint_values
190
+ @constraints.each_with_object({}) do |c, hash|
191
+ case c
192
+ when Constraints::Min
193
+ hash[:min] = c.value
194
+ when Constraints::Max
195
+ hash[:max] = c.value
196
+ when Constraints::Length
197
+ hash[:length] = c.options
198
+ when Constraints::Format
199
+ hash[:format] = c.pattern
200
+ when Constraints::Enum
201
+ hash[:enum] = c.values
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end