treaty 0.15.0 → 0.16.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: a3fcf5acc02ad345e7ff61ba70124596a89be46af035b6ee5d4114de8509f883
4
- data.tar.gz: bc5d32074c9cab57438851555fb6849014b52da7f0732517a54db2f48563a198
3
+ metadata.gz: cc42fa86dd5dc35d49f0a07a189d9ddc59f619e2341905c1ed62193e0d3069b7
4
+ data.tar.gz: ffc66f03e0ced6e666bb12db50f405117831b48c26475ab034ab4a16f4c431b1
5
5
  SHA512:
6
- metadata.gz: 3ebb477674d48c4e4e0aafa30eeb9ca3ad20dd96c65f19d38d31b55f6256b7e816768b149720662c9de239242a9aeab78ed038685dd0d82754b005c9e31380c8
7
- data.tar.gz: b04b8248df32a42c41b833920cada319fcfb73344a7fa9520867b59fbba60655716f8152342f9a34765c06aa05d99160b7ff9812b826ff7bebca2ccd6a1cda94
6
+ metadata.gz: 0b31ad204ddb19df8a296007f334d29e2e220fbb771f9c5a3851309e0a9ec1909dae0dbef3608633f6a5203da0b827fb90c75132e83f5ebb3fcc28f36fd33cc9
7
+ data.tar.gz: 0c6ec71d1be9155da662498264667a63072c5ce513d1ae0987a54091d76aa35a9fd21b3c722db38a8f4154919a2894f442736c44e23ff88961c9a1aadc841c49
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  </div>
14
14
 
15
15
  > [!WARNING]
16
- > **Development Status**: Treaty is currently under active development in the 0.x version series. Breaking changes may occur between minor versions (0.x) as we refine the API and add new features. The library will stabilize with the 1.0 release. We recommend pinning to specific patch versions in your Gemfile (e.g., `gem "treaty", "~> 0.15.0"`) until the 1.0 release.
16
+ > **Development Status**: Treaty is currently under active development in the 0.x version series. Breaking changes may occur between minor versions (0.x) as we refine the API and add new features. The library will stabilize with the 1.0 release. We recommend pinning to specific patch versions in your Gemfile (e.g., `gem "treaty", "~> 0.16.0"`) until the 1.0 release.
17
17
 
18
18
  ## 📚 Documentation
19
19
 
@@ -56,6 +56,10 @@ en:
56
56
  invalid_type: "Option 'transform' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
57
57
  execution_error: "Transform failed for attribute '%{attribute}': %{error}"
58
58
 
59
+ computed:
60
+ invalid_type: "Option 'computed' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
61
+ execution_error: "Computed failed for attribute '%{attribute}': %{error}"
62
+
59
63
  cast:
60
64
  invalid_type: "Option 'cast' for attribute '%{attribute}' must be a Symbol. Got: %{type}"
61
65
  source_not_supported: "Option 'cast' for attribute '%{attribute}' cannot be used with type '%{source_type}'. Casting is only supported for: %{allowed}"
@@ -76,8 +76,9 @@ module Treaty
76
76
  # Override in subclasses if transformation is needed
77
77
  #
78
78
  # @param value [Object] The value to transform
79
+ # @param _root_data [Hash] Full raw data from root level (used by computed modifier)
79
80
  # @return [Object] Transformed value
80
- def transform_value(value)
81
+ def transform_value(value, _root_data = {})
81
82
  value
82
83
  end
83
84
 
@@ -79,8 +79,9 @@ module Treaty
79
79
  # The renaming is handled by the orchestrator using target_name
80
80
  #
81
81
  # @param value [Object] The value to transform
82
+ # @param _root_data [Hash] Unused root data parameter
82
83
  # @return [Object] Unchanged value
83
- def transform_value(value)
84
+ def transform_value(value, _root_data = {})
84
85
  value
85
86
  end
86
87
  end
@@ -162,8 +162,9 @@ module Treaty
162
162
  # Skips conversion for nil values (handled by RequiredValidator)
163
163
  #
164
164
  # @param value [Object] The current value
165
+ # @param _root_data [Hash] Unused root data parameter
165
166
  # @return [Object] Converted value
166
- def transform_value(value) # rubocop:disable Metrics/MethodLength
167
+ def transform_value(value, _root_data = {}) # rubocop:disable Metrics/MethodLength
167
168
  return value if value.nil? # Cast doesn't modify nil, required validator handles it.
168
169
 
169
170
  target_type = option_value
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Attribute
5
+ module Option
6
+ module Modifiers
7
+ # Computes attribute values from all available raw data.
8
+ #
9
+ # ## Key Difference from Transform
10
+ #
11
+ # - `transform:` receives only `value:` (the current attribute's value)
12
+ # - `computed:` receives `**attributes` (ALL raw data from root level)
13
+ #
14
+ # ## Usage Examples
15
+ #
16
+ # Simple mode:
17
+ # string :full_name, computed: ->(**attrs) {
18
+ # "#{attrs.dig(:user, :first_name)} #{attrs.dig(:user, :last_name)}"
19
+ # }
20
+ #
21
+ # Advanced mode with custom error message:
22
+ # string :full_name, computed: {
23
+ # is: ->(**attrs) { "#{attrs.dig(:user, :first_name)} #{attrs.dig(:user, :last_name)}" },
24
+ # message: "Failed to compute full name"
25
+ # }
26
+ #
27
+ # ## Use Cases
28
+ #
29
+ # 1. **Derived fields (full name from parts)**:
30
+ # ```ruby
31
+ # response 200 do
32
+ # object :user do
33
+ # string :first_name
34
+ # string :last_name
35
+ # string :full_name, computed: ->(**attrs) {
36
+ # "#{attrs.dig(:user, :first_name)} #{attrs.dig(:user, :last_name)}"
37
+ # }
38
+ # end
39
+ # end
40
+ # ```
41
+ #
42
+ # 2. **Calculated values (word count)**:
43
+ # ```ruby
44
+ # response 200 do
45
+ # object :post do
46
+ # string :content
47
+ # integer :word_count, computed: ->(**attrs) {
48
+ # attrs.dig(:post, :content).to_s.split.size
49
+ # }
50
+ # end
51
+ # end
52
+ # ```
53
+ #
54
+ # 3. **Cross-object computations**:
55
+ # ```ruby
56
+ # response 200 do
57
+ # object :order do
58
+ # integer :quantity
59
+ # integer :unit_price
60
+ # integer :total, computed: ->(**attrs) {
61
+ # attrs.dig(:order, :quantity).to_i * attrs.dig(:order, :unit_price).to_i
62
+ # }
63
+ # end
64
+ # end
65
+ # ```
66
+ #
67
+ # ## Important Notes
68
+ #
69
+ # - Lambda must accept `**attributes` (named argument splat)
70
+ # - Receives full raw data from root level (not just current object)
71
+ # - **Always computes** - ignores any existing value, result replaces everything
72
+ # - All exceptions raised in lambda are caught and re-raised as Validation errors
73
+ # - Computation is applied during Phase 3 (transformation phase)
74
+ # - Executes FIRST in modifier chain: computed -> transform -> cast -> default -> as
75
+ #
76
+ # ## Advanced Mode
77
+ #
78
+ # Schema format: `{ is: lambda, message: nil }`
79
+ class ComputedModifier < Treaty::Attribute::Option::Base
80
+ # Validates that computed value is a lambda
81
+ #
82
+ # @raise [Treaty::Exceptions::Validation] If computed is not a Proc/lambda
83
+ # @return [void]
84
+ def validate_schema!
85
+ computed_lambda = option_value
86
+
87
+ return if computed_lambda.respond_to?(:call)
88
+
89
+ raise Treaty::Exceptions::Validation,
90
+ I18n.t(
91
+ "treaty.attributes.modifiers.computed.invalid_type",
92
+ attribute: @attribute_name,
93
+ type: computed_lambda.class
94
+ )
95
+ end
96
+
97
+ # Computes value using the provided lambda and full root data
98
+ # Always executes - ignores any existing value
99
+ #
100
+ # @param _value [Object] The current value (ignored - always computes)
101
+ # @param root_data [Hash] Full raw data from root level
102
+ # @return [Object] Computed value
103
+ def transform_value(_value, root_data = {}) # rubocop:disable Metrics/MethodLength
104
+ computed_lambda = option_value
105
+
106
+ # Call lambda with full root data as named arguments
107
+ computed_lambda.call(**root_data)
108
+ rescue StandardError => e
109
+ attributes = {
110
+ attribute: @attribute_name,
111
+ error: e.message
112
+ }
113
+
114
+ # Catch all exceptions from lambda execution
115
+ error_message = resolve_custom_message(**attributes) || I18n.t(
116
+ "treaty.attributes.modifiers.computed.execution_error",
117
+ **attributes
118
+ )
119
+
120
+ raise Treaty::Exceptions::Validation, error_message
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -80,9 +80,9 @@ module Treaty
80
80
  # Empty strings, empty arrays, and false are NOT replaced
81
81
  #
82
82
  # @param value [Object] The current value
83
- # @param _context [Hash] Unused context parameter
83
+ # @param _root_data [Hash] Unused root data parameter
84
84
  # @return [Object] Default value if original is nil, otherwise original value
85
- def transform_value(value, _context = {})
85
+ def transform_value(value, _root_data = {})
86
86
  # Only apply default if value is nil
87
87
  # Empty strings, empty arrays, false are NOT replaced
88
88
  return value unless value.nil?
@@ -82,8 +82,9 @@ module Treaty
82
82
  # Skips transformation for nil values (handled by RequiredValidator)
83
83
  #
84
84
  # @param value [Object] The current value
85
+ # @param _root_data [Hash] Unused root data parameter
85
86
  # @return [Object] Transformed value
86
- def transform_value(value) # rubocop:disable Metrics/MethodLength
87
+ def transform_value(value, _root_data = {}) # rubocop:disable Metrics/MethodLength
87
88
  return value if value.nil? # Transform doesn't modify nil, required validator handles it.
88
89
 
89
90
  transform_lambda = option_value
@@ -26,10 +26,11 @@ module Treaty
26
26
  #
27
27
  # ## Built-in Modifiers
28
28
  #
29
- # - `:as` → AsModifier - Renames attributes
30
- # - `:default` → DefaultModifier - Provides default values
29
+ # - `:computed` → ComputedModifier - Computes values from all raw data (executes first)
31
30
  # - `:transform` → TransformModifier - Transforms values using custom lambdas
32
31
  # - `:cast` → CastModifier - Converts values between types automatically
32
+ # - `:default` → DefaultModifier - Provides default values
33
+ # - `:as` → AsModifier - Renames attributes
33
34
  #
34
35
  # ## Built-in Conditionals
35
36
  #
@@ -85,13 +86,15 @@ module Treaty
85
86
  end
86
87
 
87
88
  # Registers all built-in modifiers
89
+ # Order matters: computed runs first, then transform, cast, default, as
88
90
  #
89
91
  # @return [void]
90
92
  def register_modifiers!
91
- Registry.register(:as, Modifiers::AsModifier, category: :modifier)
92
- Registry.register(:default, Modifiers::DefaultModifier, category: :modifier)
93
+ Registry.register(:computed, Modifiers::ComputedModifier, category: :modifier)
93
94
  Registry.register(:transform, Modifiers::TransformModifier, category: :modifier)
94
95
  Registry.register(:cast, Modifiers::CastModifier, category: :modifier)
96
+ Registry.register(:default, Modifiers::DefaultModifier, category: :modifier)
97
+ Registry.register(:as, Modifiers::AsModifier, category: :modifier)
95
98
  end
96
99
 
97
100
  # Registers all built-in conditionals
@@ -103,10 +103,11 @@ module Treaty
103
103
  # Applies transformations like defaults, type coercion, etc.
104
104
  #
105
105
  # @param value [Object] The value to transform
106
+ # @param root_data [Hash] Full raw data from root level (used by computed modifier)
106
107
  # @return [Object] Transformed value
107
- def transform_value(value)
108
+ def transform_value(value, root_data = {})
108
109
  @processors.values.reduce(value) do |current_value, processor|
109
- processor.transform_value(current_value)
110
+ processor.transform_value(current_value, root_data)
110
111
  end
111
112
  end
112
113
 
@@ -68,9 +68,10 @@ module Treaty
68
68
  # Transforms attribute value through all modifiers
69
69
  #
70
70
  # @param value [Object] The value to transform
71
+ # @param root_data [Hash] Full raw data from root level (used by computed modifier)
71
72
  # @return [Object] Transformed value
72
- def transform_value(value)
73
- option_orchestrator.transform_value(value)
73
+ def transform_value(value, root_data = {})
74
+ option_orchestrator.transform_value(value, root_data)
74
75
  end
75
76
 
76
77
  # Checks if attribute name is transformed
@@ -22,15 +22,16 @@ module Treaty
22
22
  # Returns original value if nil or not nested
23
23
  #
24
24
  # @param value [Object] The value to transform
25
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
25
26
  # @return [Object] Transformed value
26
- def transform(value)
27
+ def transform(value, root_data = {})
27
28
  return value if value.nil?
28
29
 
29
30
  case attribute.type
30
31
  when :object
31
- transform_object(value)
32
+ transform_object(value, root_data)
32
33
  when :array
33
- transform_array(value)
34
+ transform_array(value, root_data)
34
35
  else
35
36
  value
36
37
  end
@@ -41,23 +42,25 @@ module Treaty
41
42
  # Transforms object (hash) value
42
43
  #
43
44
  # @param value [Hash] The hash to transform
45
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
44
46
  # @return [Hash] Transformed hash
45
- def transform_object(value)
47
+ def transform_object(value, root_data = {})
46
48
  return value unless attribute.nested?
47
49
 
48
50
  transformer = ObjectTransformer.new(attribute)
49
- transformer.transform(value)
51
+ transformer.transform(value, root_data)
50
52
  end
51
53
 
52
54
  # Transforms array value
53
55
  #
54
56
  # @param value [Array] The array to transform
57
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
55
58
  # @return [Array] Transformed array
56
- def transform_array(value)
59
+ def transform_array(value, root_data = {})
57
60
  return value unless attribute.nested?
58
61
 
59
62
  transformer = ArrayTransformer.new(attribute)
60
- transformer.transform(value)
63
+ transformer.transform(value, root_data)
61
64
  end
62
65
 
63
66
  # Transforms object (hash) with nested attributes
@@ -74,15 +77,16 @@ module Treaty
74
77
  # Transforms hash by processing all nested attributes
75
78
  #
76
79
  # @param value [Hash] The source hash
80
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
77
81
  # @return [Hash] Transformed hash with processed attributes
78
- def transform(value)
82
+ def transform(value, root_data = {})
79
83
  transformed = {}
80
84
 
81
85
  attribute.collection_of_attributes.each do |nested_attribute|
82
86
  # Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
83
87
  next unless should_process_attribute?(nested_attribute, value)
84
88
 
85
- process_attribute(nested_attribute, value, transformed)
89
+ process_attribute(nested_attribute, value, transformed, root_data)
86
90
  end
87
91
 
88
92
  transformed
@@ -177,8 +181,9 @@ module Treaty
177
181
  # @param nested_attribute [Attribute::Base] Attribute to process
178
182
  # @param source_hash [Hash] Source data
179
183
  # @param target_hash [Hash] Target hash to populate
184
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
180
185
  # @return [void]
181
- def process_attribute(nested_attribute, source_hash, target_hash) # rubocop:disable Metrics/MethodLength
186
+ def process_attribute(nested_attribute, source_hash, target_hash, root_data = {}) # rubocop:disable Metrics/MethodLength
182
187
  source_name = nested_attribute.name
183
188
  nested_value = source_hash.fetch(source_name, nil)
184
189
 
@@ -189,10 +194,10 @@ module Treaty
189
194
  nested_transformer = NestedTransformer.new(nested_attribute)
190
195
  validator.validate_type!(nested_value) unless nested_value.nil?
191
196
  validator.validate_required!(nested_value)
192
- nested_transformer.transform(nested_value)
197
+ nested_transformer.transform(nested_value, root_data)
193
198
  else
194
199
  validator.validate_value!(nested_value)
195
- validator.transform_value(nested_value)
200
+ validator.transform_value(nested_value, root_data)
196
201
  end
197
202
 
198
203
  target_name = validator.target_name
@@ -218,13 +223,14 @@ module Treaty
218
223
  # Handles both simple arrays (:_self) and complex arrays (objects)
219
224
  #
220
225
  # @param value [Array] The source array
226
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
221
227
  # @return [Array] Transformed array
222
- def transform(value)
228
+ def transform(value, root_data = {})
223
229
  value.each_with_index.map do |item, index|
224
230
  if simple_array?
225
- transform_simple_element(item, index)
231
+ transform_simple_element(item, index, root_data)
226
232
  else
227
- transform_array_item(item, index)
233
+ transform_array_item(item, index, root_data)
228
234
  end
229
235
  end
230
236
  end
@@ -325,16 +331,17 @@ module Treaty
325
331
  #
326
332
  # @param item [Object] Array element to transform
327
333
  # @param index [Integer] Element index for error messages
334
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
328
335
  # @raise [Treaty::Exceptions::Validation] If validation fails
329
336
  # @return [Object] Transformed element value
330
- def transform_simple_element(item, index) # rubocop:disable Metrics/MethodLength
337
+ def transform_simple_element(item, index, root_data = {}) # rubocop:disable Metrics/MethodLength
331
338
  self_attribute = attribute.collection_of_attributes.first
332
339
  validator = AttributeValidator.new(self_attribute)
333
340
  validator.validate_schema!
334
341
 
335
342
  begin
336
343
  validator.validate_value!(item)
337
- validator.transform_value(item)
344
+ validator.transform_value(item, root_data)
338
345
  rescue Treaty::Exceptions::Validation => e
339
346
  raise Treaty::Exceptions::Validation,
340
347
  I18n.t(
@@ -350,9 +357,10 @@ module Treaty
350
357
  #
351
358
  # @param item [Hash] Array element to transform
352
359
  # @param index [Integer] Element index for error messages
360
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
353
361
  # @raise [Treaty::Exceptions::Validation] If item is not a Hash
354
362
  # @return [Hash] Transformed hash
355
- def transform_array_item(item, index) # rubocop:disable Metrics/MethodLength
363
+ def transform_array_item(item, index, root_data = {}) # rubocop:disable Metrics/MethodLength
356
364
  unless item.is_a?(Hash)
357
365
  raise Treaty::Exceptions::Validation,
358
366
  I18n.t(
@@ -369,7 +377,7 @@ module Treaty
369
377
  # Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
370
378
  next unless should_process_attribute?(nested_attribute, item)
371
379
 
372
- process_attribute(nested_attribute, item, transformed, index)
380
+ process_attribute(nested_attribute, item, transformed, index, root_data)
373
381
  end
374
382
 
375
383
  transformed
@@ -382,9 +390,10 @@ module Treaty
382
390
  # @param source_hash [Hash] Source data
383
391
  # @param target_hash [Hash] Target hash to populate
384
392
  # @param index [Integer] Array index for error messages
393
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
385
394
  # @raise [Treaty::Exceptions::Validation] If validation fails
386
395
  # @return [void]
387
- def process_attribute(nested_attribute, source_hash, target_hash, index) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
396
+ def process_attribute(nested_attribute, source_hash, target_hash, index, root_data = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
388
397
  source_name = nested_attribute.name
389
398
  nested_value = source_hash.fetch(source_name, nil)
390
399
 
@@ -396,10 +405,10 @@ module Treaty
396
405
  nested_transformer = NestedTransformer.new(nested_attribute)
397
406
  validator.validate_type!(nested_value) unless nested_value.nil?
398
407
  validator.validate_required!(nested_value)
399
- nested_transformer.transform(nested_value)
408
+ nested_transformer.transform(nested_value, root_data)
400
409
  else
401
410
  validator.validate_value!(nested_value)
402
- validator.transform_value(nested_value)
411
+ validator.transform_value(nested_value, root_data)
403
412
  end
404
413
  rescue Treaty::Exceptions::Validation => e
405
414
  raise Treaty::Exceptions::Validation,
@@ -219,7 +219,7 @@ module Treaty
219
219
  validate_and_transform_nested(attribute, value, validator)
220
220
  else
221
221
  validator.validate_value!(value)
222
- validator.transform_value(value)
222
+ validator.transform_value(value, data)
223
223
  end
224
224
  end
225
225
 
@@ -249,8 +249,9 @@ module Treaty
249
249
 
250
250
  # Step 4: Transform non-nil value
251
251
  # At this point, value is guaranteed to be non-nil
252
+ # Pass full root data as context for computed modifiers
252
253
  transformer = NestedTransformer.new(attribute)
253
- transformer.transform(value)
254
+ transformer.transform(value, data)
254
255
  end
255
256
  end
256
257
  end
@@ -3,7 +3,7 @@
3
3
  module Treaty
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 15
6
+ MINOR = 16
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: treaty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Sokolov
@@ -160,6 +160,7 @@ files:
160
160
  - lib/treaty/attribute/option/conditionals/unless_conditional.rb
161
161
  - lib/treaty/attribute/option/modifiers/as_modifier.rb
162
162
  - lib/treaty/attribute/option/modifiers/cast_modifier.rb
163
+ - lib/treaty/attribute/option/modifiers/computed_modifier.rb
163
164
  - lib/treaty/attribute/option/modifiers/default_modifier.rb
164
165
  - lib/treaty/attribute/option/modifiers/transform_modifier.rb
165
166
  - lib/treaty/attribute/option/registry.rb