interactor-validation 0.3.1 → 0.3.3

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.
@@ -4,16 +4,20 @@ module Interactor
4
4
  module Validation
5
5
  # Configuration class for interactor validation behavior
6
6
  class Configuration
7
- attr_accessor :halt_on_first_error, :regex_timeout, :max_array_size,
7
+ attr_accessor :halt, :regex_timeout, :max_array_size,
8
8
  :enable_instrumentation, :cache_regex_patterns
9
9
  attr_reader :error_mode
10
10
 
11
+ # Backward compatibility alias for halt_on_first_error
12
+ alias halt_on_first_error halt
13
+ alias halt_on_first_error= halt=
14
+
11
15
  # Available error modes:
12
16
  # - :default - Uses ActiveModel-style human-readable messages [DEFAULT]
13
17
  # - :code - Returns structured error codes (e.g., USERNAME_IS_REQUIRED)
14
18
  def initialize
15
19
  @error_mode = :default
16
- @halt_on_first_error = false
20
+ @halt = false
17
21
  @regex_timeout = 0.1 # 100ms timeout for regex matching (ReDoS protection)
18
22
  @max_array_size = 1000 # Maximum array size for nested validation (memory protection)
19
23
  @enable_instrumentation = false # ActiveSupport::Notifications instrumentation
@@ -21,9 +21,11 @@ module Interactor
21
21
 
22
22
  def self.included(base)
23
23
  super
24
- # Include ActiveModel::Validations first, then prepend our override
24
+ # Include ActiveModel::Validations first, then prepend our overrides
25
25
  base.include ActiveModel::Validations unless base.included_modules.include?(ActiveModel::Validations)
26
26
  base.singleton_class.prepend(ClassMethodsOverride)
27
+ # Prepend our instance method overrides to take precedence over ActiveModel
28
+ base.prepend(InstanceMethodsOverride)
27
29
  end
28
30
 
29
31
  module ClassMethodsOverride
@@ -88,22 +90,126 @@ module Interactor
88
90
  end
89
91
  end
90
92
 
93
+ # Wrapper for ActiveModel::Errors that intercepts halt: option
94
+ class ErrorsWrapper < SimpleDelegator
95
+ def initialize(errors, interactor)
96
+ super(errors)
97
+ @interactor = interactor
98
+ end
99
+
100
+ # Override add to intercept halt: option
101
+ def add(attribute, message = :invalid, options = {})
102
+ # Extract halt option before passing to ActiveModel
103
+ halt = options.delete(:halt)
104
+
105
+ # Call the original add method
106
+ __getobj__.add(attribute, message, **options)
107
+
108
+ # Set halt flag if requested
109
+ @interactor.instance_variable_set(:@halt_validation, true) if halt
110
+ end
111
+ end
112
+
113
+ # Module prepended to override ActiveModel instance methods
114
+ module InstanceMethodsOverride
115
+ # Override errors to return a wrapper that intercepts halt: option
116
+ def errors
117
+ @errors ||= ErrorsWrapper.new(super, self)
118
+ end
119
+
120
+ # Override ActiveModel's validate! to prevent exception-raising behavior
121
+ # This hook is called automatically after validate_params!
122
+ # Users can override this to add custom validation logic
123
+ # @return [void]
124
+ # @example
125
+ # def validate!
126
+ # super # Optional: call parent implementation if needed
127
+ # errors.add(:base, "Custom error") if some_condition?
128
+ # end
129
+ # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
130
+ def validate!
131
+ # Preserve errors from validate_params! before calling super
132
+ # because super might trigger ActiveModel's valid? which clears errors
133
+ existing_error_details = errors.map do |error|
134
+ { attribute: error.attribute, type: error.type, options: error.options }
135
+ rescue ArgumentError => e
136
+ # For anonymous classes, accessing error properties may fail
137
+ raise unless e.message.include?("Class name cannot be blank")
138
+
139
+ { attribute: error.attribute, type: :invalid, options: {} }
140
+ end
141
+
142
+ # Skip custom validations if halt was requested
143
+ unless @halt_validation
144
+ # Call super to allow class's validate! to run and add custom errors
145
+ # Rescue exceptions that might be raised
146
+ begin
147
+ super
148
+ rescue NoMethodError
149
+ # No parent validate! method, which is fine
150
+ rescue ArgumentError => e
151
+ # For anonymous classes, super may fail when generating messages
152
+ raise unless e.message.include?("Class name cannot be blank")
153
+ rescue ActiveModel::ValidationError
154
+ # ActiveModel's validate! raises this when there are errors
155
+ # We handle errors differently, so just ignore this exception
156
+ end
157
+ end
158
+
159
+ # Restore errors from validate_params! and add any new ones from custom validate!
160
+ existing_error_details.each do |error_detail|
161
+ # Only add if not already present (avoid duplicates)
162
+ next if errors.where(error_detail[:attribute], error_detail[:type]).any?
163
+
164
+ begin
165
+ errors.add(error_detail[:attribute], error_detail[:type], **error_detail[:options])
166
+ rescue ArgumentError => e
167
+ # For anonymous classes, fall back to adding with message directly
168
+ raise unless e.message.include?("Class name cannot be blank")
169
+
170
+ message = error_detail[:options][:message] || error_detail[:type].to_s
171
+ errors.add(error_detail[:attribute], message)
172
+ end
173
+ end
174
+
175
+ # Reset halt flag after validation completes
176
+ @halt_validation = false
177
+
178
+ # Check all accumulated errors from validate_params! and this hook
179
+ # Fail the context if any errors exist
180
+ return if errors.empty?
181
+
182
+ # Use the existing formatted_errors method which handles all the edge cases
183
+ context.fail!(errors: formatted_errors)
184
+ end
185
+ # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
186
+ end
187
+
91
188
  private
92
189
 
93
190
  # Validates all declared parameters before execution
191
+ # Accumulates errors but does not fail the context
192
+ # The validate! hook will fail the context if there are any errors
94
193
  # @return [void]
95
- # @raise [Interactor::Failure] if validation fails
96
194
  def validate_params!
97
195
  # Memoize config for performance
98
196
  @current_config = current_config
197
+ # Initialize halt flag (but don't reset if already set)
198
+ @halt_validation ||= false
99
199
 
100
200
  # Instrument validation if enabled
101
201
  instrument("validate_params.interactor_validation") do
102
202
  # Trigger ActiveModel validations first (validate callbacks)
103
203
  # This runs any custom validations defined with validate :method_name
104
- # NOTE: valid? must be called BEFORE adding our custom errors
105
- # because it clears the errors object
106
- valid?
204
+ begin
205
+ valid?
206
+ rescue ArgumentError => e
207
+ # For anonymous classes, valid? may fail when generating messages
208
+ raise unless e.message.include?("Class name cannot be blank")
209
+ end
210
+
211
+ # Check if halt was requested during ActiveModel validations
212
+ return if @halt_validation || (@current_config.halt && errors.any?)
107
213
 
108
214
  # Run our custom param validations after ActiveModel validations
109
215
  self.class._param_validations.each do |param_name, rules|
@@ -111,16 +217,16 @@ module Interactor
111
217
  value = context.respond_to?(param_name) ? context.public_send(param_name) : nil
112
218
  validate_param(param_name, value, rules)
113
219
 
114
- # Halt on first error if configured
115
- break if @current_config.halt_on_first_error && errors.any?
220
+ # Halt on first error if configured or if halt was explicitly requested
221
+ break if @halt_validation || (@current_config.halt && errors.any?)
116
222
  end
117
223
 
118
- return if errors.empty?
119
-
120
- context.fail!(errors: formatted_errors)
224
+ # Don't fail here - let validate! hook handle failure
225
+ # This allows validate! to run and add additional custom errors
121
226
  end
122
227
  ensure
123
228
  @current_config = nil # Clear memoization
229
+ # Don't reset @halt_validation here - let validate! handle it
124
230
  end
125
231
 
126
232
  # Get the current configuration (instance config overrides global config)
@@ -142,6 +248,7 @@ module Interactor
142
248
  end
143
249
 
144
250
  # Validates a single parameter with the given rules
251
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
145
252
  def validate_param(param_name, value, rules)
146
253
  # Skip validation if explicitly marked
147
254
  return if rules[:_skip]
@@ -152,14 +259,25 @@ module Interactor
152
259
  return
153
260
  end
154
261
 
155
- # Standard validations
262
+ # Standard validations - halt if requested or configured after each validation
156
263
  validate_presence(param_name, value, rules)
264
+ return if @halt_validation || (@current_config.halt && errors.any?)
265
+
157
266
  validate_boolean(param_name, value, rules)
267
+ return if @halt_validation || (@current_config.halt && errors.any?)
268
+
158
269
  validate_format(param_name, value, rules)
270
+ return if @halt_validation || (@current_config.halt && errors.any?)
271
+
159
272
  validate_length(param_name, value, rules)
273
+ return if @halt_validation || (@current_config.halt && errors.any?)
274
+
160
275
  validate_inclusion(param_name, value, rules)
276
+ return if @halt_validation || (@current_config.halt && errors.any?)
277
+
161
278
  validate_numericality(param_name, value, rules)
162
279
  end
280
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
163
281
 
164
282
  # Validates nested attributes in a hash or array
165
283
  def validate_nested(param_name, value, nested_rules)
@@ -350,7 +468,7 @@ module Interactor
350
468
 
351
469
  # Add error for nested validation
352
470
  # rubocop:disable Metrics/ParameterLists
353
- def add_nested_error(param_name, attr_name, custom_message, error_type, index: nil, **interpolations)
471
+ def add_nested_error(param_name, attr_name, custom_message, error_type, index: nil, halt: false, **interpolations)
354
472
  # Build the attribute path for the error
355
473
  attribute_path = if index.nil?
356
474
  # Hash validation: param_name.attr_name
@@ -370,6 +488,9 @@ module Interactor
370
488
  else
371
489
  errors.add(attribute_path, error_type, **interpolations)
372
490
  end
491
+
492
+ # Set halt flag if requested
493
+ @halt_validation = true if halt
373
494
  end
374
495
  # rubocop:enable Metrics/ParameterLists
375
496
 
@@ -562,8 +683,9 @@ module Interactor
562
683
  # @param param_name [Symbol] the parameter name
563
684
  # @param custom_message [String, nil] custom error message if provided
564
685
  # @param error_type [Symbol] the type of validation error
686
+ # @param halt [Boolean] if true, halts validation immediately after adding this error
565
687
  # @param interpolations [Hash] values to interpolate into the message
566
- def add_error(param_name, custom_message, error_type, **interpolations)
688
+ def add_error(param_name, custom_message, error_type, halt: false, **interpolations)
567
689
  if current_config.error_mode == :code
568
690
  # Code mode: use custom message or generate code
569
691
  code_message = custom_message || error_code_for(error_type, **interpolations)
@@ -574,6 +696,9 @@ module Interactor
574
696
  else
575
697
  errors.add(param_name, error_type, **interpolations)
576
698
  end
699
+
700
+ # Set halt flag if requested
701
+ @halt_validation = true if halt
577
702
  end
578
703
 
579
704
  # Generate error code for :code mode using constants
@@ -628,9 +753,14 @@ module Interactor
628
753
  errors.map do |error|
629
754
  # Convert attribute path to uppercase, handling nested paths
630
755
  # Example: "attributes.username" -> "ATTRIBUTES.USERNAME"
631
- # Example: "attributes[0].username" -> "ATTRIBUTES[0].USERNAME"
756
+ # Example: "attributes[0].username" -> "ATTRIBUTES[0]_USERNAME"
632
757
  param_name = format_attribute_for_code(error.attribute)
633
- message = error.message
758
+ begin
759
+ message = error.message
760
+ rescue ArgumentError, NoMethodError
761
+ # For anonymous classes or other edge cases, fall back to type
762
+ message = error.type.to_s.upcase
763
+ end
634
764
 
635
765
  { code: "#{param_name}_#{message}" }
636
766
  end
@@ -672,7 +802,15 @@ module Interactor
672
802
  "#{attribute_name} #{error_message}"
673
803
  elsif error.respond_to?(:message)
674
804
  # Try to use ActiveModel's message for simple attributes
675
- error.message
805
+ message = error.message
806
+ # If translation is missing, fall back to our default messages
807
+ if message.include?("Translation missing")
808
+ attribute_name = error.attribute.to_s.humanize
809
+ error_message = error.options[:message] || default_message_for_type(error.type, error.options)
810
+ "#{attribute_name} #{error_message}"
811
+ else
812
+ message
813
+ end
676
814
  end
677
815
  rescue ArgumentError, NoMethodError
678
816
  # Fallback for anonymous classes or other issues
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Interactor
4
4
  module Validation
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.3"
6
6
  end
7
7
  end
@@ -38,15 +38,21 @@ module Interactor
38
38
 
39
39
  def self.included(base)
40
40
  super
41
- # Set up the validation hook after all modules are included
41
+ # Set up the validation hooks after all modules are included
42
42
  # Use class_eval to ensure we're in the right context
43
43
  base.class_eval do
44
+ # Register both validate_params! and validate! hooks
45
+ # Parameter validations run first, then custom validate! hook
44
46
  before :validate_params! if respond_to?(:before)
47
+ before :validate! if respond_to?(:before)
45
48
 
46
- # Set up inherited hook to ensure child classes also get the before hook
49
+ # Set up inherited hook to ensure child classes also get the before hooks
47
50
  def self.inherited(subclass)
48
51
  super
49
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)
50
56
  end
51
57
  end
52
58
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: interactor-validation
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wilson Anciro
@@ -13,28 +13,28 @@ dependencies:
13
13
  name: activemodel
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
18
  version: '6.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '6.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activesupport
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - ">="
30
+ - - "~>"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '6.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - ">="
37
+ - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '6.0'
40
40
  - !ruby/object:Gem::Dependency
@@ -133,7 +133,6 @@ licenses:
133
133
  - MIT
134
134
  metadata:
135
135
  homepage_uri: https://github.com/zyxzen/interactor-validation
136
- source_code_uri: https://github.com/zyxzen/interactor-validation
137
136
  changelog_uri: https://github.com/zyxzen/interactor-validation/blob/main/CHANGELOG.md
138
137
  rdoc_options: []
139
138
  require_paths: