easy_talk 3.3.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1b72ef842cb6e49f4cbc0d1eab096e737f26c118e715170c35e791ceb28a0cd
4
- data.tar.gz: 800ce6db8a8d0607589250fb76a0834bc1372de11617f2746abad2c00496bc77
3
+ metadata.gz: b571b59c30a196b82285a7fa67c275002cb9f4bc6a07a0e0c70cc69d04d60b50
4
+ data.tar.gz: 599c489167248facc490dbc3add9c0fddb005b9aab654d919e0377a1ae2352af
5
5
  SHA512:
6
- metadata.gz: 1cb143e9d16027929bcaf119cdcc12254fd4324f2f902a493be7a55095cfce1f8d721bd44c5233fb5ef62cde19aab3017f6fdc78cb66c57ab670a304293b6fb0
7
- data.tar.gz: 2968e2ab36843d8237759d9b7528cbe9a72388123b677b8c8117c27192c51d50f577df884a0a96d9cc7e308f2119e2485bcbf0b13bb5742675fb5cd1338ae31a
6
+ metadata.gz: 8f27233da948ebf61071d179f8b8b1031df4ffa262bd9050488aa09e82e462d8c8a52b25290b8af1965c7c23f39c73a43c8b95972ac3d388aea645c50d7754c9
7
+ data.tar.gz: 8cba0f544c9746674f15005232c714d385a3e407c3add6d4089eb2260a1a6889a0e7a0160283d42fdc388f91c26ac96c52d8fae8147458821cd346bde2c60651
data/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ ## [3.3.2] - 2026-03-09
2
+
3
+ ### Fixed
4
+
5
+ - **Schema Cache Corruption**: Fixed schema cache corruption when nested models have constraints (#163)
6
+ - **Mutable Default Values (Model)**: Fixed mutable default values (arrays, hashes) being shared across `EasyTalk::Model` instances (#165)
7
+ - **Nilable Properties Rejecting Nil**: Fixed nilable properties incorrectly rejecting `nil` in ActiveModel validations (#166)
8
+ - **Nested Model Blank False Positive**: Fixed nested model blank false positive in `apply_object_validations` (#167)
9
+ - **Nilable Boolean Validation**: Fixed `T.nilable(T::Boolean)` rejecting `nil` in ActiveModel validations (#168)
10
+ - **Schema False Value Discard**: Fixed `EasyTalk::Schema` silently discarding `false` values on initialization (#169)
11
+ - **Mutable Default Values (Schema)**: Fixed mutable default values shared across `EasyTalk::Schema` instances (#170)
12
+ - **Double-Call Stale Schema**: Fixed `define_schema` double-call leaving stale schema and validators (#171)
13
+ - **Incorrect Fallback String**: Fixed incorrect fallback string in type introspection for composition types (#175)
14
+ - **Optional Array Nil Rejection**: Fixed optional array properties incorrectly rejecting `nil` (#177)
15
+
16
+ ### Added
17
+
18
+ - **Option Validation for UnionBuilder and NullBuilder**: Added constraint option validation to `UnionBuilder` and `NullBuilder`, raising errors on unsupported options (#176)
19
+
20
+ ### Internal
21
+
22
+ - **SchemaBase Extraction**: Extracted `SchemaBase` module to eliminate duplication between `Model` and `Schema` (#172)
23
+ - **Type Introspection Consolidation**: Consolidated type introspection logic into the `TypeIntrospection` module, removing duplicate checks from adapters and helpers (#173, #174)
24
+ - **Test Suite Consolidation**: Consolidated standalone bug-fix specs into the regular test suite for better organization
25
+
1
26
  ## [3.3.1] - 2026-02-03
2
27
 
3
28
  ### Added
data/easy_talk.gemspec ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/easy_talk/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'easy_talk'
7
+ spec.version = EasyTalk::VERSION
8
+ spec.authors = ['Sergio Bayona']
9
+ spec.email = ['bayona.sergio@gmail.com']
10
+
11
+ spec.summary = 'JSON Schema generation and validation for Ruby classes, ideal for LLM function calling.'
12
+ spec.description = 'Define schemas using a clean DSL and get both JSON Schema documents and runtime validations. ' \
13
+ 'Perfect for API request/response validation, LLM function definitions (OpenAI, Anthropic), ' \
14
+ 'and structured data modeling. Features Sorbet-style types, schema composition, ' \
15
+ 'pluggable validation adapters, and multiple error output formats (JSON:API, RFC 7807).'
16
+ spec.homepage = 'https://github.com/sergiobayona/easy_talk'
17
+ spec.license = 'MIT'
18
+ spec.required_ruby_version = '>= 3.2'
19
+
20
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
21
+
22
+ spec.metadata['homepage_uri'] = spec.homepage
23
+ spec.metadata['changelog_uri'] = 'https://github.com/sergiobayona/easy_talk/blob/main/CHANGELOG.md'
24
+
25
+ # Specify which files should be added to the gem when it is released.
26
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
27
+ spec.files = Dir.chdir(__dir__) do
28
+ `git ls-files -z`.split("\x0").reject do |f|
29
+ (File.expand_path(f) == __FILE__) ||
30
+ f.start_with?(*%w[bin/ spec/ .git .github Gemfile])
31
+ end
32
+ end
33
+
34
+ spec.require_paths = ['lib']
35
+
36
+ spec.add_dependency 'activemodel', ['>= 7.0', '< 9.0']
37
+ spec.add_dependency 'activesupport', ['>= 7.0', '< 9.0']
38
+ spec.add_dependency 'js_regex', '~> 3.0'
39
+ spec.add_dependency 'sorbet-runtime', '~> 0.5'
40
+
41
+ spec.metadata['rubygems_mfa_required'] = 'true'
42
+ end
@@ -17,6 +17,8 @@ module EasyTalk
17
17
  'OneOfBuilder' => 'oneOf'
18
18
  }.freeze
19
19
 
20
+ VALID_OPTIONS = %i[title description optional as validate ref].freeze
21
+
20
22
  sig { params(name: Symbol, type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).void }
21
23
  # Initializes a new instance of the CompositionBuilder class.
22
24
  #
@@ -24,6 +26,7 @@ module EasyTalk
24
26
  # @param type [Class] The type of the composition.
25
27
  # @param constraints [Hash] The constraints for the composition.
26
28
  def initialize(name, type, constraints)
29
+ EasyTalk.assert_valid_property_options(name, constraints, VALID_OPTIONS)
27
30
  @composer_type = self.class.name.split('::').last
28
31
  @name = name
29
32
  @type = type
@@ -9,10 +9,12 @@ module EasyTalk
9
9
  class NullBuilder < BaseBuilder
10
10
  extend T::Sig
11
11
 
12
+ VALID_OPTIONS = {}.freeze
13
+
12
14
  # Initializes a new instance of the NullBuilder class.
13
- sig { params(name: Symbol, _constraints: T::Hash[Symbol, T.untyped]).void }
14
- def initialize(name, _constraints = {})
15
- super(name, { type: 'null' }, {}, {})
15
+ sig { params(name: Symbol, constraints: T::Hash[Symbol, T.untyped]).void }
16
+ def initialize(name, constraints = {})
17
+ super(name, { type: 'null' }, constraints, VALID_OPTIONS)
16
18
  end
17
19
  end
18
20
  end
@@ -41,7 +41,7 @@ module EasyTalk
41
41
  # Keep a reference to the original schema definition
42
42
  @schema_definition = schema_definition
43
43
  # Deep duplicate the raw schema hash so we can mutate it safely
44
- @original_schema = deep_dup(schema_definition.schema)
44
+ @original_schema = EasyTalk.deep_dup(schema_definition.schema)
45
45
 
46
46
  # We'll collect required property names in this Set
47
47
  @required_properties = Set.new
@@ -71,24 +71,6 @@ module EasyTalk
71
71
 
72
72
  private
73
73
 
74
- ##
75
- # Deep duplicates a hash, including nested hashes.
76
- # This prevents mutations from leaking back to the original schema.
77
- #
78
- def deep_dup(obj)
79
- case obj
80
- when Hash
81
- obj.transform_values { |v| deep_dup(v) }
82
- when Array
83
- obj.map { |v| deep_dup(v) }
84
- when Class, Module
85
- # Don't duplicate Class or Module objects - they represent types
86
- obj
87
- else
88
- obj.duplicable? ? obj.dup : obj
89
- end
90
- end
91
-
92
74
  ##
93
75
  # Main aggregator: merges the top-level schema keys (like :properties, :subschemas)
94
76
  # into a single hash that we'll feed to BaseBuilder.
@@ -250,7 +232,7 @@ module EasyTalk
250
232
  elsif prop_type.is_a?(EasyTalk::Types::Composer)
251
233
  collect_ref_models(prop_type.items, constraints)
252
234
  # Handle typed arrays with EasyTalk model items
253
- elsif typed_array?(prop_type)
235
+ elsif TypeIntrospection.typed_array?(prop_type)
254
236
  extract_inner_types(prop_type).each { |inner_type| collect_ref_models(inner_type, constraints) }
255
237
  # Handle nilable types
256
238
  elsif nilable_with_model?(prop_type)
@@ -272,12 +254,8 @@ module EasyTalk
272
254
  EasyTalk.configuration.use_refs
273
255
  end
274
256
 
275
- def typed_array?(prop_type)
276
- prop_type.is_a?(T::Types::TypedArray)
277
- end
278
-
279
257
  def extract_inner_types(prop_type)
280
- return [] unless typed_array?(prop_type)
258
+ return [] unless TypeIntrospection.typed_array?(prop_type)
281
259
 
282
260
  if prop_type.type.is_a?(EasyTalk::Types::Composer)
283
261
  prop_type.type.items
@@ -302,9 +280,7 @@ module EasyTalk
302
280
  # Adds $defs entries for all collected ref models.
303
281
  #
304
282
  def add_ref_model_defs(schema_hash)
305
- definitions = @ref_models.each_with_object({}) do |model, acc|
306
- acc[model.name] = model.schema
307
- end
283
+ definitions = @ref_models.to_h { |model| [model.name, EasyTalk.deep_dup(model.schema)] }
308
284
 
309
285
  existing_defs = schema_hash[:defs] || {}
310
286
  schema_hash[:defs] = existing_defs.merge(definitions)
@@ -327,9 +303,7 @@ module EasyTalk
327
303
  #
328
304
  def add_defs_from_subschema(schema_hash, subschema)
329
305
  # Build up a hash of class_name => schema for each sub-item
330
- definitions = subschema.items.each_with_object({}) do |item, acc|
331
- acc[item.name] = item.schema
332
- end
306
+ definitions = subschema.items.to_h { |item| [item.name, EasyTalk.deep_dup(item.schema)] }
333
307
  # Merge or create :defs
334
308
  existing_defs = schema_hash[:defs] || {}
335
309
  schema_hash[:defs] = existing_defs.merge(definitions)
@@ -10,8 +10,11 @@ module EasyTalk
10
10
  extend CollectionHelpers
11
11
  extend T::Sig
12
12
 
13
+ VALID_OPTIONS = %i[title description optional as validate].freeze
14
+
13
15
  sig { params(name: Symbol, type: T.untyped, constraints: T::Hash[Symbol, T.untyped]).void }
14
16
  def initialize(name, type, constraints)
17
+ EasyTalk.assert_valid_property_options(name, constraints, VALID_OPTIONS)
15
18
  @name = name
16
19
  @type = type
17
20
  @constraints = constraints
@@ -25,7 +25,7 @@ module EasyTalk
25
25
  raise UnknownOptionError, message
26
26
  end
27
27
 
28
- def self.extract_inner_type(type_info)
28
+ def self.extract_element_type(type_info)
29
29
  # No change needed here
30
30
  if type_info.respond_to?(:type) && type_info.type.respond_to?(:raw_type)
31
31
  type_info.type.raw_type
@@ -49,7 +49,7 @@ module EasyTalk
49
49
  def self.validate_typed_array_values(property_name:, constraint_name:, type_info:, array_value:)
50
50
  # Raise error if value is not an array but type expects one
51
51
  unless array_value.is_a?(Array)
52
- inner_type = extract_inner_type(type_info)
52
+ inner_type = extract_element_type(type_info)
53
53
  expected_desc = TypeIntrospection.boolean_type?(inner_type) ? 'Boolean (true or false)' : inner_type.to_s
54
54
  raise_constraint_error(
55
55
  property_name: property_name,
@@ -59,7 +59,7 @@ module EasyTalk
59
59
  )
60
60
  end
61
61
 
62
- inner_type = extract_inner_type(type_info)
62
+ inner_type = extract_element_type(type_info)
63
63
  array_value.each_with_index do |element, index|
64
64
  validate_array_element(
65
65
  property_name: property_name,
@@ -153,7 +153,7 @@ module EasyTalk
153
153
  # Handle Sorbet type objects
154
154
  elsif value_type.class.ancestors.include?(T::Types::Base)
155
155
  # Extract the inner type
156
- inner_type = extract_inner_type(value_type)
156
+ inner_type = extract_element_type(value_type)
157
157
 
158
158
  if inner_type.is_a?(Array)
159
159
  # For union types, check if the value matches any of the allowed types
@@ -8,8 +8,7 @@ require 'active_support/time'
8
8
  require 'active_support/concern'
9
9
  require 'active_support/json'
10
10
  require 'active_model'
11
- require_relative 'builders/object_builder'
12
- require_relative 'schema_definition'
11
+ require_relative 'schema_base'
13
12
  require_relative 'validation_builder'
14
13
  require_relative 'error_formatter'
15
14
  require_relative 'extensions/ruby_llm_compatibility'
@@ -55,106 +54,15 @@ module EasyTalk
55
54
 
56
55
  # Instance methods mixed into models that include EasyTalk::Model
57
56
  module InstanceMethods
57
+ include SchemaBase::InstanceMethods
58
+
58
59
  def initialize(attributes = {})
59
60
  @additional_properties = {}
60
61
  provided_keys = attributes.keys.to_set(&:to_sym)
61
62
 
62
- super # Perform initial mass assignment
63
-
64
- schema_def = self.class.schema_definition
65
- return unless schema_def.respond_to?(:schema) && schema_def.schema.is_a?(Hash)
66
-
67
- (schema_def.schema[:properties] || {}).each do |prop_name, prop_definition|
68
- process_property_initialization(prop_name, prop_definition, provided_keys)
69
- end
70
- end
71
-
72
- private
73
-
74
- def process_property_initialization(prop_name, prop_definition, provided_keys)
75
- defined_type = prop_definition[:type]
76
- nilable_type = defined_type.respond_to?(:nilable?) && defined_type.nilable?
77
-
78
- apply_default_value(prop_name, prop_definition, provided_keys)
79
-
80
- current_value = public_send(prop_name)
81
- return if nilable_type && current_value.nil?
82
-
83
- defined_type = T::Utils::Nilable.get_underlying_type(defined_type) if nilable_type
84
- instantiate_nested_models(prop_name, defined_type, current_value)
85
- end
86
-
87
- def apply_default_value(prop_name, prop_definition, provided_keys)
88
- return if provided_keys.include?(prop_name)
89
-
90
- default_value = prop_definition.dig(:constraints, :default)
91
- public_send("#{prop_name}=", default_value) unless default_value.nil?
92
- end
93
-
94
- def instantiate_nested_models(prop_name, defined_type, current_value)
95
- # Single nested model: convert Hash to model instance
96
- if defined_type.is_a?(Class) && defined_type.include?(EasyTalk::Model) && current_value.is_a?(Hash)
97
- public_send("#{prop_name}=", defined_type.new(current_value))
98
- return
99
- end
100
-
101
- # Array of nested models: convert Hash items to model instances
102
- instantiate_array_items(prop_name, defined_type, current_value)
103
- end
104
-
105
- def instantiate_array_items(prop_name, defined_type, current_value)
106
- return unless defined_type.is_a?(T::Types::TypedArray) && current_value.is_a?(Array)
107
-
108
- item_type = defined_type.type.respond_to?(:raw_type) ? defined_type.type.raw_type : nil
109
- return unless item_type.is_a?(Class) && item_type.include?(EasyTalk::Model)
110
-
111
- instantiated = current_value.map { |item| item.is_a?(Hash) ? item_type.new(item) : item }
112
- public_send("#{prop_name}=", instantiated)
113
- end
114
-
115
- public
116
-
117
- def method_missing(method_name, *args)
118
- method_string = method_name.to_s
119
- if method_string.end_with?('=')
120
- property_name = method_string.chomp('=')
121
- if self.class.additional_properties_allowed?
122
- @additional_properties[property_name] = args.first
123
- else
124
- super
125
- end
126
- elsif self.class.additional_properties_allowed? && @additional_properties.key?(method_string)
127
- @additional_properties[method_string]
128
- else
129
- super
130
- end
131
- end
132
-
133
- def respond_to_missing?(method_name, include_private = false)
134
- return super unless self.class.additional_properties_allowed?
135
-
136
- method_string = method_name.to_s
137
- method_string.end_with?('=') || @additional_properties.key?(method_string) || super
138
- end
139
-
140
- # Add to_hash method to convert defined properties to hash
141
- def to_hash
142
- properties_to_include = (self.class.schema_definition.schema[:properties] || {}).keys
143
- return {} if properties_to_include.empty?
63
+ super # Perform initial mass assignment via ActiveModel::API
144
64
 
145
- properties_to_include.each_with_object({}) do |prop, hash|
146
- hash[prop.to_s] = send(prop)
147
- end
148
- end
149
-
150
- # Override as_json to include both defined and additional properties
151
- def as_json(_options = {})
152
- to_hash.merge(@additional_properties)
153
- end
154
-
155
- # to_h includes both defined and additional properties
156
- def to_h
157
- to_hash.merge(@additional_properties)
65
+ initialize_schema_properties(provided_keys)
158
66
  end
159
67
 
160
68
  # Returns a Hash representing the schema in a format compatible with RubyLLM.
@@ -164,39 +72,11 @@ module EasyTalk
164
72
  def to_json_schema
165
73
  self.class.to_json_schema
166
74
  end
167
-
168
- # Allow comparison with hashes
169
- def ==(other)
170
- case other
171
- when Hash
172
- # Convert both to comparable format for comparison
173
- self_hash = (self.class.schema_definition.schema[:properties] || {}).keys.each_with_object({}) do |prop, hash|
174
- hash[prop] = send(prop)
175
- end
176
-
177
- # Handle both symbol and string keys in the other hash
178
- other_normalized = other.transform_keys(&:to_sym)
179
- self_hash == other_normalized
180
- else
181
- super
182
- end
183
- end
184
75
  end
185
76
 
186
77
  # Module containing class-level methods for defining and accessing the schema of a model.
187
78
  module ClassMethods
188
- include SchemaMethods
189
-
190
- # Returns the schema for the model.
191
- #
192
- # @return [Schema] The schema for the model.
193
- def schema
194
- @schema ||= if defined?(@schema_definition) && @schema_definition
195
- build_schema(@schema_definition)
196
- else
197
- {}
198
- end
199
- end
79
+ include SchemaBase::ClassMethods
200
80
 
201
81
  # Define the schema for the model using the provided block.
202
82
  #
@@ -220,22 +100,11 @@ module EasyTalk
220
100
  # property :name, String
221
101
  # end
222
102
  def define_schema(options = {}, &)
223
- raise ArgumentError, 'The class must have a name' unless name.present?
224
-
225
- @schema_definition = SchemaDefinition.new(name)
226
- @schema_definition.klass = self # Pass the model class to the schema definition
227
- @schema_definition.instance_eval(&)
103
+ super(&)
228
104
 
229
105
  # Store validation options for this model
230
106
  @validation_options = normalize_validation_options(options)
231
107
 
232
- # Define accessors immediately based on schema_definition
233
- defined_properties = (@schema_definition.schema[:properties] || {}).keys
234
- attr_accessor(*defined_properties)
235
-
236
- # Track which properties have had validations applied
237
- @validated_properties ||= Set.new
238
-
239
108
  # Initialize mutex eagerly for thread-safe schema-level validation application
240
109
  @schema_level_validation_lock = Mutex.new
241
110
 
@@ -247,6 +116,19 @@ module EasyTalk
247
116
 
248
117
  private
249
118
 
119
+ # Reset all memoized schema state and clear previously registered
120
+ # ActiveModel validators so a second define_schema call is never ignored.
121
+ def clear_schema_state!
122
+ super
123
+ @schema_level_validations_applied = false
124
+ @validated_properties = Set.new
125
+
126
+ return unless @schema_definition
127
+
128
+ reset_callbacks(:validate)
129
+ _validators.clear
130
+ end
131
+
250
132
  # Normalize validation options from various input formats.
251
133
  #
252
134
  # @param options [Hash] The options hash from define_schema
@@ -313,37 +195,6 @@ module EasyTalk
313
195
  @schema_level_validations_applied = true
314
196
  end
315
197
  end
316
-
317
- public
318
-
319
- # Returns the unvalidated schema definition for the model.
320
- #
321
- # @return [SchemaDefinition] The unvalidated schema definition for the model.
322
- def schema_definition
323
- @schema_definition ||= {}
324
- end
325
-
326
- def additional_properties_allowed?
327
- ap = @schema_definition&.schema&.fetch(:additional_properties, false)
328
- # Allow if true, or if it's a schema object (Class or Hash with type)
329
- ap == true || ap.is_a?(Class) || ap.is_a?(Hash)
330
- end
331
-
332
- # Returns the property names defined in the schema
333
- #
334
- # @return [Array<Symbol>] Array of property names as symbols
335
- def properties
336
- (@schema_definition&.schema&.dig(:properties) || {}).keys
337
- end
338
-
339
- # Builds the schema using the provided schema definition.
340
- # This is the convergence point for all schema generation.
341
- #
342
- # @param schema_definition [SchemaDefinition] The schema definition.
343
- # @return [Schema] The validated schema.
344
- def build_schema(schema_definition)
345
- Builders::ObjectBuilder.new(schema_definition).build
346
- end
347
198
  end
348
199
  end
349
200
  end
@@ -112,9 +112,8 @@ module EasyTalk
112
112
  args = is_collection ? [name, type, constraints] : [name, constraints]
113
113
  builder_class.new(*args).build
114
114
  elsif type.respond_to?(:schema)
115
- # merge the top-level constraints from *this* property
116
- # e.g. :title, :description, :default, etc
117
- type.schema.merge!(constraints)
115
+ # deep_dup so nested hashes in the cached schema aren't shared with the result
116
+ EasyTalk.deep_dup(type.schema).merge(constraints)
118
117
  else
119
118
  raise UnknownTypeError,
120
119
  "Unknown type '#{type.inspect}' for property '#{name}'. " \
@@ -7,8 +7,7 @@ require 'active_support/core_ext'
7
7
  require 'active_support/time'
8
8
  require 'active_support/concern'
9
9
  require 'active_support/json'
10
- require_relative 'builders/object_builder'
11
- require_relative 'schema_definition'
10
+ require_relative 'schema_base'
12
11
 
13
12
  module EasyTalk
14
13
  # A lightweight module for schema generation without ActiveModel validations.
@@ -50,150 +49,41 @@ module EasyTalk
50
49
 
51
50
  # Instance methods for schema-only models.
52
51
  module InstanceMethods
52
+ include SchemaBase::InstanceMethods
53
+
53
54
  # Initialize the schema object with attributes.
55
+ # Performs manual attribute assignment (no ActiveModel) then applies
56
+ # defaults and nested model instantiation via the shared base.
54
57
  #
55
58
  # @param attributes [Hash] The attributes to set
56
59
  def initialize(attributes = {})
57
60
  @additional_properties = {}
58
- schema_def = self.class.schema_definition
59
-
60
- return unless schema_def.respond_to?(:schema) && schema_def.schema.is_a?(Hash)
61
-
62
- (schema_def.schema[:properties] || {}).each do |prop_name, prop_definition|
63
- value = attributes[prop_name] || attributes[prop_name.to_s]
64
-
65
- # Handle default values
66
- if value.nil? && !attributes.key?(prop_name) && !attributes.key?(prop_name.to_s)
67
- default_value = prop_definition.dig(:constraints, :default)
68
- value = default_value unless default_value.nil?
69
- end
70
-
71
- # Handle nested EasyTalk::Schema or EasyTalk::Model objects
72
- defined_type = prop_definition[:type]
73
- nilable_type = defined_type.respond_to?(:nilable?) && defined_type.nilable?
74
- defined_type = T::Utils::Nilable.get_underlying_type(defined_type) if nilable_type
75
-
76
- if defined_type.is_a?(Class) &&
77
- (defined_type.include?(EasyTalk::Schema) || defined_type.include?(EasyTalk::Model)) &&
78
- value.is_a?(Hash)
79
- value = defined_type.new(value)
80
- end
81
-
82
- instance_variable_set("@#{prop_name}", value)
83
- end
84
- end
61
+ provided_keys = Set.new
85
62
 
86
- # Convert defined properties to a hash.
87
- #
88
- # @return [Hash] The properties as a hash
89
- def to_hash
90
- properties_to_include = (self.class.schema_definition.schema[:properties] || {}).keys
91
- return {} if properties_to_include.empty?
92
-
93
- properties_to_include.each_with_object({}) do |prop, hash|
94
- hash[prop.to_s] = send(prop)
95
- end
63
+ assign_schema_attributes(attributes, provided_keys)
64
+ initialize_schema_properties(provided_keys)
96
65
  end
97
66
 
98
- # Convert to JSON-compatible hash including additional properties.
99
- #
100
- # @param _options [Hash] JSON options (ignored)
101
- # @return [Hash] The combined hash
102
- def as_json(_options = {})
103
- to_hash.merge(@additional_properties)
104
- end
67
+ private
105
68
 
106
- # Convert to hash including additional properties.
107
- #
108
- # @return [Hash] The combined hash
109
- def to_h
110
- to_hash.merge(@additional_properties)
111
- end
69
+ def assign_schema_attributes(attributes, provided_keys)
70
+ defined_properties = self.class.properties.to_set
112
71
 
113
- # Allow comparison with hashes.
114
- #
115
- # @param other [Object] The object to compare with
116
- # @return [Boolean] True if equal
117
- def ==(other)
118
- case other
119
- when Hash
120
- self_hash = (self.class.schema_definition.schema[:properties] || {}).keys.each_with_object({}) do |prop, hash|
121
- hash[prop] = send(prop)
72
+ attributes.each do |key, value|
73
+ prop_name = key.to_sym
74
+ if defined_properties.include?(prop_name)
75
+ provided_keys << prop_name
76
+ public_send("#{prop_name}=", value)
77
+ elsif self.class.additional_properties_allowed?
78
+ @additional_properties[key.to_s] = value
122
79
  end
123
- other_normalized = other.transform_keys(&:to_sym)
124
- self_hash == other_normalized
125
- else
126
- super
127
80
  end
128
81
  end
129
82
  end
130
83
 
131
84
  # Class methods for schema-only models.
132
85
  module ClassMethods
133
- include SchemaMethods
134
-
135
- # Returns the schema for the model.
136
- #
137
- # @return [Hash] The schema for the model.
138
- def schema
139
- @schema ||= if defined?(@schema_definition) && @schema_definition
140
- build_schema(@schema_definition)
141
- else
142
- {}
143
- end
144
- end
145
-
146
- # Define the schema for the model using the provided block.
147
- # Unlike EasyTalk::Model, this does NOT apply any validations.
148
- #
149
- # @yield The block to define the schema.
150
- # @raise [ArgumentError] If the class does not have a name.
151
- def define_schema(&)
152
- raise ArgumentError, 'The class must have a name' unless name.present?
153
-
154
- @schema_definition = SchemaDefinition.new(name)
155
- @schema_definition.klass = self
156
- @schema_definition.instance_eval(&)
157
-
158
- # Define accessors for all properties
159
- defined_properties = (@schema_definition.schema[:properties] || {}).keys
160
- attr_accessor(*defined_properties)
161
-
162
- # NO validations are applied - this is schema-only
163
-
164
- @schema_definition
165
- end
166
-
167
- # Returns the schema definition for the model.
168
- #
169
- # @return [SchemaDefinition] The schema definition.
170
- def schema_definition
171
- @schema_definition ||= {}
172
- end
173
-
174
- # Check if additional properties are allowed.
175
- #
176
- # @return [Boolean] True if additional properties are allowed.
177
- def additional_properties_allowed?
178
- @schema_definition&.schema&.fetch(:additional_properties, false)
179
- end
180
-
181
- # Returns the property names defined in the schema.
182
- #
183
- # @return [Array<Symbol>] Array of property names as symbols.
184
- def properties
185
- (@schema_definition&.schema&.dig(:properties) || {}).keys
186
- end
187
-
188
- private
189
-
190
- # Builds the schema using the provided schema definition.
191
- #
192
- # @param schema_definition [SchemaDefinition] The schema definition.
193
- # @return [Hash] The built schema.
194
- def build_schema(schema_definition)
195
- Builders::ObjectBuilder.new(schema_definition).build
196
- end
86
+ include SchemaBase::ClassMethods
197
87
  end
198
88
  end
199
89
  end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require_relative 'builders/object_builder'
5
+ require_relative 'schema_definition'
6
+
7
+ module EasyTalk
8
+ # Shared foundation for both EasyTalk::Schema and EasyTalk::Model.
9
+ #
10
+ # This module extracts the common instance and class methods so that
11
+ # Schema (lightweight, no validations) and Model (full ActiveModel
12
+ # validations) stay in sync without code duplication.
13
+ module SchemaBase
14
+ # Instance methods shared by Schema and Model.
15
+ #
16
+ # Each including module provides its own `initialize` that:
17
+ # 1. Sets `@additional_properties = {}`
18
+ # 2. Performs attribute assignment (manually or via ActiveModel)
19
+ # 3. Calls `initialize_schema_properties(provided_keys)`
20
+ module InstanceMethods
21
+ private
22
+
23
+ def initialize_schema_properties(provided_keys)
24
+ schema_def = self.class.schema_definition
25
+ return unless schema_def.respond_to?(:schema) && schema_def.schema.is_a?(Hash)
26
+
27
+ (schema_def.schema[:properties] || {}).each do |prop_name, prop_definition|
28
+ process_property_initialization(prop_name, prop_definition, provided_keys)
29
+ end
30
+ end
31
+
32
+ def process_property_initialization(prop_name, prop_definition, provided_keys)
33
+ defined_type = prop_definition[:type]
34
+ nilable_type = defined_type.respond_to?(:nilable?) && defined_type.nilable?
35
+
36
+ apply_default_value(prop_name, prop_definition, provided_keys)
37
+
38
+ current_value = public_send(prop_name)
39
+ return if nilable_type && current_value.nil?
40
+
41
+ defined_type = T::Utils::Nilable.get_underlying_type(defined_type) if nilable_type
42
+ instantiate_nested_models(prop_name, defined_type, current_value)
43
+ end
44
+
45
+ def apply_default_value(prop_name, prop_definition, provided_keys)
46
+ return if provided_keys.include?(prop_name)
47
+
48
+ default_value = prop_definition.dig(:constraints, :default)
49
+ public_send("#{prop_name}=", EasyTalk.deep_dup(default_value)) unless default_value.nil?
50
+ end
51
+
52
+ def instantiate_nested_models(prop_name, defined_type, current_value)
53
+ if easy_talk_class?(defined_type) && current_value.is_a?(Hash)
54
+ public_send("#{prop_name}=", defined_type.new(current_value))
55
+ return
56
+ end
57
+
58
+ instantiate_array_items(prop_name, defined_type, current_value)
59
+ end
60
+
61
+ def easy_talk_class?(type)
62
+ type.is_a?(Class) && (
63
+ type.include?(EasyTalk::Model) || type.include?(EasyTalk::Schema)
64
+ )
65
+ end
66
+
67
+ def instantiate_array_items(prop_name, defined_type, current_value)
68
+ return unless defined_type.is_a?(T::Types::TypedArray) && current_value.is_a?(Array)
69
+
70
+ item_type = defined_type.type.respond_to?(:raw_type) ? defined_type.type.raw_type : nil
71
+ return unless easy_talk_class?(item_type)
72
+
73
+ instantiated = current_value.map { |item| item.is_a?(Hash) ? item_type.new(item) : item }
74
+ public_send("#{prop_name}=", instantiated)
75
+ end
76
+
77
+ public
78
+
79
+ def method_missing(method_name, *args)
80
+ method_string = method_name.to_s
81
+ if method_string.end_with?('=')
82
+ property_name = method_string.chomp('=')
83
+ if self.class.additional_properties_allowed?
84
+ @additional_properties[property_name] = args.first
85
+ else
86
+ super
87
+ end
88
+ elsif self.class.additional_properties_allowed? && @additional_properties.key?(method_string)
89
+ @additional_properties[method_string]
90
+ else
91
+ super
92
+ end
93
+ end
94
+
95
+ def respond_to_missing?(method_name, include_private = false)
96
+ return super unless self.class.additional_properties_allowed?
97
+
98
+ method_string = method_name.to_s
99
+ method_string.end_with?('=') || @additional_properties.key?(method_string) || super
100
+ end
101
+
102
+ def to_hash
103
+ properties_to_include = (self.class.schema_definition.schema[:properties] || {}).keys
104
+ return {} if properties_to_include.empty?
105
+
106
+ properties_to_include.to_h { |prop| [prop.to_s, send(prop)] }
107
+ end
108
+
109
+ def as_json(_options = {})
110
+ to_hash.merge(@additional_properties)
111
+ end
112
+
113
+ def to_h
114
+ to_hash.merge(@additional_properties)
115
+ end
116
+
117
+ def ==(other)
118
+ case other
119
+ when Hash
120
+ self_hash = (self.class.schema_definition.schema[:properties] || {}).keys.to_h { |prop| [prop, send(prop)] }
121
+ other_normalized = other.transform_keys(&:to_sym)
122
+ self_hash == other_normalized
123
+ else
124
+ super
125
+ end
126
+ end
127
+ end
128
+
129
+ # Class methods shared by Schema and Model.
130
+ module ClassMethods
131
+ include SchemaMethods
132
+
133
+ def schema
134
+ @schema ||= if defined?(@schema_definition) && @schema_definition
135
+ build_schema(@schema_definition)
136
+ else
137
+ {}
138
+ end
139
+ end
140
+
141
+ def define_schema(&)
142
+ raise ArgumentError, 'The class must have a name' unless name.present?
143
+
144
+ clear_schema_state!
145
+
146
+ @schema_definition = SchemaDefinition.new(name)
147
+ @schema_definition.klass = self
148
+ @schema_definition.instance_eval(&)
149
+
150
+ defined_properties = (@schema_definition.schema[:properties] || {}).keys
151
+ attr_accessor(*defined_properties)
152
+
153
+ @schema_definition
154
+ end
155
+
156
+ def schema_definition
157
+ @schema_definition ||= {}
158
+ end
159
+
160
+ def additional_properties_allowed?
161
+ ap = @schema_definition&.schema&.fetch(:additional_properties, false)
162
+ ap == true || ap.is_a?(Class) || ap.is_a?(Hash)
163
+ end
164
+
165
+ def properties
166
+ (@schema_definition&.schema&.dig(:properties) || {}).keys
167
+ end
168
+
169
+ private
170
+
171
+ def clear_schema_state!
172
+ @schema = nil
173
+ @json_schema = nil
174
+ end
175
+
176
+ def build_schema(schema_definition)
177
+ Builders::ObjectBuilder.new(schema_definition).build
178
+ end
179
+ end
180
+ end
181
+ end
@@ -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.1'
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.1
4
+ version: 3.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
@@ -106,6 +106,7 @@ files:
106
106
  - docs/primitive-schema-rfc.md
107
107
  - docs/property-types.markdown
108
108
  - docs/schema-definition.markdown
109
+ - easy_talk.gemspec
109
110
  - examples/ruby_llm/Gemfile
110
111
  - examples/ruby_llm/structured_output.rb
111
112
  - examples/ruby_llm/tools_integration.rb
@@ -144,6 +145,7 @@ files:
144
145
  - lib/easy_talk/property.rb
145
146
  - lib/easy_talk/ref_helper.rb
146
147
  - lib/easy_talk/schema.rb
148
+ - lib/easy_talk/schema_base.rb
147
149
  - lib/easy_talk/schema_definition.rb
148
150
  - lib/easy_talk/schema_methods.rb
149
151
  - lib/easy_talk/sorbet_extension.rb