interactor-validation 0.3.7 → 0.3.9

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: d6d255c1cdb4048fbd485a9bc8b95096d521e592e6babe4ea7961ec429312386
4
- data.tar.gz: a9bc28cb3c10f5f2415da43c3a63b8c774436e13d27e2e6facaaa0cbcbeb19b9
3
+ metadata.gz: f9c7888fc968eb40b00dbb2225455cd34f60444dfa710eb2d2e3ca991178a572
4
+ data.tar.gz: 2ff3d6b998b0bc1ec4112c888c7169842335f4829fd215b40e08b98e7e6a38a6
5
5
  SHA512:
6
- metadata.gz: f5e73c0e35a659668dd31783d5fbea0869f54efe3db2d1033320c66d90fe9fb7997a2fc7079b602608ac6aa1bec60467588ba4b6fdea6bdd3b890cd24ea55bf5
7
- data.tar.gz: 7da53549b4bd1ef65065b9480dca243f1a78922045f096281025f7e35a034c8418ce88c90cac698fd6dd8816a9c434e23050b88a42701d96803e2bcf73d8f78a
6
+ metadata.gz: 622c1af2f52477ccef49d4fea329a2d27d34b4a9cf30387a5c213ad58c7ae0ee8f645a942cde0dc12e2605128c607a204a1d44d7c54004c4205f27a6a3745107
7
+ data.tar.gz: 57adf88f0a34b152a3af76afdcce71b183dc0e5ef4d79b4bf2ccde88c4dc5cbc15173a2c1e5a257674487b0367ce29261cd721e483a333ac255ca31e5604db59
data/README.md CHANGED
@@ -213,19 +213,49 @@ validates :terms_accepted, boolean: true
213
213
 
214
214
  ### Nested Validation
215
215
 
216
- Validate nested hashes and arrays.
216
+ Validate nested hashes and arrays with support for both optional and required parameters.
217
217
 
218
- **Hash Validation:**
218
+ **Optional Nested Validation (parameter can be nil):**
219
+
220
+ ```ruby
221
+ params :filters
222
+ validates :filters do
223
+ attribute :type, presence: true
224
+ attribute :value
225
+ end
226
+
227
+ # When filters is nil or missing - succeeds
228
+ result = SearchItems.call(filters: nil)
229
+ result.success? # => true
230
+
231
+ # When filters is present - validates nested attributes
232
+ result = SearchItems.call(filters: { type: "category" })
233
+ result.success? # => true
234
+
235
+ # When filters is present but invalid - fails
236
+ result = SearchItems.call(filters: { value: "test" })
237
+ result.errors # => [{ attribute: "filters.type", type: :blank, message: "Filters.type can't be blank" }]
238
+ ```
239
+
240
+ **Required Nested Validation (parameter must be present):**
219
241
 
220
242
  ```ruby
221
243
  params :user
222
- validates :user do
244
+ validates :user, presence: true do
223
245
  attribute :name, presence: true
224
246
  attribute :email, format: { with: /@/ }
225
247
  attribute :age, numericality: { greater_than: 0 }
226
248
  end
227
249
 
228
- # Usage
250
+ # When user is nil - fails with presence error
251
+ result = CreateUser.call(user: nil)
252
+ result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
253
+
254
+ # When user is empty hash - fails with presence error (empty hashes are not .present?)
255
+ result = CreateUser.call(user: {})
256
+ result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
257
+
258
+ # When user is present - validates nested attributes
229
259
  result = CreateUser.call(user: { name: "", email: "bad", age: -1 })
230
260
  result.errors # => [
231
261
  # { attribute: "user.name", type: :blank, message: "User.name can't be blank" },
@@ -252,6 +282,18 @@ result.errors # => [
252
282
  # { attribute: "items[1].name", type: :blank, message: "Items[1].name can't be blank" },
253
283
  # { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" }
254
284
  # ]
285
+
286
+ # Optional array (can be nil)
287
+ result = ProcessItems.call(items: nil)
288
+ result.success? # => true
289
+
290
+ # Required array (must be present)
291
+ validates :items, presence: true do
292
+ attribute :name, presence: true
293
+ end
294
+
295
+ result = ProcessItems.call(items: nil)
296
+ result.errors # => [{ attribute: :items, type: :blank, message: "Items can't be blank" }]
255
297
  ```
256
298
 
257
299
  ---
@@ -323,6 +365,9 @@ Interactor::Validation.configure do |config|
323
365
  # Stop at first error for better performance
324
366
  config.halt = false # Set to true to stop on first validation error
325
367
 
368
+ # Skip custom validate! hook when parameter validation fails
369
+ config.skip_validate = true # Set to false to always run validate! hook
370
+
326
371
  # Security settings
327
372
  config.regex_timeout = 0.1 # Regex timeout in seconds (ReDoS protection)
328
373
  config.max_array_size = 1000 # Max array size for nested validation
@@ -434,6 +479,75 @@ end
434
479
  - Improve API response times by failing fast
435
480
  - Provide cleaner error messages (only the most relevant error)
436
481
 
482
+ ### Skip Custom Validations on Parameter Errors
483
+
484
+ Control whether custom `validate!` hooks run when parameter validation fails:
485
+
486
+ ```ruby
487
+ # Default behavior (skip_validate = true)
488
+ class CreateUser
489
+ include Interactor
490
+ include Interactor::Validation
491
+
492
+ configure_validation do |config|
493
+ config.skip_validate = true # Default
494
+ end
495
+
496
+ params :username, :email
497
+
498
+ validates :username, presence: true
499
+ validates :email, presence: true
500
+
501
+ def validate!
502
+ # This will NOT run if username or email validation fails
503
+ # Improves performance by skipping expensive checks
504
+ user_exists = User.exists?(username: username)
505
+ errors.add(:username, "already taken") if user_exists
506
+ end
507
+
508
+ def call
509
+ User.create!(username: username, email: email)
510
+ end
511
+ end
512
+
513
+ # With skip_validate = false, always run validate!
514
+ class CreateUserAlwaysValidate
515
+ include Interactor
516
+ include Interactor::Validation
517
+
518
+ configure_validation do |config|
519
+ config.skip_validate = false # Run validate! even with param errors
520
+ end
521
+
522
+ params :username, :email
523
+
524
+ validates :username, presence: true
525
+ validates :email, presence: true
526
+
527
+ def validate!
528
+ # This WILL run even if username or email is missing
529
+ # Useful when you need to collect all possible errors
530
+ user_exists = User.exists?(username: username) if username.present?
531
+ errors.add(:username, "already taken") if user_exists
532
+ end
533
+
534
+ def call
535
+ User.create!(username: username, email: email)
536
+ end
537
+ end
538
+ ```
539
+
540
+ **When to use skip_validate = true (default):**
541
+ - Skip expensive database lookups when basic params are invalid
542
+ - Avoid unnecessary API calls when required fields are missing
543
+ - Improve performance in high-traffic endpoints
544
+ - Fail fast with only parameter validation errors
545
+
546
+ **When to use skip_validate = false:**
547
+ - Collect all possible validation errors in a single request
548
+ - Always run business logic validations regardless of param state
549
+ - Provide comprehensive error feedback to users
550
+
437
551
  ### ActiveModel Integration
438
552
 
439
553
  Use ActiveModel's custom validation callbacks:
@@ -1059,14 +1173,45 @@ end
1059
1173
  ### Nested Validation Examples
1060
1174
 
1061
1175
  ```ruby
1062
- # Hash validation
1176
+ # Optional hash validation (parameter can be nil)
1177
+ class SearchWithFilters
1178
+ include Interactor
1179
+ include Interactor::Validation
1180
+
1181
+ params :filters
1182
+
1183
+ validates :filters do
1184
+ attribute :category, presence: true
1185
+ attribute :min_price, numericality: { greater_than_or_equal_to: 0 }
1186
+ attribute :max_price, numericality: { greater_than_or_equal_to: 0 }
1187
+ end
1188
+
1189
+ def call
1190
+ # filters is optional - if nil, skip filtering
1191
+ results = filters ? apply_filters(Product.all) : Product.all
1192
+ context.results = results
1193
+ end
1194
+ end
1195
+
1196
+ # When filters is nil - succeeds
1197
+ result = SearchWithFilters.call(filters: nil)
1198
+ result.success? # => true
1199
+
1200
+ # When filters is present but invalid - fails
1201
+ result = SearchWithFilters.call(filters: { min_price: -10 })
1202
+ result.errors # => [
1203
+ # { attribute: "filters.category", type: :blank, message: "Filters.category can't be blank" },
1204
+ # { attribute: "filters.min_price", type: :greater_than_or_equal_to, message: "..." }
1205
+ # ]
1206
+
1207
+ # Required hash validation (parameter must be present)
1063
1208
  class CreateUserWithProfile
1064
1209
  include Interactor
1065
1210
  include Interactor::Validation
1066
1211
 
1067
1212
  params :user
1068
1213
 
1069
- validates :user do
1214
+ validates :user, presence: true do
1070
1215
  attribute :name, presence: true
1071
1216
  attribute :email, format: { with: /@/ }
1072
1217
  attribute :age, numericality: { greater_than: 0 }
@@ -1078,7 +1223,14 @@ class CreateUserWithProfile
1078
1223
  end
1079
1224
  end
1080
1225
 
1081
- # Usage
1226
+ # When user is nil or empty - fails with presence error
1227
+ result = CreateUserWithProfile.call(user: nil)
1228
+ result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
1229
+
1230
+ result = CreateUserWithProfile.call(user: {})
1231
+ result.errors # => [{ attribute: :user, type: :blank, message: "User can't be blank" }]
1232
+
1233
+ # When user is present but invalid - validates nested attributes
1082
1234
  result = CreateUserWithProfile.call(
1083
1235
  user: {
1084
1236
  name: "",
@@ -1094,7 +1246,7 @@ result.errors # => [
1094
1246
  # { attribute: "user.bio", type: :too_long, message: "User.bio is too long (maximum is 500 characters)" }
1095
1247
  # ]
1096
1248
 
1097
- # Array validation
1249
+ # Array validation (optional)
1098
1250
  class BulkCreateItems
1099
1251
  include Interactor
1100
1252
  include Interactor::Validation
@@ -1108,11 +1260,16 @@ class BulkCreateItems
1108
1260
  end
1109
1261
 
1110
1262
  def call
1263
+ return if items.nil? # Optional - handle nil gracefully
1111
1264
  items.each { |item| Item.create!(item) }
1112
1265
  end
1113
1266
  end
1114
1267
 
1115
- # Usage
1268
+ # When items is nil - succeeds
1269
+ result = BulkCreateItems.call(items: nil)
1270
+ result.success? # => true
1271
+
1272
+ # When items is present - validates each item
1116
1273
  result = BulkCreateItems.call(
1117
1274
  items: [
1118
1275
  { name: "Widget", price: 10, quantity: 5 },
@@ -1124,6 +1281,30 @@ result.errors # => [
1124
1281
  # { attribute: "items[1].price", type: :greater_than, message: "Items[1].price must be greater than 0" },
1125
1282
  # { attribute: "items[1].quantity", type: :greater_than_or_equal_to, message: "Items[1].quantity must be greater than or equal to 1" }
1126
1283
  # ]
1284
+
1285
+ # Required array validation
1286
+ class BulkCreateRequiredItems
1287
+ include Interactor
1288
+ include Interactor::Validation
1289
+
1290
+ params :items
1291
+
1292
+ validates :items, presence: true do
1293
+ attribute :name, presence: true
1294
+ attribute :price, numericality: { greater_than: 0 }
1295
+ end
1296
+
1297
+ def call
1298
+ items.each { |item| Item.create!(item) }
1299
+ end
1300
+ end
1301
+
1302
+ # When items is nil or empty - fails with presence error
1303
+ result = BulkCreateRequiredItems.call(items: nil)
1304
+ result.errors # => [{ attribute: :items, type: :blank, message: "Items can't be blank" }]
1305
+
1306
+ result = BulkCreateRequiredItems.call(items: [])
1307
+ result.errors # => [{ attribute: :items, type: :blank, message: "Items can't be blank" }]
1127
1308
  ```
1128
1309
 
1129
1310
  ### ActiveModel Integration
@@ -5,7 +5,8 @@ module Interactor
5
5
  # Configuration class for interactor validation behavior
6
6
  class Configuration
7
7
  attr_accessor :halt, :regex_timeout, :max_array_size,
8
- :enable_instrumentation, :cache_regex_patterns
8
+ :enable_instrumentation, :cache_regex_patterns,
9
+ :skip_validate
9
10
  attr_reader :error_mode
10
11
 
11
12
  # Backward compatibility alias for halt_on_first_error
@@ -22,6 +23,7 @@ module Interactor
22
23
  @max_array_size = 1000 # Maximum array size for nested validation (memory protection)
23
24
  @enable_instrumentation = false # ActiveSupport::Notifications instrumentation
24
25
  @cache_regex_patterns = true # Cache compiled regex patterns for performance
26
+ @skip_validate = true # Skip validate! hook if validate_params! has errors
25
27
  end
26
28
 
27
29
  def error_mode=(mode)
@@ -46,8 +46,9 @@ module Interactor
46
46
  nested_rules = build_nested_rules(&)
47
47
  current_validations = _param_validations.dup
48
48
  existing_rules = current_validations[param_name] || {}
49
+ # Merge both the validation rules (like presence: true) AND the nested rules
49
50
  self._param_validations = current_validations.merge(
50
- param_name => existing_rules.merge(_nested: nested_rules)
51
+ param_name => existing_rules.merge(rules).merge(_nested: nested_rules)
51
52
  )
52
53
  return
53
54
  end
@@ -145,8 +146,11 @@ module Interactor
145
146
  { attribute: error.attribute, type: :invalid, options: {} }
146
147
  end
147
148
 
148
- # Skip custom validations if halt was requested
149
- unless @halt_validation
149
+ # Skip custom validations if configured to skip when param errors exist
150
+ skip_custom_validation = current_config.skip_validate && existing_error_details.any?
151
+
152
+ # Skip custom validations if halt was requested or if configured to skip on param errors
153
+ unless @halt_validation || skip_custom_validation
150
154
  # Call super to allow class's validate! to run and add custom errors
151
155
  # Rescue exceptions that might be raised
152
156
  begin
@@ -244,7 +248,14 @@ module Interactor
244
248
  # Get the current configuration (instance config overrides global config)
245
249
  # @return [Configuration] the active configuration
246
250
  def current_config
247
- @current_config || self.class.validation_config || Interactor::Validation.configuration
251
+ return @current_config if @current_config
252
+
253
+ # Try to get class-level config if available
254
+ if self.class.respond_to?(:validation_config)
255
+ self.class.validation_config || Interactor::Validation.configuration
256
+ else
257
+ Interactor::Validation.configuration
258
+ end
248
259
  end
249
260
 
250
261
  # Instrument a block of code if instrumentation is enabled
@@ -260,14 +271,23 @@ module Interactor
260
271
  end
261
272
 
262
273
  # Validates a single parameter with the given rules
263
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
274
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
264
275
  def validate_param(param_name, value, rules)
265
276
  # Skip validation if explicitly marked
266
277
  return if rules[:_skip]
267
278
 
268
279
  # Handle nested validation (hash or array)
269
280
  if rules[:_nested]
270
- validate_nested(param_name, value, rules[:_nested])
281
+ # Run presence validation first if it exists
282
+ # This allows optional vs required nested validation
283
+ validate_presence(param_name, value, rules)
284
+ return if @halt_validation || (@current_config.halt && errors.any?)
285
+
286
+ # Only run nested validation if value is present (not nil and not empty)
287
+ # If parent has no presence requirement, treat empty hash/array as "not provided"
288
+ # If parent has presence: true, empty hash/array already failed presence check above
289
+ should_validate_nested = value.present? || value == false
290
+ validate_nested(param_name, value, rules[:_nested]) if should_validate_nested
271
291
  return
272
292
  end
273
293
 
@@ -289,10 +309,13 @@ module Interactor
289
309
 
290
310
  validate_numericality(param_name, value, rules)
291
311
  end
292
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
312
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
293
313
 
294
314
  # Validates nested attributes in a hash or array
295
315
  def validate_nested(param_name, value, nested_rules)
316
+ # Return early if value is nil - presence validation handles this if required
317
+ return if value.nil?
318
+
296
319
  if value.is_a?(Array)
297
320
  validate_array_of_hashes(param_name, value, nested_rules)
298
321
  elsif value.is_a?(Hash)
@@ -501,7 +524,7 @@ module Interactor
501
524
  errors.add(attribute_path, error_type, halt: halt, **interpolations)
502
525
  end
503
526
 
504
- # Note: halt flag is set by ErrorsWrapper.add() if halt: true
527
+ # NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
505
528
  end
506
529
  # rubocop:enable Metrics/ParameterLists
507
530
 
@@ -708,7 +731,7 @@ module Interactor
708
731
  errors.add(param_name, error_type, halt: halt, **interpolations)
709
732
  end
710
733
 
711
- # Note: halt flag is set by ErrorsWrapper.add() if halt: true
734
+ # NOTE: halt flag is set by ErrorsWrapper.add() if halt: true
712
735
  end
713
736
 
714
737
  # Generate error code for :code mode using constants
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Interactor
4
4
  module Validation
5
- VERSION = "0.3.7"
5
+ VERSION = "0.3.9"
6
6
  end
7
7
  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.7
4
+ version: 0.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wilson Anciro