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 +4 -4
- data/CHANGELOG.md +25 -0
- data/easy_talk.gemspec +42 -0
- data/lib/easy_talk/builders/composition_builder.rb +3 -0
- data/lib/easy_talk/builders/null_builder.rb +5 -3
- data/lib/easy_talk/builders/object_builder.rb +5 -31
- data/lib/easy_talk/builders/union_builder.rb +3 -0
- data/lib/easy_talk/errors_helper.rb +4 -4
- data/lib/easy_talk/model.rb +20 -169
- data/lib/easy_talk/property.rb +2 -3
- data/lib/easy_talk/schema.rb +19 -129
- data/lib/easy_talk/schema_base.rb +181 -0
- data/lib/easy_talk/schema_definition.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +9 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +37 -37
- data/lib/easy_talk/validation_adapters/base.rb +7 -39
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +23 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b571b59c30a196b82285a7fa67c275002cb9f4bc6a07a0e0c70cc69d04d60b50
|
|
4
|
+
data.tar.gz: 599c489167248facc490dbc3add9c0fddb005b9aab654d919e0377a1ae2352af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
14
|
-
def initialize(name,
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/easy_talk/model.rb
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/easy_talk/property.rb
CHANGED
|
@@ -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
|
-
#
|
|
116
|
-
|
|
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}'. " \
|
data/lib/easy_talk/schema.rb
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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..].
|
|
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] =
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
#
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
491
|
-
#
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
#
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/easy_talk/version.rb
CHANGED
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.
|
|
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
|