interactor_support 1.0.1 → 1.0.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.
- checksums.yaml +4 -4
- data/.prettierignore +1 -0
- data/.yardopts +19 -0
- data/CHANGELOG.md +10 -0
- data/README.md +258 -5
- data/lib/interactor_support/actions.rb +31 -0
- data/lib/interactor_support/concerns/findable.rb +64 -20
- data/lib/interactor_support/concerns/skippable.rb +42 -0
- data/lib/interactor_support/concerns/transactionable.rb +37 -0
- data/lib/interactor_support/concerns/transformable.rb +78 -15
- data/lib/interactor_support/concerns/updatable.rb +43 -1
- data/lib/interactor_support/configuration.rb +32 -9
- data/lib/interactor_support/core.rb +19 -1
- data/lib/interactor_support/request_object.rb +131 -19
- data/lib/interactor_support/validations.rb +89 -9
- data/lib/interactor_support/version.rb +1 -1
- data/lib/interactor_support.rb +44 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b479b98d9029e0865ab4526efcc08ec47736ecba96547d2ab9be294f54d2880
|
|
4
|
+
data.tar.gz: 74f5d2a0024afb6b7c49ff153a339d6373d25072d37e546827407e65cfd0bc8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1d24d1b7fe20316f26d14f029387d90e1c88e2dc9654500dd40ae9546e030121efcb5de991d091b752308f0f976533a4dabaebc3cb2bebc269b7f5ca70ab7d3
|
|
7
|
+
data.tar.gz: 32c320144c9f90dc53769bd104a308d17ce663e8618cc0f55f8435d89835ed3aa2cb3049df023422de0d34068ced12795e12127e8eb976f18a3db8a56c0e759a
|
data/.prettierignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.yardopts
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -7,3 +7,13 @@
|
|
|
7
7
|
## [1.0.1] - 2025-03-26
|
|
8
8
|
|
|
9
9
|
- Removed runtime requirements for rails and interactor.
|
|
10
|
+
|
|
11
|
+
## [1.0.2] - 2025-03-28
|
|
12
|
+
|
|
13
|
+
- Added support for mixing symbols and procs in the transformable concern
|
|
14
|
+
|
|
15
|
+
## [1.0.3] - 2025-04-02
|
|
16
|
+
|
|
17
|
+
- Added support for rewriting attribute names in a request object
|
|
18
|
+
- Better support for type coersion, using Active model + Array, Hash, and Symbol
|
|
19
|
+
- Better support for `AnyClass` type validations
|
data/README.md
CHANGED
|
@@ -28,7 +28,7 @@ Make your **Rails interactors** clean, powerful, and less error-prone!
|
|
|
28
28
|
Add to your Gemfile:
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
|
|
31
|
+
gem 'interactor_support', '~> 1.0', '>= 1.0.1'
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Or install manually:
|
|
@@ -84,7 +84,7 @@ class UpdateTodoTitle
|
|
|
84
84
|
include Interactor
|
|
85
85
|
include InteractorSupport
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
required :todo_id, :title
|
|
88
88
|
transform :title, with: :strip
|
|
89
89
|
find_by :todo, query: { id: :todo_id }, required: true
|
|
90
90
|
update :todo, attributes: { title: :title }
|
|
@@ -110,7 +110,7 @@ class CompleteTodo
|
|
|
110
110
|
include InteractorSupport
|
|
111
111
|
|
|
112
112
|
transaction
|
|
113
|
-
|
|
113
|
+
required :todo_id
|
|
114
114
|
find_by :todo, query: { id: :todo_id }, required: true
|
|
115
115
|
|
|
116
116
|
update :todo, attributes: {
|
|
@@ -133,7 +133,7 @@ class CompleteTodo
|
|
|
133
133
|
include InteractorSupport
|
|
134
134
|
|
|
135
135
|
transaction
|
|
136
|
-
|
|
136
|
+
required :todo
|
|
137
137
|
skip if: -> { todo.completed? }
|
|
138
138
|
update :todo, attributes: { completed: true, completed_at: -> { Time.current } }
|
|
139
139
|
end
|
|
@@ -235,10 +235,53 @@ find_where :post, where: { user_id: :user_id }, context_key: :user_posts
|
|
|
235
235
|
|
|
236
236
|
Provides `transform` to **sanitize and normalize inputs**.
|
|
237
237
|
|
|
238
|
+
```rb
|
|
239
|
+
# any method that the attribute responds to will work
|
|
240
|
+
transform :title, with: :strip
|
|
241
|
+
|
|
242
|
+
# You can chain transformers on an attribute
|
|
243
|
+
# show_the_thing == "1" => "1".to_i.positive? => true
|
|
244
|
+
transform :show_the_thing, with: [:to_i, :positive?]
|
|
245
|
+
|
|
246
|
+
# transforming a string to a boolean using a lambda, eg: "true" => true
|
|
247
|
+
transform :my_param, with: -> (val) { ActiveModel::Type::Boolean.new.cast(val) }
|
|
248
|
+
|
|
249
|
+
# added in 1.0.2
|
|
250
|
+
# mixing symbols and keys. eg: " True " => true
|
|
251
|
+
transform :my_param, with: [
|
|
252
|
+
:strip,
|
|
253
|
+
:downcase,
|
|
254
|
+
-> (val) { ActiveModel::Type::Boolean.new.cast(val) }
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
|
|
238
259
|
#### 🔹 **`InteractorSupport::Concerns::Skippable`**
|
|
239
260
|
|
|
240
261
|
Allows an interactor to **skip execution** if a condition is met.
|
|
241
262
|
|
|
263
|
+
```rb
|
|
264
|
+
# skips execution
|
|
265
|
+
skip if: true
|
|
266
|
+
# skips execution when a lambda is passed
|
|
267
|
+
skip if: -> { true }
|
|
268
|
+
# using a method
|
|
269
|
+
skip if: :some_method?
|
|
270
|
+
# using a context variable
|
|
271
|
+
skip if: :condition
|
|
272
|
+
|
|
273
|
+
# Using `unless`
|
|
274
|
+
|
|
275
|
+
# skips execution
|
|
276
|
+
skip unless: false
|
|
277
|
+
# skips execution when a lambda is passed
|
|
278
|
+
skip unless: -> { false }
|
|
279
|
+
# using a method
|
|
280
|
+
skip unless: :some_method?
|
|
281
|
+
# using a context variable
|
|
282
|
+
skip unless: :condition
|
|
283
|
+
```
|
|
284
|
+
|
|
242
285
|
#### 🔹 **`InteractorSupport::Validations`**
|
|
243
286
|
|
|
244
287
|
Provides **automatic input validation** before execution. This includes `ActiveModel::Validations` and
|
|
@@ -272,7 +315,217 @@ If any validation fails, context.fail!(errors: errors.full_messages) will automa
|
|
|
272
315
|
|
|
273
316
|
#### 🔹 **`InteractorSupport::RequestObject`**
|
|
274
317
|
|
|
275
|
-
|
|
318
|
+
A flexible, form-like abstraction for service object inputs, built on top of ActiveModel. InteractorSupport::RequestObject extends ActiveModel::Model and ActiveModel::Validations to provide structured, validated, and transformed input objects. It adds first-class support for nested objects, type coercion, attribute transformation, and array handling. It's ideal for use with any architecture that benefits from strong input modeling.
|
|
319
|
+
|
|
320
|
+
_RequestObject Enforces Input Integrity, and 🔐 allow-lists attributes by default_
|
|
321
|
+
|
|
322
|
+
**Features**
|
|
323
|
+
|
|
324
|
+
- Define attributes with types and transformation pipelines
|
|
325
|
+
- Supports primitive and custom object types
|
|
326
|
+
- Deeply nested input coercion and validation
|
|
327
|
+
- Array support for any type
|
|
328
|
+
- Auto-generated context hashes or structs
|
|
329
|
+
- Key rewriting for internal/external mapping
|
|
330
|
+
- Full ActiveModel validation support
|
|
331
|
+
|
|
332
|
+
Rather than manually massaging and validating hashes or params in your services, define intent-driven objects that:
|
|
333
|
+
|
|
334
|
+
- clean incoming values
|
|
335
|
+
- validate data structure and content
|
|
336
|
+
- expose clean interfaces for business logic
|
|
337
|
+
|
|
338
|
+
🚀 Getting Started
|
|
339
|
+
|
|
340
|
+
1. Define a Request Object
|
|
341
|
+
|
|
342
|
+
```rb
|
|
343
|
+
class GenreRequest
|
|
344
|
+
include InteractorSupport::RequestObject
|
|
345
|
+
|
|
346
|
+
attribute :title, transform: :strip
|
|
347
|
+
attribute :description, transform: :strip
|
|
348
|
+
|
|
349
|
+
validates :title, :description, presence: true
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
2. Use it in your Interactor, Service, or Controller
|
|
354
|
+
|
|
355
|
+
```rb
|
|
356
|
+
class GenresController < ApplicationController
|
|
357
|
+
def create
|
|
358
|
+
context = SomeOrganizerForCreatingGenres.call(
|
|
359
|
+
GenreRequest.new(params.permit!) # 😉 request objects are a safe and powerful replacement for strong params
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# render context.genre & handle success? vs failure?
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Attribute Features
|
|
368
|
+
|
|
369
|
+
#### Transformations:
|
|
370
|
+
|
|
371
|
+
Apply one or more transformations when values are assigned.
|
|
372
|
+
|
|
373
|
+
```rb
|
|
374
|
+
attribute :email, transform: [:strip, :downcase]
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
- You can use any transform that the value can `respond_to?`
|
|
378
|
+
- Define custom transforms as instance methods.
|
|
379
|
+
|
|
380
|
+
Type Casting:
|
|
381
|
+
Cast inputs to expected types automatically:
|
|
382
|
+
|
|
383
|
+
```rb
|
|
384
|
+
attribute :age, type: :integer
|
|
385
|
+
attribute :tags, type: :string, array: true
|
|
386
|
+
attribute :config, type: Hash
|
|
387
|
+
attribute :published_at, type: :datetime
|
|
388
|
+
attribute :user, type: User
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
If the value is already of the expected type, it will just pass through. Otherwise, it will try to cast it.
|
|
392
|
+
If casting fails, or you specify an unsupported type, it will raise an `InteractorSupport::RequestObject::TypeError`
|
|
393
|
+
|
|
394
|
+
Supported types are
|
|
395
|
+
|
|
396
|
+
- Any ActiveModel::Type, provided as a symbol.
|
|
397
|
+
- The following primitives, Array, Hash, Symbol
|
|
398
|
+
- RequestObject subclasses (for nesting request objects)
|
|
399
|
+
|
|
400
|
+
#### Nesting Request Objects
|
|
401
|
+
|
|
402
|
+
```rb
|
|
403
|
+
class AuthorRequest
|
|
404
|
+
include InteractorSupport::RequestObject
|
|
405
|
+
|
|
406
|
+
attribute :name
|
|
407
|
+
attribute :location, type: LocationRequest
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
class PostRequest
|
|
411
|
+
include InteractorSupport::RequestObject
|
|
412
|
+
|
|
413
|
+
attribute :authors, type: AuthorRequest, array: true
|
|
414
|
+
end
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Nested objects are instantiated recursively and validated automatically.
|
|
418
|
+
|
|
419
|
+
## Rewrite Keys
|
|
420
|
+
|
|
421
|
+
Rename external keys for internal use.
|
|
422
|
+
|
|
423
|
+
```rb
|
|
424
|
+
attribute :image, rewrite: :image_url, transform: :strip
|
|
425
|
+
|
|
426
|
+
request = ImageUploadRequest.new(image: ' https://url.com ')
|
|
427
|
+
request.image_url # => "https://url.com"
|
|
428
|
+
request.respond_to?(:image) # => false
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## to_context Output
|
|
432
|
+
|
|
433
|
+
Return a nested Hash, Struct, or self:
|
|
434
|
+
|
|
435
|
+
```rb
|
|
436
|
+
# Default
|
|
437
|
+
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
|
|
438
|
+
# returns a hash with symbol keys => {:authors=>[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]}
|
|
439
|
+
|
|
440
|
+
# Configure globally
|
|
441
|
+
InteractorSupport.configure do |config|
|
|
442
|
+
config.request_object_behavior = :returns_context # or :returns_self
|
|
443
|
+
config.request_object_key_type = :symbol # or :string, :struct
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# request_object_behavior = :returns_context, request_object_key_type = :string
|
|
447
|
+
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
|
|
448
|
+
# returns a hash with string keys => {"authors"=>[{"name"=>"Ruby", "location"=>{"city"=>"Seattle"}}]}
|
|
449
|
+
|
|
450
|
+
# request_object_behavior = :returns_context, request_object_key_type = :struct
|
|
451
|
+
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
|
|
452
|
+
# returns a Struct => #<struct authors=[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]>
|
|
453
|
+
|
|
454
|
+
# request_object_behavior = :returns_self, request_object_key_type = :symbol
|
|
455
|
+
request = PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
|
|
456
|
+
# returns the request object => #<PostRequest authors=[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]>
|
|
457
|
+
# request.authors.first.location.city => "Seattle"
|
|
458
|
+
# request.to_context => {:authors=>[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
🛡 Replacing Strong Parameters Safely
|
|
462
|
+
|
|
463
|
+
InteractorSupport::RequestObject is a safe, testable, and expressive alternative to Rails’ strong_parameters. While strong_params are great for sanitizing controller input, they tend to:
|
|
464
|
+
|
|
465
|
+
- Leak into your business logic
|
|
466
|
+
- Lack structure and type safety
|
|
467
|
+
- Require repetitive permit/require declarations
|
|
468
|
+
- Get clumsy with nesting and arrays
|
|
469
|
+
|
|
470
|
+
Instead, RequestObject defines the expected shape and behavior of input once, and gives you:
|
|
471
|
+
|
|
472
|
+
- Input sanitization via transform:
|
|
473
|
+
- Validation via ActiveModel
|
|
474
|
+
- Type coercion (including arrays and nesting)
|
|
475
|
+
- Reusable, composable input classes
|
|
476
|
+
|
|
477
|
+
StrongParams Example
|
|
478
|
+
|
|
479
|
+
```rb
|
|
480
|
+
def user_params
|
|
481
|
+
params.require(:user).permit(:name, :email, :age)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def create
|
|
485
|
+
user = User.new(user_params)
|
|
486
|
+
...
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Even with this, you still have to:
|
|
491
|
+
• Validate formats (like email)
|
|
492
|
+
• Coerce types (:age is still a string!)
|
|
493
|
+
• Repeat this logic elsewhere
|
|
494
|
+
|
|
495
|
+
**Request Object Equivelent**
|
|
496
|
+
|
|
497
|
+
```rb
|
|
498
|
+
class UserRequest
|
|
499
|
+
include InteractorSupport::RequestObject
|
|
500
|
+
|
|
501
|
+
attribute :name, transform: :strip
|
|
502
|
+
attribute :email, transform: [:strip, :downcase]
|
|
503
|
+
attribute :age, type: :integer # or transform: [:to_i]
|
|
504
|
+
|
|
505
|
+
validates :name, presence: true
|
|
506
|
+
validates_format_of :email, with: URI::MailTo::EMAIL_REGEXP
|
|
507
|
+
end
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**Why replace Strong Params?**
|
|
511
|
+
| Feature | Strong Params | Request Object |
|
|
512
|
+
|----------------------------------|---------------------|----------------------|
|
|
513
|
+
| Requires manual permit/require | ✅ Yes | ❌ Not needed |
|
|
514
|
+
| Validates types/formats | ❌ No | ✅ Yes |
|
|
515
|
+
| Handles nested objects | 😬 With effort | ✅ First-class support |
|
|
516
|
+
| Works outside controllers | ❌ Not cleanly | ✅ Perfect for services/interactors |
|
|
517
|
+
| Self-documenting input shape | ❌ No | ✅ Defined via attribute DSL |
|
|
518
|
+
| Testable as a unit | ❌ Not directly | ✅ Easily tested like a form object |
|
|
519
|
+
|
|
520
|
+
💡 Tip
|
|
521
|
+
|
|
522
|
+
You can still use params.require(...).permit(...) in the controller if you want to restrict top-level keys, then pass that sanitized hash to your RequestObject:
|
|
523
|
+
|
|
524
|
+
```rb
|
|
525
|
+
UserRequest.new(params.require(:user).permit(:name, :email, :age))
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
But with RequestObject, that’s often unnecessary because you’re already defining a schema.
|
|
276
529
|
|
|
277
530
|
## 🤝 **Contributing**
|
|
278
531
|
|
|
@@ -1,4 +1,35 @@
|
|
|
1
|
+
# lib/interactor_support/version.rb
|
|
1
2
|
module InteractorSupport
|
|
3
|
+
##
|
|
4
|
+
# A bundle of DSL-style concerns that enhance interactors with expressive,
|
|
5
|
+
# composable behavior.
|
|
6
|
+
#
|
|
7
|
+
# This module is intended to be included into an `Interactor` or `Organizer`,
|
|
8
|
+
# providing access to a suite of declarative action helpers:
|
|
9
|
+
#
|
|
10
|
+
# - {Skippable} — Conditionally skip execution
|
|
11
|
+
# - {Transactionable} — Wrap logic in an ActiveRecord transaction
|
|
12
|
+
# - {Updatable} — Update records using context-driven attributes
|
|
13
|
+
# - {Findable} — Find one or many records into context
|
|
14
|
+
# - {Transformable} — Normalize or modify context values before execution
|
|
15
|
+
#
|
|
16
|
+
# @example Use in an interactor
|
|
17
|
+
# class UpdateUser
|
|
18
|
+
# include Interactor
|
|
19
|
+
# include InteractorSupport::Actions
|
|
20
|
+
#
|
|
21
|
+
# find_by :user
|
|
22
|
+
#
|
|
23
|
+
# transform :email, with: [:strip, :downcase]
|
|
24
|
+
#
|
|
25
|
+
# update :user, attributes: { email: :email }
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @see InteractorSupport::Concerns::Skippable
|
|
29
|
+
# @see InteractorSupport::Concerns::Transactionable
|
|
30
|
+
# @see InteractorSupport::Concerns::Updatable
|
|
31
|
+
# @see InteractorSupport::Concerns::Findable
|
|
32
|
+
# @see InteractorSupport::Concerns::Transformable
|
|
2
33
|
module Actions
|
|
3
34
|
extend ActiveSupport::Concern
|
|
4
35
|
included do
|
|
@@ -1,18 +1,50 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
2
|
module Concerns
|
|
3
|
+
##
|
|
4
|
+
# Adds dynamic model-finding helpers (`find_by`, `find_where`) to an interactor.
|
|
5
|
+
#
|
|
6
|
+
# This concern wraps ActiveRecord `.find_by` and `.where` queries into
|
|
7
|
+
# declarative DSL methods that load records into the interactor context.
|
|
8
|
+
#
|
|
9
|
+
# These methods support symbols (for context keys) and lambdas (for dynamic runtime evaluation).
|
|
10
|
+
#
|
|
11
|
+
# @example Find a post by ID from the context
|
|
12
|
+
# find_by :post
|
|
13
|
+
#
|
|
14
|
+
# @example Find by query using context value
|
|
15
|
+
# find_by :post, query: { slug: :slug }, required: true
|
|
16
|
+
#
|
|
17
|
+
# @example Find using a dynamic lambda
|
|
18
|
+
# find_by :post, query: { created_at: -> { 1.week.ago..Time.current } }
|
|
19
|
+
#
|
|
20
|
+
# @example Find all posts for a user with a scope
|
|
21
|
+
# find_where :post, where: { user_id: :user_id }, scope: :published
|
|
22
|
+
#
|
|
23
|
+
# @see InteractorSupport::Actions
|
|
3
24
|
module Findable
|
|
4
25
|
extend ActiveSupport::Concern
|
|
5
26
|
include InteractorSupport::Core
|
|
6
|
-
|
|
27
|
+
|
|
7
28
|
included do
|
|
8
29
|
class << self
|
|
9
|
-
#
|
|
10
|
-
#
|
|
30
|
+
# Adds a `before` callback to find a single record and assign it to context.
|
|
31
|
+
#
|
|
32
|
+
# This method searches for a record based on the provided query parameters.
|
|
33
|
+
# It supports dynamic values using symbols (context keys) and lambdas (for runtime evaluation).
|
|
34
|
+
#
|
|
35
|
+
# @param model [Symbol, String] the name of the model to query (e.g., `:post`)
|
|
36
|
+
# @param query [Hash{Symbol=>Object,Proc}] a hash of attributes to match (can use symbols for context lookup or lambdas)
|
|
37
|
+
# @param context_key [Symbol, nil] the key under which to store the result in context (defaults to the model name)
|
|
38
|
+
# @param required [Boolean] if true, fails the context if no record is found
|
|
11
39
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
40
|
+
# @example Basic ID-based lookup
|
|
41
|
+
# find_by :post
|
|
42
|
+
#
|
|
43
|
+
# @example Query with context value
|
|
44
|
+
# find_by :post, query: { slug: :slug }
|
|
45
|
+
#
|
|
46
|
+
# @example Query with a lambda
|
|
47
|
+
# find_by :post, query: { created_at: -> { 1.week.ago..Time.current } }
|
|
16
48
|
def find_by(model, query: {}, context_key: nil, required: false)
|
|
17
49
|
context_key ||= model
|
|
18
50
|
before do
|
|
@@ -24,9 +56,9 @@ module InteractorSupport
|
|
|
24
56
|
model.to_s.classify.constantize.find_by(
|
|
25
57
|
query.transform_values do |v|
|
|
26
58
|
case v
|
|
27
|
-
when Symbol then context[v]
|
|
28
|
-
when Proc then instance_exec(&v)
|
|
29
|
-
else v
|
|
59
|
+
when Symbol then context[v]
|
|
60
|
+
when Proc then instance_exec(&v)
|
|
61
|
+
else v
|
|
30
62
|
end
|
|
31
63
|
end,
|
|
32
64
|
)
|
|
@@ -37,14 +69,26 @@ module InteractorSupport
|
|
|
37
69
|
end
|
|
38
70
|
end
|
|
39
71
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
72
|
+
# Adds a `before` callback to find multiple records and assign them to context.
|
|
73
|
+
#
|
|
74
|
+
# This method performs a `.where` query with optional `.where.not` and `.scope`,
|
|
75
|
+
# allowing dynamic values using symbols (for context lookup) and lambdas (for runtime evaluation).
|
|
42
76
|
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
77
|
+
# @param model [Symbol, String] the name of the model to query (e.g., `:post`)
|
|
78
|
+
# @param where [Hash{Symbol=>Object,Proc}] conditions for `.where` (can use symbols or lambdas)
|
|
79
|
+
# @param where_not [Hash{Symbol=>Object,Proc}] conditions for `.where.not`
|
|
80
|
+
# @param scope [Symbol, nil] optional named scope to call on the model
|
|
81
|
+
# @param context_key [Symbol, nil] the key under which to store the result in context (defaults to pluralized model name)
|
|
82
|
+
# @param required [Boolean] if true, fails the context if no records are found
|
|
83
|
+
#
|
|
84
|
+
# @example Where query with symbol context values
|
|
85
|
+
# find_where :post, where: { user_id: :user_id }
|
|
86
|
+
#
|
|
87
|
+
# @example Where with a lambda and scope
|
|
88
|
+
# find_where :post, where: { created_at: -> { 5.days.ago..Time.current } }, scope: :published
|
|
89
|
+
#
|
|
90
|
+
# @example Where with exclusions
|
|
91
|
+
# find_where :post, where: { user_id: :user_id }, where_not: { active: false }
|
|
48
92
|
def find_where(model, where: {}, where_not: {}, scope: nil, context_key: nil, required: false)
|
|
49
93
|
context_key ||= model.to_s.pluralize.to_sym
|
|
50
94
|
before do
|
|
@@ -53,9 +97,9 @@ module InteractorSupport
|
|
|
53
97
|
query = query.where(
|
|
54
98
|
where.transform_values do |v|
|
|
55
99
|
case v
|
|
56
|
-
when Symbol then context[v]
|
|
57
|
-
when Proc then instance_exec(&v)
|
|
58
|
-
else v
|
|
100
|
+
when Symbol then context[v]
|
|
101
|
+
when Proc then instance_exec(&v)
|
|
102
|
+
else v
|
|
59
103
|
end
|
|
60
104
|
end,
|
|
61
105
|
) if where.present?
|
|
@@ -1,11 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module InteractorSupport
|
|
2
4
|
module Concerns
|
|
5
|
+
##
|
|
6
|
+
# Adds a DSL method to conditionally skip an interactor.
|
|
7
|
+
#
|
|
8
|
+
# This concern provides a `skip` method that wraps the interactor in an `around` block.
|
|
9
|
+
# You can pass an `:if` or `:unless` condition using a Proc, Symbol, or literal.
|
|
10
|
+
# The condition will be evaluated at runtime to determine whether to run the interactor.
|
|
11
|
+
#
|
|
12
|
+
# - Symbols will be looked up on the interactor or in the context.
|
|
13
|
+
# - Lambdas/Procs are evaluated using `instance_exec` with full access to context.
|
|
14
|
+
#
|
|
15
|
+
# @example Skip if the user is already authenticated (symbol in context)
|
|
16
|
+
# skip if: :user_authenticated
|
|
17
|
+
#
|
|
18
|
+
# @example Skip unless a method returns true
|
|
19
|
+
# skip unless: :should_run?
|
|
20
|
+
#
|
|
21
|
+
# @example Skip based on a lambda
|
|
22
|
+
# skip if: -> { mode == "test" }
|
|
23
|
+
#
|
|
24
|
+
# @see InteractorSupport::Actions
|
|
3
25
|
module Skippable
|
|
4
26
|
extend ActiveSupport::Concern
|
|
5
27
|
include InteractorSupport::Core
|
|
6
28
|
|
|
7
29
|
included do
|
|
8
30
|
class << self
|
|
31
|
+
##
|
|
32
|
+
# Skips the interactor based on a condition provided via `:if` or `:unless`.
|
|
33
|
+
#
|
|
34
|
+
# This wraps the interactor in an `around` hook, and conditionally skips
|
|
35
|
+
# execution based on truthy/falsy evaluation of the provided options.
|
|
36
|
+
#
|
|
37
|
+
# The condition can be a Proc (evaluated in context), a Symbol (used to call a method or context key), or a literal value.
|
|
38
|
+
#
|
|
39
|
+
# @param options [Hash]
|
|
40
|
+
# @option options [Proc, Symbol, Boolean] :if a condition that must be truthy to skip
|
|
41
|
+
# @option options [Proc, Symbol, Boolean] :unless a condition that must be falsy to skip
|
|
42
|
+
#
|
|
43
|
+
# @example Skip if a context value is truthy
|
|
44
|
+
# skip if: :user_authenticated
|
|
45
|
+
#
|
|
46
|
+
# @example Skip unless a method returns true
|
|
47
|
+
# skip unless: :should_run?
|
|
48
|
+
#
|
|
49
|
+
# @example Skip based on a lambda
|
|
50
|
+
# skip if: -> { context[:mode] == "test" }
|
|
9
51
|
def skip(**options)
|
|
10
52
|
around do |interactor|
|
|
11
53
|
unless options[:if].nil?
|
|
@@ -1,11 +1,48 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
2
|
module Concerns
|
|
3
|
+
##
|
|
4
|
+
# Adds transactional support to your interactor using ActiveRecord.
|
|
5
|
+
#
|
|
6
|
+
# The `transaction` method wraps the interactor execution in an `around` block
|
|
7
|
+
# that uses `ActiveRecord::Base.transaction`. If the context fails (via `context.fail!`),
|
|
8
|
+
# the transaction is rolled back automatically using `ActiveRecord::Rollback`.
|
|
9
|
+
#
|
|
10
|
+
# This is useful for ensuring your interactor behaves atomically.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# class CreateUser
|
|
14
|
+
# include Interactor
|
|
15
|
+
# include InteractorSupport::Transactionable
|
|
16
|
+
#
|
|
17
|
+
# transaction
|
|
18
|
+
#
|
|
19
|
+
# def call
|
|
20
|
+
# User.create!(context.user_params)
|
|
21
|
+
# context.fail!(message: "Simulated failure") if something_wrong?
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @see InteractorSupport::Actions
|
|
3
26
|
module Transactionable
|
|
4
27
|
extend ActiveSupport::Concern
|
|
5
28
|
include InteractorSupport::Core
|
|
6
29
|
|
|
7
30
|
included do
|
|
8
31
|
class << self
|
|
32
|
+
# Wraps the interactor in a database transaction.
|
|
33
|
+
#
|
|
34
|
+
# If the context fails (`context.failure?`), a rollback is triggered automatically.
|
|
35
|
+
# You can customize the transaction behavior using standard ActiveRecord options.
|
|
36
|
+
#
|
|
37
|
+
# @param isolation [Symbol, nil] the transaction isolation level (e.g., `:read_committed`, `:serializable`)
|
|
38
|
+
# @param joinable [Boolean] whether this transaction can join an existing one
|
|
39
|
+
# @param requires_new [Boolean] whether to force a new transaction, even if one already exists
|
|
40
|
+
#
|
|
41
|
+
# @example Wrap in a basic transaction
|
|
42
|
+
# transaction
|
|
43
|
+
#
|
|
44
|
+
# @example With custom options
|
|
45
|
+
# transaction requires_new: true, isolation: :serializable
|
|
9
46
|
def transaction(isolation: nil, joinable: true, requires_new: false)
|
|
10
47
|
around do |interactor|
|
|
11
48
|
ActiveRecord::Base.transaction(isolation: isolation, joinable: joinable, requires_new: requires_new) do
|