opera 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
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, and within -- 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 :output
126
+
127
+ def schema
128
+ Opera::Operation::Result.new(output: params)
129
+ end
130
+
131
+ def prepare
132
+ self.validated = context[:schema_output]
133
+ context[:counter] = 0
134
+ end
135
+
136
+ def skip_processing?
137
+ params[:skip] == true
138
+ end
139
+
140
+ def create_record
141
+ self.profile = { id: rand(1000), name: validated[:name] }
142
+ end
143
+
144
+ def update_record
145
+ profile[:updated_at] = Time.now.to_i
146
+ end
147
+
148
+ def run_inner
149
+ InnerOperation.call(params: { batch_size: params.fetch(:batch_size, 5) })
150
+ end
151
+
152
+ def heavy_computation
153
+ # Simulate CPU work: string operations in a loop
154
+ 50.times do |i|
155
+ context[:counter] += i
156
+ "operation-#{i}-#{context[:counter]}".hash
157
+ end
158
+ end
159
+
160
+ def notify
161
+ context[:notified] = true
162
+ end
163
+
164
+ def log_audit
165
+ context[:audited] = true
166
+ end
167
+
168
+ def output
169
+ result.output = {
170
+ profile: profile,
171
+ counter: context[:counter],
172
+ batch: context[:run_inner_output]
173
+ }
174
+ end
175
+
176
+ def with_timing
177
+ yield
178
+ end
179
+ end
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Within operation — wraps steps and inner operations with a custom method
183
+ # ---------------------------------------------------------------------------
184
+ WithinOperation = Class.new(Opera::Operation::Base) do
185
+ configure do |config|
186
+ config.transaction_class = FakeTransaction
187
+ end
188
+
189
+ context do
190
+ attr_accessor :log, default: -> { [] }
191
+ end
192
+
193
+ step :prepare
194
+
195
+ within :with_connection do
196
+ step :query_one
197
+ step :query_two
198
+ operation :fetch_leaf
199
+ end
200
+
201
+ transaction do
202
+ within :with_lock do
203
+ step :write_one
204
+ step :write_two
205
+ end
206
+ step :write_three
207
+ end
208
+
209
+ step :output
210
+
211
+ def prepare
212
+ context[:counter] = 0
213
+ end
214
+
215
+ def query_one
216
+ context[:counter] += 1
217
+ log << :query_one
218
+ end
219
+
220
+ def query_two
221
+ context[:counter] += 1
222
+ log << :query_two
223
+ end
224
+
225
+ def fetch_leaf
226
+ LeafOperation.call(params: { n: context[:counter] })
227
+ end
228
+
229
+ def write_one
230
+ context[:counter] += 10
231
+ log << :write_one
232
+ end
233
+
234
+ def write_two
235
+ context[:counter] += 10
236
+ log << :write_two
237
+ end
238
+
239
+ def write_three
240
+ context[:counter] += 1
241
+ log << :write_three
242
+ end
243
+
244
+ def output
245
+ result.output = {
246
+ counter: context[:counter],
247
+ log: log,
248
+ leaf: context[:fetch_leaf_output]
249
+ }
250
+ end
251
+
252
+ def with_connection
253
+ log << :connect
254
+ yield
255
+ log << :disconnect
256
+ end
257
+
258
+ def with_lock
259
+ log << :lock
260
+ yield
261
+ log << :unlock
262
+ end
263
+ end
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Operations (plural) consumer — calls multiple inner operations
267
+ # ---------------------------------------------------------------------------
268
+ BatchOperation = Class.new(Opera::Operation::Base) do
269
+ operations :run_all
270
+ step :output
271
+
272
+ def run_all
273
+ (1..params.fetch(:count, 3)).map do |n|
274
+ LeafOperation.call(params: { n: n })
275
+ end
276
+ end
277
+
278
+ def output
279
+ result.output = context[:run_all_output]
280
+ end
281
+ end
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Benchmark
285
+ # ---------------------------------------------------------------------------
286
+ ITERATIONS = 1000
287
+ PARAMS = { name: 'benchmark', batch_size: 5 }.freeze
288
+ BATCH_PARAMS = { count: 5 }.freeze
289
+ VALIDATION_PARAMS = { first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com' }.freeze
290
+ WITHIN_PARAMS = {}.freeze
291
+
292
+ # Warm up
293
+ 3.times do
294
+ ComplexOperation.call(params: PARAMS)
295
+ BatchOperation.call(params: BATCH_PARAMS)
296
+ ValidationOperation.call(params: VALIDATION_PARAMS)
297
+ WithinOperation.call(params: WITHIN_PARAMS)
298
+ end
299
+
300
+ puts "Opera v#{Opera::VERSION} — #{ITERATIONS} iterations each"
301
+ puts "Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
302
+ puts '-' * 60
303
+
304
+ Benchmark.bm(35) do |x|
305
+ x.report('ComplexOperation (nested + tx):') do
306
+ ITERATIONS.times { ComplexOperation.call(params: PARAMS) }
307
+ end
308
+
309
+ x.report('BatchOperation (operations):') do
310
+ ITERATIONS.times { BatchOperation.call(params: BATCH_PARAMS) }
311
+ end
312
+
313
+ x.report('ValidationOperation (validate):') do
314
+ ITERATIONS.times { ValidationOperation.call(params: VALIDATION_PARAMS) }
315
+ end
316
+
317
+ x.report('WithinOperation (within + tx):') do
318
+ ITERATIONS.times { WithinOperation.call(params: WITHIN_PARAMS) }
319
+ end
320
+
321
+ x.report('LeafOperation (minimal):') do
322
+ ITERATIONS.times { LeafOperation.call(params: { n: 42 }) }
323
+ end
324
+
325
+ # Total operations executed in ComplexOperation run:
326
+ # 1 complex + 1 inner + 5 leaf = 7 operations per iteration
327
+ # = 7000 total operation instantiations for ComplexOperation alone
328
+ total_ops = ITERATIONS * 7
329
+ puts "\nComplexOperation spawns ~#{total_ops} total operation instances across #{ITERATIONS} calls"
330
+ end
@@ -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
+ ```
@@ -0,0 +1,122 @@
1
+ # Context, Params & Dependencies
2
+
3
+ Opera provides typed accessor blocks for managing state within an operation.
4
+
5
+ ## context
6
+
7
+ Mutable hash for passing data between steps. Supports `attr_reader`, `attr_writer`, and `attr_accessor`.
8
+
9
+ ```ruby
10
+ context do
11
+ attr_accessor :profile
12
+ attr_accessor :account, default: -> { Account.new }
13
+ attr_reader :schema_output
14
+ end
15
+ ```
16
+
17
+ - `attr_accessor` defines getter and setter methods that read/write to the `context` hash
18
+ - `attr_reader` defines only a getter
19
+ - `default` accepts a lambda, evaluated lazily on first access when the key is missing
20
+
21
+ ```ruby
22
+ context do
23
+ attr_accessor :profile, :account
24
+ end
25
+
26
+ step :fetch_profile
27
+ step :update_profile
28
+
29
+ def fetch_profile
30
+ self.profile = ProfileFetcher.call # sets context[:profile]
31
+ end
32
+
33
+ def update_profile
34
+ profile.update!(name: 'John') # reads profile from context[:profile]
35
+ end
36
+ ```
37
+
38
+ ## params
39
+
40
+ Immutable hash received in the `call` method. Only supports `attr_reader`.
41
+
42
+ ```ruby
43
+ params do
44
+ attr_reader :activity, :requester
45
+ end
46
+ ```
47
+
48
+ ## dependencies
49
+
50
+ Immutable hash received in the `call` method. Only supports `attr_reader`.
51
+
52
+ ```ruby
53
+ dependencies do
54
+ attr_reader :current_account, :mailer
55
+ end
56
+ ```
57
+
58
+ ## context_reader with defaults
59
+
60
+ Use `context_reader` to read step outputs from the context hash:
61
+
62
+ ```ruby
63
+ context_reader :schema_output
64
+
65
+ validate :schema # context = { schema_output: { id: 1 } }
66
+ step :do_something
67
+
68
+ def do_something
69
+ puts schema_output # outputs: { id: 1 }
70
+ end
71
+ ```
72
+
73
+ Use `default` to provide a fallback value when the key is missing:
74
+
75
+ ```ruby
76
+ context_reader :profile, default: -> { Profile.new }
77
+
78
+ step :fetch_profile
79
+ step :do_something
80
+
81
+ def fetch_profile
82
+ return if App.http_disabled?
83
+
84
+ context[:profile] = ProfileFetcher.call
85
+ end
86
+
87
+ def update_profile
88
+ profile.name = 'John'
89
+ profile.save!
90
+ end
91
+ ```
92
+
93
+ ## Best practices
94
+
95
+ **Good** -- Use `context_reader` for step outputs and shared state:
96
+
97
+ ```ruby
98
+ context_reader :schema_output
99
+ ```
100
+
101
+ **Bad** -- Don't use `context_reader` with `default` for transient objects that aren't stored in context:
102
+
103
+ ```ruby
104
+ # BAD: suggests serializer is part of persistent state
105
+ context_reader :serializer, default: -> { ProfileSerializer.new }
106
+ ```
107
+
108
+ **Better** -- Use private methods for transient dependencies:
109
+
110
+ ```ruby
111
+ step :output
112
+
113
+ def output
114
+ self.result = serializer.to_json({...})
115
+ end
116
+
117
+ private
118
+
119
+ def serializer
120
+ ProfileSerializer.new
121
+ end
122
+ ```
@@ -0,0 +1,67 @@
1
+ # Finish If
2
+
3
+ `finish_if` evaluates a method and stops execution (successfully) if the method returns a truthy value. Subsequent steps are skipped, but the operation is considered successful.
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
+ finish_if :profile_create_only
19
+ step :update
20
+
21
+ success do
22
+ step :send_email
23
+ step :output
24
+ end
25
+
26
+ def profile_schema
27
+ Dry::Validation.Schema do
28
+ required(:first_name).filled
29
+ end.call(params)
30
+ end
31
+
32
+ def create
33
+ self.profile = current_account.profiles.create(params)
34
+ end
35
+
36
+ def profile_create_only
37
+ dependencies[:create_only].present?
38
+ end
39
+
40
+ def update
41
+ profile.update(updated_at: 1.day.ago)
42
+ end
43
+
44
+ # NOTE: We can add an error in this step and it won't break the execution
45
+ def send_email
46
+ result.add_error('mailer', 'Missing dependency')
47
+ mailer&.send_mail(profile: profile)
48
+ end
49
+
50
+ def output
51
+ result.output = { model: context[:profile] }
52
+ end
53
+ end
54
+ ```
55
+
56
+ ## Example
57
+
58
+ ```ruby
59
+ Profile::Create.call(params: {
60
+ first_name: :foo,
61
+ last_name: :bar
62
+ }, dependencies: {
63
+ create_only: true,
64
+ current_account: Account.find(1)
65
+ })
66
+ #<Opera::Operation::Result:0x007fd0248e5638 @errors={}, @information={}, @executions=[:profile_schema, :create, :profile_create_only], @output={}>
67
+ ```
@@ -0,0 +1,94 @@
1
+ # Inner Operations
2
+
3
+ ## Single operation
4
+
5
+ Use `operation` to call another Opera operation from within a step. The method must return an `Opera::Operation::Result`. If the inner operation fails, errors are propagated and execution stops. If it succeeds, its output is stored in context as `:<method_name>_output`.
6
+
7
+ ```ruby
8
+ class Profile::Find < Opera::Operation::Base
9
+ step :find
10
+
11
+ def find
12
+ result.output = Profile.find(params[:id])
13
+ end
14
+ end
15
+
16
+ class Profile::Create < Opera::Operation::Base
17
+ validate :profile_schema
18
+
19
+ operation :find
20
+
21
+ step :create
22
+
23
+ step :output
24
+
25
+ def profile_schema
26
+ Dry::Validation.Schema do
27
+ optional(:id).filled
28
+ end.call(params)
29
+ end
30
+
31
+ def find
32
+ Profile::Find.call(params: params, dependencies: dependencies)
33
+ end
34
+
35
+ def create
36
+ return if context[:find_output]
37
+ puts 'not found'
38
+ end
39
+
40
+ def output
41
+ result.output = { model: context[:find_output] }
42
+ end
43
+ end
44
+ ```
45
+
46
+ ### Example with inner operation doing the find
47
+
48
+ ```ruby
49
+ Profile::Create.call(params: {
50
+ id: 1
51
+ }, dependencies: {
52
+ current_account: Account.find(1)
53
+ })
54
+ #<Opera::Operation::Result:0x007f99b25f0f20 @errors={}, @information={}, @executions=[:profile_schema, :find, :create, :output], @output={:model=>{:id=>1, :user_id=>1, :linkedin_uid=>nil, ...}}>
55
+ ```
56
+
57
+ ## Multiple operations
58
+
59
+ Use `operations` when a method returns an array of `Opera::Operation::Result` objects. If any of the inner operations fail, all their errors are collected and execution stops.
60
+
61
+ ```ruby
62
+ class Profile::Create < Opera::Operation::Base
63
+ step :validate
64
+ step :create
65
+
66
+ def validate; end
67
+
68
+ def create
69
+ result.output = { model: "Profile #{Kernel.rand(100)}" }
70
+ end
71
+ end
72
+
73
+ class Profile::CreateMultiple < Opera::Operation::Base
74
+ operations :create_multiple
75
+
76
+ step :output
77
+
78
+ def create_multiple
79
+ (0..params[:number]).map do
80
+ Profile::Create.call
81
+ end
82
+ end
83
+
84
+ def output
85
+ result.output = context[:create_multiple_output]
86
+ end
87
+ end
88
+ ```
89
+
90
+ ```ruby
91
+ Profile::CreateMultiple.call(params: { number: 3 })
92
+
93
+ #<Opera::Operation::Result:0x0000564189f38c90 @errors={}, @information={}, @executions=[{:create_multiple=>[[:validate, :create], [:validate, :create], [:validate, :create], [:validate, :create]]}, :output], @output=[{:model=>"Profile 1"}, {:model=>"Profile 7"}, {:model=>"Profile 69"}, {:model=>"Profile 92"}]>
94
+ ```