interactor_support 1.0.2 → 1.0.4
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 +302 -319
- data/lib/interactor_support/actions.rb +33 -0
- data/lib/interactor_support/concerns/findable.rb +64 -20
- data/lib/interactor_support/concerns/organizable.rb +194 -0
- 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 +64 -10
- 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 +132 -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 +5 -2
|
@@ -1,6 +1,39 @@
|
|
|
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
|
+
# - {InteractorSupport::Concerns::Skippable} — Conditionally skip execution
|
|
11
|
+
# - {InteractorSupport::Concerns::Transactionable} — Wrap logic in an ActiveRecord transaction
|
|
12
|
+
# - {InteractorSupport::Concerns::Updatable} — Update records using context-driven attributes
|
|
13
|
+
# - {InteractorSupport::Concerns::Findable} — Find one or many records into context
|
|
14
|
+
# - {InteractorSupport::Concerns::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
|
+
#
|
|
29
|
+
# @see InteractorSupport::Concerns::Skippable
|
|
30
|
+
# @see InteractorSupport::Concerns::Transactionable
|
|
31
|
+
# @see InteractorSupport::Concerns::Updatable
|
|
32
|
+
# @see InteractorSupport::Concerns::Findable
|
|
33
|
+
# @see InteractorSupport::Concerns::Transformable
|
|
2
34
|
module Actions
|
|
3
35
|
extend ActiveSupport::Concern
|
|
36
|
+
|
|
4
37
|
included do
|
|
5
38
|
include InteractorSupport::Concerns::Skippable
|
|
6
39
|
include InteractorSupport::Concerns::Transactionable
|
|
@@ -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?
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InteractorSupport
|
|
4
|
+
module Concerns
|
|
5
|
+
##
|
|
6
|
+
# The `Organizable` module provides utility methods for organizing interactors
|
|
7
|
+
# and shaping request parameters in a structured way.
|
|
8
|
+
#
|
|
9
|
+
# It is intended to be included into a controller or a base service class that
|
|
10
|
+
# delegates to interactors using request objects.
|
|
11
|
+
#
|
|
12
|
+
# @example Include in a controller
|
|
13
|
+
# class ApplicationController < ActionController::Base
|
|
14
|
+
# include InteractorSupport::Organizable
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @see InteractorSupport::Organizable#organize
|
|
18
|
+
# @see InteractorSupport::Organizable#request_params
|
|
19
|
+
module Organizable
|
|
20
|
+
include ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
# Calls the given interactor with a request object.
|
|
23
|
+
# Optionally wraps the request object under a key in the interactor context.
|
|
24
|
+
#
|
|
25
|
+
# @param interactor [Class] The interactor class to call.
|
|
26
|
+
# @param params [Hash] Parameters to initialize the request object.
|
|
27
|
+
# @param request_object [Class] A request object class that responds to `#new(params)`.
|
|
28
|
+
# @param context_key [Symbol, nil] Optional key to assign the request object under in the context.
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# organize(MyInteractor, params: request_params, request_object: MyRequest)
|
|
34
|
+
# # => Calls MyInteractor with an instance of MyRequest initialized with request_params.
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# organize(MyInteractor, params: request_params, request_object: MyRequest, context_key: :request)
|
|
38
|
+
# # => Calls MyInteractor with an instance of MyRequest initialized with request_params at :context_key.
|
|
39
|
+
# # # => The context will contain { request: MyRequest.new(request_params) }
|
|
40
|
+
def organize(interactor, params:, request_object:, context_key: nil)
|
|
41
|
+
@context = interactor.call(
|
|
42
|
+
context_key ? { context_key => request_object.new(params) } : request_object.new(params),
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds a structured and optionally transformed parameter hash from Rails' `params`.
|
|
47
|
+
#
|
|
48
|
+
# This method supports extracting specific top-level keys, applying optional rewrite
|
|
49
|
+
# transformations, merging in additional values, and excluding unwanted keys.
|
|
50
|
+
#
|
|
51
|
+
# @param top_level_keys [Array<Symbol>] Top-level keys to extract from `params`. If empty, all keys are included.
|
|
52
|
+
# @param merge [Hash] Additional values to merge into the final result.
|
|
53
|
+
# @param except [Array<Symbol, Array<Symbol>>] Keys or nested key paths to exclude from the result.
|
|
54
|
+
# @param rewrite [Array<Hash>] A set of transformation rules applied to the top-level keys.
|
|
55
|
+
#
|
|
56
|
+
# @return [Hash] The final, shaped parameters hash.
|
|
57
|
+
#
|
|
58
|
+
# @example Extracting a specific top-level key
|
|
59
|
+
# # Given: params = { order: { product_id: 1, quantity: 2 } }
|
|
60
|
+
# request_params(:order)
|
|
61
|
+
# # => { order: { product_id: 1, quantity: 2 } }
|
|
62
|
+
#
|
|
63
|
+
# @example Without top-level keys (includes all)
|
|
64
|
+
# # Given: params = { order: { product_id: 1 }, app_id: 123 }
|
|
65
|
+
# request_params()
|
|
66
|
+
# # => { order: { product_id: 1 }, app_id: 123 }
|
|
67
|
+
#
|
|
68
|
+
# @example Merging and excluding
|
|
69
|
+
# # Given: params = { order: { product_id: 1, quantity: 2 }, internal: "yes" }
|
|
70
|
+
# request_params(:order, merge: { user_id: 123 }, except: [[:order, :quantity], :internal])
|
|
71
|
+
# # => { order: { product_id: 1 }, user_id: 123 }
|
|
72
|
+
#
|
|
73
|
+
# @example Flattening a nested hash into the top-level
|
|
74
|
+
# # Given: params = { order: { product_id: 1, quantity: 2 }, app_id: 123 }
|
|
75
|
+
# request_params(:order, rewrite: [{ order: { flatten: true } }])
|
|
76
|
+
# # => { product_id: 1, quantity: 2 }
|
|
77
|
+
#
|
|
78
|
+
# @example Rename a top-level key and filter nested keys
|
|
79
|
+
# # Given: params = { metadata: { source: "mobile", internal: "x" } }
|
|
80
|
+
# request_params(:metadata, rewrite: [
|
|
81
|
+
# { metadata: { as: :meta, only: [:source] } }
|
|
82
|
+
# ])
|
|
83
|
+
# # => { meta: { source: "mobile" } }
|
|
84
|
+
#
|
|
85
|
+
# @example Provide a default value if a key is missing
|
|
86
|
+
# # Given: params = {}
|
|
87
|
+
# request_params(:session, rewrite: [
|
|
88
|
+
# { session: { default: { id: nil } } }
|
|
89
|
+
# ])
|
|
90
|
+
# # => { session: { id: nil } }
|
|
91
|
+
#
|
|
92
|
+
# @example Merge values into a nested structure
|
|
93
|
+
# # Given: params = { flags: { foo: true } }
|
|
94
|
+
# request_params(:flags, rewrite: [
|
|
95
|
+
# { flags: { merge: { debug: true } } }
|
|
96
|
+
# ])
|
|
97
|
+
# # => { flags: { foo: true, debug: true } }
|
|
98
|
+
#
|
|
99
|
+
# @example Combine multiple rewrite rules
|
|
100
|
+
# # Given:
|
|
101
|
+
# # params = {
|
|
102
|
+
# # order: { product_id: 1, quantity: 2 },
|
|
103
|
+
# # metadata: { source: "mobile", location: { ip: "1.2.3.4" } },
|
|
104
|
+
# # tracking: { click_id: "abc", session_id: "def" }
|
|
105
|
+
# # }
|
|
106
|
+
# request_params(:order, :metadata, :tracking, rewrite: [
|
|
107
|
+
# { order: { flatten: true } },
|
|
108
|
+
# { metadata: { as: :meta, only: [:source, :location], flatten: [:location] } }
|
|
109
|
+
# ])
|
|
110
|
+
# # => {
|
|
111
|
+
# # product_id: 1,
|
|
112
|
+
# # quantity: 2,
|
|
113
|
+
# # meta: { source: "mobile", ip: "1.2.3.4" },
|
|
114
|
+
# # tracking: { click_id: "abc", session_id: "def" }
|
|
115
|
+
# # }
|
|
116
|
+
def request_params(*top_level_keys, merge: {}, except: [], rewrite: [])
|
|
117
|
+
permitted = params.permit!.to_h.deep_symbolize_keys
|
|
118
|
+
data = top_level_keys.any? ? permitted.slice(*top_level_keys) : permitted
|
|
119
|
+
|
|
120
|
+
apply_rewrites!(data, rewrite)
|
|
121
|
+
|
|
122
|
+
data
|
|
123
|
+
.deep_merge(merge)
|
|
124
|
+
.then { |result| except.any? ? deep_except(result, except) : result }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def apply_rewrites!(data, rewrites)
|
|
130
|
+
rewrites.each do |rule|
|
|
131
|
+
key, config = rule.first
|
|
132
|
+
config = { flatten: true } if config == :flatten
|
|
133
|
+
|
|
134
|
+
original = data.key?(key) ? data.delete(key) : nil
|
|
135
|
+
transformed = original.deep_dup if original.is_a?(Hash)
|
|
136
|
+
transformed ||= original
|
|
137
|
+
|
|
138
|
+
# Filtering
|
|
139
|
+
transformed.slice!(*config[:only]) if config[:only] && transformed.respond_to?(:slice!)
|
|
140
|
+
transformed.except!(*config[:except]) if config[:except] && transformed.respond_to?(:except!)
|
|
141
|
+
|
|
142
|
+
# Flatten specific nested keys
|
|
143
|
+
if config[:flatten].is_a?(Array) && transformed.is_a?(Hash)
|
|
144
|
+
config[:flatten].each do |subkey|
|
|
145
|
+
nested = transformed.delete(subkey)
|
|
146
|
+
if nested.is_a?(Hash)
|
|
147
|
+
transformed.merge!(nested)
|
|
148
|
+
elsif nested.is_a?(Array)
|
|
149
|
+
raise ArgumentError,
|
|
150
|
+
"Cannot flatten array for the key `#{subkey}`. Flattening arrays of hashes is not supported."
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Apply default if nil or missing
|
|
156
|
+
transformed ||= config[:default]
|
|
157
|
+
|
|
158
|
+
# Merge additional keys
|
|
159
|
+
if config[:merge]
|
|
160
|
+
transformed = transformed.is_a?(Hash) ? transformed.merge(config[:merge]) : config[:merge]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Fully flatten to top level
|
|
164
|
+
if config[:flatten] == true && transformed.is_a?(Hash)
|
|
165
|
+
data.merge!(transformed)
|
|
166
|
+
else
|
|
167
|
+
target_key = config[:as] || key
|
|
168
|
+
data[target_key] = transformed
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def deep_except(hash, paths)
|
|
174
|
+
paths.reduce(hash) { |acc, path| remove_nested_key(acc, Array(path)) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def remove_nested_key(hash, path)
|
|
178
|
+
return hash unless path.is_a?(Array) && path.any?
|
|
179
|
+
|
|
180
|
+
key, *rest = path
|
|
181
|
+
return hash unless hash.key?(key)
|
|
182
|
+
|
|
183
|
+
duped = hash.dup
|
|
184
|
+
if rest.empty?
|
|
185
|
+
duped.delete(key)
|
|
186
|
+
elsif duped[key].is_a?(Hash)
|
|
187
|
+
duped[key] = remove_nested_key(duped[key], rest)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
duped
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -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
|
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
2
|
module Concerns
|
|
3
|
+
##
|
|
4
|
+
# Adds helpers for assigning and transforming values in interactor context.
|
|
5
|
+
#
|
|
6
|
+
# The `context_variable` method sets static or dynamic values before the interactor runs.
|
|
7
|
+
# The `transform` method allows chaining transformations (methods or lambdas) on context values.
|
|
8
|
+
#
|
|
9
|
+
# @example Assign context variables before the interactor runs
|
|
10
|
+
# context_variable user: -> { User.find(user_id) }, numbers: [1, 2, 3]
|
|
11
|
+
#
|
|
12
|
+
# @example Normalize email and name before using them
|
|
13
|
+
# transform :email, :name, with: [:strip, :downcase]
|
|
14
|
+
#
|
|
15
|
+
# @example Apply a lambda to clean up input
|
|
16
|
+
# transform :name, with: ->(value) { value.gsub(/\s+/, ' ').strip }
|
|
17
|
+
#
|
|
18
|
+
# @example Mixing symbols and lambdas
|
|
19
|
+
# transform :email, with: [:strip, :downcase, -> { email.gsub(/\s+/, '') }]
|
|
20
|
+
#
|
|
21
|
+
# @see InteractorSupport::Actions
|
|
3
22
|
module Transformable
|
|
4
23
|
extend ActiveSupport::Concern
|
|
5
24
|
include InteractorSupport::Core
|
|
6
25
|
|
|
7
26
|
included do
|
|
8
27
|
class << self
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
28
|
+
# Assigns one or more values to the context before the interactor runs.
|
|
29
|
+
#
|
|
30
|
+
# Values can be static or lazily evaluated with a lambda/proc using `instance_exec`,
|
|
31
|
+
# which provides access to the context and interactor instance.
|
|
32
|
+
#
|
|
33
|
+
# @param key_values [Hash{Symbol => Object, Proc}] a mapping of context keys to values or Procs
|
|
34
|
+
#
|
|
35
|
+
# @example Static and dynamic values
|
|
36
|
+
# context_variable first_post: Post.first
|
|
37
|
+
# context_variable user: -> { User.find(user_id) }
|
|
38
|
+
# context_variable numbers: [1, 2, 3]
|
|
13
39
|
def context_variable(key_values)
|
|
14
40
|
before do
|
|
15
41
|
key_values.each do |key, value|
|
|
@@ -22,12 +48,40 @@ module InteractorSupport
|
|
|
22
48
|
end
|
|
23
49
|
end
|
|
24
50
|
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
51
|
+
# Transforms one or more context values using a method, a proc, or a sequence of methods.
|
|
52
|
+
#
|
|
53
|
+
# This allows simple transformations like `:strip` or `:downcase`, or more complex lambdas.
|
|
54
|
+
# You can also chain transformations by passing an array of method names.
|
|
55
|
+
#
|
|
56
|
+
# If a transformation fails, the context fails with an error message.
|
|
57
|
+
#
|
|
58
|
+
# @param keys [Array<Symbol>] one or more context keys to transform
|
|
59
|
+
# @param with [Symbol, Array<Symbol>, Proc] a single method name, an array of method names, or a proc
|
|
60
|
+
#
|
|
61
|
+
# @raise [ArgumentError] if no keys are given, or if an invalid `with:` value is passed
|
|
62
|
+
#
|
|
63
|
+
# @example Single method
|
|
64
|
+
# transform :email, with: :strip
|
|
65
|
+
#
|
|
66
|
+
# @example Method chain
|
|
67
|
+
# transform :email, with: [:strip, :downcase]
|
|
68
|
+
#
|
|
69
|
+
# @example Lambda
|
|
70
|
+
# transform :url, with: ->(value) { value.downcase.strip }
|
|
71
|
+
#
|
|
72
|
+
# @example Multiple keys
|
|
73
|
+
# transform :email, :name, with: [:downcase, :strip]
|
|
74
|
+
#
|
|
75
|
+
# @example Normalize user input
|
|
76
|
+
# transform :email, :name, with: [
|
|
77
|
+
# :strip,
|
|
78
|
+
# :downcase,
|
|
79
|
+
# ->(value) { value.gsub(/\s+/, ' ') }, # collapse duplicate spaces
|
|
80
|
+
# ]
|
|
81
|
+
#
|
|
82
|
+
# # Result:
|
|
83
|
+
# # context[:email] = "someone@example.com"
|
|
84
|
+
# # context[:name] = "john doe"
|
|
31
85
|
def transform(*keys, with: [])
|
|
32
86
|
before do
|
|
33
87
|
if keys.empty?
|
|
@@ -1,11 +1,54 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
2
|
module Concerns
|
|
3
|
+
##
|
|
4
|
+
# Adds an `update` DSL method for updating a context-loaded model with attributes.
|
|
5
|
+
#
|
|
6
|
+
# This concern allows flexible updates using data from the interactor's context.
|
|
7
|
+
# It supports direct mapping from context keys, nested attribute extraction from parent objects,
|
|
8
|
+
# lambdas for dynamic evaluation, or passing a symbol pointing to an entire context object.
|
|
9
|
+
#
|
|
10
|
+
# This is useful for updating records cleanly and consistently in declarative steps.
|
|
11
|
+
#
|
|
12
|
+
# @example Update a user using context values
|
|
13
|
+
# update :user, attributes: { name: :new_name, email: :new_email }
|
|
14
|
+
#
|
|
15
|
+
# @example Extract nested fields from a context object
|
|
16
|
+
# update :user, attributes: { form_data: [:name, :email] }
|
|
17
|
+
#
|
|
18
|
+
# @example Use a lambda for computed value
|
|
19
|
+
# update :post, attributes: { published_at: -> { Time.current } }
|
|
20
|
+
#
|
|
21
|
+
# @see InteractorSupport::Actions
|
|
3
22
|
module Updatable
|
|
4
23
|
extend ActiveSupport::Concern
|
|
5
24
|
include InteractorSupport::Core
|
|
6
25
|
|
|
7
26
|
included do
|
|
8
27
|
class << self
|
|
28
|
+
# Updates a model using values from the context before the interactor runs.
|
|
29
|
+
#
|
|
30
|
+
# Supports flexible ways of specifying attributes:
|
|
31
|
+
# - A hash mapping attribute names to context keys, nested keys, or lambdas
|
|
32
|
+
# - A symbol pointing to a hash in context
|
|
33
|
+
#
|
|
34
|
+
# If the record or required data is missing, the context fails with an error.
|
|
35
|
+
#
|
|
36
|
+
# @param model [Symbol] the key in the context holding the record to update
|
|
37
|
+
# @param attributes [Hash, Symbol] a hash mapping attributes to context keys/lambdas, or a symbol pointing to a context hash
|
|
38
|
+
# @param context_key [Symbol, nil] key to assign the updated record to in context (defaults to `model`)
|
|
39
|
+
#
|
|
40
|
+
# @example Basic attribute update using context keys
|
|
41
|
+
# update :user, attributes: { name: :new_name, email: :new_email }
|
|
42
|
+
#
|
|
43
|
+
# @example Use a lambda for dynamic value
|
|
44
|
+
# update :post, attributes: { published_at: -> { Time.current } }
|
|
45
|
+
#
|
|
46
|
+
# @example Nested context value lookup from a parent object
|
|
47
|
+
# # Assuming context[:form_data] = OpenStruct.new(name: "Hi", email: "hi@example.com")
|
|
48
|
+
# update :user, attributes: { form_data: [:name, :email] }
|
|
49
|
+
#
|
|
50
|
+
# @example Using a symbol to fetch all attributes from another context object
|
|
51
|
+
# update :order, attributes: :order_attributes
|
|
9
52
|
def update(model, attributes: {}, context_key: nil)
|
|
10
53
|
context_key ||= model
|
|
11
54
|
|
|
@@ -46,7 +89,6 @@ module InteractorSupport
|
|
|
46
89
|
|
|
47
90
|
record.update!(update_data)
|
|
48
91
|
|
|
49
|
-
# Assign the updated record to context
|
|
50
92
|
context[context_key] = record
|
|
51
93
|
end
|
|
52
94
|
end
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
module InteractorSupport
|
|
2
|
+
##
|
|
3
|
+
# Global configuration for InteractorSupport.
|
|
4
|
+
#
|
|
5
|
+
# This allows customization of how request objects behave when used in interactors.
|
|
6
|
+
#
|
|
7
|
+
# @example Set custom behavior
|
|
8
|
+
# InteractorSupport.configuration.request_object_behavior = :returns_self
|
|
9
|
+
# InteractorSupport.configuration.request_object_key_type = :struct
|
|
10
|
+
#
|
|
11
|
+
# @see InteractorSupport.configuration
|
|
2
12
|
class Configuration
|
|
3
|
-
|
|
13
|
+
##
|
|
14
|
+
# Defines how request objects behave when called.
|
|
15
|
+
#
|
|
16
|
+
# - `:returns_context` — The request object returns an Interactor-style context.
|
|
17
|
+
# - `:returns_self` — The request object returns itself, allowing method chaining.
|
|
18
|
+
#
|
|
19
|
+
# @return [:returns_context, :returns_self]
|
|
20
|
+
attr_accessor :request_object_behavior
|
|
4
21
|
|
|
22
|
+
##
|
|
23
|
+
# Defines the key type used in request object context when `:returns_context` is active.
|
|
24
|
+
#
|
|
25
|
+
# - `:string` — Keys are string-based (`"name"`)
|
|
26
|
+
# - `:symbol` — Keys are symbol-based (`:name`)
|
|
27
|
+
# - `:struct` — Keys are accessed via struct-style method calls (`name`)
|
|
28
|
+
#
|
|
29
|
+
# @return [:string, :symbol, :struct]
|
|
30
|
+
attr_accessor :request_object_key_type
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Initializes the configuration with default values:
|
|
34
|
+
# - `request_object_behavior` defaults to `:returns_context`
|
|
35
|
+
# - `request_object_key_type` defaults to `:symbol`
|
|
5
36
|
def initialize
|
|
6
|
-
# Default configuration values.
|
|
7
|
-
# :returns_context - request objects return a context object.
|
|
8
|
-
# :returns_self - request objects return self.
|
|
9
37
|
@request_object_behavior = :returns_context
|
|
10
|
-
|
|
11
|
-
# Default configuration values, only applies when request_object_behavior is :returns_context.
|
|
12
|
-
# :string - request object keys are strings.
|
|
13
|
-
# :symbol - request object keys are symbols.
|
|
14
|
-
# :struct - request object keys are struct objects.
|
|
15
38
|
@request_object_key_type = :symbol
|
|
16
39
|
end
|
|
17
40
|
end
|