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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +1 -1
- data/README.md +109 -1060
- data/benchmarks/operation_benchmark.rb +385 -0
- data/docs/examples/always.md +267 -0
- data/docs/examples/basic-operation.md +79 -0
- data/docs/examples/context-params-dependencies.md +122 -0
- data/docs/examples/finish-if.md +67 -0
- data/docs/examples/inner-operations.md +94 -0
- data/docs/examples/success-blocks.md +68 -0
- data/docs/examples/transactions.md +227 -0
- data/docs/examples/validations.md +139 -0
- data/docs/examples/within.md +166 -0
- data/lib/opera/operation/builder.rb +21 -3
- data/lib/opera/operation/config.rb +2 -6
- data/lib/opera/operation/executor.rb +16 -4
- data/lib/opera/operation/instructions/executors/always.rb +15 -0
- data/lib/opera/operation/instructions/executors/finish_if.rb +1 -2
- data/lib/opera/operation/instructions/executors/operation.rb +1 -2
- data/lib/opera/operation/instructions/executors/operations.rb +1 -2
- data/lib/opera/operation/instructions/executors/step.rb +1 -6
- data/lib/opera/operation/instructions/executors/success.rb +5 -2
- data/lib/opera/operation/instructions/executors/validate.rb +1 -2
- data/lib/opera/operation/instructions/executors/within.rb +1 -1
- data/lib/opera/operation.rb +1 -0
- data/lib/opera/version.rb +1 -1
- metadata +13 -2
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Performance benchmark for Opera operations.
|
|
4
|
+
#
|
|
5
|
+
# Exercises the full execution path: step dispatch, instruction iteration,
|
|
6
|
+
# context accessors, validate, transaction, success, finish_if, operation,
|
|
7
|
+
# operations, within, and always -- with nested inner operations and loops to
|
|
8
|
+
# simulate realistic workloads.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# ruby benchmarks/operation_benchmark.rb
|
|
12
|
+
|
|
13
|
+
require 'bundler/setup'
|
|
14
|
+
require 'benchmark'
|
|
15
|
+
require 'opera'
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Fake transaction class (no DB, just yields)
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
FakeTransaction = Class.new do
|
|
21
|
+
def self.transaction
|
|
22
|
+
yield
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Opera::Operation::Config.configure do |config|
|
|
27
|
+
config.transaction_class = FakeTransaction
|
|
28
|
+
config.mode = :production # skip execution traces, like real production
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Leaf operation — called many times from within loops
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
LeafOperation = Class.new(Opera::Operation::Base) do
|
|
35
|
+
step :compute
|
|
36
|
+
step :output
|
|
37
|
+
|
|
38
|
+
def compute
|
|
39
|
+
context[:value] = params.fetch(:n, 1) * 2
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def output
|
|
43
|
+
result.output = { value: context[:value] }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Inner operation — calls LeafOperation in a loop
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
InnerOperation = Class.new(Opera::Operation::Base) do
|
|
51
|
+
context do
|
|
52
|
+
attr_accessor :results
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
step :process_batch
|
|
56
|
+
step :output
|
|
57
|
+
|
|
58
|
+
def process_batch
|
|
59
|
+
self.results = (1..params.fetch(:batch_size, 5)).map do |n|
|
|
60
|
+
LeafOperation.call(params: { n: n })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def output
|
|
65
|
+
result.output = { batch: results.map(&:output) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Validation-heavy operation
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
ValidationOperation = Class.new(Opera::Operation::Base) do
|
|
73
|
+
validate :schema
|
|
74
|
+
|
|
75
|
+
step :transform
|
|
76
|
+
step :output
|
|
77
|
+
|
|
78
|
+
def schema
|
|
79
|
+
# Return a successful Opera::Operation::Result (simulates dry-validation)
|
|
80
|
+
Opera::Operation::Result.new(output: params)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def transform
|
|
84
|
+
context[:transformed] = params.transform_values { |v| v.to_s.upcase }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def output
|
|
88
|
+
result.output = context[:transformed]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Complex operation — combines everything
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
ComplexOperation = Class.new(Opera::Operation::Base) do
|
|
96
|
+
configure do |config|
|
|
97
|
+
config.transaction_class = FakeTransaction
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
context do
|
|
101
|
+
attr_accessor :profile, :batch_results, :validated
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
validate :schema
|
|
105
|
+
|
|
106
|
+
step :prepare
|
|
107
|
+
finish_if :skip_processing?
|
|
108
|
+
|
|
109
|
+
transaction do
|
|
110
|
+
step :create_record
|
|
111
|
+
step :update_record
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
operation :run_inner
|
|
115
|
+
|
|
116
|
+
within :with_timing do
|
|
117
|
+
step :heavy_computation
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
success do
|
|
121
|
+
step :notify
|
|
122
|
+
step :log_audit
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
step :processing_error
|
|
126
|
+
|
|
127
|
+
always :output
|
|
128
|
+
|
|
129
|
+
def schema
|
|
130
|
+
Opera::Operation::Result.new(output: params)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def prepare
|
|
134
|
+
self.validated = context[:schema_output]
|
|
135
|
+
context[:counter] = 0
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def skip_processing?
|
|
139
|
+
params[:skip] == true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def create_record
|
|
143
|
+
self.profile = { id: rand(1000), name: validated[:name] }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def update_record
|
|
147
|
+
profile[:updated_at] = Time.now.to_i
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def run_inner
|
|
151
|
+
InnerOperation.call(params: { batch_size: params.fetch(:batch_size, 5) })
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def heavy_computation
|
|
155
|
+
# Simulate CPU work: string operations in a loop
|
|
156
|
+
50.times do |i|
|
|
157
|
+
context[:counter] += i
|
|
158
|
+
"operation-#{i}-#{context[:counter]}".hash
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def notify
|
|
163
|
+
context[:notified] = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def log_audit
|
|
167
|
+
context[:audited] = true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def processing_error
|
|
171
|
+
result.add_error(:base, 'processing failed')
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def output
|
|
175
|
+
result.output = {
|
|
176
|
+
profile: profile,
|
|
177
|
+
counter: context[:counter],
|
|
178
|
+
batch: context[:run_inner_output]
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def with_timing
|
|
183
|
+
yield
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Within operation — wraps steps and inner operations with a custom method
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
WithinOperation = Class.new(Opera::Operation::Base) do
|
|
191
|
+
configure do |config|
|
|
192
|
+
config.transaction_class = FakeTransaction
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
context do
|
|
196
|
+
attr_accessor :log, default: -> { [] }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
step :prepare
|
|
200
|
+
|
|
201
|
+
within :with_connection do
|
|
202
|
+
step :query_one
|
|
203
|
+
step :query_two
|
|
204
|
+
operation :fetch_leaf
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
transaction do
|
|
208
|
+
within :with_lock do
|
|
209
|
+
step :write_one
|
|
210
|
+
step :write_two
|
|
211
|
+
end
|
|
212
|
+
step :write_three
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
step :output
|
|
216
|
+
|
|
217
|
+
def prepare
|
|
218
|
+
context[:counter] = 0
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def query_one
|
|
222
|
+
context[:counter] += 1
|
|
223
|
+
log << :query_one
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def query_two
|
|
227
|
+
context[:counter] += 1
|
|
228
|
+
log << :query_two
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def fetch_leaf
|
|
232
|
+
LeafOperation.call(params: { n: context[:counter] })
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def write_one
|
|
236
|
+
context[:counter] += 10
|
|
237
|
+
log << :write_one
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def write_two
|
|
241
|
+
context[:counter] += 10
|
|
242
|
+
log << :write_two
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def write_three
|
|
246
|
+
context[:counter] += 1
|
|
247
|
+
log << :write_three
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def output
|
|
251
|
+
result.output = {
|
|
252
|
+
counter: context[:counter],
|
|
253
|
+
log: log,
|
|
254
|
+
leaf: context[:fetch_leaf_output]
|
|
255
|
+
}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def with_connection
|
|
259
|
+
log << :connect
|
|
260
|
+
yield
|
|
261
|
+
log << :disconnect
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def with_lock
|
|
265
|
+
log << :lock
|
|
266
|
+
yield
|
|
267
|
+
log << :unlock
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Operations (plural) consumer — calls multiple inner operations
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
BatchOperation = Class.new(Opera::Operation::Base) do
|
|
275
|
+
operations :run_all
|
|
276
|
+
step :output
|
|
277
|
+
|
|
278
|
+
def run_all
|
|
279
|
+
(1..params.fetch(:count, 3)).map do |n|
|
|
280
|
+
LeafOperation.call(params: { n: n })
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def output
|
|
285
|
+
result.output = context[:run_all_output]
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Always operation — exercises always steps on both success and failure paths
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
AlwaysOperation = Class.new(Opera::Operation::Base) do
|
|
293
|
+
context do
|
|
294
|
+
attr_accessor :log, default: -> { [] }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
step :prepare
|
|
298
|
+
step :process
|
|
299
|
+
always :audit
|
|
300
|
+
always :cleanup
|
|
301
|
+
|
|
302
|
+
def prepare
|
|
303
|
+
log << :prepare
|
|
304
|
+
context[:value] = params.fetch(:value, 0)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def process
|
|
308
|
+
if params[:fail]
|
|
309
|
+
result.add_error(:base, 'processing failed')
|
|
310
|
+
else
|
|
311
|
+
context[:value] *= 2
|
|
312
|
+
log << :process
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def audit
|
|
317
|
+
log << (result.success? ? :audit_success : :audit_failure)
|
|
318
|
+
result.output = { value: context[:value], log: log, success: result.success?, failure: result.failure? }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def cleanup
|
|
322
|
+
log << :cleanup
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Benchmark
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
ITERATIONS = 1000
|
|
330
|
+
PARAMS = { name: 'benchmark', batch_size: 5 }.freeze
|
|
331
|
+
BATCH_PARAMS = { count: 5 }.freeze
|
|
332
|
+
VALIDATION_PARAMS = { first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com' }.freeze
|
|
333
|
+
WITHIN_PARAMS = {}.freeze
|
|
334
|
+
ALWAYS_SUCCESS_PARAMS = { value: 21 }.freeze
|
|
335
|
+
ALWAYS_FAILURE_PARAMS = { value: 21, fail: true }.freeze
|
|
336
|
+
|
|
337
|
+
# Warm up
|
|
338
|
+
3.times do
|
|
339
|
+
ComplexOperation.call(params: PARAMS)
|
|
340
|
+
BatchOperation.call(params: BATCH_PARAMS)
|
|
341
|
+
ValidationOperation.call(params: VALIDATION_PARAMS)
|
|
342
|
+
WithinOperation.call(params: WITHIN_PARAMS)
|
|
343
|
+
AlwaysOperation.call(params: ALWAYS_SUCCESS_PARAMS)
|
|
344
|
+
AlwaysOperation.call(params: ALWAYS_FAILURE_PARAMS)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
puts "Opera v#{Opera::VERSION} — #{ITERATIONS} iterations each"
|
|
348
|
+
puts "Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
|
|
349
|
+
puts '-' * 60
|
|
350
|
+
|
|
351
|
+
Benchmark.bm(35) do |x|
|
|
352
|
+
x.report('ComplexOperation (nested + tx):') do
|
|
353
|
+
ITERATIONS.times { ComplexOperation.call(params: PARAMS) }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
x.report('BatchOperation (operations):') do
|
|
357
|
+
ITERATIONS.times { BatchOperation.call(params: BATCH_PARAMS) }
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
x.report('ValidationOperation (validate):') do
|
|
361
|
+
ITERATIONS.times { ValidationOperation.call(params: VALIDATION_PARAMS) }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
x.report('WithinOperation (within + tx):') do
|
|
365
|
+
ITERATIONS.times { WithinOperation.call(params: WITHIN_PARAMS) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
x.report('LeafOperation (minimal):') do
|
|
369
|
+
ITERATIONS.times { LeafOperation.call(params: { n: 42 }) }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
x.report('AlwaysOperation (success path):') do
|
|
373
|
+
ITERATIONS.times { AlwaysOperation.call(params: ALWAYS_SUCCESS_PARAMS) }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
x.report('AlwaysOperation (failure path):') do
|
|
377
|
+
ITERATIONS.times { AlwaysOperation.call(params: ALWAYS_FAILURE_PARAMS) }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Total operations executed in ComplexOperation run:
|
|
381
|
+
# 1 complex + 1 inner + 5 leaf = 7 operations per iteration
|
|
382
|
+
# = 7000 total operation instantiations for ComplexOperation alone
|
|
383
|
+
total_ops = ITERATIONS * 7
|
|
384
|
+
puts "\nComplexOperation spawns ~#{total_ops} total operation instances across #{ITERATIONS} calls"
|
|
385
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# Always
|
|
2
|
+
|
|
3
|
+
`always` runs a step unconditionally at the end of the operation pipeline, after all regular steps have run (or been skipped). Unlike a regular `step`, it is never skipped — not when a prior step adds an error, not when `finish!` or `finish_if` DSL is called.
|
|
4
|
+
|
|
5
|
+
## Placement rules
|
|
6
|
+
|
|
7
|
+
- `always` steps must appear **after all other instructions** at the top level of the operation.
|
|
8
|
+
- Once an `always` is declared, only further `always` steps may follow — any other instruction (`step`, `operation`, `transaction`, `within`, etc.) raises an `ArgumentError` at class load time.
|
|
9
|
+
- `always` **cannot** be used inside blocks (`transaction do`, `within do`, `success do`, `validate do`). Doing so raises an `ArgumentError` at class load time.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# correct
|
|
13
|
+
step :a
|
|
14
|
+
step :b
|
|
15
|
+
always :c
|
|
16
|
+
always :d
|
|
17
|
+
|
|
18
|
+
# raises ArgumentError — step follows always
|
|
19
|
+
step :a
|
|
20
|
+
always :b
|
|
21
|
+
step :c
|
|
22
|
+
|
|
23
|
+
# raises ArgumentError — always inside a transaction block
|
|
24
|
+
transaction do
|
|
25
|
+
step :a
|
|
26
|
+
always :b # not allowed here
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Basic usage
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
class Order::Submit < Opera::Operation::Base
|
|
34
|
+
context do
|
|
35
|
+
attr_accessor :order
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
dependencies do
|
|
39
|
+
attr_reader :current_account, :audit_logger
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
step :build
|
|
43
|
+
step :charge
|
|
44
|
+
step :send_confirmation
|
|
45
|
+
always :audit_log
|
|
46
|
+
|
|
47
|
+
def build
|
|
48
|
+
self.order = current_account.orders.build(params)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def charge
|
|
52
|
+
result.add_error(:base, 'card declined') unless order.charge!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def send_confirmation
|
|
56
|
+
# only reached when charge succeeds
|
|
57
|
+
OrderMailer.confirmation(order).deliver_later
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def audit_log
|
|
61
|
+
# always runs, regardless of whether charge succeeded or failed
|
|
62
|
+
audit_logger.record(order: order, success: result.success?)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Inspecting result state inside an always step
|
|
68
|
+
|
|
69
|
+
`result.success?` and `result.failure?` reflect the state of the operation **at the point `always` runs** — i.e. after all regular steps have executed (or been skipped due to failure). This lets you branch on the final outcome:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class Profile::Delete < Opera::Operation::Base
|
|
73
|
+
context do
|
|
74
|
+
attr_accessor :profile
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
dependencies do
|
|
78
|
+
attr_reader :current_account, :notifier
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
step :find
|
|
82
|
+
step :destroy
|
|
83
|
+
always :notify
|
|
84
|
+
|
|
85
|
+
def find
|
|
86
|
+
self.profile = current_account.profiles.find(params[:id])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def destroy
|
|
90
|
+
result.add_error(:base, 'cannot delete') unless profile.destroy
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def notify
|
|
94
|
+
if result.success?
|
|
95
|
+
notifier.call(event: :deleted, profile_id: params[:id])
|
|
96
|
+
else
|
|
97
|
+
notifier.call(event: :delete_failed, profile_id: params[:id], errors: result.errors)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Multiple always steps
|
|
104
|
+
|
|
105
|
+
Multiple `always` steps are allowed and run in the order they are declared in:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class Report::Generate < Opera::Operation::Base
|
|
109
|
+
dependencies do
|
|
110
|
+
attr_reader :audit_logger, :metrics
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
step :fetch_data
|
|
114
|
+
step :render
|
|
115
|
+
always :record_audit
|
|
116
|
+
always :record_metrics
|
|
117
|
+
|
|
118
|
+
def fetch_data
|
|
119
|
+
# ...
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def render
|
|
123
|
+
# ...
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def record_audit
|
|
127
|
+
audit_logger.call(success: result.success?, errors: result.errors)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def record_metrics
|
|
131
|
+
metrics.increment(result.success? ? 'report.success' : 'report.failure')
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Operation finishes early
|
|
137
|
+
|
|
138
|
+
### With finish_if
|
|
139
|
+
|
|
140
|
+
`finish_if` halts execution successfully when its method returns truthy — subsequent regular steps are skipped, but `always` steps still run. Inside the always step, `result.success?` returns `true` because no errors were added:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
class Import::Run < Opera::Operation::Base
|
|
144
|
+
dependencies do
|
|
145
|
+
attr_reader :audit_logger
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
step :check_preconditions
|
|
149
|
+
finish_if :already_imported?
|
|
150
|
+
step :import
|
|
151
|
+
step :output
|
|
152
|
+
always :record_attempt
|
|
153
|
+
|
|
154
|
+
def check_preconditions
|
|
155
|
+
# validate source data is present
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def already_imported?
|
|
159
|
+
Import.exists?(ref: params[:ref])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def import
|
|
163
|
+
Import.create!(params)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def output
|
|
167
|
+
result.output = { imported: true }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def record_attempt
|
|
171
|
+
# called whether import ran, was skipped via finish_if, or failed
|
|
172
|
+
audit_logger.call(ref: params[:ref], success: result.success?)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### With finish!
|
|
178
|
+
|
|
179
|
+
Calling `finish!` inside a step halts execution immediately and marks the operation successful. `always` steps still run afterwards:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
class Profile::Upsert < Opera::Operation::Base
|
|
183
|
+
context do
|
|
184
|
+
attr_accessor :profile
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
dependencies do
|
|
188
|
+
attr_reader :current_account, :audit_logger
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
step :find_existing
|
|
192
|
+
step :update_existing
|
|
193
|
+
step :create_new
|
|
194
|
+
step :output
|
|
195
|
+
always :audit_log
|
|
196
|
+
|
|
197
|
+
def find_existing
|
|
198
|
+
self.profile = current_account.profiles.find_by(email: params[:email])
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def update_existing
|
|
202
|
+
return unless profile
|
|
203
|
+
|
|
204
|
+
profile.update!(params)
|
|
205
|
+
finish!
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def create_new
|
|
209
|
+
self.profile = current_account.profiles.create!(params)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def output
|
|
213
|
+
result.output = { model: profile }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def audit_log
|
|
217
|
+
# runs whether the record was updated (finish! path), created, or failed
|
|
218
|
+
audit_logger.record(profile: profile, success: result.success?)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Combining with DSL blocks
|
|
224
|
+
|
|
225
|
+
`always` cannot be placed inside `transaction`, `within` or `validate` blocks. Place it after those blocks at the top level:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
class Ledger::Transfer < Opera::Operation::Base
|
|
229
|
+
configure do |config|
|
|
230
|
+
config.transaction_class = ActiveRecord::Base
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
dependencies do
|
|
234
|
+
attr_reader :audit_logger
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
transaction do
|
|
238
|
+
step :debit
|
|
239
|
+
step :credit
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
step :output
|
|
243
|
+
always :record_attempt
|
|
244
|
+
|
|
245
|
+
def debit
|
|
246
|
+
result.add_error(:base, 'insufficient funds') unless account.debit(params[:amount])
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def credit
|
|
250
|
+
account.credit(params[:amount])
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def output
|
|
254
|
+
result.output = { transferred: params[:amount] }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def record_attempt
|
|
258
|
+
# runs after the transaction (and rollback, if any) has settled.
|
|
259
|
+
# result.success? / result.failure? reflect the final outcome.
|
|
260
|
+
audit_logger.call(
|
|
261
|
+
params: params,
|
|
262
|
+
success: result.success?,
|
|
263
|
+
errors: result.errors
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Basic Operation
|
|
2
|
+
|
|
3
|
+
A simple operation that validates input, creates a record, sends an email, and returns output.
|
|
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
|
+
step :send_email
|
|
19
|
+
step :output
|
|
20
|
+
|
|
21
|
+
def profile_schema
|
|
22
|
+
Dry::Validation.Schema do
|
|
23
|
+
required(:first_name).filled
|
|
24
|
+
end.call(params)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create
|
|
28
|
+
self.profile = current_account.profiles.create(params)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def send_email
|
|
32
|
+
mailer&.send_mail(profile: profile)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def output
|
|
36
|
+
result.output = { model: profile }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Call with valid parameters
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
Profile::Create.call(params: {
|
|
45
|
+
first_name: :foo,
|
|
46
|
+
last_name: :bar
|
|
47
|
+
}, dependencies: {
|
|
48
|
+
mailer: MyMailer,
|
|
49
|
+
current_account: Account.find(1)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
#<Opera::Operation::Result:0x0000561636dced60 @errors={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 30, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-14 16:04:08", updated_at: "2020-08-14 16:04: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: []>}>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Call with INVALID parameters - missing first_name
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Profile::Create.call(params: {
|
|
59
|
+
last_name: :bar
|
|
60
|
+
}, dependencies: {
|
|
61
|
+
mailer: MyMailer,
|
|
62
|
+
current_account: Account.find(1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
#<Opera::Operation::Result:0x0000562d3f635390 @errors={:first_name=>["is missing"]}, @information={}, @executions=[:profile_schema]>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Call with MISSING dependencies
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
Profile::Create.call(params: {
|
|
72
|
+
first_name: :foo,
|
|
73
|
+
last_name: :bar
|
|
74
|
+
}, dependencies: {
|
|
75
|
+
current_account: Account.find(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
#<Opera::Operation::Result:0x007f87ba2c8f00 @errors={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 33, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2019-01-03 12:04:25", updated_at: "2019-01-03 12:04:25", 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: []>}>
|
|
79
|
+
```
|