opera 0.5.0 → 0.6.0

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,122 @@
1
+ # Context, Params & Dependencies
2
+
3
+ Opera provides typed accessor blocks for managing state within an operation.
4
+
5
+ ## context
6
+
7
+ Mutable hash for passing data between steps. Supports `attr_reader`, `attr_writer`, and `attr_accessor`.
8
+
9
+ ```ruby
10
+ context do
11
+ attr_accessor :profile
12
+ attr_accessor :account, default: -> { Account.new }
13
+ attr_reader :schema_output
14
+ end
15
+ ```
16
+
17
+ - `attr_accessor` defines getter and setter methods that read/write to the `context` hash
18
+ - `attr_reader` defines only a getter
19
+ - `default` accepts a lambda, evaluated lazily on first access when the key is missing
20
+
21
+ ```ruby
22
+ context do
23
+ attr_accessor :profile, :account
24
+ end
25
+
26
+ step :fetch_profile
27
+ step :update_profile
28
+
29
+ def fetch_profile
30
+ self.profile = ProfileFetcher.call # sets context[:profile]
31
+ end
32
+
33
+ def update_profile
34
+ profile.update!(name: 'John') # reads profile from context[:profile]
35
+ end
36
+ ```
37
+
38
+ ## params
39
+
40
+ Immutable hash received in the `call` method. Only supports `attr_reader`.
41
+
42
+ ```ruby
43
+ params do
44
+ attr_reader :activity, :requester
45
+ end
46
+ ```
47
+
48
+ ## dependencies
49
+
50
+ Immutable hash received in the `call` method. Only supports `attr_reader`.
51
+
52
+ ```ruby
53
+ dependencies do
54
+ attr_reader :current_account, :mailer
55
+ end
56
+ ```
57
+
58
+ ## context_reader with defaults
59
+
60
+ Use `context_reader` to read step outputs from the context hash:
61
+
62
+ ```ruby
63
+ context_reader :schema_output
64
+
65
+ validate :schema # context = { schema_output: { id: 1 } }
66
+ step :do_something
67
+
68
+ def do_something
69
+ puts schema_output # outputs: { id: 1 }
70
+ end
71
+ ```
72
+
73
+ Use `default` to provide a fallback value when the key is missing:
74
+
75
+ ```ruby
76
+ context_reader :profile, default: -> { Profile.new }
77
+
78
+ step :fetch_profile
79
+ step :do_something
80
+
81
+ def fetch_profile
82
+ return if App.http_disabled?
83
+
84
+ context[:profile] = ProfileFetcher.call
85
+ end
86
+
87
+ def update_profile
88
+ profile.name = 'John'
89
+ profile.save!
90
+ end
91
+ ```
92
+
93
+ ## Best practices
94
+
95
+ **Good** -- Use `context_reader` for step outputs and shared state:
96
+
97
+ ```ruby
98
+ context_reader :schema_output
99
+ ```
100
+
101
+ **Bad** -- Don't use `context_reader` with `default` for transient objects that aren't stored in context:
102
+
103
+ ```ruby
104
+ # BAD: suggests serializer is part of persistent state
105
+ context_reader :serializer, default: -> { ProfileSerializer.new }
106
+ ```
107
+
108
+ **Better** -- Use private methods for transient dependencies:
109
+
110
+ ```ruby
111
+ step :output
112
+
113
+ def output
114
+ self.result = serializer.to_json({...})
115
+ end
116
+
117
+ private
118
+
119
+ def serializer
120
+ ProfileSerializer.new
121
+ end
122
+ ```
@@ -0,0 +1,67 @@
1
+ # Finish If
2
+
3
+ `finish_if` evaluates a method and stops execution (successfully) if the method returns a truthy value. Subsequent steps are skipped, but the operation is considered successful.
4
+
5
+ ```ruby
6
+ class Profile::Create < Opera::Operation::Base
7
+ context do
8
+ attr_accessor :profile
9
+ end
10
+
11
+ dependencies do
12
+ attr_reader :current_account, :mailer
13
+ end
14
+
15
+ validate :profile_schema
16
+
17
+ step :create
18
+ finish_if :profile_create_only
19
+ step :update
20
+
21
+ success do
22
+ step :send_email
23
+ step :output
24
+ end
25
+
26
+ def profile_schema
27
+ Dry::Validation.Schema do
28
+ required(:first_name).filled
29
+ end.call(params)
30
+ end
31
+
32
+ def create
33
+ self.profile = current_account.profiles.create(params)
34
+ end
35
+
36
+ def profile_create_only
37
+ dependencies[:create_only].present?
38
+ end
39
+
40
+ def update
41
+ profile.update(updated_at: 1.day.ago)
42
+ end
43
+
44
+ # NOTE: We can add an error in this step and it won't break the execution
45
+ def send_email
46
+ result.add_error('mailer', 'Missing dependency')
47
+ mailer&.send_mail(profile: profile)
48
+ end
49
+
50
+ def output
51
+ result.output = { model: context[:profile] }
52
+ end
53
+ end
54
+ ```
55
+
56
+ ## Example
57
+
58
+ ```ruby
59
+ Profile::Create.call(params: {
60
+ first_name: :foo,
61
+ last_name: :bar
62
+ }, dependencies: {
63
+ create_only: true,
64
+ current_account: Account.find(1)
65
+ })
66
+ #<Opera::Operation::Result:0x007fd0248e5638 @errors={}, @information={}, @executions=[:profile_schema, :create, :profile_create_only], @output={}>
67
+ ```
@@ -0,0 +1,94 @@
1
+ # Inner Operations
2
+
3
+ ## Single operation
4
+
5
+ Use `operation` to call another Opera operation from within a step. The method must return an `Opera::Operation::Result`. If the inner operation fails, errors are propagated and execution stops. If it succeeds, its output is stored in context as `:<method_name>_output`.
6
+
7
+ ```ruby
8
+ class Profile::Find < Opera::Operation::Base
9
+ step :find
10
+
11
+ def find
12
+ result.output = Profile.find(params[:id])
13
+ end
14
+ end
15
+
16
+ class Profile::Create < Opera::Operation::Base
17
+ validate :profile_schema
18
+
19
+ operation :find
20
+
21
+ step :create
22
+
23
+ step :output
24
+
25
+ def profile_schema
26
+ Dry::Validation.Schema do
27
+ optional(:id).filled
28
+ end.call(params)
29
+ end
30
+
31
+ def find
32
+ Profile::Find.call(params: params, dependencies: dependencies)
33
+ end
34
+
35
+ def create
36
+ return if context[:find_output]
37
+ puts 'not found'
38
+ end
39
+
40
+ def output
41
+ result.output = { model: context[:find_output] }
42
+ end
43
+ end
44
+ ```
45
+
46
+ ### Example with inner operation doing the find
47
+
48
+ ```ruby
49
+ Profile::Create.call(params: {
50
+ id: 1
51
+ }, dependencies: {
52
+ current_account: Account.find(1)
53
+ })
54
+ #<Opera::Operation::Result:0x007f99b25f0f20 @errors={}, @information={}, @executions=[:profile_schema, :find, :create, :output], @output={:model=>{:id=>1, :user_id=>1, :linkedin_uid=>nil, ...}}>
55
+ ```
56
+
57
+ ## Multiple operations
58
+
59
+ Use `operations` when a method returns an array of `Opera::Operation::Result` objects. If any of the inner operations fail, all their errors are collected and execution stops.
60
+
61
+ ```ruby
62
+ class Profile::Create < Opera::Operation::Base
63
+ step :validate
64
+ step :create
65
+
66
+ def validate; end
67
+
68
+ def create
69
+ result.output = { model: "Profile #{Kernel.rand(100)}" }
70
+ end
71
+ end
72
+
73
+ class Profile::CreateMultiple < Opera::Operation::Base
74
+ operations :create_multiple
75
+
76
+ step :output
77
+
78
+ def create_multiple
79
+ (0..params[:number]).map do
80
+ Profile::Create.call
81
+ end
82
+ end
83
+
84
+ def output
85
+ result.output = context[:create_multiple_output]
86
+ end
87
+ end
88
+ ```
89
+
90
+ ```ruby
91
+ Profile::CreateMultiple.call(params: { number: 3 })
92
+
93
+ #<Opera::Operation::Result:0x0000564189f38c90 @errors={}, @information={}, @executions=[{:create_multiple=>[[:validate, :create], [:validate, :create], [:validate, :create], [:validate, :create]]}, :output], @output=[{:model=>"Profile 1"}, {:model=>"Profile 7"}, {:model=>"Profile 69"}, {:model=>"Profile 92"}]>
94
+ ```
@@ -0,0 +1,68 @@
1
+ # Success Blocks
2
+
3
+ Steps inside a `success` block continue executing even if they return `false`. Errors added inside a success block **do** stop execution of subsequent non-success steps. Use success blocks for side effects that shouldn't halt the pipeline on falsy returns (e.g., sending emails, logging).
4
+
5
+ ```ruby
6
+ class Profile::Create < Opera::Operation::Base
7
+ context do
8
+ attr_accessor :profile
9
+ end
10
+
11
+ dependencies do
12
+ attr_reader :current_account, :mailer
13
+ end
14
+
15
+ validate :profile_schema
16
+
17
+ success :populate
18
+
19
+ step :create
20
+ step :update
21
+
22
+ success do
23
+ step :send_email
24
+ step :output
25
+ end
26
+
27
+ def profile_schema
28
+ Dry::Validation.Schema do
29
+ required(:first_name).filled
30
+ end.call(params)
31
+ end
32
+
33
+ def populate
34
+ context[:attributes] = {}
35
+ context[:valid] = false
36
+ end
37
+
38
+ def create
39
+ self.profile = current_account.profiles.create(params)
40
+ end
41
+
42
+ def update
43
+ profile.update(updated_at: 1.day.ago)
44
+ end
45
+
46
+ # NOTE: We can add an error in this step and it won't break the execution
47
+ def send_email
48
+ result.add_error('mailer', 'Missing dependency')
49
+ mailer&.send_mail(profile: profile)
50
+ end
51
+
52
+ def output
53
+ result.output = { model: context[:profile] }
54
+ end
55
+ end
56
+ ```
57
+
58
+ ## Example output
59
+
60
+ ```ruby
61
+ Profile::Create.call(params: {
62
+ first_name: :foo,
63
+ last_name: :bar
64
+ }, dependencies: {
65
+ current_account: Account.find(1)
66
+ })
67
+ #<Opera::Operation::Result:0x007fd0248e5638 @errors={"mailer"=>["Missing dependency"]}, @information={}, @executions=[:profile_schema, :populate, :create, :update, :send_email, :output], @output={:model=>#<Profile id: 40, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2019-01-03 12:21:35", updated_at: "2019-01-02 12:21:35", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
68
+ ```
@@ -0,0 +1,227 @@
1
+ # Transactions
2
+
3
+ Wrap multiple steps in a database transaction. If any step adds an error or raises an exception, the transaction is rolled back.
4
+
5
+ ## Configuration
6
+
7
+ Set the transaction class either globally or per-operation:
8
+
9
+ ```ruby
10
+ # Global
11
+ Opera::Operation::Config.configure do |config|
12
+ config.transaction_class = ActiveRecord::Base
13
+ config.transaction_method = :transaction # default
14
+ config.transaction_options = { requires_new: true } # optional
15
+ end
16
+
17
+ # Per-operation
18
+ class MyOperation < Opera::Operation::Base
19
+ configure do |config|
20
+ config.transaction_class = Profile
21
+ end
22
+ end
23
+ ```
24
+
25
+ ## Failing transaction
26
+
27
+ When a step inside a transaction fails, the entire transaction is rolled back:
28
+
29
+ ```ruby
30
+ class Profile::Create < Opera::Operation::Base
31
+ configure do |config|
32
+ config.transaction_class = Profile
33
+ end
34
+
35
+ context do
36
+ attr_accessor :profile
37
+ end
38
+
39
+ dependencies do
40
+ attr_reader :current_account, :mailer
41
+ end
42
+
43
+ validate :profile_schema
44
+
45
+ transaction do
46
+ step :create
47
+ step :update
48
+ end
49
+
50
+ step :send_email
51
+ step :output
52
+
53
+ def profile_schema
54
+ Dry::Validation.Schema do
55
+ required(:first_name).filled
56
+ end.call(params)
57
+ end
58
+
59
+ def create
60
+ self.profile = current_account.profiles.create(params)
61
+ end
62
+
63
+ def update
64
+ profile.update(example_attr: :Example)
65
+ end
66
+
67
+ def send_email
68
+ return true unless mailer
69
+
70
+ mailer.send_mail(profile: profile)
71
+ end
72
+
73
+ def output
74
+ result.output = { model: profile }
75
+ end
76
+ end
77
+ ```
78
+
79
+ ### Example with non-existing attribute
80
+
81
+ ```ruby
82
+ Profile::Create.call(params: {
83
+ first_name: :foo,
84
+ last_name: :bar
85
+ }, dependencies: {
86
+ mailer: MyMailer,
87
+ current_account: Account.find(1)
88
+ })
89
+
90
+ D, [2020-08-14T16:13:30.946466 #2504] DEBUG -- : Account Load (0.5ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
91
+ D, [2020-08-14T16:13:30.960254 #2504] DEBUG -- : (0.2ms) BEGIN
92
+ D, [2020-08-14T16:13:30.983981 #2504] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-14 16:13:30.982289"], ["updated_at", "2020-08-14 16:13:30.982289"], ["account_id", 1]]
93
+ D, [2020-08-14T16:13:30.986233 #2504] DEBUG -- : (0.2ms) ROLLBACK
94
+ D, [2020-08-14T16:13:30.988231 #2504] DEBUG -- : unknown attribute 'example_attr' for Profile. (ActiveModel::UnknownAttributeError)
95
+ ```
96
+
97
+ ## Passing transaction
98
+
99
+ ```ruby
100
+ class Profile::Create < Opera::Operation::Base
101
+ configure do |config|
102
+ config.transaction_class = Profile
103
+ end
104
+
105
+ context do
106
+ attr_accessor :profile
107
+ end
108
+
109
+ dependencies do
110
+ attr_reader :current_account, :mailer
111
+ end
112
+
113
+ validate :profile_schema
114
+
115
+ transaction do
116
+ step :create
117
+ step :update
118
+ end
119
+
120
+ step :send_email
121
+ step :output
122
+
123
+ def profile_schema
124
+ Dry::Validation.Schema do
125
+ required(:first_name).filled
126
+ end.call(params)
127
+ end
128
+
129
+ def create
130
+ self.profile = current_account.profiles.create(params)
131
+ end
132
+
133
+ def update
134
+ profile.update(updated_at: 1.day.ago)
135
+ end
136
+
137
+ def send_email
138
+ return true unless mailer
139
+
140
+ mailer.send_mail(profile: profile)
141
+ end
142
+
143
+ def output
144
+ result.output = { model: profile }
145
+ end
146
+ end
147
+ ```
148
+
149
+ ### Example with updating timestamp
150
+
151
+ ```ruby
152
+ Profile::Create.call(params: {
153
+ first_name: :foo,
154
+ last_name: :bar
155
+ }, dependencies: {
156
+ mailer: MyMailer,
157
+ current_account: Account.find(1)
158
+ })
159
+ D, [2020-08-17T12:10:44.842392 #2741] DEBUG -- : Account Load (0.7ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
160
+ D, [2020-08-17T12:10:44.856964 #2741] DEBUG -- : (0.2ms) BEGIN
161
+ D, [2020-08-17T12:10:44.881332 #2741] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-17 12:10:44.879684"], ["updated_at", "2020-08-17 12:10:44.879684"], ["account_id", 1]]
162
+ D, [2020-08-17T12:10:44.886168 #2741] DEBUG -- : SQL (0.6ms) UPDATE "profiles" SET "updated_at" = $1 WHERE "profiles"."id" = $2 [["updated_at", "2020-08-16 12:10:44.883164"], ["id", 47]]
163
+ D, [2020-08-17T12:10:44.898132 #2741] DEBUG -- : (10.3ms) COMMIT
164
+ #<Opera::Operation::Result:0x0000556528f29058 @errors={}, @information={}, @executions=[:profile_schema, :create, :update, :send_email, :output], @output={:model=>#<Profile id: 47, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-17 12:10:44", updated_at: "2020-08-16 12:10:44", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
165
+ ```
166
+
167
+ ## Using finish! inside a transaction
168
+
169
+ Calling `finish!` inside a transaction stops execution without rolling back -- the transaction commits successfully:
170
+
171
+ ```ruby
172
+ class Profile::Create < Opera::Operation::Base
173
+ context do
174
+ attr_accessor :profile
175
+ end
176
+
177
+ dependencies do
178
+ attr_reader :current_account, :mailer
179
+ end
180
+
181
+ validate :profile_schema
182
+
183
+ step :build_record
184
+ step :create
185
+ step :send_email
186
+ step :output
187
+
188
+ def profile_schema
189
+ Dry::Validation.Schema do
190
+ required(:first_name).filled
191
+ end.call(params)
192
+ end
193
+
194
+ def build_record
195
+ self.profile = current_account.profiles.build(params)
196
+ self.profile.force_name_validation = true
197
+ end
198
+
199
+ def create
200
+ self.profile = profile.save
201
+ finish!
202
+ end
203
+
204
+ def send_email
205
+ return true unless mailer
206
+
207
+ mailer.send_mail(profile: profile)
208
+ end
209
+
210
+ def output
211
+ result.output(model: profile)
212
+ end
213
+ end
214
+ ```
215
+
216
+ ### Call
217
+
218
+ ```ruby
219
+ result = Profile::Create.call(params: {
220
+ first_name: :foo,
221
+ last_name: :bar
222
+ }, dependencies: {
223
+ current_account: Account.find(1)
224
+ })
225
+
226
+ #<Opera::Operation::Result:0x007fc2c59a8460 @errors={}, @information={}, @executions=[:profile_schema, :build_record, :create]>
227
+ ```
@@ -0,0 +1,139 @@
1
+ # Validations
2
+
3
+ Opera supports `Dry::Validation::Result` and `Opera::Operation::Result` as return types from validate steps. Validations accumulate errors -- all validations run even if earlier ones fail, so the caller gets all errors at once.
4
+
5
+ ## Example with sanitizing parameters
6
+
7
+ ```ruby
8
+ class Profile::Create < Opera::Operation::Base
9
+ context do
10
+ attr_accessor :profile
11
+ end
12
+
13
+ dependencies do
14
+ attr_reader :current_account, :mailer
15
+ end
16
+
17
+ validate :profile_schema
18
+
19
+ step :create
20
+ step :send_email
21
+ step :output
22
+
23
+ def profile_schema
24
+ Dry::Validation.Schema do
25
+ configure { config.input_processor = :sanitizer }
26
+
27
+ required(:first_name).filled
28
+ end.call(params)
29
+ end
30
+
31
+ def create
32
+ self.profile = current_account.profiles.create(context[:profile_schema_output])
33
+ end
34
+
35
+ def send_email
36
+ return true unless mailer
37
+
38
+ mailer.send_mail(profile: profile)
39
+ end
40
+
41
+ def output
42
+ result.output = { model: profile }
43
+ end
44
+ end
45
+ ```
46
+
47
+ ```ruby
48
+ Profile::Create.call(params: {
49
+ first_name: :foo,
50
+ last_name: :bar
51
+ }, dependencies: {
52
+ mailer: MyMailer,
53
+ current_account: Account.find(1)
54
+ })
55
+
56
+ # NOTE: Last name is missing in output model
57
+ #<Opera::Operation::Result:0x000055e36a1fab78 @errors={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 44, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: nil, created_at: "2020-08-17 11:07:08", updated_at: "2020-08-17 11:07:08", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
58
+ ```
59
+
60
+ ## Example with old (ActiveModel) validations
61
+
62
+ ```ruby
63
+ class Profile::Create < Opera::Operation::Base
64
+ context do
65
+ attr_accessor :profile
66
+ end
67
+
68
+ dependencies do
69
+ attr_reader :current_account, :mailer
70
+ end
71
+
72
+ validate :profile_schema
73
+
74
+ step :build_record
75
+ step :old_validation
76
+ step :create
77
+ step :send_email
78
+ step :output
79
+
80
+ def profile_schema
81
+ Dry::Validation.Schema do
82
+ required(:first_name).filled
83
+ end.call(params)
84
+ end
85
+
86
+ def build_record
87
+ self.profile = current_account.profiles.build(params)
88
+ self.profile.force_name_validation = true
89
+ end
90
+
91
+ def old_validation
92
+ return true if profile.valid?
93
+
94
+ result.add_information(missing_validations: "Please check dry validations")
95
+ result.add_errors(profile.errors.messages)
96
+
97
+ false
98
+ end
99
+
100
+ def create
101
+ profile.save
102
+ end
103
+
104
+ def send_email
105
+ mailer.send_mail(profile: profile)
106
+ end
107
+
108
+ def output
109
+ result.output = { model: profile }
110
+ end
111
+ end
112
+ ```
113
+
114
+ ### Call with valid parameters
115
+
116
+ ```ruby
117
+ Profile::Create.call(params: {
118
+ first_name: :foo,
119
+ last_name: :bar
120
+ }, dependencies: {
121
+ mailer: MyMailer,
122
+ current_account: Account.find(1)
123
+ })
124
+
125
+ #<Opera::Operation::Result:0x0000560ebc9e7a98 @errors={}, @information={}, @executions=[:profile_schema, :build_record, :old_validation, :create, :send_email, :output], @output={:model=>#<Profile id: 41, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-14 19:15:12", updated_at: "2020-08-14 19:15:12", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
126
+ ```
127
+
128
+ ### Call with INVALID parameters
129
+
130
+ ```ruby
131
+ Profile::Create.call(params: {
132
+ first_name: :foo
133
+ }, dependencies: {
134
+ mailer: MyMailer,
135
+ current_account: Account.find(1)
136
+ })
137
+
138
+ #<Opera::Operation::Result:0x0000560ef76ba588 @errors={:last_name=>["can't be blank"]}, @information={:missing_validations=>"Please check dry validations"}, @executions=[:build_record, :old_validation]>
139
+ ```