treaty 0.14.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: 1fe35b7cf209d578b34ee7d904bdd146db4a3ac61f022a1f5f02c80bfe21875e
4
- data.tar.gz: 4be4a2f29fbfb30115e696baeaf618eaf59fe5c2fc309a0d1ced3eff61713671
3
+ metadata.gz: cc42fa86dd5dc35d49f0a07a189d9ddc59f619e2341905c1ed62193e0d3069b7
4
+ data.tar.gz: ffc66f03e0ced6e666bb12db50f405117831b48c26475ab034ab4a16f4c431b1
5
5
  SHA512:
6
- metadata.gz: d256fd30a7553ebfc015c06049c4fc0edceddaa782b3b0bda807364568009465556e72867324bc4a707d61c52da579f5a40f4324922af4349f1a7553023a9443
7
- data.tar.gz: 4d71a4831e946faf207b9c21947531f3bc6263941f07e3a627c2e504a8c471d6aec597fe167090c0d0d49979b3b4dbf2f8e0620cfe9cd12f492286f60b5a3e7a
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.14.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
 
@@ -133,4 +133,4 @@ Thank you to all [contributors](https://github.com/servactory/treaty/graphs/cont
133
133
 
134
134
  ## 📄 License
135
135
 
136
- Treaty is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
136
+ Treaty is available as open source under the terms of the [MIT License](./LICENSE).
@@ -45,6 +45,7 @@ en:
45
45
  # Attribute options
46
46
  options:
47
47
  unknown: "Unknown options for attribute '%{attribute}': %{unknown}. Known options: %{known}"
48
+ message_evaluation_error: "Custom message evaluation failed for attribute '%{attribute}': %{error}"
48
49
 
49
50
  # Attribute modifiers
50
51
  modifiers:
@@ -55,6 +56,10 @@ en:
55
56
  invalid_type: "Option 'transform' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
56
57
  execution_error: "Transform failed for attribute '%{attribute}': %{error}"
57
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
+
58
63
  cast:
59
64
  invalid_type: "Option 'cast' for attribute '%{attribute}' must be a Symbol. Got: %{type}"
60
65
  source_not_supported: "Option 'cast' for attribute '%{attribute}' cannot be used with type '%{source_type}'. Casting is only supported for: %{allowed}"
@@ -62,6 +67,18 @@ en:
62
67
  conversion_not_supported: "Option 'cast' for attribute '%{attribute}' does not support conversion from '%{from}' to '%{to}'"
63
68
  conversion_error: "Cast failed for attribute '%{attribute}' from '%{from}' to '%{to}'. Value: '%{value}'. Error: %{error}"
64
69
 
70
+ # Attribute conditionals
71
+ conditionals:
72
+ if:
73
+ invalid_type: "Option 'if' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
74
+ evaluation_error: "Conditional evaluation failed for attribute '%{attribute}': %{error}"
75
+
76
+ unless:
77
+ invalid_type: "Option 'unless' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
78
+ evaluation_error: "Conditional evaluation failed for attribute '%{attribute}': %{error}"
79
+
80
+ mutual_exclusivity_error: "Attribute '%{attribute}' cannot have both 'if' and 'unless' options. Use only one conditional option."
81
+
65
82
  # Attribute builder DSL
66
83
  builder:
67
84
  not_implemented: "%{class} must implement #create_attribute"
@@ -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
 
@@ -143,10 +144,12 @@ module Treaty
143
144
 
144
145
  # Resolves custom message with lambda support
145
146
  # If message is a lambda, calls it with provided named arguments
147
+ # Catches all exceptions from lambda execution and re-raises as Validation errors
146
148
  #
147
149
  # @param attributes [Hash] Named arguments to pass to lambda
148
150
  # @return [String, nil] Resolved message string or nil
149
- def resolve_custom_message(**attributes)
151
+ # @raise [Treaty::Exceptions::Validation] If custom message lambda raises an exception
152
+ def resolve_custom_message(**attributes) # rubocop:disable Metrics/MethodLength
150
153
  message = custom_message
151
154
  return nil if message.nil?
152
155
 
@@ -155,6 +158,15 @@ module Treaty
155
158
  else
156
159
  message
157
160
  end
161
+ rescue StandardError => e
162
+ # Catch all exceptions from custom message lambda execution
163
+ error_message = I18n.t(
164
+ "treaty.attributes.options.message_evaluation_error",
165
+ attribute: @attribute_name,
166
+ error: e.message
167
+ )
168
+
169
+ raise Treaty::Exceptions::Validation, error_message
158
170
  end
159
171
 
160
172
  # Checks if schema is in advanced mode
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Attribute
5
+ module Option
6
+ module Conditionals
7
+ # Base class for conditional option processors.
8
+ #
9
+ # ## Purpose
10
+ #
11
+ # Conditionals control whether an attribute should be processed at all.
12
+ # Unlike validators (which check data) and modifiers (which transform data),
13
+ # conditionals determine attribute visibility based on runtime conditions.
14
+ #
15
+ # ## Key Difference from Validators/Modifiers
16
+ #
17
+ # - **Validators**: Check if data is valid
18
+ # - **Modifiers**: Transform data values
19
+ # - **Conditionals**: Decide if attribute exists in output
20
+ #
21
+ # ## Processing
22
+ #
23
+ # Conditionals are evaluated BEFORE validators and modifiers:
24
+ # 1. If condition evaluates to `false` → attribute is skipped entirely
25
+ # 2. If condition evaluates to `true` → attribute is processed normally
26
+ #
27
+ # ## Mode Support
28
+ #
29
+ # Conditionals do NOT support simple/advanced modes.
30
+ # They only accept lambda/proc directly:
31
+ #
32
+ # ```ruby
33
+ # # Correct
34
+ # integer :rating, if: ->(**attributes) { attributes.dig(:post, :published_at).present? }
35
+ # array :tags, if: ->(post:) { post[:published_at].present? }
36
+ #
37
+ # # Incorrect - no simple/advanced mode
38
+ # integer :rating, if: true # Not supported
39
+ # integer :rating, if: { is: ..., message: ... } # Not supported
40
+ # ```
41
+ #
42
+ # ## Implementation
43
+ #
44
+ # Subclasses must implement:
45
+ # - `validate_schema!` - Validate the conditional schema at definition time
46
+ # - `evaluate_condition(data)` - Evaluate condition with runtime data
47
+ class Base < Treaty::Attribute::Option::Base
48
+ # Phase 1: Validates conditional schema
49
+ # Must be overridden in subclasses
50
+ #
51
+ # @raise [Treaty::Exceptions::Validation] If schema is invalid
52
+ # @return [void]
53
+ def validate_schema!
54
+ raise Treaty::Exceptions::NotImplemented,
55
+ "#{self.class} must implement #validate_schema!"
56
+ end
57
+
58
+ # Evaluates the conditional with runtime data
59
+ # Must be overridden in subclasses
60
+ #
61
+ # @param _data [Hash] Raw data to evaluate condition against
62
+ # @raise [Treaty::Exceptions::Validation] If evaluation fails
63
+ # @return [Boolean] True if attribute should be processed, false otherwise
64
+ def evaluate_condition(_data)
65
+ raise Treaty::Exceptions::NotImplemented,
66
+ "#{self.class} must implement #evaluate_condition"
67
+ end
68
+
69
+ # Conditionals do not validate values
70
+ # This is a no-op for conditionals
71
+ #
72
+ # @param _value [Object] The value (unused)
73
+ # @return [void]
74
+ def validate_value!(_value)
75
+ # No-op: conditionals don't validate values
76
+ end
77
+
78
+ # Conditionals do not transform values
79
+ # This is a no-op for conditionals
80
+ #
81
+ # @param value [Object] The value to pass through
82
+ # @return [Object] The unchanged value
83
+ def transform_value(value)
84
+ value
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Attribute
5
+ module Option
6
+ module Conditionals
7
+ # Conditionally includes attributes based on runtime data evaluation.
8
+ #
9
+ # ## Usage Examples
10
+ #
11
+ # Basic usage with keyword arguments splat:
12
+ # array :tags, if: ->(**attributes) { attributes.dig(:post, :published_at).present? }
13
+ # integer :rating, if: ->(**attributes) { attributes.dig(:post, :published_at).present? }
14
+ #
15
+ # Named argument pattern:
16
+ # array :tags, if: ->(post:) { post[:published_at].present? }
17
+ # integer :views, if: ->(post:) { post[:published_at].present? }
18
+ #
19
+ # Complex conditions:
20
+ # string :admin_note, if: ->(**attrs) {
21
+ # attrs.dig(:user, :role) == "admin" && attrs.dig(:post, :flagged)
22
+ # }
23
+ #
24
+ # ## Use Cases
25
+ #
26
+ # 1. **Show fields only when published**:
27
+ # ```ruby
28
+ # response 200 do
29
+ # object :post do
30
+ # string :id
31
+ # string :title
32
+ # datetime :published_at, :optional
33
+ # integer :rating, if: ->(**attrs) { attrs.dig(:post, :published_at).present? }
34
+ # end
35
+ # end
36
+ # # If published_at is nil → rating is excluded from response
37
+ # # If published_at exists → rating is included
38
+ # ```
39
+ #
40
+ # 2. **Role-based field visibility**:
41
+ # ```ruby
42
+ # response 200 do
43
+ # object :user do
44
+ # string :name
45
+ # string :email, if: ->(user:) { user[:role] == "admin" }
46
+ # end
47
+ # end
48
+ # ```
49
+ #
50
+ # 3. **Nested attribute conditionals**:
51
+ # ```ruby
52
+ # object :post do
53
+ # string :title
54
+ # array :tags, if: ->(post:) { post[:published_at].present? } do
55
+ # string :_self
56
+ # end
57
+ # end
58
+ # ```
59
+ #
60
+ # ## Important Notes
61
+ #
62
+ # - Lambda receives raw data as named arguments
63
+ # - Lambda MUST return truthy/falsy value
64
+ # - If condition is false → attribute is completely omitted
65
+ # - If condition is true → attribute is validated and transformed normally
66
+ # - All exceptions in lambda are caught and wrapped in Treaty::Exceptions::Validation
67
+ # - Does NOT support simple mode (if: true) or advanced mode (if: { is: ..., message: ... })
68
+ #
69
+ # ## Error Handling
70
+ #
71
+ # If the lambda raises any exception, it's caught and converted to a
72
+ # Treaty::Exceptions::Validation with detailed error message including:
73
+ # - Attribute name
74
+ # - Original exception message
75
+ #
76
+ # ## Data Access Pattern
77
+ #
78
+ # The lambda receives the same data structure that the orchestrator processes.
79
+ # For nested attributes, you can access parent data using dig:
80
+ #
81
+ # ```ruby
82
+ # # For response with { post: { title: "...", published_at: "..." } }
83
+ # integer :rating, if: ->(**attrs) { attrs.dig(:post, :published_at).present? }
84
+ #
85
+ # # Alternative: named argument pattern
86
+ # integer :rating, if: ->(post:) { post[:published_at].present? }
87
+ # ```
88
+ class IfConditional < Treaty::Attribute::Option::Conditionals::Base
89
+ # Validates that if option is a callable (Proc/Lambda)
90
+ #
91
+ # @raise [Treaty::Exceptions::Validation] If if is not a Proc/lambda
92
+ # @return [void]
93
+ def validate_schema!
94
+ conditional_lambda = @option_schema
95
+
96
+ return if conditional_lambda.respond_to?(:call)
97
+
98
+ raise Treaty::Exceptions::Validation,
99
+ I18n.t(
100
+ "treaty.attributes.conditionals.if.invalid_type",
101
+ attribute: @attribute_name,
102
+ type: conditional_lambda.class
103
+ )
104
+ end
105
+
106
+ # Evaluates the conditional lambda with runtime data
107
+ # Returns boolean indicating if attribute should be processed
108
+ #
109
+ # @param data [Hash] Raw data from request/response/entity
110
+ # @raise [Treaty::Exceptions::Validation] If lambda execution fails
111
+ # @return [Boolean] True if attribute should be processed, false to skip it
112
+ def evaluate_condition(data)
113
+ conditional_lambda = @option_schema
114
+
115
+ # Call lambda with raw data as named arguments
116
+ # The lambda can use **attributes or specific named args like post:
117
+ result = conditional_lambda.call(**data)
118
+
119
+ # Convert result to boolean
120
+ !!result
121
+ rescue StandardError => e
122
+ # Catch all exceptions from lambda execution
123
+ raise Treaty::Exceptions::Validation,
124
+ I18n.t(
125
+ "treaty.attributes.conditionals.if.evaluation_error",
126
+ attribute: @attribute_name,
127
+ error: e.message
128
+ )
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Attribute
5
+ module Option
6
+ module Conditionals
7
+ # Conditionally excludes attributes based on runtime data evaluation.
8
+ #
9
+ # ## Usage Examples
10
+ #
11
+ # Basic usage with keyword arguments splat:
12
+ # array :tags, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
13
+ # integer :draft_views, unless: ->(**attributes) { attributes.dig(:post, :published_at).present? }
14
+ #
15
+ # Named argument pattern:
16
+ # array :draft_notes, unless: ->(post:) { post[:published_at].present? }
17
+ # integer :edit_count, unless: ->(post:) { post[:published_at].present? }
18
+ #
19
+ # Complex conditions:
20
+ # string :internal_note, unless: ->(**attrs) {
21
+ # attrs.dig(:user, :role) == "admin" && attrs.dig(:post, :flagged)
22
+ # }
23
+ #
24
+ # ## Use Cases
25
+ #
26
+ # 1. **Hide fields when published**:
27
+ # ```ruby
28
+ # response 200 do
29
+ # object :post do
30
+ # string :id
31
+ # string :title
32
+ # datetime :published_at, :optional
33
+ # integer :draft_views, unless: ->(**attrs) { attrs.dig(:post, :published_at).present? }
34
+ # end
35
+ # end
36
+ # # If published_at is nil → draft_views is included in response
37
+ # # If published_at exists → draft_views is excluded
38
+ # ```
39
+ #
40
+ # 2. **Role-based field exclusion**:
41
+ # ```ruby
42
+ # response 200 do
43
+ # object :user do
44
+ # string :name
45
+ # string :internal_id, unless: ->(user:) { user[:role] == "public" }
46
+ # end
47
+ # end
48
+ # ```
49
+ #
50
+ # 3. **Nested attribute conditionals**:
51
+ # ```ruby
52
+ # object :post do
53
+ # string :title
54
+ # array :draft_notes, unless: ->(post:) { post[:published_at].present? } do
55
+ # string :_self
56
+ # end
57
+ # end
58
+ # ```
59
+ #
60
+ # ## Important Notes
61
+ #
62
+ # - Lambda receives raw data as named arguments
63
+ # - Lambda MUST return truthy/falsy value
64
+ # - If condition is true → attribute is completely omitted (OPPOSITE of `if`)
65
+ # - If condition is false → attribute is validated and transformed normally
66
+ # - All exceptions in lambda are caught and wrapped in Treaty::Exceptions::Validation
67
+ # - Does NOT support simple mode (unless: true) or advanced mode (unless: { is: ..., message: ... })
68
+ #
69
+ # ## Difference from `if` Option
70
+ #
71
+ # `unless` is the logical opposite of `if`:
72
+ # - `if` includes attribute when condition is TRUE
73
+ # - `unless` includes attribute when condition is FALSE
74
+ #
75
+ # ```ruby
76
+ # # These are equivalent:
77
+ # integer :rating, if: ->(**attrs) { attrs.dig(:post, :published_at).present? }
78
+ # integer :rating, unless: ->(**attrs) { attrs.dig(:post, :published_at).blank? }
79
+ #
80
+ # # These are also equivalent:
81
+ # integer :draft_views, unless: ->(**attrs) { attrs.dig(:post, :published_at).present? }
82
+ # integer :draft_views, if: ->(**attrs) { attrs.dig(:post, :published_at).blank? }
83
+ # ```
84
+ #
85
+ # ## Error Handling
86
+ #
87
+ # If the lambda raises any exception, it's caught and converted to a
88
+ # Treaty::Exceptions::Validation with detailed error message including:
89
+ # - Attribute name
90
+ # - Original exception message
91
+ #
92
+ # ## Data Access Pattern
93
+ #
94
+ # The lambda receives the same data structure that the orchestrator processes.
95
+ # For nested attributes, you can access parent data using dig:
96
+ #
97
+ # ```ruby
98
+ # # For response with { post: { title: "...", published_at: "..." } }
99
+ # integer :draft_views, unless: ->(**attrs) { attrs.dig(:post, :published_at).present? }
100
+ #
101
+ # # Alternative: named argument pattern
102
+ # integer :draft_views, unless: ->(post:) { post[:published_at].present? }
103
+ # ```
104
+ class UnlessConditional < Treaty::Attribute::Option::Conditionals::Base
105
+ # Validates that unless option is a callable (Proc/Lambda)
106
+ #
107
+ # @raise [Treaty::Exceptions::Validation] If unless is not a Proc/lambda
108
+ # @return [void]
109
+ def validate_schema!
110
+ conditional_lambda = @option_schema
111
+
112
+ return if conditional_lambda.respond_to?(:call)
113
+
114
+ raise Treaty::Exceptions::Validation,
115
+ I18n.t(
116
+ "treaty.attributes.conditionals.unless.invalid_type",
117
+ attribute: @attribute_name,
118
+ type: conditional_lambda.class
119
+ )
120
+ end
121
+
122
+ # Evaluates the conditional lambda with runtime data
123
+ # Returns boolean indicating if attribute should be processed
124
+ #
125
+ # @param data [Hash] Raw data from request/response/entity
126
+ # @raise [Treaty::Exceptions::Validation] If lambda execution fails
127
+ # @return [Boolean] True if attribute should be processed (when condition is FALSE), false to skip it
128
+ def evaluate_condition(data)
129
+ conditional_lambda = @option_schema
130
+
131
+ # Call lambda with raw data as named arguments
132
+ # The lambda can use **attributes or specific named args like post:
133
+ result = conditional_lambda.call(**data)
134
+
135
+ # Convert result to boolean and NEGATE it (opposite of if)
136
+ # unless includes attribute when condition is FALSE
137
+ !result
138
+ rescue StandardError => e
139
+ # Catch all exceptions from lambda execution
140
+ raise Treaty::Exceptions::Validation,
141
+ I18n.t(
142
+ "treaty.attributes.conditionals.unless.evaluation_error",
143
+ attribute: @attribute_name,
144
+ error: e.message
145
+ )
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -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