interactor-validation 0.3.9 → 0.4.1

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.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactor
4
+ module Validation
5
+ module Validators
6
+ module Array
7
+ def validate_array(param, value, nested_rules)
8
+ return unless value.is_a?(::Array)
9
+
10
+ value.each_with_index do |item, idx|
11
+ validate_nested_item(param, item, nested_rules, idx)
12
+
13
+ # Halt on first error if configured
14
+ break if validation_config(:halt) && errors.any?
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactor
4
+ module Validation
5
+ module Validators
6
+ module Boolean
7
+ def validate_boolean(param, value)
8
+ return if [true, false].include?(value)
9
+
10
+ errors.add(param, :invalid, message: "must be true or false")
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactor
4
+ module Validation
5
+ module Validators
6
+ module Format
7
+ def validate_format(param, value, rule)
8
+ pattern = rule.is_a?(::Hash) ? rule[:with] : rule
9
+ return if value.to_s.match?(pattern)
10
+
11
+ msg = rule.is_a?(::Hash) ? rule[:message] : nil
12
+ errors.add(param, :invalid, message: msg || "is invalid")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactor
4
+ module Validation
5
+ module Validators
6
+ module Hash
7
+ def validate_hash(param, value, nested_rules)
8
+ return unless value.is_a?(::Hash)
9
+
10
+ validate_nested_item(param, value, nested_rules)
11
+ end
12
+
13
+ private
14
+
15
+ def validate_nested_item(param, item, nested_rules, index = nil)
16
+ return unless item.is_a?(::Hash)
17
+
18
+ nested_rules.each do |attr, attr_rules|
19
+ attr_path = index.nil? ? "#{param}.#{attr}" : "#{param}[#{index}].#{attr}"
20
+ # Use key? to avoid false being treated as nil due to || operator
21
+ attr_value = item.key?(attr) ? item[attr] : item[attr.to_s]
22
+ validate_nested_attribute(attr_path.to_sym, attr_value, attr_rules)
23
+ end
24
+ end
25
+
26
+ def validate_nested_attribute(attr_path, value, rules)
27
+ if rules[:presence] && !value.present? && value != false
28
+ msg = rules[:presence].is_a?(::Hash) ? rules[:presence][:message] : nil
29
+ errors.add(attr_path, :blank, message: msg || "can't be blank")
30
+ end
31
+
32
+ return unless value.present? || value == false
33
+
34
+ validate_format(attr_path, value, rules[:format]) if rules[:format]
35
+ validate_length(attr_path, value, rules[:length]) if rules[:length]
36
+ validate_inclusion(attr_path, value, rules[:inclusion]) if rules[:inclusion]
37
+ validate_numeric(attr_path, value, rules[:numeric]) if rules[:numeric]
38
+ validate_numeric(attr_path, value, rules[:numericality]) if rules[:numericality]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactor
4
+ module Validation
5
+ module Validators
6
+ module Inclusion
7
+ def validate_inclusion(param, value, rule)
8
+ allowed = rule.is_a?(::Hash) ? rule[:in] : rule
9
+ return if allowed.include?(value)
10
+
11
+ msg = rule.is_a?(::Hash) ? rule[:message] : nil
12
+ errors.add(param, :inclusion, message: msg || "is not included in the list")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactor
4
+ module Validation
5
+ module Validators
6
+ module Length
7
+ def validate_length(param, value, rule)
8
+ # Use .length for collections (Array/Hash), .to_s.length for other types
9
+ len = value.respond_to?(:length) && !value.is_a?(String) ? value.length : value.to_s.length
10
+ unit = collection?(value) ? "items" : "characters"
11
+
12
+ validate_minimum_length(param, len, rule[:minimum], unit, rule[:message]) if rule[:minimum]
13
+ validate_maximum_length(param, len, rule[:maximum], unit, rule[:message]) if rule[:maximum]
14
+ validate_exact_length(param, len, rule[:is], unit, rule[:message]) if rule[:is]
15
+ end
16
+
17
+ private
18
+
19
+ def collection?(value)
20
+ (value.is_a?(::Array) || value.is_a?(::Hash)) && !value.is_a?(String)
21
+ end
22
+
23
+ def validate_minimum_length(param, len, minimum, unit, custom_message)
24
+ return unless len < minimum
25
+
26
+ msg = custom_message || "is too short (minimum is #{minimum} #{unit})"
27
+ errors.add(param, :too_short, message: msg, count: minimum)
28
+ end
29
+
30
+ def validate_maximum_length(param, len, maximum, unit, custom_message)
31
+ return unless len > maximum
32
+
33
+ msg = custom_message || "is too long (maximum is #{maximum} #{unit})"
34
+ errors.add(param, :too_long, message: msg, count: maximum)
35
+ end
36
+
37
+ def validate_exact_length(param, len, exact, unit, custom_message)
38
+ return unless len != exact
39
+
40
+ msg = custom_message || "is the wrong length (should be #{exact} #{unit})"
41
+ errors.add(param, :wrong_length, message: msg, count: exact)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactor
4
+ module Validation
5
+ module Validators
6
+ module Numeric
7
+ def validate_numeric(param, value, rule)
8
+ unless numeric?(value)
9
+ msg = rule.is_a?(::Hash) ? rule[:message] : nil
10
+ errors.add(param, :not_a_number, message: msg || "is not a number")
11
+ return
12
+ end
13
+
14
+ num = coerce_numeric(value)
15
+ rule = {} unless rule.is_a?(::Hash)
16
+
17
+ check_numeric_constraint(param, num, :greater_than, rule) { |n, v| n <= v }
18
+ check_numeric_constraint(param, num, :greater_than_or_equal_to, rule) { |n, v| n < v }
19
+ check_numeric_constraint(param, num, :less_than, rule) { |n, v| n >= v }
20
+ check_numeric_constraint(param, num, :less_than_or_equal_to, rule) { |n, v| n > v }
21
+ check_numeric_constraint(param, num, :equal_to, rule) { |n, v| n != v }
22
+ end
23
+
24
+ private
25
+
26
+ def check_numeric_constraint(param, num, type, rule)
27
+ return unless rule[type]
28
+ return unless yield(num, rule[type])
29
+
30
+ msg = rule[:message] || "must be #{type.to_s.tr("_", " ")} #{rule[type]}"
31
+ errors.add(param, type, message: msg, count: rule[type])
32
+ end
33
+
34
+ def numeric?(value)
35
+ value.is_a?(::Numeric) || value.to_s.match?(/\A-?\d+(\.\d+)?\z/)
36
+ end
37
+
38
+ def coerce_numeric(value)
39
+ return value if value.is_a?(::Numeric)
40
+
41
+ value.to_s.include?(".") ? value.to_f : value.to_i
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactor
4
+ module Validation
5
+ module Validators
6
+ module Presence
7
+ def validate_presence(param, value, rule)
8
+ return if value.present? || value == false
9
+
10
+ msg = rule.is_a?(::Hash) ? rule[:message] : nil
11
+ errors.add(param, :blank, message: msg || "can't be blank")
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Interactor
4
4
  module Validation
5
- VERSION = "0.3.9"
5
+ VERSION = "0.4.1"
6
6
  end
7
7
  end
@@ -1,59 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "interactor"
4
- require "active_support/concern"
5
- require "active_model"
6
-
7
- require_relative "validation/version"
4
+ require_relative "validation/core_ext"
8
5
  require_relative "validation/configuration"
9
- require_relative "validation/error_codes"
6
+ require_relative "validation/errors"
10
7
  require_relative "validation/params"
11
8
  require_relative "validation/validates"
9
+ require_relative "validation/version"
12
10
 
13
11
  module Interactor
14
12
  module Validation
15
- class Error < StandardError; end
16
-
17
- extend ActiveSupport::Concern
18
-
19
- included do
20
- include Params
21
- include Validates
22
-
23
- # Instance-level configuration (can override global config)
24
- class_attribute :validation_config, instance_writer: false, default: nil
25
- end
13
+ def self.included(base)
14
+ base.include Params
15
+ base.include Validates
16
+ base.before :run_validations! if base.respond_to?(:before)
26
17
 
27
- class_methods do
28
- # Configure validation behavior for this interactor class
29
- # @example
30
- # configure_validation do |config|
31
- # config.error_mode = :code
32
- # end
33
- def configure_validation
34
- self.validation_config ||= Configuration.new
35
- yield(validation_config)
36
- end
18
+ # Ensure before hook is set up on child classes
19
+ base.singleton_class.prepend(InheritanceHook) if base.respond_to?(:before)
37
20
  end
38
21
 
39
- def self.included(base)
40
- super
41
- # Set up the validation hooks after all modules are included
42
- # Use class_eval to ensure we're in the right context
43
- base.class_eval do
44
- # Register both validate_params! and validate! hooks
45
- # Parameter validations run first, then custom validate! hook
46
- before :validate_params! if respond_to?(:before)
47
- before :validate! if respond_to?(:before)
48
-
49
- # Set up inherited hook to ensure child classes also get the before hooks
50
- def self.inherited(subclass)
51
- super
52
- subclass.before :validate_params! if subclass.respond_to?(:before)
53
- subclass.before :validate! if subclass.respond_to?(:before)
54
- # Also prepend InstanceMethodsOverride to child classes
55
- subclass.prepend(Interactor::Validation::Validates::InstanceMethodsOverride)
56
- end
22
+ module InheritanceHook
23
+ def inherited(subclass)
24
+ super
25
+ subclass.before :run_validations! if subclass.respond_to?(:before)
57
26
  end
58
27
  end
59
28
  end
data/smoke_test.rb ADDED
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "lib/interactor/validation"
5
+
6
+ # Test 1: Basic validation
7
+ class TestBasicValidation
8
+ include Interactor
9
+ include Interactor::Validation
10
+
11
+ params :email, :username, :age
12
+
13
+ validates :email, presence: true, format: { with: /@/ }
14
+ validates :username, presence: true
15
+ validates :age, numericality: { greater_than: 0 }
16
+
17
+ def call
18
+ context.result = "User created: #{email}, #{username}, #{age}"
19
+ end
20
+ end
21
+
22
+ puts "Test 1: Basic Validation"
23
+ puts "=" * 50
24
+
25
+ # Should succeed
26
+ result = TestBasicValidation.call(email: "user@example.com", username: "john", age: 25)
27
+ puts "✓ Valid params: #{result.success? ? 'PASS' : 'FAIL'}"
28
+ puts " Result: #{result.result}" if result.success?
29
+
30
+ # Should fail
31
+ result = TestBasicValidation.call(email: "", username: "", age: -5)
32
+ puts "✗ Invalid params: #{result.failure? ? 'PASS' : 'FAIL'}"
33
+ puts " Errors: #{result.errors.size} errors" if result.failure?
34
+ result.errors.each { |e| puts " - #{e[:attribute]}: #{e[:message]}" } if result.failure?
35
+
36
+ puts
37
+
38
+ # Test 2: Nested validation
39
+ class TestNestedValidation
40
+ include Interactor
41
+ include Interactor::Validation
42
+
43
+ params :user
44
+
45
+ validates :user, presence: true do
46
+ attribute :name, presence: true
47
+ attribute :email, format: { with: /@/ }
48
+ attribute :age, numericality: { greater_than: 0 }
49
+ end
50
+
51
+ def call
52
+ context.result = "User created: #{user[:name]}"
53
+ end
54
+ end
55
+
56
+ puts "Test 2: Nested Validation"
57
+ puts "=" * 50
58
+
59
+ # Should succeed
60
+ result = TestNestedValidation.call(user: { name: "John", email: "john@example.com", age: 30 })
61
+ puts "✓ Valid nested params: #{result.success? ? 'PASS' : 'FAIL'}"
62
+ puts " Result: #{result.result}" if result.success?
63
+
64
+ # Should fail
65
+ result = TestNestedValidation.call(user: { name: "", email: "invalid", age: -5 })
66
+ puts "✗ Invalid nested params: #{result.failure? ? 'PASS' : 'FAIL'}"
67
+ puts " Errors: #{result.errors.size} errors" if result.failure?
68
+ result.errors.each { |e| puts " - #{e[:attribute]}: #{e[:message]}" } if result.failure?
69
+
70
+ puts
71
+
72
+ # Test 3: Array validation
73
+ class TestArrayValidation
74
+ include Interactor
75
+ include Interactor::Validation
76
+
77
+ params :items
78
+
79
+ validates :items do
80
+ attribute :name, presence: true
81
+ attribute :price, numericality: { greater_than: 0 }
82
+ end
83
+
84
+ def call
85
+ context.result = "Processed #{items.size} items"
86
+ end
87
+ end
88
+
89
+ puts "Test 3: Array Validation"
90
+ puts "=" * 50
91
+
92
+ # Should succeed
93
+ result = TestArrayValidation.call(items: [
94
+ { name: "Widget", price: 10 },
95
+ { name: "Gadget", price: 20 }
96
+ ])
97
+ puts "✓ Valid array: #{result.success? ? 'PASS' : 'FAIL'}"
98
+ puts " Result: #{result.result}" if result.success?
99
+
100
+ # Should fail
101
+ result = TestArrayValidation.call(items: [
102
+ { name: "Widget", price: 10 },
103
+ { name: "", price: -5 }
104
+ ])
105
+ puts "✗ Invalid array item: #{result.failure? ? 'PASS' : 'FAIL'}"
106
+ puts " Errors: #{result.errors.size} errors" if result.failure?
107
+ result.errors.each { |e| puts " - #{e[:attribute]}: #{e[:message]}" } if result.failure?
108
+
109
+ puts
110
+
111
+ # Test 4: Custom validation
112
+ class TestCustomValidation
113
+ include Interactor
114
+ include Interactor::Validation
115
+
116
+ params :product_id, :quantity
117
+
118
+ validates :product_id, presence: true
119
+ validates :quantity, numericality: { greater_than: 0 }
120
+
121
+ def validate!
122
+ super
123
+
124
+ # Custom business logic
125
+ if product_id == 999
126
+ errors.add(:product_id, :not_found, message: "Product not found")
127
+ end
128
+ end
129
+
130
+ def call
131
+ context.result = "Order created for product #{product_id}"
132
+ end
133
+ end
134
+
135
+ puts "Test 4: Custom Validation"
136
+ puts "=" * 50
137
+
138
+ # Should succeed
139
+ result = TestCustomValidation.call(product_id: 123, quantity: 5)
140
+ puts "✓ Valid with custom validation: #{result.success? ? 'PASS' : 'FAIL'}"
141
+ puts " Result: #{result.result}" if result.success?
142
+
143
+ # Should fail with custom error
144
+ result = TestCustomValidation.call(product_id: 999, quantity: 5)
145
+ puts "✗ Custom validation error: #{result.failure? ? 'PASS' : 'FAIL'}"
146
+ puts " Errors: #{result.errors.size} errors" if result.failure?
147
+ result.errors.each { |e| puts " - #{e[:attribute]}: #{e[:message]}" } if result.failure?
148
+
149
+ puts
150
+
151
+ # Test 5: Halt on first error (disabled by default)
152
+ class TestHaltDisabled
153
+ include Interactor
154
+ include Interactor::Validation
155
+
156
+ params :email, :username, :age
157
+
158
+ validates :email, presence: true, format: { with: /@/ }
159
+ validates :username, presence: true
160
+ validates :age, numericality: { greater_than: 0 }
161
+
162
+ def call
163
+ context.result = "User created"
164
+ end
165
+ end
166
+
167
+ puts "Test 5: Halt Disabled (Default Behavior)"
168
+ puts "=" * 50
169
+
170
+ # Should collect all errors
171
+ result = TestHaltDisabled.call(email: "", username: "", age: -5)
172
+ puts "✗ All errors collected: #{result.failure? && result.errors.size == 3 ? 'PASS' : 'FAIL'}"
173
+ puts " Expected 3 errors, got #{result.errors.size}" if result.failure?
174
+ result.errors.each { |e| puts " - #{e[:attribute]}: #{e[:type]}" } if result.failure?
175
+
176
+ puts
177
+
178
+ # Test 6: Halt on first error (enabled)
179
+ class TestHaltEnabled
180
+ include Interactor
181
+ include Interactor::Validation
182
+
183
+ params :email, :username, :age
184
+
185
+ validates :email, presence: true, format: { with: /@/ }
186
+ validates :username, presence: true
187
+ validates :age, numericality: { greater_than: 0 }
188
+
189
+ def call
190
+ context.result = "User created"
191
+ end
192
+ end
193
+
194
+ puts "Test 6: Halt Enabled (Stop on First Error)"
195
+ puts "=" * 50
196
+
197
+ # Enable halt
198
+ Interactor::Validation.configure do |config|
199
+ config.halt = true
200
+ end
201
+
202
+ # Should stop at first error
203
+ result = TestHaltEnabled.call(email: "", username: "", age: -5)
204
+ puts "✗ Halted on first error: #{result.failure? && result.errors.size == 1 ? 'PASS' : 'FAIL'}"
205
+ puts " Expected 1 error, got #{result.errors.size}" if result.failure?
206
+ result.errors.each { |e| puts " - #{e[:attribute]}: #{e[:type]}" } if result.failure?
207
+
208
+ # Reset configuration
209
+ Interactor::Validation.configure do |config|
210
+ config.halt = false
211
+ end
212
+
213
+ puts
214
+
215
+ # Test 7: Halt with valid first param, invalid second
216
+ class TestHaltPartialErrors
217
+ include Interactor
218
+ include Interactor::Validation
219
+
220
+ params :email, :username, :age
221
+
222
+ validates :email, presence: true, format: { with: /@/ }
223
+ validates :username, presence: true, length: { minimum: 3 }
224
+ validates :age, numericality: { greater_than: 0 }
225
+
226
+ def call
227
+ context.result = "User created"
228
+ end
229
+ end
230
+
231
+ puts "Test 7: Halt with Valid First Param"
232
+ puts "=" * 50
233
+
234
+ # Enable halt
235
+ Interactor::Validation.configure do |config|
236
+ config.halt = true
237
+ end
238
+
239
+ # Valid email, invalid username and age - should stop at username
240
+ result = TestHaltPartialErrors.call(email: "valid@example.com", username: "", age: -5)
241
+ puts "✗ Halted at username error: #{result.failure? && result.errors.size == 1 && result.errors.first[:attribute] == :username ? 'PASS' : 'FAIL'}"
242
+ puts " Expected 1 error on username, got #{result.errors.size} errors" if result.failure?
243
+ result.errors.each { |e| puts " - #{e[:attribute]}: #{e[:type]}" } if result.failure?
244
+
245
+ # Reset configuration
246
+ Interactor::Validation.configure do |config|
247
+ config.halt = false
248
+ end
249
+
250
+ puts
251
+ puts "=" * 50
252
+ puts "Smoke tests complete!"