opera 0.5.0 → 0.5.1

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,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
+ ```
@@ -0,0 +1,166 @@
1
+ # Within
2
+
3
+ `within` wraps one or more steps with a method you define on the operation. The method must `yield` to execute the nested steps. If it does not yield, the nested steps are skipped. Normal break conditions (errors, `finish!`) still apply inside the block.
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
13
+ end
14
+
15
+ step :build
16
+
17
+ within :read_from_replica do
18
+ step :check_duplicate
19
+ step :validate_quota
20
+ end
21
+
22
+ step :create
23
+ step :output
24
+
25
+ def build
26
+ self.profile = current_account.profiles.build(params)
27
+ end
28
+
29
+ def check_duplicate
30
+ result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email])
31
+ end
32
+
33
+ def validate_quota
34
+ result.add_error(:base, 'quota exceeded') if current_account.profiles.count >= 100
35
+ end
36
+
37
+ def create
38
+ profile.save!
39
+ end
40
+
41
+ def output
42
+ result.output = { model: profile }
43
+ end
44
+
45
+ private
46
+
47
+ def read_from_replica(&block)
48
+ ActiveRecord::Base.connected_to(role: :reading, &block)
49
+ end
50
+ end
51
+ ```
52
+
53
+ ## Inline usage
54
+
55
+ The wrapper method can also be used inline inside any step method when you need the wrapper for only part of that method's logic:
56
+
57
+ ```ruby
58
+ def some_step
59
+ value = read_from_replica { Profile.count }
60
+ result.output = { count: value }
61
+ end
62
+
63
+ private
64
+
65
+ def read_from_replica(&block)
66
+ ActiveRecord::Base.connected_to(role: :reading, &block)
67
+ end
68
+ ```
69
+
70
+ ## Mixing step and operation inside within
71
+
72
+ `within` can wrap any combination of `step` and `operation` instructions. All of them execute inside the wrapper, and their outputs are available in context afterwards as usual.
73
+
74
+ ```ruby
75
+ class Profile::Create < Opera::Operation::Base
76
+ context do
77
+ attr_accessor :profile
78
+ end
79
+
80
+ dependencies do
81
+ attr_reader :current_account, :quota_checker
82
+ end
83
+
84
+ within :read_from_replica do
85
+ step :check_duplicate
86
+ operation :fetch_quota
87
+ end
88
+
89
+ step :create
90
+ step :output
91
+
92
+ def check_duplicate
93
+ result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email])
94
+ end
95
+
96
+ def fetch_quota
97
+ quota_checker.call(params: params)
98
+ end
99
+
100
+ def create
101
+ self.profile = current_account.profiles.create(params)
102
+ end
103
+
104
+ def output
105
+ result.output = { model: profile, quota: context[:fetch_quota_output] }
106
+ end
107
+
108
+ private
109
+
110
+ def read_from_replica(&block)
111
+ ActiveRecord::Base.connected_to(role: :reading, &block)
112
+ end
113
+ end
114
+ ```
115
+
116
+ ## Nesting within inside a transaction
117
+
118
+ `within` can be placed inside a `transaction` block alongside other instructions. If any step or operation inside `within` fails, the error propagates up and the transaction is rolled back as normal.
119
+
120
+ ```ruby
121
+ class Profile::Create < Opera::Operation::Base
122
+ configure do |config|
123
+ config.transaction_class = ActiveRecord::Base
124
+ end
125
+
126
+ context do
127
+ attr_accessor :profile
128
+ end
129
+
130
+ dependencies do
131
+ attr_reader :current_account, :quota_checker, :audit_logger
132
+ end
133
+
134
+ transaction do
135
+ within :read_from_replica do
136
+ step :check_duplicate
137
+ operation :fetch_quota
138
+ end
139
+ operation :write_audit_log
140
+ end
141
+
142
+ step :output
143
+
144
+ def check_duplicate
145
+ result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email])
146
+ end
147
+
148
+ def fetch_quota
149
+ quota_checker.call(params: params)
150
+ end
151
+
152
+ def write_audit_log
153
+ audit_logger.call(params: params)
154
+ end
155
+
156
+ def output
157
+ result.output = { quota: context[:fetch_quota_output] }
158
+ end
159
+
160
+ private
161
+
162
+ def read_from_replica(&block)
163
+ ActiveRecord::Base.connected_to(role: :reading, &block)
164
+ end
165
+ end
166
+ ```
@@ -17,7 +17,7 @@ module Opera
17
17
  @instrumentation_class = self.class.instrumentation_class
18
18
 
19
19
  @mode = self.class.mode || DEVELOPMENT_MODE
20
- @reporter = custom_reporter || self.class.reporter
20
+ @reporter = self.class.reporter
21
21
 
22
22
  validate!
23
23
  end
@@ -26,10 +26,6 @@ module Opera
26
26
  yield self
27
27
  end
28
28
 
29
- def custom_reporter
30
- Rails.application.config.x.reporter.presence if defined?(Rails)
31
- end
32
-
33
29
  private
34
30
 
35
31
  def validate!
@@ -47,7 +43,7 @@ module Opera
47
43
  end
48
44
 
49
45
  def development_mode?
50
- mode == DEFAULT_MODE
46
+ mode == DEVELOPMENT_MODE
51
47
  end
52
48
 
53
49
  def production_mode?
@@ -20,15 +20,24 @@ module Opera
20
20
  end
21
21
 
22
22
  def evaluate_instructions(instructions = [])
23
- instruction_copy = Marshal.load(Marshal.dump(instructions))
24
-
25
- while instruction_copy.any?
26
- instruction = instruction_copy.shift
23
+ instructions.each do |instruction|
27
24
  evaluate_instruction(instruction)
28
25
  break if break_condition
29
26
  end
30
27
  end
31
28
 
29
+ # Executes the operation method named in the instruction, instruments it,
30
+ # and records the execution. This is the shared primitive that all executors
31
+ # use to invoke a step method without mutating the instruction hash.
32
+ def execute_step(instruction)
33
+ method = instruction[:method]
34
+
35
+ Instrumentation.new(operation).instrument(name: "##{method}", level: :step) do
36
+ result.add_execution(method) unless production_mode?
37
+ operation.send(method)
38
+ end
39
+ end
40
+
32
41
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
33
42
  def evaluate_instruction(instruction)
34
43
  case instruction[:kind]
@@ -6,8 +6,7 @@ module Opera
6
6
  module Executors
7
7
  class FinishIf < Executor
8
8
  def call(instruction)
9
- instruction[:kind] = :step
10
- operation.finish! if super
9
+ operation.finish! if execute_step(instruction)
11
10
  end
12
11
  end
13
12
  end
@@ -6,8 +6,7 @@ module Opera
6
6
  module Executors
7
7
  class Operation < Executor
8
8
  def call(instruction)
9
- instruction[:kind] = :step
10
- operation_result = super
9
+ operation_result = execute_step(instruction)
11
10
  save_information(operation_result)
12
11
 
13
12
  if operation_result.success?
@@ -9,8 +9,7 @@ module Opera
9
9
 
10
10
  # rubocop:disable Metrics/MethodLength
11
11
  def call(instruction)
12
- instruction[:kind] = :step
13
- operations_results = super
12
+ operations_results = execute_step(instruction)
14
13
 
15
14
  case operations_results
16
15
  when Array
@@ -6,12 +6,7 @@ module Opera
6
6
  module Executors
7
7
  class Step < Executor
8
8
  def call(instruction)
9
- method = instruction[:method]
10
-
11
- Instrumentation.new(operation).instrument(name: "##{method}", level: :step) do
12
- operation.result.add_execution(method) unless production_mode?
13
- operation.send(method)
14
- end
9
+ execute_step(instruction)
15
10
  end
16
11
  end
17
12
  end