treaty 0.13.0 → 0.15.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 +4 -4
- data/README.md +3 -3
- data/config/locales/en.yml +16 -1
- data/lib/treaty/attribute/option/base.rb +12 -1
- data/lib/treaty/attribute/option/conditionals/base.rb +90 -0
- data/lib/treaty/attribute/option/conditionals/if_conditional.rb +134 -0
- data/lib/treaty/attribute/option/conditionals/unless_conditional.rb +151 -0
- data/lib/treaty/attribute/option/modifiers/cast_modifier.rb +44 -6
- data/lib/treaty/attribute/option/registry.rb +18 -2
- data/lib/treaty/attribute/option/registry_initializer.rb +17 -2
- data/lib/treaty/attribute/option/validators/type_validator.rb +37 -8
- data/lib/treaty/attribute/validation/nested_transformer.rb +171 -3
- data/lib/treaty/attribute/validation/orchestrator/base.rb +82 -1
- data/lib/treaty/result.rb +4 -3
- data/lib/treaty/version.rb +1 -1
- data/lib/treaty/versions/workspace.rb +2 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3fcf5acc02ad345e7ff61ba70124596a89be46af035b6ee5d4114de8509f883
|
|
4
|
+
data.tar.gz: bc5d32074c9cab57438851555fb6849014b52da7f0732517a54db2f48563a198
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ebb477674d48c4e4e0aafa30eeb9ca3ad20dd96c65f19d38d31b55f6256b7e816768b149720662c9de239242a9aeab78ed038685dd0d82754b005c9e31380c8
|
|
7
|
+
data.tar.gz: b04b8248df32a42c41b833920cada319fcfb73344a7fa9520867b59fbba60655716f8152342f9a34765c06aa05d99160b7ff9812b826ff7bebca2ccd6a1cda94
|
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.
|
|
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.
|
|
17
17
|
|
|
18
18
|
## 📚 Documentation
|
|
19
19
|
|
|
@@ -79,7 +79,7 @@ module Posts
|
|
|
79
79
|
string :title
|
|
80
80
|
string :content
|
|
81
81
|
string :summary
|
|
82
|
-
|
|
82
|
+
time :created_at
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
@@ -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](
|
|
136
|
+
Treaty is available as open source under the terms of the [MIT License](./LICENSE).
|
data/config/locales/en.yml
CHANGED
|
@@ -17,7 +17,9 @@ en:
|
|
|
17
17
|
boolean: "Attribute '%{attribute}' must be a Boolean (true or false), got %{actual}"
|
|
18
18
|
object: "Attribute '%{attribute}' must be a Hash (object), got %{actual}"
|
|
19
19
|
array: "Attribute '%{attribute}' must be an Array, got %{actual}"
|
|
20
|
-
|
|
20
|
+
date: "Attribute '%{attribute}' must be a Date, got %{actual}"
|
|
21
|
+
time: "Attribute '%{attribute}' must be a Time, got %{actual}"
|
|
22
|
+
datetime: "Attribute '%{attribute}' must be a DateTime, got %{actual}"
|
|
21
23
|
|
|
22
24
|
inclusion:
|
|
23
25
|
invalid_schema: "Option 'inclusion' for attribute '%{attribute}' must have a non-empty array of allowed values"
|
|
@@ -43,6 +45,7 @@ en:
|
|
|
43
45
|
# Attribute options
|
|
44
46
|
options:
|
|
45
47
|
unknown: "Unknown options for attribute '%{attribute}': %{unknown}. Known options: %{known}"
|
|
48
|
+
message_evaluation_error: "Custom message evaluation failed for attribute '%{attribute}': %{error}"
|
|
46
49
|
|
|
47
50
|
# Attribute modifiers
|
|
48
51
|
modifiers:
|
|
@@ -60,6 +63,18 @@ en:
|
|
|
60
63
|
conversion_not_supported: "Option 'cast' for attribute '%{attribute}' does not support conversion from '%{from}' to '%{to}'"
|
|
61
64
|
conversion_error: "Cast failed for attribute '%{attribute}' from '%{from}' to '%{to}'. Value: '%{value}'. Error: %{error}"
|
|
62
65
|
|
|
66
|
+
# Attribute conditionals
|
|
67
|
+
conditionals:
|
|
68
|
+
if:
|
|
69
|
+
invalid_type: "Option 'if' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
|
|
70
|
+
evaluation_error: "Conditional evaluation failed for attribute '%{attribute}': %{error}"
|
|
71
|
+
|
|
72
|
+
unless:
|
|
73
|
+
invalid_type: "Option 'unless' for attribute '%{attribute}' must be a Proc or Lambda. Got: %{type}"
|
|
74
|
+
evaluation_error: "Conditional evaluation failed for attribute '%{attribute}': %{error}"
|
|
75
|
+
|
|
76
|
+
mutual_exclusivity_error: "Attribute '%{attribute}' cannot have both 'if' and 'unless' options. Use only one conditional option."
|
|
77
|
+
|
|
63
78
|
# Attribute builder DSL
|
|
64
79
|
builder:
|
|
65
80
|
not_implemented: "%{class} must implement #create_attribute"
|
|
@@ -143,10 +143,12 @@ module Treaty
|
|
|
143
143
|
|
|
144
144
|
# Resolves custom message with lambda support
|
|
145
145
|
# If message is a lambda, calls it with provided named arguments
|
|
146
|
+
# Catches all exceptions from lambda execution and re-raises as Validation errors
|
|
146
147
|
#
|
|
147
148
|
# @param attributes [Hash] Named arguments to pass to lambda
|
|
148
149
|
# @return [String, nil] Resolved message string or nil
|
|
149
|
-
|
|
150
|
+
# @raise [Treaty::Exceptions::Validation] If custom message lambda raises an exception
|
|
151
|
+
def resolve_custom_message(**attributes) # rubocop:disable Metrics/MethodLength
|
|
150
152
|
message = custom_message
|
|
151
153
|
return nil if message.nil?
|
|
152
154
|
|
|
@@ -155,6 +157,15 @@ module Treaty
|
|
|
155
157
|
else
|
|
156
158
|
message
|
|
157
159
|
end
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
# Catch all exceptions from custom message lambda execution
|
|
162
|
+
error_message = I18n.t(
|
|
163
|
+
"treaty.attributes.options.message_evaluation_error",
|
|
164
|
+
attribute: @attribute_name,
|
|
165
|
+
error: e.message
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
raise Treaty::Exceptions::Validation, error_message
|
|
158
169
|
end
|
|
159
170
|
|
|
160
171
|
# 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
|
|
@@ -50,24 +50,42 @@ module Treaty
|
|
|
50
50
|
# ### From Integer
|
|
51
51
|
# - integer -> string: Converts to string representation
|
|
52
52
|
# - integer -> boolean: 0 = false, non-zero = true
|
|
53
|
-
# - integer ->
|
|
53
|
+
# - integer -> date: Treats as Unix timestamp, converts to date
|
|
54
|
+
# - integer -> time: Treats as Unix timestamp
|
|
55
|
+
# - integer -> datetime: Treats as Unix timestamp, converts to datetime
|
|
54
56
|
#
|
|
55
57
|
# ### From String
|
|
56
58
|
# - string -> integer: Parses integer from string
|
|
57
59
|
# - string -> boolean: Parses truthy/falsy strings (true/false, yes/no, 1/0, on/off)
|
|
60
|
+
# - string -> date: Parses date string
|
|
61
|
+
# - string -> time: Parses time string
|
|
58
62
|
# - string -> datetime: Parses datetime string (ISO8601, RFC3339, etc.)
|
|
59
63
|
#
|
|
60
64
|
# ### From Boolean
|
|
61
65
|
# - boolean -> string: Converts to "true" or "false"
|
|
62
66
|
# - boolean -> integer: true = 1, false = 0
|
|
63
67
|
#
|
|
68
|
+
# ### From Date
|
|
69
|
+
# - date -> string: Converts to ISO8601 format
|
|
70
|
+
# - date -> integer: Converts to Unix timestamp
|
|
71
|
+
# - date -> time: Converts to Time at midnight
|
|
72
|
+
# - date -> datetime: Converts to DateTime at midnight
|
|
73
|
+
#
|
|
74
|
+
# ### From Time
|
|
75
|
+
# - time -> string: Converts to ISO8601 format
|
|
76
|
+
# - time -> integer: Converts to Unix timestamp
|
|
77
|
+
# - time -> date: Converts to Date
|
|
78
|
+
# - time -> datetime: Converts to DateTime
|
|
79
|
+
#
|
|
64
80
|
# ### From DateTime
|
|
65
81
|
# - datetime -> string: Converts to ISO8601 format
|
|
66
82
|
# - datetime -> integer: Converts to Unix timestamp
|
|
83
|
+
# - datetime -> date: Converts to Date
|
|
84
|
+
# - datetime -> time: Converts to Time
|
|
67
85
|
#
|
|
68
86
|
# ## Important Notes
|
|
69
87
|
#
|
|
70
|
-
# - Cast option only works with scalar types (integer, string, boolean, datetime)
|
|
88
|
+
# - Cast option only works with scalar types (integer, string, boolean, date, time, datetime)
|
|
71
89
|
# - Array and Object types are not supported for casting
|
|
72
90
|
# - Casting to the same type is allowed (no-op)
|
|
73
91
|
# - Nil values are not transformed (handled by RequiredValidator)
|
|
@@ -82,9 +100,9 @@ module Treaty
|
|
|
82
100
|
#
|
|
83
101
|
# Schema format: `{ to: :target_type, message: "Custom error" }`
|
|
84
102
|
# Note: Uses `:to` key instead of the default `:is` key.
|
|
85
|
-
class CastModifier < Treaty::Attribute::Option::Base
|
|
103
|
+
class CastModifier < Treaty::Attribute::Option::Base # rubocop:disable Metrics/ClassLength
|
|
86
104
|
# Types that support casting (scalar types only)
|
|
87
|
-
ALLOWED_CAST_TYPES = %i[integer string boolean datetime].freeze
|
|
105
|
+
ALLOWED_CAST_TYPES = %i[integer string boolean date time datetime].freeze
|
|
88
106
|
|
|
89
107
|
# Validates that cast option is correctly configured
|
|
90
108
|
#
|
|
@@ -202,12 +220,16 @@ module Treaty
|
|
|
202
220
|
integer: ->(value:) { value }, # No-op for same type
|
|
203
221
|
string: ->(value:) { value.to_s },
|
|
204
222
|
boolean: ->(value:) { value != 0 },
|
|
205
|
-
|
|
223
|
+
date: ->(value:) { Time.at(value).to_date },
|
|
224
|
+
time: ->(value:) { Time.at(value) },
|
|
225
|
+
datetime: ->(value:) { Time.at(value).to_datetime }
|
|
206
226
|
},
|
|
207
227
|
string: {
|
|
208
228
|
string: ->(value:) { value }, # No-op for same type
|
|
209
229
|
integer: ->(value:) { Integer(value) },
|
|
210
230
|
boolean: ->(value:) { parse_boolean(value) },
|
|
231
|
+
date: ->(value:) { Date.parse(value) },
|
|
232
|
+
time: ->(value:) { Time.parse(value) },
|
|
211
233
|
datetime: ->(value:) { DateTime.parse(value) }
|
|
212
234
|
},
|
|
213
235
|
boolean: {
|
|
@@ -215,10 +237,26 @@ module Treaty
|
|
|
215
237
|
string: ->(value:) { value.to_s },
|
|
216
238
|
integer: ->(value:) { value ? 1 : 0 }
|
|
217
239
|
},
|
|
240
|
+
date: {
|
|
241
|
+
date: ->(value:) { value }, # No-op for same type
|
|
242
|
+
string: ->(value:) { value.iso8601 },
|
|
243
|
+
integer: ->(value:) { value.to_time.to_i },
|
|
244
|
+
time: ->(value:) { value.to_time },
|
|
245
|
+
datetime: ->(value:) { value.to_datetime }
|
|
246
|
+
},
|
|
247
|
+
time: {
|
|
248
|
+
time: ->(value:) { value }, # No-op for same type
|
|
249
|
+
string: ->(value:) { value.iso8601 },
|
|
250
|
+
integer: ->(value:) { value.to_i },
|
|
251
|
+
date: ->(value:) { value.to_date },
|
|
252
|
+
datetime: ->(value:) { value.to_datetime }
|
|
253
|
+
},
|
|
218
254
|
datetime: {
|
|
219
255
|
datetime: ->(value:) { value }, # No-op for same type
|
|
220
256
|
string: ->(value:) { value.iso8601 },
|
|
221
|
-
integer: ->(value:) { value.to_i }
|
|
257
|
+
integer: ->(value:) { value.to_i },
|
|
258
|
+
date: ->(value:) { value.to_date },
|
|
259
|
+
time: ->(value:) { value.to_time }
|
|
222
260
|
}
|
|
223
261
|
}
|
|
224
262
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Treaty
|
|
4
4
|
module Attribute
|
|
5
5
|
module Option
|
|
6
|
-
# Central registry for all option processors (validators and
|
|
6
|
+
# Central registry for all option processors (validators, modifiers, and conditionals).
|
|
7
7
|
#
|
|
8
8
|
# ## Purpose
|
|
9
9
|
#
|
|
@@ -14,7 +14,7 @@ module Treaty
|
|
|
14
14
|
#
|
|
15
15
|
# 1. **Registration** - Stores option processor classes
|
|
16
16
|
# 2. **Retrieval** - Provides access to registered processors
|
|
17
|
-
# 3. **Categorization** - Organizes processors by category (validator/modifier)
|
|
17
|
+
# 3. **Categorization** - Organizes processors by category (validator/modifier/conditional)
|
|
18
18
|
# 4. **Validation** - Checks if options are registered
|
|
19
19
|
#
|
|
20
20
|
# ## Registered Options
|
|
@@ -23,15 +23,23 @@ module Treaty
|
|
|
23
23
|
# - `:required` → RequiredValidator
|
|
24
24
|
# - `:type` → TypeValidator
|
|
25
25
|
# - `:inclusion` → InclusionValidator
|
|
26
|
+
# - `:format` → FormatValidator
|
|
26
27
|
#
|
|
27
28
|
# ### Modifiers
|
|
28
29
|
# - `:as` → AsModifier
|
|
29
30
|
# - `:default` → DefaultModifier
|
|
31
|
+
# - `:transform` → TransformModifier
|
|
32
|
+
# - `:cast` → CastModifier
|
|
33
|
+
#
|
|
34
|
+
# ### Conditionals
|
|
35
|
+
# - `:if` → IfConditional
|
|
36
|
+
# - `:unless` → UnlessConditional
|
|
30
37
|
#
|
|
31
38
|
# ## Usage
|
|
32
39
|
#
|
|
33
40
|
# Registration (done in RegistryInitializer):
|
|
34
41
|
# Registry.register(:required, RequiredValidator, category: :validator)
|
|
42
|
+
# Registry.register(:if, IfConditional, category: :conditional)
|
|
35
43
|
#
|
|
36
44
|
# Retrieval (done in OptionOrchestrator):
|
|
37
45
|
# processor_class = Registry.processor_for(:required)
|
|
@@ -111,6 +119,14 @@ module Treaty
|
|
|
111
119
|
.transform_values { |info| info.fetch(:processor_class) }
|
|
112
120
|
end
|
|
113
121
|
|
|
122
|
+
# Get all conditionals
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash] Hash of option_name => processor_class for conditionals
|
|
125
|
+
def conditionals
|
|
126
|
+
registry.select { |_, info| info.fetch(:category) == :conditional }
|
|
127
|
+
.transform_values { |info| info.fetch(:processor_class) }
|
|
128
|
+
end
|
|
129
|
+
|
|
114
130
|
# Reset registry (mainly for testing)
|
|
115
131
|
def reset!
|
|
116
132
|
@registry = nil
|
|
@@ -7,14 +7,15 @@ module Treaty
|
|
|
7
7
|
#
|
|
8
8
|
# ## Purpose
|
|
9
9
|
#
|
|
10
|
-
# Centralized registration point for all option processors (validators and
|
|
10
|
+
# Centralized registration point for all option processors (validators, modifiers, and conditionals).
|
|
11
11
|
# Automatically registers all built-in options when loaded.
|
|
12
12
|
#
|
|
13
13
|
# ## Responsibilities
|
|
14
14
|
#
|
|
15
15
|
# 1. **Validator Registration** - Registers all built-in validators
|
|
16
16
|
# 2. **Modifier Registration** - Registers all built-in modifiers
|
|
17
|
-
# 3. **
|
|
17
|
+
# 3. **Conditional Registration** - Registers all built-in conditionals
|
|
18
|
+
# 4. **Auto-Loading** - Executes automatically when file is loaded
|
|
18
19
|
#
|
|
19
20
|
# ## Built-in Validators
|
|
20
21
|
#
|
|
@@ -30,6 +31,11 @@ module Treaty
|
|
|
30
31
|
# - `:transform` → TransformModifier - Transforms values using custom lambdas
|
|
31
32
|
# - `:cast` → CastModifier - Converts values between types automatically
|
|
32
33
|
#
|
|
34
|
+
# ## Built-in Conditionals
|
|
35
|
+
#
|
|
36
|
+
# - `:if` → IfConditional - Conditionally includes attributes based on runtime data
|
|
37
|
+
# - `:unless` → UnlessConditional - Conditionally excludes attributes based on runtime data
|
|
38
|
+
#
|
|
33
39
|
# ## Auto-Registration
|
|
34
40
|
#
|
|
35
41
|
# This file calls `register_all!` when loaded, ensuring all processors
|
|
@@ -63,6 +69,7 @@ module Treaty
|
|
|
63
69
|
def register_all!
|
|
64
70
|
register_validators!
|
|
65
71
|
register_modifiers!
|
|
72
|
+
register_conditionals!
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
private
|
|
@@ -86,6 +93,14 @@ module Treaty
|
|
|
86
93
|
Registry.register(:transform, Modifiers::TransformModifier, category: :modifier)
|
|
87
94
|
Registry.register(:cast, Modifiers::CastModifier, category: :modifier)
|
|
88
95
|
end
|
|
96
|
+
|
|
97
|
+
# Registers all built-in conditionals
|
|
98
|
+
#
|
|
99
|
+
# @return [void]
|
|
100
|
+
def register_conditionals!
|
|
101
|
+
Registry.register(:if, Conditionals::IfConditional, category: :conditional)
|
|
102
|
+
Registry.register(:unless, Conditionals::UnlessConditional, category: :conditional)
|
|
103
|
+
end
|
|
89
104
|
end
|
|
90
105
|
end
|
|
91
106
|
end
|
|
@@ -13,7 +13,9 @@ module Treaty
|
|
|
13
13
|
# - `:boolean` - Ruby TrueClass or FalseClass
|
|
14
14
|
# - `:object` - Ruby Hash (for nested objects)
|
|
15
15
|
# - `:array` - Ruby Array (for collections)
|
|
16
|
-
# - `:
|
|
16
|
+
# - `:date` - Ruby Date
|
|
17
|
+
# - `:time` - Ruby Time
|
|
18
|
+
# - `:datetime` - Ruby DateTime
|
|
17
19
|
#
|
|
18
20
|
# ## Usage Examples
|
|
19
21
|
#
|
|
@@ -21,7 +23,9 @@ module Treaty
|
|
|
21
23
|
# integer :age
|
|
22
24
|
# string :name
|
|
23
25
|
# boolean :published
|
|
24
|
-
#
|
|
26
|
+
# date :published_on
|
|
27
|
+
# time :created_at
|
|
28
|
+
# datetime :updated_at
|
|
25
29
|
#
|
|
26
30
|
# Nested structures:
|
|
27
31
|
# object :author do
|
|
@@ -36,14 +40,16 @@ module Treaty
|
|
|
36
40
|
#
|
|
37
41
|
# - Validates only non-nil values (nil handling is done by RequiredValidator)
|
|
38
42
|
# - Type mismatch raises Treaty::Exceptions::Validation
|
|
39
|
-
# -
|
|
43
|
+
# - Date accepts only Date objects (not DateTime or Time)
|
|
44
|
+
# - Time accepts only Time objects (not Date or DateTime)
|
|
45
|
+
# - DateTime accepts only DateTime objects (not Date or Time)
|
|
40
46
|
#
|
|
41
47
|
# ## Note
|
|
42
48
|
#
|
|
43
49
|
# TypeValidator doesn't use option_schema - it validates based on attribute_type.
|
|
44
50
|
# This validator is always active for all attributes.
|
|
45
51
|
class TypeValidator < Treaty::Attribute::Option::Base
|
|
46
|
-
ALLOWED_TYPES = %i[integer string boolean object array datetime].freeze
|
|
52
|
+
ALLOWED_TYPES = %i[integer string boolean object array date time datetime].freeze
|
|
47
53
|
|
|
48
54
|
# Validates that the attribute type is one of the allowed types
|
|
49
55
|
#
|
|
@@ -81,6 +87,10 @@ module Treaty
|
|
|
81
87
|
validate_object!(value)
|
|
82
88
|
when :array
|
|
83
89
|
validate_array!(value)
|
|
90
|
+
when :date
|
|
91
|
+
validate_date!(value)
|
|
92
|
+
when :time
|
|
93
|
+
validate_time!(value)
|
|
84
94
|
when :datetime
|
|
85
95
|
validate_datetime!(value)
|
|
86
96
|
end
|
|
@@ -172,14 +182,33 @@ module Treaty
|
|
|
172
182
|
validate_type!(value, :array) { |v| v.is_a?(Array) }
|
|
173
183
|
end
|
|
174
184
|
|
|
175
|
-
# Validates that value is a DateTime,
|
|
185
|
+
# Validates that value is a Date (but not DateTime, since DateTime < Date)
|
|
176
186
|
#
|
|
177
187
|
# @param value [Object] The value to validate
|
|
178
|
-
# @raise [Treaty::Exceptions::Validation] If value is not a
|
|
188
|
+
# @raise [Treaty::Exceptions::Validation] If value is not a Date
|
|
189
|
+
# @return [void]
|
|
190
|
+
def validate_date!(value)
|
|
191
|
+
validate_type!(value, :date) { |v| v.is_a?(Date) && !v.is_a?(DateTime) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Validates that value is a Time or ActiveSupport::TimeWithZone
|
|
195
|
+
#
|
|
196
|
+
# @param value [Object] The value to validate
|
|
197
|
+
# @raise [Treaty::Exceptions::Validation] If value is not a Time
|
|
198
|
+
# @return [void]
|
|
199
|
+
def validate_time!(value)
|
|
200
|
+
validate_type!(value, :time) do |v|
|
|
201
|
+
v.is_a?(Time) || (defined?(ActiveSupport::TimeWithZone) && v.is_a?(ActiveSupport::TimeWithZone))
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Validates that value is a DateTime
|
|
206
|
+
#
|
|
207
|
+
# @param value [Object] The value to validate
|
|
208
|
+
# @raise [Treaty::Exceptions::Validation] If value is not a DateTime
|
|
179
209
|
# @return [void]
|
|
180
210
|
def validate_datetime!(value)
|
|
181
|
-
|
|
182
|
-
validate_type!(value, :datetime) { |v| v.is_a?(DateTime) || v.is_a?(Time) || v.is_a?(Date) }
|
|
211
|
+
validate_type!(value, :datetime) { |v| v.is_a?(DateTime) }
|
|
183
212
|
end
|
|
184
213
|
end
|
|
185
214
|
end
|
|
@@ -79,6 +79,9 @@ module Treaty
|
|
|
79
79
|
transformed = {}
|
|
80
80
|
|
|
81
81
|
attribute.collection_of_attributes.each do |nested_attribute|
|
|
82
|
+
# Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
|
|
83
|
+
next unless should_process_attribute?(nested_attribute, value)
|
|
84
|
+
|
|
82
85
|
process_attribute(nested_attribute, value, transformed)
|
|
83
86
|
end
|
|
84
87
|
|
|
@@ -87,6 +90,87 @@ module Treaty
|
|
|
87
90
|
|
|
88
91
|
private
|
|
89
92
|
|
|
93
|
+
# Returns the conditional option name if present (:if or :unless)
|
|
94
|
+
# Raises error if both are present (mutual exclusivity)
|
|
95
|
+
#
|
|
96
|
+
# @param nested_attribute [Attribute::Base] The attribute to check
|
|
97
|
+
# @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
|
|
98
|
+
# @return [Symbol, nil] :if, :unless, or nil
|
|
99
|
+
def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength
|
|
100
|
+
has_if = nested_attribute.options.key?(:if)
|
|
101
|
+
has_unless = nested_attribute.options.key?(:unless)
|
|
102
|
+
|
|
103
|
+
if has_if && has_unless
|
|
104
|
+
raise Treaty::Exceptions::Validation,
|
|
105
|
+
I18n.t(
|
|
106
|
+
"treaty.attributes.conditionals.mutual_exclusivity_error",
|
|
107
|
+
attribute: nested_attribute.name
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
return :if if has_if
|
|
112
|
+
return :unless if has_unless
|
|
113
|
+
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Gets cached conditional processors for attributes or builds them
|
|
118
|
+
#
|
|
119
|
+
# @return [Hash] Hash of attribute => conditional processor
|
|
120
|
+
def conditionals_for_attributes
|
|
121
|
+
@conditionals_for_attributes ||= build_conditionals_for_attributes
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Builds conditional processors for attributes with :if or :unless option
|
|
125
|
+
# Validates schema at definition time for performance
|
|
126
|
+
#
|
|
127
|
+
# @return [Hash] Hash of attribute => conditional processor
|
|
128
|
+
def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
|
|
129
|
+
attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache|
|
|
130
|
+
# Get conditional option name (:if or :unless)
|
|
131
|
+
conditional_type = conditional_option_for(nested_attribute)
|
|
132
|
+
next if conditional_type.nil?
|
|
133
|
+
|
|
134
|
+
processor_class = Option::Registry.processor_for(conditional_type)
|
|
135
|
+
next if processor_class.nil?
|
|
136
|
+
|
|
137
|
+
# Create processor instance
|
|
138
|
+
conditional = processor_class.new(
|
|
139
|
+
attribute_name: nested_attribute.name,
|
|
140
|
+
attribute_type: nested_attribute.type,
|
|
141
|
+
option_schema: nested_attribute.options.fetch(conditional_type)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Validate schema at definition time (not runtime)
|
|
145
|
+
conditional.validate_schema!
|
|
146
|
+
|
|
147
|
+
cache[nested_attribute] = conditional
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Checks if an attribute should be processed based on its conditional (if/unless option)
|
|
152
|
+
# Returns true if no conditional is defined or if conditional evaluates appropriately
|
|
153
|
+
#
|
|
154
|
+
# @param nested_attribute [Attribute::Base] The attribute to check
|
|
155
|
+
# @param source_hash [Hash] Source data to pass to conditional
|
|
156
|
+
# @return [Boolean] True if attribute should be processed, false to skip it
|
|
157
|
+
def should_process_attribute?(nested_attribute, source_hash)
|
|
158
|
+
# Check if attribute has a conditional option
|
|
159
|
+
conditional_type = conditional_option_for(nested_attribute)
|
|
160
|
+
return true if conditional_type.nil?
|
|
161
|
+
|
|
162
|
+
# Get cached conditional processor
|
|
163
|
+
conditional = conditionals_for_attributes[nested_attribute]
|
|
164
|
+
return true if conditional.nil?
|
|
165
|
+
|
|
166
|
+
# Evaluate condition with source hash data wrapped with parent object name
|
|
167
|
+
wrapped_data = { attribute.name => source_hash }
|
|
168
|
+
conditional.evaluate_condition(wrapped_data)
|
|
169
|
+
rescue StandardError
|
|
170
|
+
# If conditional evaluation fails, skip the attribute
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
|
|
90
174
|
# Processes a single nested attribute
|
|
91
175
|
# Validates, transforms, and adds to target hash
|
|
92
176
|
#
|
|
@@ -117,7 +201,7 @@ module Treaty
|
|
|
117
201
|
end
|
|
118
202
|
|
|
119
203
|
# Transforms array with nested attributes
|
|
120
|
-
class ArrayTransformer
|
|
204
|
+
class ArrayTransformer # rubocop:disable Metrics/ClassLength
|
|
121
205
|
SELF_OBJECT = :_self
|
|
122
206
|
private_constant :SELF_OBJECT
|
|
123
207
|
|
|
@@ -147,6 +231,87 @@ module Treaty
|
|
|
147
231
|
|
|
148
232
|
private
|
|
149
233
|
|
|
234
|
+
# Returns the conditional option name if present (:if or :unless)
|
|
235
|
+
# Raises error if both are present (mutual exclusivity)
|
|
236
|
+
#
|
|
237
|
+
# @param nested_attribute [Attribute::Base] The attribute to check
|
|
238
|
+
# @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
|
|
239
|
+
# @return [Symbol, nil] :if, :unless, or nil
|
|
240
|
+
def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength
|
|
241
|
+
has_if = nested_attribute.options.key?(:if)
|
|
242
|
+
has_unless = nested_attribute.options.key?(:unless)
|
|
243
|
+
|
|
244
|
+
if has_if && has_unless
|
|
245
|
+
raise Treaty::Exceptions::Validation,
|
|
246
|
+
I18n.t(
|
|
247
|
+
"treaty.attributes.conditionals.mutual_exclusivity_error",
|
|
248
|
+
attribute: nested_attribute.name
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
return :if if has_if
|
|
253
|
+
return :unless if has_unless
|
|
254
|
+
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Gets cached conditional processors for attributes or builds them
|
|
259
|
+
#
|
|
260
|
+
# @return [Hash] Hash of attribute => conditional processor
|
|
261
|
+
def conditionals_for_attributes
|
|
262
|
+
@conditionals_for_attributes ||= build_conditionals_for_attributes
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Builds conditional processors for attributes with :if or :unless option
|
|
266
|
+
# Validates schema at definition time for performance
|
|
267
|
+
#
|
|
268
|
+
# @return [Hash] Hash of attribute => conditional processor
|
|
269
|
+
def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
|
|
270
|
+
attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache|
|
|
271
|
+
# Get conditional option name (:if or :unless)
|
|
272
|
+
conditional_type = conditional_option_for(nested_attribute)
|
|
273
|
+
next if conditional_type.nil?
|
|
274
|
+
|
|
275
|
+
processor_class = Option::Registry.processor_for(conditional_type)
|
|
276
|
+
next if processor_class.nil?
|
|
277
|
+
|
|
278
|
+
# Create processor instance
|
|
279
|
+
conditional = processor_class.new(
|
|
280
|
+
attribute_name: nested_attribute.name,
|
|
281
|
+
attribute_type: nested_attribute.type,
|
|
282
|
+
option_schema: nested_attribute.options.fetch(conditional_type)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Validate schema at definition time (not runtime)
|
|
286
|
+
conditional.validate_schema!
|
|
287
|
+
|
|
288
|
+
cache[nested_attribute] = conditional
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Checks if an attribute should be processed based on its conditional (if/unless option)
|
|
293
|
+
# Returns true if no conditional is defined or if conditional evaluates appropriately
|
|
294
|
+
#
|
|
295
|
+
# @param nested_attribute [Attribute::Base] The attribute to check
|
|
296
|
+
# @param source_hash [Hash] Source data to pass to conditional
|
|
297
|
+
# @return [Boolean] True if attribute should be processed, false to skip it
|
|
298
|
+
def should_process_attribute?(nested_attribute, source_hash)
|
|
299
|
+
# Check if attribute has a conditional option
|
|
300
|
+
conditional_type = conditional_option_for(nested_attribute)
|
|
301
|
+
return true if conditional_type.nil?
|
|
302
|
+
|
|
303
|
+
# Get cached conditional processor
|
|
304
|
+
conditional = conditionals_for_attributes[nested_attribute]
|
|
305
|
+
return true if conditional.nil?
|
|
306
|
+
|
|
307
|
+
# Evaluate condition with source hash data wrapped with parent array attribute name
|
|
308
|
+
wrapped_data = { attribute.name => source_hash }
|
|
309
|
+
conditional.evaluate_condition(wrapped_data)
|
|
310
|
+
rescue StandardError
|
|
311
|
+
# If conditional evaluation fails, skip the attribute
|
|
312
|
+
false
|
|
313
|
+
end
|
|
314
|
+
|
|
150
315
|
# Checks if this is a simple array (primitive values)
|
|
151
316
|
#
|
|
152
317
|
# @return [Boolean] True if array contains primitive values with :_self attribute
|
|
@@ -163,8 +328,8 @@ module Treaty
|
|
|
163
328
|
# @raise [Treaty::Exceptions::Validation] If validation fails
|
|
164
329
|
# @return [Object] Transformed element value
|
|
165
330
|
def transform_simple_element(item, index) # rubocop:disable Metrics/MethodLength
|
|
166
|
-
|
|
167
|
-
validator = AttributeValidator.new(
|
|
331
|
+
self_attribute = attribute.collection_of_attributes.first
|
|
332
|
+
validator = AttributeValidator.new(self_attribute)
|
|
168
333
|
validator.validate_schema!
|
|
169
334
|
|
|
170
335
|
begin
|
|
@@ -201,6 +366,9 @@ module Treaty
|
|
|
201
366
|
transformed = {}
|
|
202
367
|
|
|
203
368
|
attribute.collection_of_attributes.each do |nested_attribute|
|
|
369
|
+
# Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
|
|
370
|
+
next unless should_process_attribute?(nested_attribute, item)
|
|
371
|
+
|
|
204
372
|
process_attribute(nested_attribute, item, transformed, index)
|
|
205
373
|
end
|
|
206
374
|
|
|
@@ -70,12 +70,16 @@ module Treaty
|
|
|
70
70
|
|
|
71
71
|
# Validates and transforms all attributes
|
|
72
72
|
# Iterates through attributes, processes them, handles :_self objects
|
|
73
|
+
# Skips attributes with false conditional (if/unless option)
|
|
73
74
|
#
|
|
74
75
|
# @return [Hash] Transformed data with all attributes processed
|
|
75
|
-
def validate!
|
|
76
|
+
def validate! # rubocop:disable Metrics/MethodLength
|
|
76
77
|
transformed_data = {}
|
|
77
78
|
|
|
78
79
|
collection_of_attributes.each do |attribute|
|
|
80
|
+
# Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
|
|
81
|
+
next unless should_process_attribute?(attribute)
|
|
82
|
+
|
|
79
83
|
transformed_value = validate_and_transform_attribute!(attribute)
|
|
80
84
|
|
|
81
85
|
if attribute.name == SELF_OBJECT && attribute.type == :object
|
|
@@ -91,6 +95,49 @@ module Treaty
|
|
|
91
95
|
|
|
92
96
|
private
|
|
93
97
|
|
|
98
|
+
# Returns the conditional option name if present (:if or :unless)
|
|
99
|
+
# Raises error if both are present (mutual exclusivity)
|
|
100
|
+
#
|
|
101
|
+
# @param attribute [Attribute::Base] The attribute to check
|
|
102
|
+
# @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
|
|
103
|
+
# @return [Symbol, nil] :if, :unless, or nil
|
|
104
|
+
def conditional_option_for(attribute) # rubocop:disable Metrics/MethodLength
|
|
105
|
+
has_if = attribute.options.key?(:if)
|
|
106
|
+
has_unless = attribute.options.key?(:unless)
|
|
107
|
+
|
|
108
|
+
if has_if && has_unless
|
|
109
|
+
raise Treaty::Exceptions::Validation,
|
|
110
|
+
I18n.t(
|
|
111
|
+
"treaty.attributes.conditionals.mutual_exclusivity_error",
|
|
112
|
+
attribute: attribute.name
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return :if if has_if
|
|
117
|
+
return :unless if has_unless
|
|
118
|
+
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Checks if an attribute should be processed based on its conditional (if/unless option)
|
|
123
|
+
# Returns true if no conditional is defined or if conditional evaluates appropriately
|
|
124
|
+
#
|
|
125
|
+
# @param attribute [Attribute::Base] The attribute to check
|
|
126
|
+
# @return [Boolean] True if attribute should be processed, false to skip it
|
|
127
|
+
def should_process_attribute?(attribute)
|
|
128
|
+
# Check if attribute has a conditional option
|
|
129
|
+
conditional_type = conditional_option_for(attribute)
|
|
130
|
+
return true if conditional_type.nil?
|
|
131
|
+
|
|
132
|
+
# Get cached conditional processor
|
|
133
|
+
conditional = conditionals_for_attributes[attribute]
|
|
134
|
+
return true if conditional.nil?
|
|
135
|
+
|
|
136
|
+
# Evaluate condition with raw data
|
|
137
|
+
# The processor's evaluate_condition already handles if/unless logic
|
|
138
|
+
conditional.evaluate_condition(data)
|
|
139
|
+
end
|
|
140
|
+
|
|
94
141
|
# Returns collection of attributes for this context
|
|
95
142
|
# Must be implemented in subclasses
|
|
96
143
|
#
|
|
@@ -119,6 +166,40 @@ module Treaty
|
|
|
119
166
|
end
|
|
120
167
|
end
|
|
121
168
|
|
|
169
|
+
# Gets cached conditional processors for attributes or builds them
|
|
170
|
+
#
|
|
171
|
+
# @return [Hash] Hash of attribute => conditional processor
|
|
172
|
+
def conditionals_for_attributes
|
|
173
|
+
@conditionals_for_attributes ||= build_conditionals_for_attributes
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Builds conditional processors for attributes with :if or :unless option
|
|
177
|
+
# Validates schema at definition time for performance
|
|
178
|
+
#
|
|
179
|
+
# @return [Hash] Hash of attribute => conditional processor
|
|
180
|
+
def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
|
|
181
|
+
collection_of_attributes.each_with_object({}) do |attribute, cache|
|
|
182
|
+
# Get conditional option name (:if or :unless)
|
|
183
|
+
conditional_type = conditional_option_for(attribute)
|
|
184
|
+
next if conditional_type.nil?
|
|
185
|
+
|
|
186
|
+
processor_class = Option::Registry.processor_for(conditional_type)
|
|
187
|
+
next if processor_class.nil?
|
|
188
|
+
|
|
189
|
+
# Create processor instance
|
|
190
|
+
conditional = processor_class.new(
|
|
191
|
+
attribute_name: attribute.name,
|
|
192
|
+
attribute_type: attribute.type,
|
|
193
|
+
option_schema: attribute.options.fetch(conditional_type)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Validate schema at definition time (not runtime)
|
|
197
|
+
conditional.validate_schema!
|
|
198
|
+
|
|
199
|
+
cache[attribute] = conditional
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
122
203
|
# Validates and transforms a single attribute
|
|
123
204
|
# Handles both nested and regular attributes
|
|
124
205
|
#
|
data/lib/treaty/result.rb
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Treaty
|
|
4
4
|
class Result
|
|
5
|
-
attr_reader :data, :status
|
|
5
|
+
attr_reader :data, :status, :version
|
|
6
6
|
|
|
7
|
-
def initialize(data:, status:)
|
|
7
|
+
def initialize(data:, status:, version:)
|
|
8
8
|
@data = data
|
|
9
9
|
@status = status
|
|
10
|
+
@version = version
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def inspect
|
|
@@ -16,7 +17,7 @@ module Treaty
|
|
|
16
17
|
private
|
|
17
18
|
|
|
18
19
|
def draw_result
|
|
19
|
-
"@data=#{@data.inspect}, @status=#{@status.inspect}"
|
|
20
|
+
"@data=#{@data.inspect}, @status=#{@status.inspect}, @version=#{@version.inspect}"
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
end
|
data/lib/treaty/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anton Sokolov
|
|
@@ -155,6 +155,9 @@ files:
|
|
|
155
155
|
- lib/treaty/attribute/entity/builder.rb
|
|
156
156
|
- lib/treaty/attribute/helper_mapper.rb
|
|
157
157
|
- lib/treaty/attribute/option/base.rb
|
|
158
|
+
- lib/treaty/attribute/option/conditionals/base.rb
|
|
159
|
+
- lib/treaty/attribute/option/conditionals/if_conditional.rb
|
|
160
|
+
- lib/treaty/attribute/option/conditionals/unless_conditional.rb
|
|
158
161
|
- lib/treaty/attribute/option/modifiers/as_modifier.rb
|
|
159
162
|
- lib/treaty/attribute/option/modifiers/cast_modifier.rb
|
|
160
163
|
- lib/treaty/attribute/option/modifiers/default_modifier.rb
|