easy_talk 3.2.0 → 3.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 +4 -4
- data/.rubocop.yml +15 -43
- data/CHANGELOG.md +89 -0
- data/README.md +447 -2115
- data/docs/json_schema_compliance.md +140 -26
- data/docs/primitive-schema-rfc.md +894 -0
- data/lib/easy_talk/builders/base_builder.rb +2 -1
- data/lib/easy_talk/builders/boolean_builder.rb +2 -1
- data/lib/easy_talk/builders/collection_helpers.rb +4 -0
- data/lib/easy_talk/builders/composition_builder.rb +7 -2
- data/lib/easy_talk/builders/integer_builder.rb +2 -1
- data/lib/easy_talk/builders/null_builder.rb +4 -1
- data/lib/easy_talk/builders/number_builder.rb +4 -1
- data/lib/easy_talk/builders/object_builder.rb +64 -3
- data/lib/easy_talk/builders/registry.rb +15 -1
- data/lib/easy_talk/builders/string_builder.rb +3 -1
- data/lib/easy_talk/builders/temporal_builder.rb +7 -0
- data/lib/easy_talk/builders/tuple_builder.rb +89 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +4 -2
- data/lib/easy_talk/builders/union_builder.rb +5 -1
- data/lib/easy_talk/configuration.rb +17 -2
- data/lib/easy_talk/errors.rb +1 -0
- data/lib/easy_talk/errors_helper.rb +3 -0
- data/lib/easy_talk/json_schema_equality.rb +46 -0
- data/lib/easy_talk/keywords.rb +0 -1
- data/lib/easy_talk/model.rb +27 -1
- data/lib/easy_talk/model_helper.rb +4 -0
- data/lib/easy_talk/naming_strategies.rb +4 -0
- data/lib/easy_talk/property.rb +7 -0
- data/lib/easy_talk/ref_helper.rb +6 -0
- data/lib/easy_talk/schema.rb +1 -0
- data/lib/easy_talk/schema_definition.rb +52 -6
- data/lib/easy_talk/schema_methods.rb +36 -5
- data/lib/easy_talk/sorbet_extension.rb +1 -0
- data/lib/easy_talk/type_introspection.rb +45 -1
- data/lib/easy_talk/types/tuple.rb +77 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +350 -62
- data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
- data/lib/easy_talk/validation_adapters/base.rb +12 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +9 -0
- data/lib/easy_talk/validation_builder.rb +1 -0
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +1 -0
- metadata +13 -4
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'uri'
|
|
4
|
+
require_relative 'active_model_schema_validation'
|
|
5
|
+
require 'easy_talk/json_schema_equality'
|
|
4
6
|
|
|
5
7
|
module EasyTalk
|
|
6
8
|
module ValidationAdapters
|
|
@@ -24,42 +26,116 @@ module EasyTalk
|
|
|
24
26
|
# user.errors[:email] # => ["must be a valid email address"]
|
|
25
27
|
#
|
|
26
28
|
class ActiveModelAdapter < Base
|
|
29
|
+
# Helper class methods for tuple validation (defined at class level for use in validate blocks)
|
|
30
|
+
# Resolve a Sorbet type to a Ruby class for type checking
|
|
31
|
+
def self.resolve_tuple_type_class(type)
|
|
32
|
+
# Handle T.untyped - any value is valid
|
|
33
|
+
return :untyped if type.is_a?(T::Types::Untyped) || type == T.untyped
|
|
34
|
+
|
|
35
|
+
# Handle union types (T.any, T.nilable)
|
|
36
|
+
return type.types.flat_map { |t| resolve_tuple_type_class(t) } if type.is_a?(T::Types::Union)
|
|
37
|
+
|
|
38
|
+
if type.respond_to?(:raw_type)
|
|
39
|
+
type.raw_type
|
|
40
|
+
elsif type == T::Boolean
|
|
41
|
+
[TrueClass, FalseClass]
|
|
42
|
+
elsif type.is_a?(Class)
|
|
43
|
+
type
|
|
44
|
+
else
|
|
45
|
+
type
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if a value matches a type class (supports arrays for union types like Boolean)
|
|
50
|
+
def self.type_matches?(value, type_class)
|
|
51
|
+
# :untyped means any value is valid (from empty schema {} in JSON Schema)
|
|
52
|
+
return true if type_class == :untyped
|
|
53
|
+
|
|
54
|
+
if type_class.is_a?(Array)
|
|
55
|
+
type_class.any? { |tc| value.is_a?(tc) }
|
|
56
|
+
else
|
|
57
|
+
value.is_a?(type_class)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Generate a human-readable type name for error messages
|
|
62
|
+
def self.type_name_for_error(type_class)
|
|
63
|
+
return 'unknown' if type_class.nil?
|
|
64
|
+
|
|
65
|
+
if type_class.is_a?(Array)
|
|
66
|
+
type_class.map { |tc| tc.respond_to?(:name) ? tc.name : tc.to_s }.join(' or ')
|
|
67
|
+
elsif type_class.respond_to?(:name) && type_class.name
|
|
68
|
+
type_class.name
|
|
69
|
+
else
|
|
70
|
+
type_class.to_s
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Regex-based format validators
|
|
75
|
+
REGEX_FORMAT_CONFIGS = {
|
|
76
|
+
'email' => { with: /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/, message: 'must be a valid email address' },
|
|
77
|
+
'uuid' => { with: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i,
|
|
78
|
+
message: 'must be a valid UUID' }
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
# Formats that require parsing validation (not just regex)
|
|
82
|
+
PARSING_FORMATS = %w[date date-time time uri url].freeze
|
|
83
|
+
|
|
84
|
+
# Build schema-level validations for object-level constraints.
|
|
85
|
+
# Delegates to ActiveModelSchemaValidation module.
|
|
86
|
+
#
|
|
87
|
+
# @param klass [Class] The model class to apply validations to
|
|
88
|
+
# @param schema [Hash] The full schema hash containing schema-level constraints
|
|
89
|
+
# @return [void]
|
|
90
|
+
def self.build_schema_validations(klass, schema)
|
|
91
|
+
ActiveModelSchemaValidation.apply(klass, schema)
|
|
92
|
+
end
|
|
93
|
+
|
|
27
94
|
# Apply validations based on property type and constraints.
|
|
28
95
|
#
|
|
29
96
|
# @return [void]
|
|
30
97
|
def apply_validations
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# Determine if the type is an array (empty arrays should be valid)
|
|
39
|
-
is_array = type_class == Array || @type.is_a?(T::Types::TypedArray)
|
|
40
|
-
|
|
41
|
-
# Skip presence validation for booleans, nilable types, and arrays
|
|
42
|
-
# (empty arrays are valid - use min_items constraint if you need non-empty)
|
|
43
|
-
apply_presence_validation unless optional? || is_boolean || nilable_type? || is_array
|
|
44
|
-
|
|
45
|
-
if nilable_type?
|
|
46
|
-
# For nilable types, get the inner type and apply validations to it
|
|
47
|
-
inner_type = extract_inner_type(@type)
|
|
48
|
-
apply_type_validations(inner_type)
|
|
49
|
-
else
|
|
50
|
-
apply_type_validations(@type)
|
|
51
|
-
end
|
|
98
|
+
context = build_validation_context
|
|
99
|
+
|
|
100
|
+
apply_presence_validation unless context.skip_presence_validation?
|
|
101
|
+
apply_array_presence_validation if context.array_requires_presence_validation?
|
|
102
|
+
|
|
103
|
+
apply_type_validations(context)
|
|
52
104
|
|
|
53
|
-
# Common validations for most types
|
|
54
105
|
apply_enum_validation if @constraints[:enum]
|
|
55
106
|
apply_const_validation if @constraints[:const]
|
|
56
107
|
end
|
|
57
108
|
|
|
109
|
+
# Build ValidationContext with pre-computed values from the adapter
|
|
110
|
+
def build_validation_context
|
|
111
|
+
is_optional = optional?
|
|
112
|
+
is_nilable = nilable_type?(@type)
|
|
113
|
+
validation_type = is_nilable ? extract_inner_type(@type) : @type
|
|
114
|
+
validation_type ||= @type
|
|
115
|
+
type_class = get_type_class(validation_type)
|
|
116
|
+
|
|
117
|
+
ValidationContext.new(
|
|
118
|
+
klass: @klass,
|
|
119
|
+
property_name: @property_name.to_sym,
|
|
120
|
+
constraints: @constraints || {},
|
|
121
|
+
validation_type: validation_type,
|
|
122
|
+
type_class: type_class,
|
|
123
|
+
optional: is_optional,
|
|
124
|
+
nilable: is_nilable
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
private :build_validation_context
|
|
128
|
+
|
|
58
129
|
private
|
|
59
130
|
|
|
60
131
|
# Apply validations based on the type of the property
|
|
61
|
-
def apply_type_validations(
|
|
62
|
-
|
|
132
|
+
def apply_type_validations(context)
|
|
133
|
+
if context.tuple_type?
|
|
134
|
+
apply_tuple_type_validations(context.validation_type)
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
type_class = context.type_class
|
|
63
139
|
|
|
64
140
|
if type_class == String
|
|
65
141
|
apply_string_validations
|
|
@@ -68,13 +144,11 @@ module EasyTalk
|
|
|
68
144
|
elsif [Float, BigDecimal].include?(type_class)
|
|
69
145
|
apply_number_validations
|
|
70
146
|
elsif type_class == Array
|
|
71
|
-
apply_array_validations(
|
|
72
|
-
elsif
|
|
73
|
-
FalseClass] || [TrueClass,
|
|
74
|
-
FalseClass].include?(type_class) || TypeIntrospection.boolean_type?(type)
|
|
147
|
+
apply_array_validations(context.validation_type)
|
|
148
|
+
elsif context.boolean?
|
|
75
149
|
apply_boolean_validations
|
|
76
|
-
elsif
|
|
77
|
-
apply_object_validations
|
|
150
|
+
elsif context.model_class
|
|
151
|
+
apply_object_validations(context.model_class)
|
|
78
152
|
end
|
|
79
153
|
end
|
|
80
154
|
|
|
@@ -89,15 +163,21 @@ module EasyTalk
|
|
|
89
163
|
apply_format_validation(@constraints[:format]) if @constraints[:format]
|
|
90
164
|
|
|
91
165
|
# Handle pattern (regex) constraints
|
|
92
|
-
if @constraints[:pattern]
|
|
93
|
-
@klass.validates @property_name, format: { with: Regexp.new(@constraints[:pattern]) },
|
|
94
|
-
allow_nil: optional?
|
|
95
|
-
end
|
|
166
|
+
apply_pattern_validation if @constraints[:pattern]
|
|
96
167
|
|
|
97
168
|
# Handle length constraints
|
|
98
169
|
apply_length_validations
|
|
99
170
|
end
|
|
100
171
|
|
|
172
|
+
# Apply pattern validation (only for string values per JSON Schema spec)
|
|
173
|
+
def apply_pattern_validation
|
|
174
|
+
property_name = @property_name
|
|
175
|
+
|
|
176
|
+
@klass.validates property_name,
|
|
177
|
+
format: { with: Regexp.new(@constraints[:pattern]) },
|
|
178
|
+
if: -> { public_send(property_name).is_a?(String) }
|
|
179
|
+
end
|
|
180
|
+
|
|
101
181
|
# Apply length validations for strings
|
|
102
182
|
def apply_length_validations
|
|
103
183
|
length_options = {}
|
|
@@ -117,24 +197,85 @@ module EasyTalk
|
|
|
117
197
|
end
|
|
118
198
|
|
|
119
199
|
# Apply format-specific validations (email, url, etc.)
|
|
200
|
+
# Per JSON Schema spec, format validation only applies to string values
|
|
120
201
|
def apply_format_validation(format)
|
|
121
|
-
|
|
122
|
-
'email' => { with: URI::MailTo::EMAIL_REGEXP, message: 'must be a valid email address' },
|
|
123
|
-
'uri' => { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a valid URL' },
|
|
124
|
-
'url' => { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a valid URL' },
|
|
125
|
-
'uuid' => { with: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i,
|
|
126
|
-
message: 'must be a valid UUID' },
|
|
127
|
-
'date' => { with: /\A\d{4}-\d{2}-\d{2}\z/, message: 'must be a valid date in YYYY-MM-DD format' },
|
|
128
|
-
'date-time' => { with: /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/,
|
|
129
|
-
message: 'must be a valid ISO 8601 date-time' },
|
|
130
|
-
'time' => { with: /\A\d{2}:\d{2}:\d{2}(?:\.\d+)?\z/, message: 'must be a valid time in HH:MM:SS format' }
|
|
131
|
-
}
|
|
202
|
+
format_str = format.to_s
|
|
132
203
|
|
|
133
|
-
|
|
204
|
+
# Handle parsing-based formats (date, date-time, time)
|
|
205
|
+
if PARSING_FORMATS.include?(format_str)
|
|
206
|
+
apply_parsing_format_validation(format_str)
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Handle regex-based formats
|
|
211
|
+
config = REGEX_FORMAT_CONFIGS[format_str]
|
|
134
212
|
return unless config
|
|
135
213
|
|
|
136
|
-
|
|
137
|
-
|
|
214
|
+
property_name = @property_name
|
|
215
|
+
# Per JSON Schema spec, format validation only applies when value is a string
|
|
216
|
+
@klass.validates property_name, format: config, if: -> { public_send(property_name).is_a?(String) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Apply parsing-based format validations for date, date-time, and time
|
|
220
|
+
def apply_parsing_format_validation(format)
|
|
221
|
+
case format
|
|
222
|
+
when 'date' then apply_date_format_validation
|
|
223
|
+
when 'date-time' then apply_datetime_format_validation
|
|
224
|
+
when 'time' then apply_time_format_validation
|
|
225
|
+
when 'uri', 'url' then apply_uri_format_validation
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def apply_uri_format_validation
|
|
230
|
+
prop_name = @property_name
|
|
231
|
+
@klass.validate do |record|
|
|
232
|
+
value = record.public_send(prop_name)
|
|
233
|
+
next unless value.is_a?(String)
|
|
234
|
+
|
|
235
|
+
begin
|
|
236
|
+
uri = URI.parse(value)
|
|
237
|
+
record.errors.add(prop_name, 'must be a valid URL') unless uri.absolute?
|
|
238
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
239
|
+
record.errors.add(prop_name, 'must be a valid URL')
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def apply_date_format_validation
|
|
245
|
+
prop_name = @property_name
|
|
246
|
+
@klass.validate do |record|
|
|
247
|
+
value = record.public_send(prop_name)
|
|
248
|
+
next if value.blank? || !value.is_a?(String)
|
|
249
|
+
|
|
250
|
+
Date.iso8601(value)
|
|
251
|
+
rescue Date::Error
|
|
252
|
+
record.errors.add(prop_name, 'must be a valid date in YYYY-MM-DD format')
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def apply_datetime_format_validation
|
|
257
|
+
prop_name = @property_name
|
|
258
|
+
@klass.validate do |record|
|
|
259
|
+
value = record.public_send(prop_name)
|
|
260
|
+
next if value.blank? || !value.is_a?(String)
|
|
261
|
+
|
|
262
|
+
DateTime.iso8601(value)
|
|
263
|
+
rescue Date::Error
|
|
264
|
+
record.errors.add(prop_name, 'must be a valid ISO 8601 date-time')
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def apply_time_format_validation
|
|
269
|
+
prop_name = @property_name
|
|
270
|
+
@klass.validate do |record|
|
|
271
|
+
value = record.public_send(prop_name)
|
|
272
|
+
next if value.blank? || !value.is_a?(String)
|
|
273
|
+
|
|
274
|
+
Time.parse(value)
|
|
275
|
+
record.errors.add(prop_name, 'must be a valid time in HH:MM:SS format') unless value.match?(/\A\d{2}:\d{2}:\d{2}/)
|
|
276
|
+
rescue ArgumentError
|
|
277
|
+
record.errors.add(prop_name, 'must be a valid time in HH:MM:SS format')
|
|
278
|
+
end
|
|
138
279
|
end
|
|
139
280
|
|
|
140
281
|
# Validate integer-specific constraints
|
|
@@ -177,28 +318,87 @@ module EasyTalk
|
|
|
177
318
|
|
|
178
319
|
# Validate array-specific constraints
|
|
179
320
|
def apply_array_validations(type)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
length_options[:maximum] = @constraints[:max_items] if @constraints[:max_items]
|
|
321
|
+
apply_array_length_validation
|
|
322
|
+
|
|
323
|
+
# Validate uniqueness within the array
|
|
324
|
+
apply_unique_items_validation if @constraints[:unique_items]
|
|
185
325
|
|
|
186
|
-
|
|
326
|
+
# Check if this is a tuple (items constraint is an array of types)
|
|
327
|
+
if @constraints[:items].is_a?(::Array)
|
|
328
|
+
apply_tuple_validations(@constraints[:items], @constraints[:additional_items])
|
|
329
|
+
elsif type.is_a?(T::Types::TypedArray)
|
|
330
|
+
# Validate array item types if using T::Array[SomeType]
|
|
331
|
+
apply_array_item_type_validation(type)
|
|
187
332
|
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Apply validations for T::Tuple[...] types
|
|
336
|
+
def apply_tuple_type_validations(tuple_type)
|
|
337
|
+
# Apply standard array constraints (min_items, max_items, unique_items)
|
|
338
|
+
apply_array_length_validation
|
|
188
339
|
|
|
189
|
-
# Validate uniqueness within the array
|
|
190
340
|
apply_unique_items_validation if @constraints[:unique_items]
|
|
191
341
|
|
|
192
|
-
#
|
|
193
|
-
|
|
342
|
+
# Extract tuple types from the Tuple type
|
|
343
|
+
item_types = tuple_type.types
|
|
344
|
+
|
|
345
|
+
# Get additional_items from constraints
|
|
346
|
+
additional_items = @constraints[:additional_items]
|
|
347
|
+
|
|
348
|
+
apply_tuple_validations(item_types, additional_items)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Apply tuple validation for arrays with positional type constraints
|
|
352
|
+
def apply_tuple_validations(item_types, additional_items)
|
|
353
|
+
prop_name = @property_name
|
|
354
|
+
# Pre-resolve type classes for use in validate block
|
|
355
|
+
resolved_item_types = item_types.map { |t| self.class.resolve_tuple_type_class(t) }
|
|
356
|
+
resolved_additional_type = additional_items && ![true, false].include?(additional_items) ? self.class.resolve_tuple_type_class(additional_items) : nil
|
|
357
|
+
|
|
358
|
+
@klass.validate do |record|
|
|
359
|
+
value = record.public_send(prop_name)
|
|
360
|
+
next unless value.is_a?(Array)
|
|
361
|
+
|
|
362
|
+
# Validate positional items
|
|
363
|
+
resolved_item_types.each_with_index do |type_class, index|
|
|
364
|
+
next if index >= value.length # Item not present (may be valid depending on minItems)
|
|
365
|
+
|
|
366
|
+
item = value[index]
|
|
367
|
+
next if ActiveModelAdapter.type_matches?(item, type_class)
|
|
368
|
+
|
|
369
|
+
type_name = ActiveModelAdapter.type_name_for_error(type_class)
|
|
370
|
+
record.errors.add(prop_name, "item at index #{index} must be a #{type_name}")
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Validate additional items constraint
|
|
374
|
+
next unless value.length > resolved_item_types.length
|
|
375
|
+
|
|
376
|
+
case additional_items
|
|
377
|
+
when false
|
|
378
|
+
record.errors.add(prop_name, "must have at most #{resolved_item_types.length} items")
|
|
379
|
+
when nil, true
|
|
380
|
+
# Any additional items allowed
|
|
381
|
+
else
|
|
382
|
+
# additional_items is a type - validate extra items against it
|
|
383
|
+
value[resolved_item_types.length..].each_with_index do |item, offset|
|
|
384
|
+
index = resolved_item_types.length + offset
|
|
385
|
+
next if ActiveModelAdapter.type_matches?(item, resolved_additional_type)
|
|
386
|
+
|
|
387
|
+
type_name = ActiveModelAdapter.type_name_for_error(resolved_additional_type)
|
|
388
|
+
record.errors.add(prop_name, "item at index #{index} must be a #{type_name}")
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
194
392
|
end
|
|
195
393
|
|
|
196
|
-
# Apply unique items validation for arrays
|
|
394
|
+
# Apply unique items validation for arrays using JSON Schema equality semantics
|
|
197
395
|
def apply_unique_items_validation
|
|
198
396
|
prop_name = @property_name
|
|
199
397
|
@klass.validate do |record|
|
|
200
398
|
value = record.public_send(prop_name)
|
|
201
|
-
|
|
399
|
+
next unless value.is_a?(Array)
|
|
400
|
+
|
|
401
|
+
record.errors.add(prop_name, 'must contain unique items') if JsonSchemaEquality.duplicates?(value)
|
|
202
402
|
end
|
|
203
403
|
end
|
|
204
404
|
|
|
@@ -252,7 +452,16 @@ module EasyTalk
|
|
|
252
452
|
prop_name = @property_name
|
|
253
453
|
@klass.validate do |record|
|
|
254
454
|
value = record.public_send(prop_name)
|
|
255
|
-
record.errors.add(prop_name,
|
|
455
|
+
record.errors.add(prop_name, :blank) if value.nil?
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Apply presence validation for arrays (nil check, but allow empty arrays)
|
|
460
|
+
def apply_array_presence_validation
|
|
461
|
+
prop_name = @property_name
|
|
462
|
+
@klass.validate do |record|
|
|
463
|
+
value = record.public_send(prop_name)
|
|
464
|
+
record.errors.add(prop_name, :blank) if value.nil?
|
|
256
465
|
end
|
|
257
466
|
end
|
|
258
467
|
|
|
@@ -266,10 +475,9 @@ module EasyTalk
|
|
|
266
475
|
end
|
|
267
476
|
|
|
268
477
|
# Validate object/hash-specific constraints
|
|
269
|
-
def apply_object_validations
|
|
478
|
+
def apply_object_validations(expected_type)
|
|
270
479
|
# Capture necessary variables outside the validation block's scope
|
|
271
480
|
prop_name = @property_name
|
|
272
|
-
expected_type = get_type_class(@type) # Get the raw model class
|
|
273
481
|
|
|
274
482
|
@klass.validate do |record|
|
|
275
483
|
nested_object = record.public_send(prop_name)
|
|
@@ -324,6 +532,86 @@ module EasyTalk
|
|
|
324
532
|
record.errors.add(prop_name, "must be equal to #{const_value}") if !value.nil? && value != const_value
|
|
325
533
|
end
|
|
326
534
|
end
|
|
535
|
+
|
|
536
|
+
# Plain data object holding pre-computed validation context.
|
|
537
|
+
# All values are computed by ActiveModelAdapter and passed in,
|
|
538
|
+
# avoiding tight coupling to adapter internals.
|
|
539
|
+
class ValidationContext
|
|
540
|
+
attr_reader :klass, :property_name, :constraints, :validation_type, :type_class, :model_class
|
|
541
|
+
|
|
542
|
+
def initialize(attrs)
|
|
543
|
+
@klass = attrs[:klass]
|
|
544
|
+
@property_name = attrs[:property_name]
|
|
545
|
+
@constraints = attrs[:constraints]
|
|
546
|
+
@validation_type = attrs[:validation_type]
|
|
547
|
+
@type_class = attrs[:type_class]
|
|
548
|
+
@optional = attrs[:optional]
|
|
549
|
+
@nilable = attrs[:nilable]
|
|
550
|
+
|
|
551
|
+
# Derive additional flags from the pre-computed values
|
|
552
|
+
@boolean_type = boolean_type_from_context?
|
|
553
|
+
@array_type = array_type_from_context?
|
|
554
|
+
@tuple_type = tuple_type_from_context?
|
|
555
|
+
@model_class = model_class_from_context
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def skip_presence_validation?
|
|
559
|
+
@optional || @boolean_type || @nilable || @array_type
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def array_requires_presence_validation?
|
|
563
|
+
@array_type && !@nilable
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def tuple_type?
|
|
567
|
+
@tuple_type
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def boolean?
|
|
571
|
+
@boolean_type
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
private
|
|
575
|
+
|
|
576
|
+
def boolean_type_from_context?
|
|
577
|
+
TypeIntrospection.boolean_type?(@validation_type) ||
|
|
578
|
+
@type_class == TrueClass ||
|
|
579
|
+
@type_class == FalseClass ||
|
|
580
|
+
TypeIntrospection.boolean_union_type?(@type_class)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def array_type_from_context?
|
|
584
|
+
return false if @validation_type.nil?
|
|
585
|
+
|
|
586
|
+
@validation_type == Array ||
|
|
587
|
+
TypeIntrospection.typed_array?(@validation_type) ||
|
|
588
|
+
tuple_type_from_context?
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def tuple_type_from_context?
|
|
592
|
+
defined?(EasyTalk::Types::Tuple) && @validation_type.is_a?(EasyTalk::Types::Tuple)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def model_class_from_context
|
|
596
|
+
return unless @type_class.is_a?(Class)
|
|
597
|
+
|
|
598
|
+
@type_class.include?(EasyTalk::Model) ? @type_class : nil
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Shared min_items/max_items handling for array-like validations
|
|
603
|
+
def apply_array_length_validation
|
|
604
|
+
length_options = {
|
|
605
|
+
minimum: @constraints[:min_items],
|
|
606
|
+
maximum: @constraints[:max_items]
|
|
607
|
+
}.compact
|
|
608
|
+
|
|
609
|
+
return if length_options.empty?
|
|
610
|
+
|
|
611
|
+
@klass.validates @property_name, length: length_options
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
private_constant :ValidationContext
|
|
327
615
|
end
|
|
328
616
|
end
|
|
329
617
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyTalk
|
|
4
|
+
module ValidationAdapters
|
|
5
|
+
# ActiveModel-specific schema-level validations for object-level constraints.
|
|
6
|
+
#
|
|
7
|
+
# This module provides ActiveModel validations for JSON Schema keywords that apply
|
|
8
|
+
# to the object as a whole rather than individual properties:
|
|
9
|
+
# - minProperties: minimum number of properties that must be present
|
|
10
|
+
# - maxProperties: maximum number of properties that can be present
|
|
11
|
+
# - dependentRequired: conditional property requirements
|
|
12
|
+
#
|
|
13
|
+
# This module is tightly coupled with ActiveModel and is used exclusively by
|
|
14
|
+
# ActiveModelAdapter. Other validation adapters should implement their own
|
|
15
|
+
# schema-level validation logic.
|
|
16
|
+
#
|
|
17
|
+
module ActiveModelSchemaValidation
|
|
18
|
+
# Apply all applicable schema-level validations.
|
|
19
|
+
#
|
|
20
|
+
# @param klass [Class] The model class to apply validations to
|
|
21
|
+
# @param schema [Hash] The full schema hash containing schema-level constraints
|
|
22
|
+
# @return [void]
|
|
23
|
+
def self.apply(klass, schema)
|
|
24
|
+
apply_min_properties_validation(klass, schema[:min_properties]) if schema[:min_properties]
|
|
25
|
+
apply_max_properties_validation(klass, schema[:max_properties]) if schema[:max_properties]
|
|
26
|
+
apply_dependent_required_validation(klass, schema[:dependent_required]) if schema[:dependent_required]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Apply minimum properties validation.
|
|
30
|
+
#
|
|
31
|
+
# @param klass [Class] The model class
|
|
32
|
+
# @param min_count [Integer] Minimum number of properties that must be present
|
|
33
|
+
def self.apply_min_properties_validation(klass, min_count)
|
|
34
|
+
define_count_method(klass)
|
|
35
|
+
|
|
36
|
+
klass.validate do |record|
|
|
37
|
+
present_count = record.send(:count_present_properties)
|
|
38
|
+
if present_count < min_count
|
|
39
|
+
record.errors.add(:base, "must have at least #{min_count} #{min_count == 1 ? 'property' : 'properties'} present")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Apply maximum properties validation.
|
|
45
|
+
#
|
|
46
|
+
# @param klass [Class] The model class
|
|
47
|
+
# @param max_count [Integer] Maximum number of properties that can be present
|
|
48
|
+
def self.apply_max_properties_validation(klass, max_count)
|
|
49
|
+
define_count_method(klass)
|
|
50
|
+
|
|
51
|
+
klass.validate do |record|
|
|
52
|
+
present_count = record.send(:count_present_properties)
|
|
53
|
+
if present_count > max_count
|
|
54
|
+
record.errors.add(:base, "must have at most #{max_count} #{max_count == 1 ? 'property' : 'properties'} present")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Apply dependent required validation.
|
|
60
|
+
# When a trigger property is present, all dependent properties must also be present.
|
|
61
|
+
#
|
|
62
|
+
# @param klass [Class] The model class
|
|
63
|
+
# @param dependencies [Hash<String, Array<String>>] Map of trigger properties to required properties
|
|
64
|
+
def self.apply_dependent_required_validation(klass, dependencies)
|
|
65
|
+
dependencies.each do |trigger_property, required_properties|
|
|
66
|
+
trigger_prop = trigger_property.to_sym
|
|
67
|
+
required_props = required_properties.map(&:to_sym)
|
|
68
|
+
|
|
69
|
+
klass.validate do |record|
|
|
70
|
+
trigger_value = record.public_send(trigger_prop)
|
|
71
|
+
trigger_present = trigger_value.present? || trigger_value == false
|
|
72
|
+
|
|
73
|
+
next unless trigger_present
|
|
74
|
+
|
|
75
|
+
required_props.each do |required_prop|
|
|
76
|
+
value = record.public_send(required_prop)
|
|
77
|
+
value_present = value.present? || value == false
|
|
78
|
+
|
|
79
|
+
record.errors.add(required_prop, "is required when #{trigger_prop} is present") unless value_present
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Define the count_present_properties private instance method on the class if not already defined.
|
|
86
|
+
# The method counts how many schema properties have non-nil/non-blank values.
|
|
87
|
+
#
|
|
88
|
+
# @param klass [Class] The model class
|
|
89
|
+
def self.define_count_method(klass)
|
|
90
|
+
# Check for private methods as well with the second argument
|
|
91
|
+
return if klass.method_defined?(:count_present_properties, true)
|
|
92
|
+
|
|
93
|
+
klass.send(:define_method, :count_present_properties) do
|
|
94
|
+
schema_props = self.class.schema_definition.schema[:properties] || {}
|
|
95
|
+
schema_props.keys.count do |prop|
|
|
96
|
+
value = public_send(prop)
|
|
97
|
+
value.present? || value == false # false is a valid present value
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
klass.send(:private, :count_present_properties)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private_class_method :define_count_method
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -44,6 +44,16 @@ module EasyTalk
|
|
|
44
44
|
new(klass, property_name, type, constraints).apply_validations
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Build schema-level validations (e.g., min_properties, max_properties, dependent_required).
|
|
48
|
+
# Subclasses can override this method to implement schema-level validations.
|
|
49
|
+
#
|
|
50
|
+
# @param klass [Class] The model class to apply validations to
|
|
51
|
+
# @param schema [Hash] The full schema hash containing schema-level constraints
|
|
52
|
+
# @return [void]
|
|
53
|
+
def self.build_schema_validations(klass, schema)
|
|
54
|
+
# Default implementation does nothing - subclasses can override
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
# Initialize a new validation adapter instance.
|
|
48
58
|
#
|
|
49
59
|
# @param klass [Class] The model class to apply validations to
|
|
@@ -125,6 +135,8 @@ module EasyTalk
|
|
|
125
135
|
type_to_resolve.raw_type
|
|
126
136
|
elsif type_to_resolve.is_a?(T::Types::TypedArray)
|
|
127
137
|
Array
|
|
138
|
+
elsif type_to_resolve.is_a?(EasyTalk::Types::Tuple)
|
|
139
|
+
Array
|
|
128
140
|
elsif type_to_resolve.is_a?(Symbol) || type_to_resolve.is_a?(String)
|
|
129
141
|
begin
|
|
130
142
|
type_to_resolve.to_s.classify.constantize
|
|
@@ -25,6 +25,15 @@ module EasyTalk
|
|
|
25
25
|
# end
|
|
26
26
|
#
|
|
27
27
|
class NoneAdapter < Base
|
|
28
|
+
# Build no schema-level validations (no-op).
|
|
29
|
+
#
|
|
30
|
+
# @param klass [Class] The model class (unused)
|
|
31
|
+
# @param schema [Hash] The schema hash (unused)
|
|
32
|
+
# @return [void]
|
|
33
|
+
def self.build_schema_validations(klass, schema)
|
|
34
|
+
# Intentionally empty - no validations applied
|
|
35
|
+
end
|
|
36
|
+
|
|
28
37
|
# Apply no validations (no-op).
|
|
29
38
|
#
|
|
30
39
|
# @return [void]
|
data/lib/easy_talk/version.rb
CHANGED
data/lib/easy_talk.rb
CHANGED