opera 0.5.1 → 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 +5 -0
- data/Gemfile.lock +1 -1
- data/README.md +34 -31
- data/benchmarks/operation_benchmark.rb +57 -2
- data/docs/examples/always.md +267 -0
- data/lib/opera/operation/builder.rb +21 -3
- data/lib/opera/operation/executor.rb +4 -1
- data/lib/opera/operation/instructions/executors/always.rb +15 -0
- data/lib/opera/operation.rb +1 -0
- data/lib/opera/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d601d62d89303ad4efaa6ab156894bdcd247964977f3bb23fd43f2236cdf7655
|
|
4
|
+
data.tar.gz: 42c7cb409cec7f9dc66cd63dd723d39170cc5c02c5704e79d886869570b7775e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3725001498c05f07eae31e1f85e9801833820a1f4a4c8175e9fff13977390c6aaad4da5d3787b669fe7ddf4f84fd65752857f16bc0c84d822a3bff97b10b7467
|
|
7
|
+
data.tar.gz: 50a96d11c1f419bfc82934403793814029f088ba2b48d8df9083ee3f5383937dc95cc312339c1ce8619fc1358bd9e2b23aac3124aca6e4b61269fd5efacb93ae
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Opera Changelog
|
|
2
2
|
|
|
3
|
+
### 0.6.0 - Apr 15, 2026
|
|
4
|
+
|
|
5
|
+
- Add `always` executor: runs its step unconditionally after all regular steps, regardless of failure or an early finish
|
|
6
|
+
|
|
3
7
|
### 0.5.1 - Apr 15, 2026
|
|
4
8
|
|
|
5
9
|
- Remove `Marshal.dump` from instruction execution for ~40-55% throughput improvement
|
|
@@ -14,6 +18,7 @@
|
|
|
14
18
|
- Remove `benchmark` executor
|
|
15
19
|
|
|
16
20
|
### 0.4.1 - Feb 18, 2026
|
|
21
|
+
|
|
17
22
|
- Add parsed errors to default `output!` exception message
|
|
18
23
|
|
|
19
24
|
### 0.4.0 - May 22, 2025
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -112,16 +112,17 @@ end
|
|
|
112
112
|
|
|
113
113
|
## DSL Reference
|
|
114
114
|
|
|
115
|
-
| Instruction
|
|
116
|
-
|
|
117
|
-
| `step :method`
|
|
118
|
-
| `validate :method`
|
|
119
|
-
| `transaction do ... end`
|
|
120
|
-
| `success :method` or `success do ... end` | Like `step`, but a falsy return does **not** stop execution. Use for side effects.
|
|
121
|
-
| `finish_if :method`
|
|
122
|
-
| `operation :method`
|
|
123
|
-
| `operations :method`
|
|
124
|
-
| `within :method do ... end`
|
|
115
|
+
| Instruction | Description |
|
|
116
|
+
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| `step :method` | Executes a method. Returns falsy to stop execution. |
|
|
118
|
+
| `validate :method` | Executes a method that must return `Dry::Validation::Result` or `Opera::Operation::Result`. Errors are accumulated -- all validations run even if some fail. |
|
|
119
|
+
| `transaction do ... end` | Wraps steps in a database transaction. Rolls back on error. |
|
|
120
|
+
| `success :method` or `success do ... end` | Like `step`, but a falsy return does **not** stop execution. Use for side effects. |
|
|
121
|
+
| `finish_if :method` | Stops execution (successfully) if the method returns truthy. |
|
|
122
|
+
| `operation :method` | Calls an inner operation. Must return `Opera::Operation::Result`. Propagates errors on failure. Output stored in `context[:<method>_output]`. |
|
|
123
|
+
| `operations :method` | Like `operation`, but the method must return an array of `Opera::Operation::Result`. |
|
|
124
|
+
| `within :method do ... end` | Wraps nested steps with a custom method that must `yield`. If it doesn't yield, nested steps are skipped. |
|
|
125
|
+
| `always :method` | Executes a step unconditionally after all regular steps, even after a failure or an early finish. Must appear at the end of the operation — only other `always` steps may follow. Cannot be used inside blocks. Use `result.success?` / `result.failure?` inside the method to branch on outcome. |
|
|
125
126
|
|
|
126
127
|
### Combining instructions
|
|
127
128
|
|
|
@@ -147,25 +148,26 @@ class MyOperation < Opera::Operation::Base
|
|
|
147
148
|
end
|
|
148
149
|
|
|
149
150
|
step :output
|
|
151
|
+
always :audit_trail
|
|
150
152
|
end
|
|
151
153
|
```
|
|
152
154
|
|
|
153
155
|
## Result API
|
|
154
156
|
|
|
155
|
-
| Method
|
|
156
|
-
|
|
157
|
-
| `success?`
|
|
158
|
-
| `failure?`
|
|
159
|
-
| `output`
|
|
160
|
-
| `output!`
|
|
161
|
-
| `output=`
|
|
162
|
-
| `errors`
|
|
163
|
-
| `failures`
|
|
164
|
-
| `information`
|
|
165
|
-
| `executions`
|
|
166
|
-
| `add_error(key, value)` |
|
|
167
|
-
| `add_errors(hash)`
|
|
168
|
-
| `add_information(hash)` |
|
|
157
|
+
| Method | Returns | Description |
|
|
158
|
+
| ----------------------- | --------- | ---------------------------------------------------------- |
|
|
159
|
+
| `success?` | `Boolean` | `true` if no errors |
|
|
160
|
+
| `failure?` | `Boolean` | `true` if any errors |
|
|
161
|
+
| `output` | `Object` | The operation's return value |
|
|
162
|
+
| `output!` | `Object` | Returns output if success, raises `OutputError` if failure |
|
|
163
|
+
| `output=` | | Sets the output |
|
|
164
|
+
| `errors` | `Hash` | Accumulated error messages |
|
|
165
|
+
| `failures` | `Hash` | Alias for `errors` |
|
|
166
|
+
| `information` | `Hash` | Developer-facing metadata |
|
|
167
|
+
| `executions` | `Array` | Ordered list of executed steps (development mode only) |
|
|
168
|
+
| `add_error(key, value)` | | Adds a single error |
|
|
169
|
+
| `add_errors(hash)` | | Merges multiple errors |
|
|
170
|
+
| `add_information(hash)` | | Merges metadata |
|
|
169
171
|
|
|
170
172
|
```ruby
|
|
171
173
|
# Pre-set output (useful in specs)
|
|
@@ -174,13 +176,13 @@ Opera::Operation::Result.new(output: 'success')
|
|
|
174
176
|
|
|
175
177
|
## Operation Instance Methods
|
|
176
178
|
|
|
177
|
-
| Method
|
|
178
|
-
|
|
179
|
-
| `context`
|
|
180
|
-
| `params`
|
|
181
|
-
| `dependencies` | Immutable `Hash` received via `call`
|
|
182
|
-
| `result`
|
|
183
|
-
| `finish!`
|
|
179
|
+
| Method | Description |
|
|
180
|
+
| -------------- | ---------------------------------------------------- |
|
|
181
|
+
| `context` | Mutable `Hash` for passing data between steps |
|
|
182
|
+
| `params` | Immutable `Hash` received via `call` |
|
|
183
|
+
| `dependencies` | Immutable `Hash` received via `call` |
|
|
184
|
+
| `result` | The `Opera::Operation::Result` instance |
|
|
185
|
+
| `finish!` | Halts step execution (operation is still successful) |
|
|
184
186
|
|
|
185
187
|
## Testing
|
|
186
188
|
|
|
@@ -204,6 +206,7 @@ Detailed examples with full input/output are available in the [`docs/examples/`]
|
|
|
204
206
|
- [Finish If](docs/examples/finish-if.md)
|
|
205
207
|
- [Inner Operations](docs/examples/inner-operations.md)
|
|
206
208
|
- [Within](docs/examples/within.md)
|
|
209
|
+
- [Always](docs/examples/always.md)
|
|
207
210
|
- [Context, Params & Dependencies](docs/examples/context-params-dependencies.md)
|
|
208
211
|
|
|
209
212
|
## Development
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Exercises the full execution path: step dispatch, instruction iteration,
|
|
6
6
|
# context accessors, validate, transaction, success, finish_if, operation,
|
|
7
|
-
# operations, and
|
|
7
|
+
# operations, within, and always -- with nested inner operations and loops to
|
|
8
8
|
# simulate realistic workloads.
|
|
9
9
|
#
|
|
10
10
|
# Usage:
|
|
@@ -122,7 +122,9 @@ ComplexOperation = Class.new(Opera::Operation::Base) do
|
|
|
122
122
|
step :log_audit
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
step :
|
|
125
|
+
step :processing_error
|
|
126
|
+
|
|
127
|
+
always :output
|
|
126
128
|
|
|
127
129
|
def schema
|
|
128
130
|
Opera::Operation::Result.new(output: params)
|
|
@@ -165,6 +167,10 @@ ComplexOperation = Class.new(Opera::Operation::Base) do
|
|
|
165
167
|
context[:audited] = true
|
|
166
168
|
end
|
|
167
169
|
|
|
170
|
+
def processing_error
|
|
171
|
+
result.add_error(:base, 'processing failed')
|
|
172
|
+
end
|
|
173
|
+
|
|
168
174
|
def output
|
|
169
175
|
result.output = {
|
|
170
176
|
profile: profile,
|
|
@@ -280,6 +286,43 @@ BatchOperation = Class.new(Opera::Operation::Base) do
|
|
|
280
286
|
end
|
|
281
287
|
end
|
|
282
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
|
+
|
|
283
326
|
# ---------------------------------------------------------------------------
|
|
284
327
|
# Benchmark
|
|
285
328
|
# ---------------------------------------------------------------------------
|
|
@@ -288,6 +331,8 @@ PARAMS = { name: 'benchmark', batch_size: 5 }.freeze
|
|
|
288
331
|
BATCH_PARAMS = { count: 5 }.freeze
|
|
289
332
|
VALIDATION_PARAMS = { first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com' }.freeze
|
|
290
333
|
WITHIN_PARAMS = {}.freeze
|
|
334
|
+
ALWAYS_SUCCESS_PARAMS = { value: 21 }.freeze
|
|
335
|
+
ALWAYS_FAILURE_PARAMS = { value: 21, fail: true }.freeze
|
|
291
336
|
|
|
292
337
|
# Warm up
|
|
293
338
|
3.times do
|
|
@@ -295,6 +340,8 @@ WITHIN_PARAMS = {}.freeze
|
|
|
295
340
|
BatchOperation.call(params: BATCH_PARAMS)
|
|
296
341
|
ValidationOperation.call(params: VALIDATION_PARAMS)
|
|
297
342
|
WithinOperation.call(params: WITHIN_PARAMS)
|
|
343
|
+
AlwaysOperation.call(params: ALWAYS_SUCCESS_PARAMS)
|
|
344
|
+
AlwaysOperation.call(params: ALWAYS_FAILURE_PARAMS)
|
|
298
345
|
end
|
|
299
346
|
|
|
300
347
|
puts "Opera v#{Opera::VERSION} — #{ITERATIONS} iterations each"
|
|
@@ -322,6 +369,14 @@ Benchmark.bm(35) do |x|
|
|
|
322
369
|
ITERATIONS.times { LeafOperation.call(params: { n: 42 }) }
|
|
323
370
|
end
|
|
324
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
|
+
|
|
325
380
|
# Total operations executed in ComplexOperation run:
|
|
326
381
|
# 1 complex + 1 inner + 5 leaf = 7 operations per iteration
|
|
327
382
|
# = 7000 total operation instantiations for ComplexOperation alone
|
|
@@ -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
|
+
```
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Opera
|
|
4
4
|
module Operation
|
|
5
5
|
module Builder
|
|
6
|
-
INSTRUCTIONS = %I[validate transaction step success finish_if operation operations within].freeze
|
|
6
|
+
INSTRUCTIONS = %I[validate transaction step success finish_if operation operations within always].freeze
|
|
7
|
+
INNER_INSTRUCTIONS = (INSTRUCTIONS - %I[always]).freeze
|
|
7
8
|
|
|
8
9
|
def self.included(base)
|
|
9
10
|
base.extend(ClassMethods)
|
|
@@ -14,12 +15,23 @@ module Opera
|
|
|
14
15
|
@instructions ||= []
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
INNER_INSTRUCTIONS.each do |instruction|
|
|
18
19
|
define_method instruction do |method = nil, &blk|
|
|
20
|
+
if instructions.any? { |i| i[:kind] == :always }
|
|
21
|
+
raise ArgumentError,
|
|
22
|
+
"`#{instruction}` cannot appear after `always`. " \
|
|
23
|
+
'All `always` steps must be at the end of the operation.'
|
|
24
|
+
end
|
|
25
|
+
|
|
19
26
|
check_method_availability!(method) if method
|
|
20
27
|
instructions.concat(InnerBuilder.new.send(instruction, method, &blk))
|
|
21
28
|
end
|
|
22
29
|
end
|
|
30
|
+
|
|
31
|
+
def always(method)
|
|
32
|
+
check_method_availability!(method)
|
|
33
|
+
instructions << { kind: :always, method: method }
|
|
34
|
+
end
|
|
23
35
|
end
|
|
24
36
|
|
|
25
37
|
class InnerBuilder
|
|
@@ -30,7 +42,7 @@ module Opera
|
|
|
30
42
|
instance_eval(&block) if block_given?
|
|
31
43
|
end
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
INNER_INSTRUCTIONS.each do |instruction|
|
|
34
46
|
define_method instruction do |method = nil, &blk|
|
|
35
47
|
instructions << if !blk.nil?
|
|
36
48
|
{
|
|
@@ -46,6 +58,12 @@ module Opera
|
|
|
46
58
|
end
|
|
47
59
|
end
|
|
48
60
|
end
|
|
61
|
+
|
|
62
|
+
def always(_method)
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
'`always` cannot be used inside a block (transaction, within, success, validate). ' \
|
|
65
|
+
'Place `always` steps at the top level of the operation, after all other instructions.'
|
|
66
|
+
end
|
|
49
67
|
end
|
|
50
68
|
end
|
|
51
69
|
end
|
|
@@ -21,8 +21,9 @@ module Opera
|
|
|
21
21
|
|
|
22
22
|
def evaluate_instructions(instructions = [])
|
|
23
23
|
instructions.each do |instruction|
|
|
24
|
+
next if instruction[:kind] != :always && break_condition
|
|
25
|
+
|
|
24
26
|
evaluate_instruction(instruction)
|
|
25
|
-
break if break_condition
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -57,6 +58,8 @@ module Opera
|
|
|
57
58
|
Instructions::Executors::FinishIf.new(operation).call(instruction)
|
|
58
59
|
when :within
|
|
59
60
|
Instructions::Executors::Within.new(operation).call(instruction)
|
|
61
|
+
when :always
|
|
62
|
+
Instructions::Executors::Always.new(operation).call(instruction)
|
|
60
63
|
else
|
|
61
64
|
raise(UnknownInstructionError, "Unknown instruction #{instruction[:kind]}")
|
|
62
65
|
end
|
data/lib/opera/operation.rb
CHANGED
|
@@ -15,6 +15,7 @@ require 'opera/operation/instructions/executors/operation'
|
|
|
15
15
|
require 'opera/operation/instructions/executors/operations'
|
|
16
16
|
require 'opera/operation/instructions/executors/step'
|
|
17
17
|
require 'opera/operation/instructions/executors/within'
|
|
18
|
+
require 'opera/operation/instructions/executors/always'
|
|
18
19
|
|
|
19
20
|
module Opera
|
|
20
21
|
module Operation
|
data/lib/opera/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: opera
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ProFinda Development Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dry-validation
|
|
@@ -77,6 +77,7 @@ files:
|
|
|
77
77
|
- bin/console
|
|
78
78
|
- bin/setup
|
|
79
79
|
- docker-compose.yml
|
|
80
|
+
- docs/examples/always.md
|
|
80
81
|
- docs/examples/basic-operation.md
|
|
81
82
|
- docs/examples/context-params-dependencies.md
|
|
82
83
|
- docs/examples/finish-if.md
|
|
@@ -93,6 +94,7 @@ files:
|
|
|
93
94
|
- lib/opera/operation/builder.rb
|
|
94
95
|
- lib/opera/operation/config.rb
|
|
95
96
|
- lib/opera/operation/executor.rb
|
|
97
|
+
- lib/opera/operation/instructions/executors/always.rb
|
|
96
98
|
- lib/opera/operation/instructions/executors/finish_if.rb
|
|
97
99
|
- lib/opera/operation/instructions/executors/operation.rb
|
|
98
100
|
- lib/opera/operation/instructions/executors/operations.rb
|