easy_talk 3.3.0 → 3.3.2

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.
@@ -121,7 +121,7 @@ module EasyTalk
121
121
  if args.size >= 1 && args.first.is_a?(Class)
122
122
  type = args.first
123
123
  # Merge all hash arguments as constraints
124
- constraints = args[1..].select { |arg| arg.is_a?(Hash) }.reduce({}, :merge)
124
+ constraints = args[1..].grep(Hash).reduce({}, :merge)
125
125
  return { type:, **constraints }
126
126
  end
127
127
 
@@ -183,6 +183,11 @@ module EasyTalk
183
183
  return Array if type.is_a?(T::Types::TypedArray) || type.is_a?(EasyTalk::Types::Tuple)
184
184
  return [TrueClass, FalseClass] if boolean_type?(type)
185
185
 
186
+ if type.is_a?(Symbol) || type.is_a?(String)
187
+ klass = type.to_s.classify.safe_constantize
188
+ return klass if klass
189
+ end
190
+
186
191
  if nilable_type?(type)
187
192
  inner = extract_inner_type(type)
188
193
  return get_type_class(inner) if inner && inner != type
@@ -208,6 +213,10 @@ module EasyTalk
208
213
  end
209
214
 
210
215
  if type.respond_to?(:types)
216
+ # Prefer TypedArray if present in the union (for T.nilable(T::Array[...]) via .types path)
217
+ typed_arr = type.types.find { |t| t.is_a?(T::Types::TypedArray) }
218
+ return typed_arr if typed_arr
219
+
211
220
  non_nil = type.types.find do |t|
212
221
  raw = t.respond_to?(:raw_type) ? t.raw_type : t
213
222
  raw != NilClass
@@ -128,6 +128,12 @@ module EasyTalk
128
128
 
129
129
  private
130
130
 
131
+ # Returns true if nil should be allowed by ActiveModel validators.
132
+ # This is the case when the property is optional or declared T.nilable.
133
+ def allow_nil?
134
+ optional? || nilable_type?
135
+ end
136
+
131
137
  # Apply validations based on the type of the property
132
138
  def apply_type_validations(context)
133
139
  if context.tuple_type?
@@ -185,7 +191,7 @@ module EasyTalk
185
191
  length_options[:maximum] = @constraints[:max_length] if valid_length_constraint?(:max_length)
186
192
  return unless length_options.any?
187
193
 
188
- length_options[:allow_nil] = optional? || nilable_type?
194
+ length_options[:allow_nil] = allow_nil?
189
195
  @klass.validates @property_name, length: length_options
190
196
  rescue ArgumentError
191
197
  # Silently ignore invalid length constraints
@@ -297,6 +303,7 @@ module EasyTalk
297
303
  options[:less_than_or_equal_to] = @constraints[:maximum] if @constraints[:maximum].is_a?(Numeric)
298
304
  options[:greater_than] = @constraints[:exclusive_minimum] if @constraints[:exclusive_minimum].is_a?(Numeric)
299
305
  options[:less_than] = @constraints[:exclusive_maximum] if @constraints[:exclusive_maximum].is_a?(Numeric)
306
+ options[:allow_nil] = true if allow_nil?
300
307
 
301
308
  @klass.validates @property_name, numericality: options
302
309
 
@@ -407,16 +414,23 @@ module EasyTalk
407
414
  # Get inner type from T::Types::TypedArray (uses .type, which returns T::Types::Simple)
408
415
  inner_type_wrapper = type.type
409
416
  inner_type = inner_type_wrapper.respond_to?(:raw_type) ? inner_type_wrapper.raw_type : inner_type_wrapper
417
+ is_boolean = TypeIntrospection.boolean_type?(inner_type)
410
418
  prop_name = @property_name
411
- is_easy_talk_model = inner_type.is_a?(Class) && inner_type.include?(EasyTalk::Model)
419
+ is_easy_talk_model = !is_boolean && inner_type.is_a?(Class) && inner_type.include?(EasyTalk::Model)
412
420
 
413
421
  @klass.validate do |record|
414
422
  value = record.public_send(prop_name)
415
423
  next unless value.is_a?(Array)
416
424
 
417
425
  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}")
426
+ type_match = if is_boolean
427
+ [true, false].include?(item)
428
+ else
429
+ item.is_a?(inner_type)
430
+ end
431
+ unless type_match
432
+ type_label = is_boolean ? 'Boolean' : inner_type.to_s
433
+ record.errors.add(prop_name, "item at index #{index} must be a #{type_label}")
420
434
  next
421
435
  end
422
436
 
@@ -433,15 +447,11 @@ module EasyTalk
433
447
 
434
448
  # Validate boolean-specific constraints
435
449
  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
450
+ # allow_nil? covers both optional and T.nilable — nil is explicitly permitted in both cases.
451
+ @klass.validates @property_name, inclusion: { in: [true, false] }, allow_nil: allow_nil?
452
+
453
+ # For required non-nilable booleans, add a custom nil check with the "can't be blank" message.
454
+ apply_boolean_presence_validation unless allow_nil?
445
455
 
446
456
  # Add type validation to ensure the value is actually a boolean
447
457
  apply_boolean_type_validation
@@ -482,33 +492,22 @@ module EasyTalk
482
492
  @klass.validate do |record|
483
493
  nested_object = record.public_send(prop_name)
484
494
 
485
- # Only validate if the nested object is present
495
+ # Nil is handled by the outer presence validation — nothing to do here.
486
496
  next unless nested_object
487
497
 
488
- # Check if the object is of the expected type (e.g., an actual Email instance)
489
498
  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
499
+ # Delegate entirely to the nested model's own validity.
500
+ # The outer presence validation already handles the nil case, so there
501
+ # is no need for a secondary "blank" heuristic here.
502
+ next if nested_object.valid?
503
+
504
+ # Propagate nested errors into the parent with a dotted-path prefix.
505
+ nested_object.errors.each do |error|
506
+ nested_key = "#{prop_name}.#{error.attribute}"
507
+ record.errors.add(nested_key.to_sym, error.message)
509
508
  end
510
509
  else
511
- # If present but not the correct type, add a type error
510
+ # Value is present but is not the expected type.
512
511
  record.errors.add(prop_name, "must be a valid #{expected_type.name}")
513
512
  end
514
513
  end
@@ -519,7 +518,7 @@ module EasyTalk
519
518
  @klass.validates @property_name, inclusion: {
520
519
  in: @constraints[:enum],
521
520
  message: "must be one of: #{@constraints[:enum].join(', ')}",
522
- allow_nil: optional?
521
+ allow_nil: allow_nil?
523
522
  }
524
523
  end
525
524
 
@@ -560,7 +559,7 @@ module EasyTalk
560
559
  end
561
560
 
562
561
  def array_requires_presence_validation?
563
- @array_type && !@nilable
562
+ @array_type && !@nilable && !@optional
564
563
  end
565
564
 
566
565
  def tuple_type?
@@ -608,6 +607,7 @@ module EasyTalk
608
607
 
609
608
  return if length_options.empty?
610
609
 
610
+ length_options[:allow_nil] = true if allow_nil?
611
611
  @klass.validates @property_name, length: length_options
612
612
  end
613
613
 
@@ -94,62 +94,30 @@ module EasyTalk
94
94
  end
95
95
 
96
96
  # Check if the type is nilable (e.g., T.nilable(String)).
97
+ # Delegates to TypeIntrospection.
97
98
  #
98
- # @param t [Class, Object] The type to check (defaults to @type)
99
+ # @param type_to_check [Class, Object] The type to check (defaults to @type)
99
100
  # @return [Boolean] true if the type is nilable
100
101
  def nilable_type?(type_to_check = @type)
101
- type_to_check.respond_to?(:nilable?) && type_to_check.nilable?
102
+ TypeIntrospection.nilable_type?(type_to_check)
102
103
  end
103
104
 
104
105
  # Extract the inner type from a complex type like T.nilable(String) or T.nilable(T::Array[Model]).
106
+ # Delegates to TypeIntrospection.
105
107
  #
106
108
  # @param type_to_unwrap [Class, Object] The type to unwrap (defaults to @type)
107
109
  # @return [Class, Object] The inner type, or the original type if not wrapped
108
110
  def extract_inner_type(type_to_unwrap = @type)
109
- if type_to_unwrap.respond_to?(:unwrap_nilable)
110
- unwrapped = type_to_unwrap.unwrap_nilable
111
- # Return TypedArray directly (for T.nilable(T::Array[Model]))
112
- return unwrapped if unwrapped.is_a?(T::Types::TypedArray)
113
- # Return raw_type for simple types (for T.nilable(String))
114
- return unwrapped.raw_type if unwrapped.respond_to?(:raw_type)
115
- end
116
-
117
- if type_to_unwrap.respond_to?(:types)
118
- # For union types, find the non-nil type
119
- # Prefer TypedArray if present, otherwise find type with raw_type
120
- type_to_unwrap.types.find { |t| t.is_a?(T::Types::TypedArray) } ||
121
- type_to_unwrap.types.find { |t| t.respond_to?(:raw_type) && t.raw_type != NilClass }
122
- else
123
- type_to_unwrap
124
- end
111
+ TypeIntrospection.extract_inner_type(type_to_unwrap)
125
112
  end
126
113
 
127
114
  # Determine the actual class for a type, handling Sorbet types.
115
+ # Delegates to TypeIntrospection.
128
116
  #
129
117
  # @param type_to_resolve [Class, Object] The type to resolve
130
118
  # @return [Class, Array<Class>] The resolved class or classes
131
119
  def get_type_class(type_to_resolve)
132
- if type_to_resolve.is_a?(Class)
133
- type_to_resolve
134
- elsif type_to_resolve.respond_to?(:raw_type)
135
- type_to_resolve.raw_type
136
- elsif type_to_resolve.is_a?(T::Types::TypedArray)
137
- Array
138
- elsif type_to_resolve.is_a?(EasyTalk::Types::Tuple)
139
- Array
140
- elsif type_to_resolve.is_a?(Symbol) || type_to_resolve.is_a?(String)
141
- begin
142
- type_to_resolve.to_s.classify.constantize
143
- rescue StandardError
144
- String
145
- end
146
- elsif TypeIntrospection.boolean_type?(type_to_resolve)
147
- [TrueClass, FalseClass]
148
- elsif nilable_type?(type_to_resolve)
149
- extract_inner_type(type_to_resolve)
150
- else
151
- String
152
- end
120
+ TypeIntrospection.get_type_class(type_to_resolve)
153
121
  end
154
122
  end
155
123
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyTalk
4
- VERSION = '3.3.0'
4
+ VERSION = '3.3.2'
5
5
  end
data/lib/easy_talk.rb CHANGED
@@ -21,9 +21,10 @@ module EasyTalk
21
21
  # Builder registry for pluggable type support
22
22
  require 'easy_talk/builders/registry'
23
23
 
24
+ require 'easy_talk/property'
25
+ require 'easy_talk/schema_base'
24
26
  require 'easy_talk/model'
25
27
  require 'easy_talk/schema'
26
- require 'easy_talk/property'
27
28
  require 'easy_talk/schema_definition'
28
29
  require 'easy_talk/validation_builder'
29
30
  require 'easy_talk/error_formatter'
@@ -57,6 +58,8 @@ module EasyTalk
57
58
  end
58
59
 
59
60
  def self.assert_valid_property_options(property_name, options, *valid_keys)
61
+ return if options.nil?
62
+
60
63
  valid_keys.flatten!
61
64
  options.each_key do |k|
62
65
  next if valid_keys.include?(k)
@@ -68,4 +71,23 @@ module EasyTalk
68
71
  def self.configure_nilable_behavior(nilable_is_optional = false)
69
72
  configuration.nilable_is_optional = nilable_is_optional
70
73
  end
74
+
75
+ # Deep duplicates a value, recursing into Hashes and Arrays.
76
+ # Class and Module objects are returned as-is since they represent types
77
+ # and cannot (and should not) be duplicated.
78
+ #
79
+ # @param obj [Object] The value to deep duplicate.
80
+ # @return [Object] A deep copy of obj.
81
+ def self.deep_dup(obj)
82
+ case obj
83
+ when Hash
84
+ obj.transform_values { |v| deep_dup(v) }
85
+ when Array
86
+ obj.map { |v| deep_dup(v) }
87
+ when Class, Module
88
+ obj
89
+ else
90
+ obj.duplicable? ? obj.dup : obj
91
+ end
92
+ end
71
93
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_talk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
@@ -106,6 +106,10 @@ files:
106
106
  - docs/primitive-schema-rfc.md
107
107
  - docs/property-types.markdown
108
108
  - docs/schema-definition.markdown
109
+ - easy_talk.gemspec
110
+ - examples/ruby_llm/Gemfile
111
+ - examples/ruby_llm/structured_output.rb
112
+ - examples/ruby_llm/tools_integration.rb
109
113
  - lib/easy_talk.rb
110
114
  - lib/easy_talk/builders/base_builder.rb
111
115
  - lib/easy_talk/builders/boolean_builder.rb
@@ -132,6 +136,7 @@ files:
132
136
  - lib/easy_talk/error_formatter/rfc7807.rb
133
137
  - lib/easy_talk/errors.rb
134
138
  - lib/easy_talk/errors_helper.rb
139
+ - lib/easy_talk/extensions/ruby_llm_compatibility.rb
135
140
  - lib/easy_talk/json_schema_equality.rb
136
141
  - lib/easy_talk/keywords.rb
137
142
  - lib/easy_talk/model.rb
@@ -140,6 +145,7 @@ files:
140
145
  - lib/easy_talk/property.rb
141
146
  - lib/easy_talk/ref_helper.rb
142
147
  - lib/easy_talk/schema.rb
148
+ - lib/easy_talk/schema_base.rb
143
149
  - lib/easy_talk/schema_definition.rb
144
150
  - lib/easy_talk/schema_methods.rb
145
151
  - lib/easy_talk/sorbet_extension.rb