easy_talk 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6244697afea285526b5b9cd3b58d036f3dd00e99ba25703777076a2c21a8f49
4
- data.tar.gz: 6cc2cc8f918d064d0907f56f234bc8e4cfa30497292559d9690bfc0dd8377522
3
+ metadata.gz: ce558538c73afc10d98c0c6b5e38e68ce033a79d1c212bb91fede27f10fb3523
4
+ data.tar.gz: e46a7a67b6c1a3209108a800cefca8659381236c23ce14986e971babd874908d
5
5
  SHA512:
6
- metadata.gz: 25e6c5f5eee7497bb4cddc0eb7f13beceafb551b0b7e5a2322467ec41e18ac66524ec4162307c881db65c6dc39a8e465fea8ddfcfcb6373ba07b4daf4ed68358
7
- data.tar.gz: 6d1b2f9635436aa41b8eb83c3eb40d9af9ce8f984e76d3ad36bf9f4025e26e3c774e286ca6886ad48e5fa48f38142fcf3208a9b2b2b201e936fde07d8e91283a
6
+ metadata.gz: b523e6cd50edc0594d98fffdb8a9de0616e5f58a2af435a23e5f4a81ac54b25692cd0bccba71c93eca812022efd298b2a9d654b34e3b8f37301ea8e7b16a4de3
7
+ data.tar.gz: 7f287df819b0e09ddf5fce9506f045860dea876b0653cf7ede7dfb58e7a1d78e545933946921f0dfb4f1f02709357629f0b071c53e7c4ff945cdcf1908f5c2b6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ ## [3.0.0] - 2025-01-03
2
+
3
+ ### BREAKING CHANGES
4
+ - **Removed ActiveRecord Support**: Completely removed ActiveRecord integration including:
5
+ - Deleted `ActiveRecordSchemaBuilder` class and database schema introspection
6
+ - Removed `enhance_schema` method for ActiveRecord models
7
+ - Removed ActiveRecord-specific configuration options (`excluded_columns`, `exclude_foreign_keys`,
8
+ `exclude_primary_key`, `exclude_timestamps`, `exclude_associations`)
9
+ - Deleted all ActiveRecord integration tests
10
+
11
+ ### Changed
12
+ - **Simplified Architecture**: EasyTalk now focuses exclusively on Plain Ruby classes with ActiveModel integration
13
+ - **Unified Integration Path**: All models now follow the same integration pattern using `ActiveModel::API` and `ActiveModel::Validations`
14
+ - **Streamlined Configuration**: Removed ActiveRecord-specific configuration options, keeping only core options
15
+ - **Updated Documentation**: Removed ActiveRecord examples and configuration references from README
16
+
17
+ ### Fixed
18
+ - **Code Quality**: Fixed ValidationBuilder class length violation by consolidating format validation methods
19
+ - **Documentation**: Updated all examples to use `define_schema` pattern instead of removed `enhance_schema`
20
+
21
+ ### Migration Guide
22
+ If you were using EasyTalk with ActiveRecord models:
23
+ - Replace `enhance_schema` calls with `define_schema` blocks
24
+ - Manually define properties instead of relying on database schema introspection
25
+ - Remove ActiveRecord-specific configuration options from your EasyTalk.configure blocks
26
+
1
27
  ## [2.0.0] - 2025-06-05
2
28
 
3
29
  ### Added
data/README.md CHANGED
@@ -11,7 +11,7 @@ EasyTalk is a Ruby library that simplifies defining and generating JSON Schema.
11
11
  ### Key Features
12
12
  * **Intuitive Schema Definition**: Use Ruby classes and methods to define JSON Schema documents easily.
13
13
  * **Automatic ActiveModel Validations**: Schema constraints automatically generate corresponding ActiveModel validations (configurable).
14
- * **Works for plain Ruby classes and ActiveRecord models**: Integrate with existing code or build from scratch.
14
+ * **Works for plain Ruby classes and ActiveModel classes**: Integrate with existing code or build from scratch.
15
15
  * **LLM Function Support**: Ideal for integrating with Large Language Models (LLMs) such as OpenAI's GPT series. EasyTalk enables you to effortlessly create JSON Schema documents describing the inputs and outputs of LLM function calls.
16
16
  * **Schema Composition**: Define EasyTalk models and reference them in other EasyTalk models to create complex schemas.
17
17
  * **Enhanced Model Integration**: Automatic instantiation of nested EasyTalk models from hash attributes.
@@ -518,119 +518,6 @@ user.address.class # => Address (automatically instantiated)
518
518
  user.address.street # => "123 Main St"
519
519
  ```
520
520
 
521
- ## ActiveRecord Integration
522
-
523
- ### Automatic Schema Generation
524
- For ActiveRecord models, EasyTalk automatically generates a schema based on the database columns:
525
-
526
- ```ruby
527
- class Product < ActiveRecord::Base
528
- include EasyTalk::Model
529
- end
530
- ```
531
-
532
- This will create a schema with properties for each column in the `products` table.
533
-
534
- ### Enhancing Generated Schemas
535
- You can enhance the auto-generated schema with the `enhance_schema` method:
536
-
537
- ```ruby
538
- class Product < ActiveRecord::Base
539
- include EasyTalk::Model
540
-
541
- enhance_schema({
542
- title: "Retail Product",
543
- description: "A product available for purchase",
544
- properties: {
545
- name: {
546
- description: "Product display name",
547
- title: "Product Name"
548
- },
549
- price: {
550
- description: "Retail price in USD"
551
- }
552
- }
553
- })
554
- end
555
- ```
556
-
557
- ### Column Exclusion Options
558
- EasyTalk provides several ways to exclude columns from your JSON schema:
559
-
560
- #### 1. Global Configuration
561
-
562
- ```ruby
563
- EasyTalk.configure do |config|
564
- # Exclude specific columns by name from all models
565
- config.excluded_columns = [:created_at, :updated_at, :deleted_at]
566
-
567
- # Exclude all foreign key columns (columns ending with '_id')
568
- config.exclude_foreign_keys = true # Default: false
569
-
570
- # Exclude all primary key columns ('id')
571
- config.exclude_primary_key = true # Default: true
572
-
573
- # Exclude timestamp columns ('created_at', 'updated_at')
574
- config.exclude_timestamps = true # Default: true
575
-
576
- # Exclude all association properties
577
- config.exclude_associations = true # Default: false
578
- end
579
- ```
580
-
581
- #### 2. Model-Specific Column Ignoring
582
-
583
- ```ruby
584
- class Product < ActiveRecord::Base
585
- include EasyTalk::Model
586
-
587
- enhance_schema({
588
- ignore: [:internal_ref_id, :legacy_code] # Model-specific exclusions
589
- })
590
- end
591
- ```
592
-
593
- ### Virtual Properties
594
- You can add properties that don't exist as database columns:
595
-
596
- ```ruby
597
- class Product < ActiveRecord::Base
598
- include EasyTalk::Model
599
-
600
- enhance_schema({
601
- properties: {
602
- full_details: {
603
- virtual: true,
604
- type: :string,
605
- description: "Complete product information"
606
- }
607
- }
608
- })
609
- end
610
- ```
611
-
612
- ### Associations and Foreign Keys
613
- By default, EasyTalk includes your model's associations in the schema:
614
-
615
- ```ruby
616
- class Product < ActiveRecord::Base
617
- include EasyTalk::Model
618
- belongs_to :category
619
- has_many :reviews
620
- end
621
- ```
622
-
623
- This will include `category` (as an object) and `reviews` (as an array) in the schema.
624
-
625
- You can control this behavior with configuration:
626
-
627
- ```ruby
628
- EasyTalk.configure do |config|
629
- config.exclude_associations = true # Don't include associations
630
- config.exclude_foreign_keys = true # Don't include foreign key columns
631
- end
632
- ```
633
-
634
521
  ## Advanced Features
635
522
 
636
523
  ### LLM Function Generation
@@ -698,19 +585,10 @@ You can configure EasyTalk globally:
698
585
 
699
586
  ```ruby
700
587
  EasyTalk.configure do |config|
701
- # ActiveRecord integration options
702
- config.excluded_columns = [:created_at, :updated_at, :deleted_at]
703
- config.exclude_foreign_keys = true
704
- config.exclude_primary_key = true
705
- config.exclude_timestamps = true
706
- config.exclude_associations = false
707
-
708
588
  # Schema behavior options
709
- config.default_additional_properties = false
710
- config.nilable_is_optional = false # Makes T.nilable properties also optional
711
-
712
- # NEW in v2.0.0: Automatic validation generation
713
- config.auto_validations = true # Automatically generate ActiveModel validations
589
+ config.default_additional_properties = false # Control additional properties on all models
590
+ config.nilable_is_optional = false # Makes T.nilable properties also optional
591
+ config.auto_validations = true # Automatically generate ActiveModel validations
714
592
  end
715
593
  ```
716
594
 
@@ -738,45 +616,18 @@ end
738
616
  ```
739
617
 
740
618
  ### Per-Model Configuration
741
- Some settings can be configured per model:
742
-
743
- ```ruby
744
- class Product < ActiveRecord::Base
745
- include EasyTalk::Model
746
-
747
- enhance_schema({
748
- additionalProperties: true,
749
- ignore: [:internal_ref_id, :legacy_code]
750
- })
751
- end
752
- ```
753
-
754
- ### Exclusion Rules
755
- Columns are excluded based on the following rules (in order of precedence):
756
-
757
- 1. Explicitly listed in `excluded_columns` global setting
758
- 2. Listed in the model's `schema_enhancements[:ignore]` array
759
- 3. Is a primary key when `exclude_primary_key` is true (default)
760
- 4. Is a timestamp column when `exclude_timestamps` is true (default)
761
- 5. Matches a foreign key pattern when `exclude_foreign_keys` is true
762
-
763
- ### Customizing Output
764
- You can customize the JSON Schema output by enhancing the schema:
619
+ You can configure additional properties for individual models:
765
620
 
766
621
  ```ruby
767
- class User < ActiveRecord::Base
622
+ class User
768
623
  include EasyTalk::Model
769
624
 
770
- enhance_schema({
771
- title: "User Account",
772
- description: "User account information",
773
- properties: {
774
- name: {
775
- title: "Full Name",
776
- description: "User's full name"
777
- }
778
- }
779
- })
625
+ define_schema do
626
+ title "User"
627
+ additional_properties true # Allow arbitrary additional properties on this model
628
+ property :name, String
629
+ property :email, String, format: "email"
630
+ end
780
631
  end
781
632
  ```
782
633
 
@@ -1277,7 +1128,7 @@ EasyTalk.configure do |config|
1277
1128
 
1278
1129
  # Existing options (unchanged)
1279
1130
  config.nilable_is_optional = false
1280
- config.exclude_foreign_keys = true
1131
+ config.default_additional_properties = false
1281
1132
  # ... other existing config
1282
1133
  end
1283
1134
  ```
@@ -1287,7 +1138,7 @@ end
1287
1138
  - **Ruby Version**: Still requires Ruby 3.2+
1288
1139
  - **Dependencies**: Core dependencies remain the same
1289
1140
  - **JSON Schema Output**: No changes to generated schemas
1290
- - **ActiveRecord Integration**: Fully backward compatible
1141
+ - **ActiveModel Integration**: Fully backward compatible
1291
1142
 
1292
1143
  ## Development and Contributing
1293
1144
 
data/easy_talk.gemspec CHANGED
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ['Sergio Bayona']
9
9
  spec.email = ['bayona.sergio@gmail.com']
10
10
 
11
- spec.summary = 'Generate json-schema from Ruby classes.'
12
- spec.description = 'Generate json-schema from plain Ruby classes.'
11
+ spec.summary = 'Generate json-schema from Ruby classes with ActiveModel integration.'
12
+ spec.description = 'Generate json-schema from plain Ruby classes with ActiveModel integration for validations and serialization.'
13
13
  spec.homepage = 'https://github.com/sergiobayona/easy_talk'
14
14
  spec.license = 'MIT'
15
15
  spec.required_ruby_version = '>= 3.2'
@@ -30,8 +30,9 @@ Gem::Specification.new do |spec|
30
30
 
31
31
  spec.require_paths = ['lib']
32
32
 
33
- spec.add_dependency 'activemodel', '~> 7.0'
34
- spec.add_dependency 'activesupport', '~> 7.0'
33
+ spec.add_dependency 'activemodel', ['>= 7.0', '< 9.0']
34
+ spec.add_dependency 'activesupport', ['>= 7.0', '< 9.0']
35
+ spec.add_dependency 'js_regex', '~> 3.0'
35
36
  spec.add_dependency 'sorbet-runtime', '~> 0.5'
36
37
 
37
38
  spec.metadata['rubygems_mfa_required'] = 'true'
@@ -7,6 +7,7 @@ module EasyTalk
7
7
  # Builder class for integer properties.
8
8
  class IntegerBuilder < BaseBuilder
9
9
  extend T::Sig
10
+
10
11
  VALID_OPTIONS = {
11
12
  minimum: { type: Integer, key: :minimum },
12
13
  maximum: { type: Integer, key: :maximum },
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'base_builder'
4
+ require 'js_regex' # Compile the ruby regex to JS regex
4
5
  require 'sorbet-runtime' # Add the import statement for the T module
5
6
 
6
7
  module EasyTalk
@@ -8,6 +9,7 @@ module EasyTalk
8
9
  # Builder class for string properties.
9
10
  class StringBuilder < BaseBuilder
10
11
  extend T::Sig
12
+
11
13
  VALID_OPTIONS = {
12
14
  format: { type: String, key: :format },
13
15
  pattern: { type: String, key: :pattern },
@@ -22,6 +24,13 @@ module EasyTalk
22
24
  def initialize(name, constraints = {})
23
25
  super(name, { type: 'string' }, constraints, VALID_OPTIONS)
24
26
  end
27
+
28
+ def build
29
+ super.tap do |schema|
30
+ pattern = schema[:pattern]
31
+ schema[:pattern] = JsRegex.new(pattern).source if pattern.is_a?(String)
32
+ end
33
+ end
25
34
  end
26
35
  end
27
36
  end
@@ -2,19 +2,12 @@
2
2
 
3
3
  module EasyTalk
4
4
  class Configuration
5
- attr_accessor :exclude_foreign_keys, :exclude_associations, :excluded_columns,
6
- :exclude_primary_key, :exclude_timestamps, :default_additional_properties,
7
- :nilable_is_optional, :auto_validations
5
+ attr_accessor :default_additional_properties, :nilable_is_optional, :auto_validations
8
6
 
9
7
  def initialize
10
- @exclude_foreign_keys = true
11
- @exclude_associations = true
12
- @excluded_columns = []
13
- @exclude_primary_key = true
14
- @exclude_timestamps = true
15
8
  @default_additional_properties = false
16
9
  @nilable_is_optional = false
17
- @auto_validations = true # New option: enable validations by default
10
+ @auto_validations = true
18
11
  end
19
12
  end
20
13
 
@@ -9,7 +9,6 @@ require 'active_support/json'
9
9
  require 'active_model'
10
10
  require_relative 'builders/object_builder'
11
11
  require_relative 'schema_definition'
12
- require_relative 'active_record_schema_builder'
13
12
  require_relative 'validation_builder'
14
13
 
15
14
  module EasyTalk
@@ -36,16 +35,12 @@ module EasyTalk
36
35
  # @see SchemaDefinition
37
36
  module Model
38
37
  def self.included(base)
39
- base.include ActiveModel::API # Include ActiveModel::API in the class including EasyTalk::Model
38
+ base.extend(ClassMethods)
39
+
40
+ base.include ActiveModel::API
40
41
  base.include ActiveModel::Validations
41
42
  base.extend ActiveModel::Callbacks
42
- base.extend(ClassMethods)
43
43
  base.include(InstanceMethods)
44
-
45
- # Apply ActiveRecord-specific functionality if appropriate
46
- return unless defined?(ActiveRecord) && base.ancestors.include?(ActiveRecord::Base)
47
-
48
- base.extend(ActiveRecordClassMethods)
49
44
  end
50
45
 
51
46
  # Instance methods mixed into models that include EasyTalk::Model
@@ -55,12 +50,7 @@ module EasyTalk
55
50
  super # Perform initial mass assignment
56
51
 
57
52
  # After initial assignment, instantiate nested EasyTalk::Model objects
58
- # Get the appropriate schema definition based on model type
59
- schema_def = if self.class.respond_to?(:active_record_schema_definition)
60
- self.class.active_record_schema_definition
61
- else
62
- self.class.schema_definition
63
- end
53
+ schema_def = self.class.schema_definition
64
54
 
65
55
  # Only proceed if we have a valid schema definition
66
56
  return unless schema_def.respond_to?(:schema) && schema_def.schema.is_a?(Hash)
@@ -69,6 +59,11 @@ module EasyTalk
69
59
  # Get the defined type and the currently assigned value
70
60
  defined_type = prop_definition[:type]
71
61
  current_value = public_send(prop_name)
62
+ nilable_type = defined_type.respond_to?(:nilable?) && defined_type.nilable?
63
+
64
+ next if nilable_type && current_value.nil?
65
+
66
+ defined_type = T::Utils::Nilable.get_underlying_type(defined_type) if nilable_type
72
67
 
73
68
  # Check if the type is another EasyTalk::Model and the value is a Hash
74
69
  next unless defined_type.is_a?(Class) && defined_type.include?(EasyTalk::Model) && current_value.is_a?(Hash)
@@ -116,6 +111,11 @@ module EasyTalk
116
111
  to_hash.merge(@additional_properties)
117
112
  end
118
113
 
114
+ # to_h includes both defined and additional properties
115
+ def to_h
116
+ to_hash.merge(@additional_properties)
117
+ end
118
+
119
119
  # Allow comparison with hashes
120
120
  def ==(other)
121
121
  case other
@@ -141,13 +141,8 @@ module EasyTalk
141
141
  # @return [Schema] The schema for the model.
142
142
  def schema
143
143
  @schema ||= if defined?(@schema_definition) && @schema_definition
144
- # Schema defined explicitly via define_schema
145
144
  build_schema(@schema_definition)
146
- elsif respond_to?(:active_record_schema_definition)
147
- # ActiveRecord model without explicit schema definition
148
- build_schema(active_record_schema_definition)
149
145
  else
150
- # Default case - empty schema
151
146
  {}
152
147
  end
153
148
  end
@@ -225,34 +220,5 @@ module EasyTalk
225
220
  Builders::ObjectBuilder.new(schema_definition).build
226
221
  end
227
222
  end
228
-
229
- # Module containing ActiveRecord-specific methods for schema generation
230
- module ActiveRecordClassMethods
231
- # Gets a SchemaDefinition that's built from the ActiveRecord database schema
232
- #
233
- # @return [SchemaDefinition] A schema definition built from the database
234
- def active_record_schema_definition
235
- @active_record_schema_definition ||= ActiveRecordSchemaBuilder.new(self).build_schema_definition
236
- end
237
-
238
- # Store enhancements to be applied to the schema
239
- #
240
- # @return [Hash] The schema enhancements
241
- def schema_enhancements
242
- @schema_enhancements ||= {}
243
- end
244
-
245
- # Enhance the generated schema with additional information
246
- #
247
- # @param enhancements [Hash] The schema enhancements
248
- # @return [void]
249
- def enhance_schema(enhancements)
250
- @schema_enhancements = enhancements
251
- # Clear cached values to force regeneration
252
- @active_record_schema_definition = nil
253
- @schema = nil
254
- @json_schema = nil
255
- end
256
- end
257
223
  end
258
224
  end
@@ -189,7 +189,7 @@ module EasyTalk
189
189
  # {"type"=>["string", "null"]}
190
190
  def build_nilable_schema
191
191
  # Extract the non-nil type from the Union
192
- actual_type = type.types.find { |t| t.raw_type != NilClass }
192
+ actual_type = T::Utils::Nilable.get_underlying_type(type)
193
193
 
194
194
  return { type: 'null' } unless actual_type
195
195
 
@@ -65,8 +65,8 @@ module EasyTalk
65
65
  end
66
66
 
67
67
  # Check if the type is nilable (e.g., T.nilable(String))
68
- def nilable_type?
69
- @type.respond_to?(:nilable?) && @type.nilable?
68
+ def nilable_type?(type = @type)
69
+ type.respond_to?(:nilable?) && type.nilable?
70
70
  end
71
71
 
72
72
  # Extract the inner type from a complex type like T.nilable(String)
@@ -118,6 +118,8 @@ module EasyTalk
118
118
  end
119
119
  elsif type.to_s.include?('T::Boolean')
120
120
  [TrueClass, FalseClass] # Return both boolean classes
121
+ elsif nilable_type?(type)
122
+ extract_inner_type(type)
121
123
  else
122
124
  String # Default fallback
123
125
  end
@@ -137,48 +139,30 @@ module EasyTalk
137
139
  @klass.validates @property_name, format: { with: Regexp.new(@constraints[:pattern]) } if @constraints[:pattern]
138
140
 
139
141
  # Handle length constraints
140
- length_options = {}
141
- length_options[:minimum] = @constraints[:min_length] if @constraints[:min_length]
142
- length_options[:maximum] = @constraints[:max_length] if @constraints[:max_length]
143
- @klass.validates @property_name, length: length_options if length_options.any?
142
+ begin
143
+ length_options = {}
144
+ length_options[:minimum] = @constraints[:min_length] if @constraints[:min_length].is_a?(Numeric) && @constraints[:min_length] >= 0
145
+ length_options[:maximum] = @constraints[:max_length] if @constraints[:max_length].is_a?(Numeric) && @constraints[:max_length] >= 0
146
+ @klass.validates @property_name, length: length_options if length_options.any?
147
+ rescue ArgumentError
148
+ # Silently ignore invalid length constraints
149
+ end
144
150
  end
145
151
 
146
152
  # Apply format-specific validations (email, url, etc.)
147
153
  def apply_format_validation(format)
148
- case format.to_s
149
- when 'email'
150
- @klass.validates @property_name, format: {
151
- with: URI::MailTo::EMAIL_REGEXP,
152
- message: 'must be a valid email address'
153
- }
154
- when 'uri', 'url'
155
- @klass.validates @property_name, format: {
156
- with: URI::DEFAULT_PARSER.make_regexp,
157
- message: 'must be a valid URL'
158
- }
159
- when 'uuid'
160
- uuid_regex = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
161
- @klass.validates @property_name, format: {
162
- with: uuid_regex,
163
- message: 'must be a valid UUID'
164
- }
165
- when 'date'
166
- @klass.validates @property_name, format: {
167
- with: /\A\d{4}-\d{2}-\d{2}\z/,
168
- message: 'must be a valid date in YYYY-MM-DD format'
169
- }
170
- when 'date-time'
171
- # ISO 8601 date-time format
172
- @klass.validates @property_name, format: {
173
- with: /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/,
174
- message: 'must be a valid ISO 8601 date-time'
175
- }
176
- when 'time'
177
- @klass.validates @property_name, format: {
178
- with: /\A\d{2}:\d{2}:\d{2}(?:\.\d+)?\z/,
179
- message: 'must be a valid time in HH:MM:SS format'
180
- }
181
- end
154
+ format_configs = {
155
+ 'email' => { with: URI::MailTo::EMAIL_REGEXP, message: 'must be a valid email address' },
156
+ 'uri' => { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a valid URL' },
157
+ 'url' => { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a valid URL' },
158
+ 'uuid' => { with: /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i, message: 'must be a valid UUID' },
159
+ 'date' => { with: /\A\d{4}-\d{2}-\d{2}\z/, message: 'must be a valid date in YYYY-MM-DD format' },
160
+ 'date-time' => { with: /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/, message: 'must be a valid ISO 8601 date-time' },
161
+ 'time' => { with: /\A\d{2}:\d{2}:\d{2}(?:\.\d+)?\z/, message: 'must be a valid time in HH:MM:SS format' }
162
+ }
163
+
164
+ config = format_configs[format.to_s]
165
+ @klass.validates @property_name, format: config if config
182
166
  end
183
167
 
184
168
  # Validate integer-specific constraints
@@ -193,15 +177,19 @@ module EasyTalk
193
177
 
194
178
  # Apply numeric validations for integers and floats
195
179
  def apply_numeric_validations(only_integer: false)
196
- options = { only_integer: only_integer }
197
-
198
- # Add range constraints
199
- options[:greater_than_or_equal_to] = @constraints[:minimum] if @constraints[:minimum]
200
- options[:less_than_or_equal_to] = @constraints[:maximum] if @constraints[:maximum]
201
- options[:greater_than] = @constraints[:exclusive_minimum] if @constraints[:exclusive_minimum]
202
- options[:less_than] = @constraints[:exclusive_maximum] if @constraints[:exclusive_maximum]
203
-
204
- @klass.validates @property_name, numericality: options
180
+ begin
181
+ options = { only_integer: only_integer }
182
+
183
+ # Add range constraints - only if they are numeric
184
+ options[:greater_than_or_equal_to] = @constraints[:minimum] if @constraints[:minimum].is_a?(Numeric)
185
+ options[:less_than_or_equal_to] = @constraints[:maximum] if @constraints[:maximum].is_a?(Numeric)
186
+ options[:greater_than] = @constraints[:exclusive_minimum] if @constraints[:exclusive_minimum].is_a?(Numeric)
187
+ options[:less_than] = @constraints[:exclusive_maximum] if @constraints[:exclusive_maximum].is_a?(Numeric)
188
+
189
+ @klass.validates @property_name, numericality: options
190
+ rescue ArgumentError
191
+ # Silently ignore invalid numeric constraints
192
+ end
205
193
 
206
194
  # Add multiple_of validation
207
195
  return unless @constraints[:multiple_of]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyTalk
4
- VERSION = '2.0.0'
4
+ VERSION = '3.0.0'
5
5
  end
metadata CHANGED
@@ -1,42 +1,68 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_talk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-06-05 00:00:00.000000000 Z
10
+ date: 2025-09-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activemodel
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: activesupport
28
34
  requirement: !ruby/object:Gem::Requirement
29
35
  requirements:
30
- - - "~>"
36
+ - - ">="
31
37
  - !ruby/object:Gem::Version
32
38
  version: '7.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.0'
33
42
  type: :runtime
34
43
  prerelease: false
35
44
  version_requirements: !ruby/object:Gem::Requirement
36
45
  requirements:
37
- - - "~>"
46
+ - - ">="
38
47
  - !ruby/object:Gem::Version
39
48
  version: '7.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: js_regex
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - "~>"
57
+ - !ruby/object:Gem::Version
58
+ version: '3.0'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '3.0'
40
66
  - !ruby/object:Gem::Dependency
41
67
  name: sorbet-runtime
42
68
  requirement: !ruby/object:Gem::Requirement
@@ -51,7 +77,8 @@ dependencies:
51
77
  - - "~>"
52
78
  - !ruby/object:Gem::Version
53
79
  version: '0.5'
54
- description: Generate json-schema from plain Ruby classes.
80
+ description: Generate json-schema from plain Ruby classes with ActiveModel integration
81
+ for validations and serialization.
55
82
  email:
56
83
  - bayona.sergio@gmail.com
57
84
  executables: []
@@ -73,7 +100,6 @@ files:
73
100
  - docs/index.markdown
74
101
  - easy_talk.gemspec
75
102
  - lib/easy_talk.rb
76
- - lib/easy_talk/active_record_schema_builder.rb
77
103
  - lib/easy_talk/builders/base_builder.rb
78
104
  - lib/easy_talk/builders/boolean_builder.rb
79
105
  - lib/easy_talk/builders/collection_helpers.rb
@@ -123,5 +149,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
123
149
  requirements: []
124
150
  rubygems_version: 3.6.2
125
151
  specification_version: 4
126
- summary: Generate json-schema from Ruby classes.
152
+ summary: Generate json-schema from Ruby classes with ActiveModel integration.
127
153
  test_files: []
@@ -1,299 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module EasyTalk
4
- # This class is responsible for building a SchemaDefinition from an ActiveRecord model
5
- # It analyzes the database schema and creates a SchemaDefinition that can be
6
- # passed to ObjectBuilder for final schema generation
7
- class ActiveRecordSchemaBuilder
8
- # Mapping of ActiveRecord column types to Ruby classes
9
- COLUMN_TYPE_MAP = {
10
- string: String,
11
- text: String,
12
- integer: Integer,
13
- bigint: Integer,
14
- float: Float,
15
- decimal: Float,
16
- boolean: T::Boolean,
17
- date: Date,
18
- datetime: DateTime,
19
- timestamp: DateTime,
20
- time: Time,
21
- json: Hash,
22
- jsonb: Hash
23
- }.freeze
24
-
25
- # Mapping for format constraints based on column type
26
- FORMAT_MAP = {
27
- date: 'date',
28
- datetime: 'date-time',
29
- timestamp: 'date-time',
30
- time: 'time'
31
- }.freeze
32
-
33
- attr_reader :model
34
-
35
- # Initialize the builder with an ActiveRecord model
36
- #
37
- # @param model [Class] An ActiveRecord model class
38
- # @raise [ArgumentError] If the provided class is not an ActiveRecord model
39
- def initialize(model)
40
- raise ArgumentError, 'Class must be an ActiveRecord model' unless model.ancestors.include?(ActiveRecord::Base)
41
-
42
- @model = model
43
- end
44
-
45
- # Build a SchemaDefinition object from the ActiveRecord model
46
- #
47
- # @return [EasyTalk::SchemaDefinition] A schema definition built from the database structure
48
- def build_schema_definition
49
- schema_def = SchemaDefinition.new(model.name)
50
-
51
- # Apply basic schema metadata
52
- apply_schema_metadata(schema_def)
53
-
54
- # Add all database columns as properties
55
- add_column_properties(schema_def)
56
-
57
- # Add model associations as properties
58
- add_association_properties(schema_def) unless EasyTalk.configuration.exclude_associations
59
-
60
- # Add virtual properties defined in schema_enhancements
61
- add_virtual_properties(schema_def)
62
-
63
- schema_def
64
- end
65
-
66
- private
67
-
68
- # Set top-level schema metadata like title, description, and additionalProperties
69
- #
70
- # @param schema_def [EasyTalk::SchemaDefinition] The schema definition to modify
71
- def apply_schema_metadata(schema_def)
72
- # Set title (from enhancements or derive from model name)
73
- title = schema_enhancements['title'] || model.name.demodulize.humanize
74
- schema_def.title(title)
75
-
76
- # Set description if provided
77
- if (description = schema_enhancements['description'])
78
- schema_def.description(description)
79
- end
80
-
81
- # Set additionalProperties (from enhancements or configuration default)
82
- additional_props = if schema_enhancements.key?('additionalProperties')
83
- schema_enhancements['additionalProperties']
84
- else
85
- EasyTalk.configuration.default_additional_properties
86
- end
87
- schema_def.additional_properties(additional_props)
88
- end
89
-
90
- # Add properties based on database columns
91
- #
92
- # @param schema_def [EasyTalk::SchemaDefinition] The schema definition to modify
93
- def add_column_properties(schema_def)
94
- filtered_columns.each do |column|
95
- # Get column enhancement info if it exists
96
- column_enhancements = schema_enhancements.dig('properties', column.name.to_s) || {}
97
-
98
- # Map the database type to Ruby type
99
- ruby_type = COLUMN_TYPE_MAP.fetch(column.type, String)
100
-
101
- # If the column is nullable, wrap the type in a Union with NilClass
102
- ruby_type = T::Types::Union.new([ruby_type, NilClass]) if column.null
103
-
104
- # Build constraints hash for this column
105
- constraints = build_column_constraints(column, column_enhancements)
106
-
107
- # Add the property to schema definition
108
- schema_def.property(column.name.to_sym, ruby_type, constraints)
109
- end
110
- end
111
-
112
- # Build constraints hash for a database column
113
- #
114
- # @param column [ActiveRecord::ConnectionAdapters::Column] The database column
115
- # @param enhancements [Hash] Any schema enhancements for this column
116
- # @return [Hash] The constraints hash
117
- def build_column_constraints(column, enhancements)
118
- constraints = {
119
- optional: enhancements['optional'],
120
- description: enhancements['description'],
121
- title: enhancements['title']
122
- }
123
-
124
- # Add format constraint for date/time columns
125
- if (format = FORMAT_MAP[column.type])
126
- constraints[:format] = format
127
- end
128
-
129
- # Add max_length for string columns with limits
130
- constraints[:max_length] = column.limit if column.type == :string && column.limit
131
-
132
- # Add precision/scale for numeric columns
133
- if column.type == :decimal && column.precision
134
- constraints[:precision] = column.precision
135
- constraints[:scale] = column.scale if column.scale
136
- end
137
-
138
- # Add default value if present and not a proc
139
- if column.default && !column.default.is_a?(Proc) && column.type == :boolean
140
- constraints[:default] = ActiveModel::Type::Boolean.new.cast(column.default)
141
- elsif column.default && !column.default.is_a?(Proc)
142
- constraints[:default] = column.default
143
- end
144
-
145
- # Remove nil values
146
- constraints.compact
147
- end
148
-
149
- # Add properties based on ActiveRecord associations
150
- #
151
- # @param schema_def [EasyTalk::SchemaDefinition] The schema definition to modify
152
- def add_association_properties(schema_def)
153
- model.reflect_on_all_associations.each do |association|
154
- # Skip if we can't determine the class or it's in the association exclusion list
155
- next if association_excluded?(association)
156
-
157
- # Get association enhancement info if it exists
158
- assoc_enhancements = schema_enhancements.dig('properties', association.name.to_s) || {}
159
-
160
- case association.macro
161
- when :belongs_to, :has_one
162
- schema_def.property(
163
- association.name,
164
- association.klass,
165
- { optional: assoc_enhancements['optional'], description: assoc_enhancements['description'] }.compact
166
- )
167
- when :has_many, :has_and_belongs_to_many
168
- schema_def.property(
169
- association.name,
170
- T::Array[association.klass],
171
- { optional: assoc_enhancements['optional'], description: assoc_enhancements['description'] }.compact
172
- )
173
- end
174
- end
175
- end
176
-
177
- # Add virtual properties defined in schema_enhancements
178
- #
179
- # @param schema_def [EasyTalk::SchemaDefinition] The schema definition to modify
180
- def add_virtual_properties(schema_def)
181
- return unless schema_enhancements['properties']
182
-
183
- schema_enhancements['properties'].each do |name, options|
184
- next unless options['virtual']
185
-
186
- # Map string type name to Ruby class
187
- ruby_type = map_type_string_to_ruby_class(options['type'] || 'string')
188
-
189
- # Build constraints for virtual property
190
- constraints = {
191
- description: options['description'],
192
- title: options['title'],
193
- optional: options['optional'],
194
- format: options['format'],
195
- default: options['default'],
196
- min_length: options['minLength'],
197
- max_length: options['maxLength'],
198
- enum: options['enum']
199
- }.compact
200
-
201
- # Add the virtual property
202
- schema_def.property(name.to_sym, ruby_type, constraints)
203
- end
204
- end
205
-
206
- # Map a type string to a Ruby class
207
- #
208
- # @param type_str [String] The type string (e.g., 'string', 'integer')
209
- # @return [Class] The corresponding Ruby class
210
- def map_type_string_to_ruby_class(type_str)
211
- case type_str.to_s.downcase
212
- when 'string' then String
213
- when 'integer' then Integer
214
- when 'number' then Float
215
- when 'boolean' then T::Boolean
216
- when 'object' then Hash
217
- when 'array' then Array
218
- when 'date' then Date
219
- when 'datetime' then DateTime
220
- when 'time' then Time
221
- else String # Default fallback
222
- end
223
- end
224
-
225
- # Get all columns that should be included in the schema
226
- #
227
- # @return [Array<ActiveRecord::ConnectionAdapters::Column>] Filtered columns
228
- def filtered_columns
229
- model.columns.reject do |column|
230
- config = EasyTalk.configuration
231
- excluded_columns.include?(column.name.to_sym) ||
232
- (config.exclude_primary_key && column.name == model.primary_key) ||
233
- (config.exclude_timestamps && timestamp_column?(column.name)) ||
234
- (config.exclude_foreign_keys && foreign_key_column?(column.name))
235
- end
236
- end
237
-
238
- # Check if a column is a timestamp column
239
- #
240
- # @param column_name [String] The column name
241
- # @return [Boolean] True if the column is a timestamp column
242
- def timestamp_column?(column_name)
243
- %w[created_at updated_at].include?(column_name)
244
- end
245
-
246
- # Check if a column is a foreign key column
247
- #
248
- # @param column_name [String] The column name
249
- # @return [Boolean] True if the column is a foreign key column
250
- def foreign_key_column?(column_name)
251
- column_name.end_with?('_id')
252
- end
253
-
254
- # Check if an association should be excluded
255
- #
256
- # @param association [ActiveRecord::Reflection::AssociationReflection] The association
257
- # @return [Boolean] True if the association should be excluded
258
- def association_excluded?(association)
259
- !association.klass ||
260
- excluded_associations.include?(association.name.to_sym) ||
261
- association.options[:polymorphic] # Skip polymorphic associations (complex to model)
262
- end
263
-
264
- # Get schema enhancements
265
- #
266
- # @return [Hash] Schema enhancements
267
- def schema_enhancements
268
- @schema_enhancements ||= if model.respond_to?(:schema_enhancements)
269
- model.schema_enhancements.deep_transform_keys(&:to_s)
270
- else
271
- {}
272
- end
273
- end
274
-
275
- # Get all excluded columns
276
- #
277
- # @return [Array<Symbol>] Excluded column names
278
- def excluded_columns
279
- @excluded_columns ||= begin
280
- config = EasyTalk.configuration
281
- global_exclusions = config.excluded_columns || []
282
- model_exclusions = schema_enhancements['ignore'] || []
283
-
284
- # Combine and convert to symbols for consistent comparison
285
- (global_exclusions + model_exclusions).map(&:to_sym)
286
- end
287
- end
288
-
289
- # Get all excluded associations
290
- #
291
- # @return [Array<Symbol>] Excluded association names
292
- def excluded_associations
293
- @excluded_associations ||= begin
294
- model_exclusions = schema_enhancements['ignore_associations'] || []
295
- model_exclusions.map(&:to_sym)
296
- end
297
- end
298
- end
299
- end