opera 0.5.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d93222fbcb9fdd360cdadc340319157bc57dfe2229cb33a03ed46b1995f31a65
4
- data.tar.gz: 7e909537e4b3af6d636d0eb174180f47212e8e49a7bf76a66c47109c60c363e7
3
+ metadata.gz: a69b196af65cb01e20f10d595444751505e0a241f6264e00c8de370c05def135
4
+ data.tar.gz: b17458445a2f96f47abb45064084498d8e4efb208a2dc04aa1e4abba1d2ab7ee
5
5
  SHA512:
6
- metadata.gz: 76e6997c8fd873844a0a91c93e03c55e7297312abfd5e8ad45471c310c2d943e222664f7bbd0439a25cf7bb287aeac92dca0dd24ea9b84d653dd6d16d6b259cd
7
- data.tar.gz: 0f2c582ef8d69e8e90010aa7c9b8db2c7e7483514dd3dc7da4da1ca521f5ae71e5f799c99744b75e8c0b4d32a220273b32e2f5e946a1a3060aa48280648674ee
6
+ metadata.gz: f4531f3b0cea01d642df058dc6a956de5e036b1259dd2dff659b4a67dc0956a34e965032442860b3b03af1997e1af9a41fe18845fbff4a13ef04a22f11adddc1
7
+ data.tar.gz: ea82fa32434682ec8f9dbbe3754204c3b9edcd586a64805debee5332ef05a69ef913523e309bf729bff626fa21eb79383058740bd3d9ee2cc5a7770f82c710d1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Opera Changelog
2
2
 
3
+ ### 0.7.0 - Apr 30, 2026
4
+
5
+ - Add `:if` / `:unless` options to all instructions except `always` (`validate`, `transaction`, `step`, `success`, `finish_if`, `operation`, `operations`, `within`) for declarative conditional execution. Conditions accept a Symbol (method name) or a Proc/Lambda (evaluated via `instance_exec` in the operation instance scope). Skipped instructions do not execute and are not recorded in `result.executions`; for block instructions (`transaction`, `within`) the whole block, including nested instructions, is skipped. For `operation` / `operations`, the conventional `<method>_output` slot in context is set to `nil` when skipped, matching the historical `return Opera::Operation::Result.new` early-exit behavior. Passing both `:if` and `:unless` on the same instruction raises `ArgumentError` at class load time.
6
+
7
+ ### 0.6.0 - Apr 15, 2026
8
+
9
+ - Add `always` executor: runs its step unconditionally after all regular steps, regardless of failure or an early finish
10
+
3
11
  ### 0.5.1 - Apr 15, 2026
4
12
 
5
13
  - Remove `Marshal.dump` from instruction execution for ~40-55% throughput improvement
@@ -14,6 +22,7 @@
14
22
  - Remove `benchmark` executor
15
23
 
16
24
  ### 0.4.1 - Feb 18, 2026
25
+
17
26
  - Add parsed errors to default `output!` exception message
18
27
 
19
28
  ### 0.4.0 - May 22, 2025
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- opera (0.5.1)
4
+ opera (0.7.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -112,16 +112,49 @@ end
112
112
 
113
113
  ## DSL Reference
114
114
 
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. |
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. |
126
+
127
+ ### Conditional execution (`:if` / `:unless`)
128
+
129
+ The `:if` and `:unless` keyword arguments provide declarative conditional
130
+ execution. They are supported on `validate`, `transaction`, `step`, `success`,
131
+ `finish_if`, `operation`, `operations`, and `within` (every instruction except
132
+ `always`). For block instructions (`transaction`, `within`), a falsy condition
133
+ skips the whole block, including its nested instructions.
134
+
135
+ The condition is evaluated **before** the instruction runs -- if the condition
136
+ is not met the instruction is skipped entirely (no method invocation, no side
137
+ effects, not recorded in `result.executions`).
138
+
139
+ The condition value can be a **Symbol** (method name on the operation) or a
140
+ **Proc/Lambda** (evaluated via `instance_exec` in the operation instance
141
+ scope).
142
+
143
+ ```ruby
144
+ # Symbol form
145
+ step :notify_user, if: :notifications_enabled?
146
+
147
+ # Lambda form
148
+ step :recalculate, unless: -> { params[:skip_recalculation] }
149
+ ```
150
+
151
+ When an `operation` or `operations` instruction is skipped, its
152
+ `context[:<method>_output]` slot is set to `nil` (matching the historical
153
+ `return Opera::Operation::Result.new` early-exit behavior). For other
154
+ instructions no context output is set.
155
+
156
+ Passing both `:if` and `:unless` on the same instruction raises `ArgumentError`
157
+ at class load time.
125
158
 
126
159
  ### Combining instructions
127
160
 
@@ -147,25 +180,26 @@ class MyOperation < Opera::Operation::Base
147
180
  end
148
181
 
149
182
  step :output
183
+ always :audit_trail
150
184
  end
151
185
  ```
152
186
 
153
187
  ## Result API
154
188
 
155
- | Method | Returns | Description |
156
- |---|---|---|
157
- | `success?` | `Boolean` | `true` if no errors |
158
- | `failure?` | `Boolean` | `true` if any errors |
159
- | `output` | `Object` | The operation's return value |
160
- | `output!` | `Object` | Returns output if success, raises `OutputError` if failure |
161
- | `output=` | | Sets the output |
162
- | `errors` | `Hash` | Accumulated error messages |
163
- | `failures` | `Hash` | Alias for `errors` |
164
- | `information` | `Hash` | Developer-facing metadata |
165
- | `executions` | `Array` | Ordered list of executed steps (development mode only) |
166
- | `add_error(key, value)` | | Adds a single error |
167
- | `add_errors(hash)` | | Merges multiple errors |
168
- | `add_information(hash)` | | Merges metadata |
189
+ | Method | Returns | Description |
190
+ | ----------------------- | --------- | ---------------------------------------------------------- |
191
+ | `success?` | `Boolean` | `true` if no errors |
192
+ | `failure?` | `Boolean` | `true` if any errors |
193
+ | `output` | `Object` | The operation's return value |
194
+ | `output!` | `Object` | Returns output if success, raises `OutputError` if failure |
195
+ | `output=` | | Sets the output |
196
+ | `errors` | `Hash` | Accumulated error messages |
197
+ | `failures` | `Hash` | Alias for `errors` |
198
+ | `information` | `Hash` | Developer-facing metadata |
199
+ | `executions` | `Array` | Ordered list of executed steps (development mode only) |
200
+ | `add_error(key, value)` | | Adds a single error |
201
+ | `add_errors(hash)` | | Merges multiple errors |
202
+ | `add_information(hash)` | | Merges metadata |
169
203
 
170
204
  ```ruby
171
205
  # Pre-set output (useful in specs)
@@ -174,13 +208,13 @@ Opera::Operation::Result.new(output: 'success')
174
208
 
175
209
  ## Operation Instance Methods
176
210
 
177
- | Method | Description |
178
- |---|---|
179
- | `context` | Mutable `Hash` for passing data between steps |
180
- | `params` | Immutable `Hash` received via `call` |
181
- | `dependencies` | Immutable `Hash` received via `call` |
182
- | `result` | The `Opera::Operation::Result` instance |
183
- | `finish!` | Halts step execution (operation is still successful) |
211
+ | Method | Description |
212
+ | -------------- | ---------------------------------------------------- |
213
+ | `context` | Mutable `Hash` for passing data between steps |
214
+ | `params` | Immutable `Hash` received via `call` |
215
+ | `dependencies` | Immutable `Hash` received via `call` |
216
+ | `result` | The `Opera::Operation::Result` instance |
217
+ | `finish!` | Halts step execution (operation is still successful) |
184
218
 
185
219
  ## Testing
186
220
 
@@ -204,6 +238,7 @@ Detailed examples with full input/output are available in the [`docs/examples/`]
204
238
  - [Finish If](docs/examples/finish-if.md)
205
239
  - [Inner Operations](docs/examples/inner-operations.md)
206
240
  - [Within](docs/examples/within.md)
241
+ - [Always](docs/examples/always.md)
207
242
  - [Context, Params & Dependencies](docs/examples/context-params-dependencies.md)
208
243
 
209
244
  ## 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 within -- with nested inner operations and loops to
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 :output
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
+ ```
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opera
4
+ module Operation
5
+ module Builder
6
+ class OptionsBuilder
7
+ ALLOWED_OPTIONS = %i[if unless].freeze
8
+
9
+ def self.build(opts)
10
+ return {} if opts.empty?
11
+
12
+ unknown = opts.keys - ALLOWED_OPTIONS
13
+ raise ArgumentError, "Unknown option(s): #{unknown.inspect}. Allowed: #{ALLOWED_OPTIONS}" if unknown.any?
14
+
15
+ { predicate: build_predicate(opts) }.compact
16
+ end
17
+
18
+ def self.build_predicate(opts)
19
+ return nil unless opts[:if] || opts[:unless]
20
+ raise ArgumentError, 'Cannot use :if and :unless together' if opts[:if] && opts[:unless]
21
+
22
+ cond = opts[:if] || opts[:unless]
23
+ condition_proc = cond.is_a?(Symbol) ? proc { send(cond) } : cond
24
+ opts.key?(:if) ? condition_proc : proc { !instance_exec(&condition_proc) }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -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
- INSTRUCTIONS.each do |instruction|
18
- define_method instruction do |method = nil, &blk|
18
+ INNER_INSTRUCTIONS.each do |instruction|
19
+ define_method instruction do |method = nil, **opts, &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
- instructions.concat(InnerBuilder.new.send(instruction, method, &blk))
27
+ instructions.concat(InnerBuilder.new.send(instruction, method, **opts, &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,22 +42,22 @@ module Opera
30
42
  instance_eval(&block) if block_given?
31
43
  end
32
44
 
33
- INSTRUCTIONS.each do |instruction|
34
- define_method instruction do |method = nil, &blk|
35
- instructions << if !blk.nil?
36
- {
37
- kind: instruction,
38
- label: method,
39
- instructions: InnerBuilder.new(&blk).instructions
40
- }
41
- else
42
- {
43
- kind: instruction,
44
- method: method
45
- }
46
- end
45
+ INNER_INSTRUCTIONS.each do |instruction|
46
+ define_method instruction do |method = nil, **opts, &blk|
47
+ entry = if blk
48
+ { kind: instruction, label: method, instructions: InnerBuilder.new(&blk).instructions }
49
+ else
50
+ { kind: instruction, method: method }
51
+ end
52
+ instructions << entry.merge(OptionsBuilder.build(opts))
47
53
  end
48
54
  end
55
+
56
+ def always(_method)
57
+ raise ArgumentError,
58
+ '`always` cannot be used inside a block (transaction, within, success, validate). ' \
59
+ 'Place `always` steps at the top level of the operation, after all other instructions.'
60
+ end
49
61
  end
50
62
  end
51
63
  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
 
@@ -40,6 +41,11 @@ module Opera
40
41
 
41
42
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
42
43
  def evaluate_instruction(instruction)
44
+ if instruction[:predicate] && !operation.instance_exec(&instruction[:predicate])
45
+ add_instruction_output(instruction, nil) if %i[operation operations].include?(instruction[:kind])
46
+ return
47
+ end
48
+
43
49
  case instruction[:kind]
44
50
  when :step
45
51
  Instructions::Executors::Step.new(operation).call(instruction)
@@ -57,6 +63,8 @@ module Opera
57
63
  Instructions::Executors::FinishIf.new(operation).call(instruction)
58
64
  when :within
59
65
  Instructions::Executors::Within.new(operation).call(instruction)
66
+ when :always
67
+ Instructions::Executors::Always.new(operation).call(instruction)
60
68
  else
61
69
  raise(UnknownInstructionError, "Unknown instruction #{instruction[:kind]}")
62
70
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opera
4
+ module Operation
5
+ module Instructions
6
+ module Executors
7
+ class Always < Executor
8
+ def call(instruction)
9
+ execute_step(instruction)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'opera/operation/attributes_dsl'
4
4
  require 'opera/operation/builder'
5
+ require 'opera/operation/builder/options_builder'
5
6
  require 'opera/operation/base'
6
7
  require 'opera/operation/executor'
7
8
  require 'opera/operation/instrumentation'
@@ -15,6 +16,7 @@ require 'opera/operation/instructions/executors/operation'
15
16
  require 'opera/operation/instructions/executors/operations'
16
17
  require 'opera/operation/instructions/executors/step'
17
18
  require 'opera/operation/instructions/executors/within'
19
+ require 'opera/operation/instructions/executors/always'
18
20
 
19
21
  module Opera
20
22
  module Operation
data/lib/opera/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Opera
4
- VERSION = '0.5.1'
4
+ VERSION = '0.7.0'
5
5
  end
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.5.1
4
+ version: 0.7.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-15 00:00:00.000000000 Z
11
+ date: 2026-07-14 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
@@ -91,8 +92,10 @@ files:
91
92
  - lib/opera/operation/attributes_dsl.rb
92
93
  - lib/opera/operation/base.rb
93
94
  - lib/opera/operation/builder.rb
95
+ - lib/opera/operation/builder/options_builder.rb
94
96
  - lib/opera/operation/config.rb
95
97
  - lib/opera/operation/executor.rb
98
+ - lib/opera/operation/instructions/executors/always.rb
96
99
  - lib/opera/operation/instructions/executors/finish_if.rb
97
100
  - lib/opera/operation/instructions/executors/operation.rb
98
101
  - lib/opera/operation/instructions/executors/operations.rb