opera 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ ```
@@ -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
+ 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
- INSTRUCTIONS.each do |instruction|
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
@@ -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,12 +20,22 @@ module Opera
20
20
  end
21
21
 
22
22
  def evaluate_instructions(instructions = [])
23
- instruction_copy = Marshal.load(Marshal.dump(instructions))
23
+ instructions.each do |instruction|
24
+ next if instruction[:kind] != :always && break_condition
24
25
 
25
- while instruction_copy.any?
26
- instruction = instruction_copy.shift
27
26
  evaluate_instruction(instruction)
28
- break if break_condition
27
+ end
28
+ end
29
+
30
+ # Executes the operation method named in the instruction, instruments it,
31
+ # and records the execution. This is the shared primitive that all executors
32
+ # use to invoke a step method without mutating the instruction hash.
33
+ def execute_step(instruction)
34
+ method = instruction[:method]
35
+
36
+ Instrumentation.new(operation).instrument(name: "##{method}", level: :step) do
37
+ result.add_execution(method) unless production_mode?
38
+ operation.send(method)
29
39
  end
30
40
  end
31
41
 
@@ -48,6 +58,8 @@ module Opera
48
58
  Instructions::Executors::FinishIf.new(operation).call(instruction)
49
59
  when :within
50
60
  Instructions::Executors::Within.new(operation).call(instruction)
61
+ when :always
62
+ Instructions::Executors::Always.new(operation).call(instruction)
51
63
  else
52
64
  raise(UnknownInstructionError, "Unknown instruction #{instruction[:kind]}")
53
65
  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
@@ -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
@@ -6,8 +6,11 @@ module Opera
6
6
  module Executors
7
7
  class Success < Executor
8
8
  def call(instruction)
9
- instruction[:kind] = :step
10
- super
9
+ if instruction[:instructions]
10
+ evaluate_instructions(instruction[:instructions])
11
+ else
12
+ execute_step(instruction)
13
+ end
11
14
  end
12
15
 
13
16
  def break_condition
@@ -12,8 +12,7 @@ module Opera
12
12
  private
13
13
 
14
14
  def evaluate_instruction(instruction)
15
- instruction[:kind] = :step
16
- validation_result = super
15
+ validation_result = execute_step(instruction)
17
16
 
18
17
  case validation_result
19
18
  when Opera::Operation::Result
@@ -13,7 +13,7 @@ module Opera
13
13
  raise ArgumentError, 'within requires a block with at least one instruction' if nested_instructions.nil?
14
14
 
15
15
  operation.send(wrapper_method) do
16
- super
16
+ evaluate_instructions(nested_instructions)
17
17
  end
18
18
  end
19
19
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Opera
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.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.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-14 00:00:00.000000000 Z
11
+ date: 2026-04-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-validation
@@ -73,9 +73,19 @@ files:
73
73
  - LICENSE.txt
74
74
  - README.md
75
75
  - Rakefile
76
+ - benchmarks/operation_benchmark.rb
76
77
  - bin/console
77
78
  - bin/setup
78
79
  - docker-compose.yml
80
+ - docs/examples/always.md
81
+ - docs/examples/basic-operation.md
82
+ - docs/examples/context-params-dependencies.md
83
+ - docs/examples/finish-if.md
84
+ - docs/examples/inner-operations.md
85
+ - docs/examples/success-blocks.md
86
+ - docs/examples/transactions.md
87
+ - docs/examples/validations.md
88
+ - docs/examples/within.md
79
89
  - lib/opera.rb
80
90
  - lib/opera/errors.rb
81
91
  - lib/opera/operation.rb
@@ -84,6 +94,7 @@ files:
84
94
  - lib/opera/operation/builder.rb
85
95
  - lib/opera/operation/config.rb
86
96
  - lib/opera/operation/executor.rb
97
+ - lib/opera/operation/instructions/executors/always.rb
87
98
  - lib/opera/operation/instructions/executors/finish_if.rb
88
99
  - lib/opera/operation/instructions/executors/operation.rb
89
100
  - lib/opera/operation/instructions/executors/operations.rb