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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -43
  3. data/CHANGELOG.md +89 -0
  4. data/README.md +447 -2115
  5. data/docs/json_schema_compliance.md +140 -26
  6. data/docs/primitive-schema-rfc.md +894 -0
  7. data/lib/easy_talk/builders/base_builder.rb +2 -1
  8. data/lib/easy_talk/builders/boolean_builder.rb +2 -1
  9. data/lib/easy_talk/builders/collection_helpers.rb +4 -0
  10. data/lib/easy_talk/builders/composition_builder.rb +7 -2
  11. data/lib/easy_talk/builders/integer_builder.rb +2 -1
  12. data/lib/easy_talk/builders/null_builder.rb +4 -1
  13. data/lib/easy_talk/builders/number_builder.rb +4 -1
  14. data/lib/easy_talk/builders/object_builder.rb +64 -3
  15. data/lib/easy_talk/builders/registry.rb +15 -1
  16. data/lib/easy_talk/builders/string_builder.rb +3 -1
  17. data/lib/easy_talk/builders/temporal_builder.rb +7 -0
  18. data/lib/easy_talk/builders/tuple_builder.rb +89 -0
  19. data/lib/easy_talk/builders/typed_array_builder.rb +4 -2
  20. data/lib/easy_talk/builders/union_builder.rb +5 -1
  21. data/lib/easy_talk/configuration.rb +17 -2
  22. data/lib/easy_talk/errors.rb +1 -0
  23. data/lib/easy_talk/errors_helper.rb +3 -0
  24. data/lib/easy_talk/json_schema_equality.rb +46 -0
  25. data/lib/easy_talk/keywords.rb +0 -1
  26. data/lib/easy_talk/model.rb +27 -1
  27. data/lib/easy_talk/model_helper.rb +4 -0
  28. data/lib/easy_talk/naming_strategies.rb +4 -0
  29. data/lib/easy_talk/property.rb +7 -0
  30. data/lib/easy_talk/ref_helper.rb +6 -0
  31. data/lib/easy_talk/schema.rb +1 -0
  32. data/lib/easy_talk/schema_definition.rb +52 -6
  33. data/lib/easy_talk/schema_methods.rb +36 -5
  34. data/lib/easy_talk/sorbet_extension.rb +1 -0
  35. data/lib/easy_talk/type_introspection.rb +45 -1
  36. data/lib/easy_talk/types/tuple.rb +77 -0
  37. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +350 -62
  38. data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
  39. data/lib/easy_talk/validation_adapters/base.rb +12 -0
  40. data/lib/easy_talk/validation_adapters/none_adapter.rb +9 -0
  41. data/lib/easy_talk/validation_builder.rb +1 -0
  42. data/lib/easy_talk/version.rb +1 -1
  43. data/lib/easy_talk.rb +1 -0
  44. 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
- # Determine if the type is boolean
32
- type_class = get_type_class(@type)
33
- is_boolean = type_class == [TrueClass, FalseClass] ||
34
- type_class == TrueClass ||
35
- type_class == FalseClass ||
36
- TypeIntrospection.boolean_type?(@type)
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(type)
62
- type_class = get_type_class(type)
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(type)
72
- elsif type_class == [TrueClass,
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 type_class.is_a?(Object) && type_class.include?(EasyTalk::Model)
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
- format_configs = {
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
- config = format_configs[format.to_s]
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
- config[:allow_nil] = optional? || nilable_type?
137
- @klass.validates @property_name, format: config
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
- # Validate array length
181
- if @constraints[:min_items] || @constraints[:max_items]
182
- length_options = {}
183
- length_options[:minimum] = @constraints[:min_items] if @constraints[:min_items]
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
- @klass.validates @property_name, length: length_options
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
- # Validate array item types if using T::Array[SomeType]
193
- apply_array_item_type_validation(type) if type.is_a?(T::Types::TypedArray)
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
- record.errors.add(prop_name, 'must contain unique items') if value && value.uniq.length != value.length
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, "can't be blank") if value.nil?
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]
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # typed: true
2
3
 
3
4
  module EasyTalk
4
5
  # @deprecated Use EasyTalk::ValidationAdapters::ActiveModelAdapter instead.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyTalk
4
- VERSION = '3.2.0'
4
+ VERSION = '3.3.0'
5
5
  end
data/lib/easy_talk.rb CHANGED
@@ -10,6 +10,7 @@ module EasyTalk
10
10
  require 'easy_talk/configuration'
11
11
  require 'easy_talk/schema_methods'
12
12
  require 'easy_talk/types/composer'
13
+ require 'easy_talk/types/tuple'
13
14
 
14
15
  # Validation adapters
15
16
  require 'easy_talk/validation_adapters/base'