easy_talk 3.1.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -39
  3. data/.yardopts +13 -0
  4. data/CHANGELOG.md +164 -0
  5. data/README.md +442 -1529
  6. data/Rakefile +27 -0
  7. data/docs/.gitignore +1 -0
  8. data/docs/about.markdown +28 -8
  9. data/docs/getting-started.markdown +102 -0
  10. data/docs/index.markdown +51 -4
  11. data/docs/json_schema_compliance.md +169 -0
  12. data/docs/nested-models.markdown +216 -0
  13. data/docs/primitive-schema-rfc.md +894 -0
  14. data/docs/property-types.markdown +212 -0
  15. data/docs/schema-definition.markdown +180 -0
  16. data/lib/easy_talk/builders/base_builder.rb +6 -3
  17. data/lib/easy_talk/builders/boolean_builder.rb +2 -1
  18. data/lib/easy_talk/builders/collection_helpers.rb +4 -0
  19. data/lib/easy_talk/builders/composition_builder.rb +16 -13
  20. data/lib/easy_talk/builders/integer_builder.rb +2 -1
  21. data/lib/easy_talk/builders/null_builder.rb +4 -1
  22. data/lib/easy_talk/builders/number_builder.rb +4 -1
  23. data/lib/easy_talk/builders/object_builder.rb +109 -33
  24. data/lib/easy_talk/builders/registry.rb +182 -0
  25. data/lib/easy_talk/builders/string_builder.rb +3 -1
  26. data/lib/easy_talk/builders/temporal_builder.rb +7 -0
  27. data/lib/easy_talk/builders/tuple_builder.rb +89 -0
  28. data/lib/easy_talk/builders/typed_array_builder.rb +19 -6
  29. data/lib/easy_talk/builders/union_builder.rb +5 -1
  30. data/lib/easy_talk/configuration.rb +47 -2
  31. data/lib/easy_talk/error_formatter/base.rb +100 -0
  32. data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
  33. data/lib/easy_talk/error_formatter/flat.rb +38 -0
  34. data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
  35. data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
  36. data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
  37. data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
  38. data/lib/easy_talk/error_formatter.rb +143 -0
  39. data/lib/easy_talk/errors.rb +3 -0
  40. data/lib/easy_talk/errors_helper.rb +66 -34
  41. data/lib/easy_talk/json_schema_equality.rb +46 -0
  42. data/lib/easy_talk/keywords.rb +0 -1
  43. data/lib/easy_talk/model.rb +148 -89
  44. data/lib/easy_talk/model_helper.rb +17 -0
  45. data/lib/easy_talk/naming_strategies.rb +24 -0
  46. data/lib/easy_talk/property.rb +23 -94
  47. data/lib/easy_talk/ref_helper.rb +33 -0
  48. data/lib/easy_talk/schema.rb +199 -0
  49. data/lib/easy_talk/schema_definition.rb +57 -5
  50. data/lib/easy_talk/schema_methods.rb +111 -0
  51. data/lib/easy_talk/sorbet_extension.rb +1 -0
  52. data/lib/easy_talk/tools/function_builder.rb +1 -1
  53. data/lib/easy_talk/type_introspection.rb +222 -0
  54. data/lib/easy_talk/types/base_composer.rb +2 -1
  55. data/lib/easy_talk/types/composer.rb +4 -0
  56. data/lib/easy_talk/types/tuple.rb +77 -0
  57. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
  58. data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
  59. data/lib/easy_talk/validation_adapters/base.rb +156 -0
  60. data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
  61. data/lib/easy_talk/validation_adapters/registry.rb +87 -0
  62. data/lib/easy_talk/validation_builder.rb +29 -309
  63. data/lib/easy_talk/version.rb +1 -1
  64. data/lib/easy_talk.rb +42 -0
  65. metadata +38 -7
  66. data/docs/404.html +0 -25
  67. data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
  68. data/easy_talk.gemspec +0 -39
@@ -0,0 +1,617 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative 'active_model_schema_validation'
5
+ require 'easy_talk/json_schema_equality'
6
+
7
+ module EasyTalk
8
+ module ValidationAdapters
9
+ # ActiveModel validation adapter.
10
+ #
11
+ # This is the default adapter that converts JSON Schema constraints into
12
+ # ActiveModel validations. It provides the same validation behavior as
13
+ # the original EasyTalk::ValidationBuilder.
14
+ #
15
+ # @example Using the ActiveModel adapter (default)
16
+ # class User
17
+ # include EasyTalk::Model
18
+ #
19
+ # define_schema do
20
+ # property :email, String, format: 'email'
21
+ # end
22
+ # end
23
+ #
24
+ # user = User.new(email: 'invalid')
25
+ # user.valid? # => false
26
+ # user.errors[:email] # => ["must be a valid email address"]
27
+ #
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
+
94
+ # Apply validations based on property type and constraints.
95
+ #
96
+ # @return [void]
97
+ def apply_validations
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)
104
+
105
+ apply_enum_validation if @constraints[:enum]
106
+ apply_const_validation if @constraints[:const]
107
+ end
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
+
129
+ private
130
+
131
+ # Apply validations based on the type of the property
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
139
+
140
+ if type_class == String
141
+ apply_string_validations
142
+ elsif type_class == Integer
143
+ apply_integer_validations
144
+ elsif [Float, BigDecimal].include?(type_class)
145
+ apply_number_validations
146
+ elsif type_class == Array
147
+ apply_array_validations(context.validation_type)
148
+ elsif context.boolean?
149
+ apply_boolean_validations
150
+ elsif context.model_class
151
+ apply_object_validations(context.model_class)
152
+ end
153
+ end
154
+
155
+ # Add presence validation for the property
156
+ def apply_presence_validation
157
+ @klass.validates @property_name, presence: true
158
+ end
159
+
160
+ # Validate string-specific constraints
161
+ def apply_string_validations
162
+ # Handle format constraints
163
+ apply_format_validation(@constraints[:format]) if @constraints[:format]
164
+
165
+ # Handle pattern (regex) constraints
166
+ apply_pattern_validation if @constraints[:pattern]
167
+
168
+ # Handle length constraints
169
+ apply_length_validations
170
+ end
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
+
181
+ # Apply length validations for strings
182
+ def apply_length_validations
183
+ length_options = {}
184
+ length_options[:minimum] = @constraints[:min_length] if valid_length_constraint?(:min_length)
185
+ length_options[:maximum] = @constraints[:max_length] if valid_length_constraint?(:max_length)
186
+ return unless length_options.any?
187
+
188
+ length_options[:allow_nil] = optional? || nilable_type?
189
+ @klass.validates @property_name, length: length_options
190
+ rescue ArgumentError
191
+ # Silently ignore invalid length constraints
192
+ end
193
+
194
+ # Check if a length constraint is valid
195
+ def valid_length_constraint?(key)
196
+ @constraints[key].is_a?(Numeric) && @constraints[key] >= 0
197
+ end
198
+
199
+ # Apply format-specific validations (email, url, etc.)
200
+ # Per JSON Schema spec, format validation only applies to string values
201
+ def apply_format_validation(format)
202
+ format_str = format.to_s
203
+
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]
212
+ return unless config
213
+
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
279
+ end
280
+
281
+ # Validate integer-specific constraints
282
+ def apply_integer_validations
283
+ apply_numeric_validations(only_integer: true)
284
+ end
285
+
286
+ # Validate number-specific constraints
287
+ def apply_number_validations
288
+ apply_numeric_validations(only_integer: false)
289
+ end
290
+
291
+ # Apply numeric validations for integers and floats
292
+ def apply_numeric_validations(only_integer: false)
293
+ options = { only_integer: only_integer }
294
+
295
+ # Add range constraints - only if they are numeric
296
+ options[:greater_than_or_equal_to] = @constraints[:minimum] if @constraints[:minimum].is_a?(Numeric)
297
+ options[:less_than_or_equal_to] = @constraints[:maximum] if @constraints[:maximum].is_a?(Numeric)
298
+ options[:greater_than] = @constraints[:exclusive_minimum] if @constraints[:exclusive_minimum].is_a?(Numeric)
299
+ options[:less_than] = @constraints[:exclusive_maximum] if @constraints[:exclusive_maximum].is_a?(Numeric)
300
+
301
+ @klass.validates @property_name, numericality: options
302
+
303
+ # Add multiple_of validation
304
+ apply_multiple_of_validation if @constraints[:multiple_of]
305
+ rescue ArgumentError
306
+ # Silently ignore invalid numeric constraints
307
+ end
308
+
309
+ # Apply multiple_of validation for numeric types
310
+ def apply_multiple_of_validation
311
+ prop_name = @property_name
312
+ multiple_of_value = @constraints[:multiple_of]
313
+ @klass.validate do |record|
314
+ value = record.public_send(prop_name)
315
+ record.errors.add(prop_name, "must be a multiple of #{multiple_of_value}") if value && (value % multiple_of_value != 0)
316
+ end
317
+ end
318
+
319
+ # Validate array-specific constraints
320
+ def apply_array_validations(type)
321
+ apply_array_length_validation
322
+
323
+ # Validate uniqueness within the array
324
+ apply_unique_items_validation if @constraints[:unique_items]
325
+
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)
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
339
+
340
+ apply_unique_items_validation if @constraints[:unique_items]
341
+
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
392
+ end
393
+
394
+ # Apply unique items validation for arrays using JSON Schema equality semantics
395
+ def apply_unique_items_validation
396
+ prop_name = @property_name
397
+ @klass.validate do |record|
398
+ value = record.public_send(prop_name)
399
+ next unless value.is_a?(Array)
400
+
401
+ record.errors.add(prop_name, 'must contain unique items') if JsonSchemaEquality.duplicates?(value)
402
+ end
403
+ end
404
+
405
+ # Apply array item type and nested model validation
406
+ def apply_array_item_type_validation(type)
407
+ # Get inner type from T::Types::TypedArray (uses .type, which returns T::Types::Simple)
408
+ inner_type_wrapper = type.type
409
+ inner_type = inner_type_wrapper.respond_to?(:raw_type) ? inner_type_wrapper.raw_type : inner_type_wrapper
410
+ prop_name = @property_name
411
+ is_easy_talk_model = inner_type.is_a?(Class) && inner_type.include?(EasyTalk::Model)
412
+
413
+ @klass.validate do |record|
414
+ value = record.public_send(prop_name)
415
+ next unless value.is_a?(Array)
416
+
417
+ value.each_with_index do |item, index|
418
+ unless item.is_a?(inner_type)
419
+ record.errors.add(prop_name, "item at index #{index} must be a #{inner_type}")
420
+ next
421
+ end
422
+
423
+ # Recursively validate nested EasyTalk::Model items
424
+ next unless is_easy_talk_model && !item.valid?
425
+
426
+ item.errors.each do |error|
427
+ nested_key = "#{prop_name}[#{index}].#{error.attribute}"
428
+ record.errors.add(nested_key.to_sym, error.message)
429
+ end
430
+ end
431
+ end
432
+ end
433
+
434
+ # Validate boolean-specific constraints
435
+ def apply_boolean_validations
436
+ # For boolean values, validate inclusion in [true, false]
437
+ # If not optional, don't allow nil (equivalent to presence validation for booleans)
438
+ if optional?
439
+ @klass.validates @property_name, inclusion: { in: [true, false] }, allow_nil: true
440
+ else
441
+ @klass.validates @property_name, inclusion: { in: [true, false] }
442
+ # Add custom validation for nil values that provides the "can't be blank" message
443
+ apply_boolean_presence_validation
444
+ end
445
+
446
+ # Add type validation to ensure the value is actually a boolean
447
+ apply_boolean_type_validation
448
+ end
449
+
450
+ # Apply presence validation for boolean (nil check with custom message)
451
+ def apply_boolean_presence_validation
452
+ prop_name = @property_name
453
+ @klass.validate do |record|
454
+ value = record.public_send(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?
465
+ end
466
+ end
467
+
468
+ # Apply type validation for boolean
469
+ def apply_boolean_type_validation
470
+ prop_name = @property_name
471
+ @klass.validate do |record|
472
+ value = record.public_send(prop_name)
473
+ record.errors.add(prop_name, 'must be a boolean') if value && ![true, false].include?(value)
474
+ end
475
+ end
476
+
477
+ # Validate object/hash-specific constraints
478
+ def apply_object_validations(expected_type)
479
+ # Capture necessary variables outside the validation block's scope
480
+ prop_name = @property_name
481
+
482
+ @klass.validate do |record|
483
+ nested_object = record.public_send(prop_name)
484
+
485
+ # Only validate if the nested object is present
486
+ next unless nested_object
487
+
488
+ # Check if the object is of the expected type (e.g., an actual Email instance)
489
+ if nested_object.is_a?(expected_type)
490
+ # Check if this object appears to be empty (created from an empty hash)
491
+ # by checking if all defined properties are nil/blank
492
+ properties = expected_type.schema_definition.schema[:properties] || {}
493
+ all_properties_blank = properties.keys.all? do |property|
494
+ value = nested_object.public_send(property)
495
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
496
+ end
497
+
498
+ if all_properties_blank
499
+ # Treat as blank and add a presence error to the parent field
500
+ record.errors.add(prop_name, "can't be blank")
501
+ elsif !nested_object.valid?
502
+ # If it's the correct type and not empty, validate it
503
+ # Merge errors from the nested object into the parent
504
+ nested_object.errors.each do |error|
505
+ # Prefix the attribute name (e.g., 'email.address')
506
+ nested_key = "#{prop_name}.#{error.attribute}"
507
+ record.errors.add(nested_key.to_sym, error.message)
508
+ end
509
+ end
510
+ else
511
+ # If present but not the correct type, add a type error
512
+ record.errors.add(prop_name, "must be a valid #{expected_type.name}")
513
+ end
514
+ end
515
+ end
516
+
517
+ # Apply enum validation for inclusion in a specific list
518
+ def apply_enum_validation
519
+ @klass.validates @property_name, inclusion: {
520
+ in: @constraints[:enum],
521
+ message: "must be one of: #{@constraints[:enum].join(', ')}",
522
+ allow_nil: optional?
523
+ }
524
+ end
525
+
526
+ # Apply const validation for equality with a specific value
527
+ def apply_const_validation
528
+ const_value = @constraints[:const]
529
+ prop_name = @property_name
530
+ @klass.validate do |record|
531
+ value = record.public_send(prop_name)
532
+ record.errors.add(prop_name, "must be equal to #{const_value}") if !value.nil? && value != const_value
533
+ end
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
615
+ end
616
+ end
617
+ end