From c034edb4132a8c6120f71829f379211487c9b35e Mon Sep 17 00:00:00 2001 From: Francisco Ruiz Date: Tue, 14 Apr 2026 15:44:29 +0200 Subject: [PATCH 1/8] Stop mutating instruction hashes and remove Marshal.dump Executors previously rewrote instruction[:kind] to :step to delegate to the Step executor via super, which forced a Marshal.dump deep copy of the entire instruction tree on every operation call. Extract execute_step as a shared primitive on the base Executor class that any executor can call directly to invoke a step method, instrument it, and record the execution -- without mutating the instruction hash. This eliminates the need for any copying and yields ~40% throughput improvement on operation calls. --- lib/opera/operation/executor.rb | 17 +++++++++++++---- .../instructions/executors/finish_if.rb | 3 +-- .../instructions/executors/operation.rb | 3 +-- .../instructions/executors/operations.rb | 3 +-- .../operation/instructions/executors/step.rb | 7 +------ .../operation/instructions/executors/success.rb | 7 +++++-- .../instructions/executors/validate.rb | 3 +-- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/lib/opera/operation/executor.rb b/lib/opera/operation/executor.rb index 945eeb4..5ec737e 100644 --- a/lib/opera/operation/executor.rb +++ b/lib/opera/operation/executor.rb @@ -20,15 +20,24 @@ def call(instruction) end def evaluate_instructions(instructions = []) - instruction_copy = Marshal.load(Marshal.dump(instructions)) - - while instruction_copy.any? - instruction = instruction_copy.shift + instructions.each do |instruction| evaluate_instruction(instruction) break if break_condition end end + # Executes the operation method named in the instruction, instruments it, + # and records the execution. This is the shared primitive that all executors + # use to invoke a step method without mutating the instruction hash. + def execute_step(instruction) + method = instruction[:method] + + Instrumentation.new(operation).instrument(name: "##{method}", level: :step) do + result.add_execution(method) unless production_mode? + operation.send(method) + end + end + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity def evaluate_instruction(instruction) case instruction[:kind] diff --git a/lib/opera/operation/instructions/executors/finish_if.rb b/lib/opera/operation/instructions/executors/finish_if.rb index 2a883ce..b9d2961 100644 --- a/lib/opera/operation/instructions/executors/finish_if.rb +++ b/lib/opera/operation/instructions/executors/finish_if.rb @@ -6,8 +6,7 @@ module Instructions module Executors class FinishIf < Executor def call(instruction) - instruction[:kind] = :step - operation.finish! if super + operation.finish! if execute_step(instruction) end end end diff --git a/lib/opera/operation/instructions/executors/operation.rb b/lib/opera/operation/instructions/executors/operation.rb index 203eeba..fdae981 100644 --- a/lib/opera/operation/instructions/executors/operation.rb +++ b/lib/opera/operation/instructions/executors/operation.rb @@ -6,8 +6,7 @@ module Instructions module Executors class Operation < Executor def call(instruction) - instruction[:kind] = :step - operation_result = super + operation_result = execute_step(instruction) save_information(operation_result) if operation_result.success? diff --git a/lib/opera/operation/instructions/executors/operations.rb b/lib/opera/operation/instructions/executors/operations.rb index 425b9cb..d5f3b79 100644 --- a/lib/opera/operation/instructions/executors/operations.rb +++ b/lib/opera/operation/instructions/executors/operations.rb @@ -9,8 +9,7 @@ class WrongOperationsResultError < Opera::Error; end # rubocop:disable Metrics/MethodLength def call(instruction) - instruction[:kind] = :step - operations_results = super + operations_results = execute_step(instruction) case operations_results when Array diff --git a/lib/opera/operation/instructions/executors/step.rb b/lib/opera/operation/instructions/executors/step.rb index dbf770b..a82e699 100644 --- a/lib/opera/operation/instructions/executors/step.rb +++ b/lib/opera/operation/instructions/executors/step.rb @@ -6,12 +6,7 @@ module Instructions module Executors class Step < Executor def call(instruction) - method = instruction[:method] - - Instrumentation.new(operation).instrument(name: "##{method}", level: :step) do - operation.result.add_execution(method) unless production_mode? - operation.send(method) - end + execute_step(instruction) end end end diff --git a/lib/opera/operation/instructions/executors/success.rb b/lib/opera/operation/instructions/executors/success.rb index e3450b0..69f9738 100644 --- a/lib/opera/operation/instructions/executors/success.rb +++ b/lib/opera/operation/instructions/executors/success.rb @@ -6,8 +6,11 @@ module Instructions module Executors class Success < Executor def call(instruction) - instruction[:kind] = :step - super + if instruction[:instructions] + evaluate_instructions(instruction[:instructions]) + else + execute_step(instruction) + end end def break_condition diff --git a/lib/opera/operation/instructions/executors/validate.rb b/lib/opera/operation/instructions/executors/validate.rb index 7f9f2b8..602e500 100644 --- a/lib/opera/operation/instructions/executors/validate.rb +++ b/lib/opera/operation/instructions/executors/validate.rb @@ -12,8 +12,7 @@ def break_condition private def evaluate_instruction(instruction) - instruction[:kind] = :step - validation_result = super + validation_result = execute_step(instruction) case validation_result when Opera::Operation::Result From d62a7af6ec4e32f5f161fd1af0de0c9278b2b82e Mon Sep 17 00:00:00 2001 From: Francisco Ruiz Date: Tue, 14 Apr 2026 15:52:21 +0200 Subject: [PATCH 2/8] Fix Config.development_mode? referencing non-existent DEFAULT_MODE constant The class method referenced DEFAULT_MODE which does not exist, causing a NameError if ever called. Corrected to DEVELOPMENT_MODE to match the constant defined at the top of the class. --- lib/opera/operation/config.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/opera/operation/config.rb b/lib/opera/operation/config.rb index c7efb69..8aa6798 100644 --- a/lib/opera/operation/config.rb +++ b/lib/opera/operation/config.rb @@ -47,7 +47,7 @@ def configure end def development_mode? - mode == DEFAULT_MODE + mode == DEVELOPMENT_MODE end def production_mode? From dd48ecbb25d474cfd6dd1aea44b59016abc4f2d5 Mon Sep 17 00:00:00 2001 From: Francisco Ruiz Date: Tue, 14 Apr 2026 15:55:43 +0200 Subject: [PATCH 3/8] Remove implicit Rails coupling from Config#custom_reporter Config previously reached into Rails.application.config.x.reporter at initialization time, silently overriding any explicitly configured reporter. The reporter should be set by the consumer via the config block, not auto-detected from the host framework. BREAKING: Apps relying on config.x.reporter must now set the reporter explicitly in their Opera initializer. --- lib/opera/operation/config.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/opera/operation/config.rb b/lib/opera/operation/config.rb index 8aa6798..b261209 100644 --- a/lib/opera/operation/config.rb +++ b/lib/opera/operation/config.rb @@ -17,7 +17,7 @@ def initialize @instrumentation_class = self.class.instrumentation_class @mode = self.class.mode || DEVELOPMENT_MODE - @reporter = custom_reporter || self.class.reporter + @reporter = self.class.reporter validate! end @@ -26,10 +26,6 @@ def configure yield self end - def custom_reporter - Rails.application.config.x.reporter.presence if defined?(Rails) - end - private def validate! From 113f20b87f0b3784c295aff354fc5ab8eea09a35 Mon Sep 17 00:00:00 2001 From: Francisco Ruiz Date: Tue, 14 Apr 2026 16:05:40 +0200 Subject: [PATCH 4/8] Restructure README into quick-start guide with separate example docs Slim the README from 1,175 to 221 lines, focusing on what developers need to get started: installation, one complete example, configuration, DSL reference table, and Result API table. All existing examples are preserved verbatim in docs/examples/ as separate files organized by topic: basic operations, validations, transactions, success blocks, finish_if, inner operations, within, and context/params/dependencies. --- README.md | 1166 ++---------------- docs/examples/basic-operation.md | 79 ++ docs/examples/context-params-dependencies.md | 122 ++ docs/examples/finish-if.md | 67 + docs/examples/inner-operations.md | 94 ++ docs/examples/success-blocks.md | 68 + docs/examples/transactions.md | 227 ++++ docs/examples/validations.md | 139 +++ docs/examples/within.md | 166 +++ 9 files changed, 1068 insertions(+), 1060 deletions(-) create mode 100644 docs/examples/basic-operation.md create mode 100644 docs/examples/context-params-dependencies.md create mode 100644 docs/examples/finish-if.md create mode 100644 docs/examples/inner-operations.md create mode 100644 docs/examples/success-blocks.md create mode 100644 docs/examples/transactions.md create mode 100644 docs/examples/validations.md create mode 100644 docs/examples/within.md diff --git a/README.md b/README.md index 77eae91..3d19c02 100644 --- a/README.md +++ b/README.md @@ -3,673 +3,30 @@ [![Gem Version](https://badge.fury.io/rb/opera.svg)](https://badge.fury.io/rb/opera) ![Master](https://github.com/Profinda/opera/actions/workflows/release.yml/badge.svg?branch=master) -Simple DSL for services/interactions classes. +A lightweight DSL for building operations, services and interactions in Ruby. Zero runtime dependencies. -Opera was born to mimic some of the philosophy of the dry gems but keeping the DSL simple. +Opera gives developers a consistent way to structure business logic as a pipeline of steps -- validate, execute, handle errors -- with a declarative DSL at the top of each class that makes the flow immediately readable. -Our aim was and is to write as many Operations, Services and Interactions using this fun and intuitive DSL to help developers have consistent code, easy to understand and maintain. - -## Installation - -Add this line to your application's Gemfile: - -```ruby -gem 'opera' -``` - -And then execute: - - $ bundle install - -Or install it yourself as: - - $ gem install opera - -Note. If you are using Ruby 2.x please use Opera 0.2.x - -## Configuration - -Opera is built to be used with or without Rails. -Simply initialize the configuration and choose a custom logger and which library to use for implementing transactions. - -```ruby -Opera::Operation::Config.configure do |config| - config.transaction_class = ActiveRecord::Base - config.transaction_method = :transaction - config.transaction_options = { requires_new: true, level: :step } # or level: :operation - default - config.instrumentation_class = Datadog::Tracing - config.instrumentation_method = :trace - config.instrumentation_options = { service: :operation } - config.mode = :development # Can be set to production too - config.reporter = defined?(Rollbar) ? Rollbar : Rails.logger -end -``` - -You can later override this configuration in each Operation to have more granularity - -## Usage - -Once opera gem is in your project you can start to build Operations - -```ruby -class A < Opera::Operation::Base - configure do |config| - config.transaction_class = Profile - config.reporter = Rails.logger - end - - success :populate - - operation :inner_operation - - validate :profile_schema - - transaction do - step :create - step :update - step :destroy - end - - validate do - step :validate_object - step :validate_relationships - end - - success do - step :send_mail - step :report_to_audit_log - end - - step :output -end -``` - -Start developing your business logic, services and interactions as Opera::Operations and benefit of code that is documented, self-explanatory, easy to maintain and debug. - -### Specs - -When using Opera::Operation inside an engine add the following -configuration to your spec_helper.rb or rails_helper.rb: - -```ruby -Opera::Operation::Config.configure do |config| - config.transaction_class = ActiveRecord::Base -end -``` - -Without this extra configuration you will receive: - -```ruby -NoMethodError: - undefined method `transaction' for nil:NilClass -``` - -### Instrumentation - -When you want to easily instrument your operations you can add this to the opera config: - -```ruby -Rails.application.configure do - config.x.instrumentation_class = Datadog::Tracing - config.x.instrumentation_method = :trace - config.x.instrumentation_options = { service: :opera } -end -``` - -You can also instrument individual operations by adding this to the operation config: - -```ruby -class A < Opera::Operation::Base - configure do |config| - config.instrumentation_class = Datadog::Tracing - config.instrumentation_method = :trace - config.instrumentation_options = { service: :opera, level: :step } - end - - # steps -end -``` - -### Content - -[Basic operation](#user-content-basic-operation) - -[Example with sanitizing parameters](#user-content-example-with-sanitizing-parameters) - -[Example operation with old validations](#user-content-example-operation-with-old-validations) - -[Failing transaction](#user-content-failing-transaction) - -[Passing transaction](#user-content-passing-transaction) - -[Success](#user-content-success) - -[Finish if](#user-content-finish-if) - -[Inner Operation](#user-content-inner-operation) - -[Inner Operations](#user-content-inner-operations) - -[Within](#user-content-within) - -## Usage examples - -Some cases and example how to use new operations - -### Basic operation - -```ruby -class Profile::Create < Opera::Operation::Base - # DEPRECATED - # context_accessor :profile - context do - attr_accessor :profile - end - # DEPRECATED - # dependencies_reader :current_account, :mailer - dependencies do - attr_reader :current_account, :mailer - end - - validate :profile_schema - - step :create - step :send_email - step :output - - def profile_schema - Dry::Validation.Schema do - required(:first_name).filled - end.call(params) - end - - def create - self.profile = current_account.profiles.create(params) - end - - def send_email - mailer&.send_mail(profile: profile) - end - - def output - result.output = { model: profile } - end -end -``` - -#### Call with valid parameters - -```ruby -Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - mailer: MyMailer, - current_account: Account.find(1) -}) - -##}> -``` - -#### Call with INVALID parameters - missing first_name - -```ruby -Profile::Create.call(params: { - last_name: :bar -}, dependencies: { - mailer: MyMailer, - current_account: Account.find(1) -}) - -#["is missing"]}, @information={}, @executions=[:profile_schema]> -``` - -#### Call with MISSING dependencies - -```ruby -Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - current_account: Account.find(1) -}) - -##}> -``` - -### Example with sanitizing parameters - -```ruby -class Profile::Create < Opera::Operation::Base - # DEPRECATED - # context_accessor :profile - context do - attr_accessor :profile - end - # DEPRECATED - # dependencies_reader :current_account, :mailer - dependencies do - attr_reader :current_account, :mailer - end - - - validate :profile_schema - - step :create - step :send_email - step :output - - def profile_schema - Dry::Validation.Schema do - configure { config.input_processor = :sanitizer } - - required(:first_name).filled - end.call(params) - end - - def create - self.profile = current_account.profiles.create(context[:profile_schema_output]) - end - - def send_email - return true unless mailer - - mailer.send_mail(profile: profile) - end - - def output - result.output = { model: profile } - end -end -``` - -```ruby -Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - mailer: MyMailer, - current_account: Account.find(1) -}) - -# NOTE: Last name is missing in output model -##}> -``` - -### Example operation with old validations - -```ruby -class Profile::Create < Opera::Operation::Base - # DEPRECATED - # context_accessor :profile - context do - attr_accessor :profile - end - # DEPRECATED - # dependencies_reader :current_account, :mailer - dependencies do - attr_reader :current_account, :mailer - end - - validate :profile_schema - - step :build_record - step :old_validation - step :create - step :send_email - step :output - - def profile_schema - Dry::Validation.Schema do - required(:first_name).filled - end.call(params) - end - - def build_record - self.profile = current_account.profiles.build(params) - self.profile.force_name_validation = true - end - - def old_validation - return true if profile.valid? - - result.add_information(missing_validations: "Please check dry validations") - result.add_errors(profile.errors.messages) - - false - end - - def create - profile.save - end - - def send_email - mailer.send_mail(profile: profile) - end - - def output - result.output = { model: profile } - end -end -``` - -#### Call with valid parameters - -```ruby -Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - mailer: MyMailer, - current_account: Account.find(1) -}) - -##}> -``` - -#### Call with INVALID parameters - -```ruby -Profile::Create.call(params: { - first_name: :foo -}, dependencies: { - mailer: MyMailer, - current_account: Account.find(1) -}) - -#["can't be blank"]}, @information={:missing_validations=>"Please check dry validations"}, @executions=[:build_record, :old_validation]> -``` - -### Example with step that finishes execution - -```ruby -class Profile::Create < Opera::Operation::Base - # DEPRECATED - # context_accessor :profile - context do - attr_accessor :profile - end - # DEPRECATED - # dependencies_reader :current_account, :mailer - dependencies do - attr_reader :current_account, :mailer - end - - validate :profile_schema - - step :build_record - step :create - step :send_email - step :output - - def profile_schema - Dry::Validation.Schema do - required(:first_name).filled - end.call(params) - end - - def build_record - self.profile = current_account.profiles.build(params) - self.profile.force_name_validation = true - end - - def create - self.profile = profile.save - finish! - end - - def send_email - return true unless mailer - - mailer.send_mail(profile: profile) - end - - def output - result.output(model: profile) - end -end -``` - -##### Call - -```ruby -result = Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - current_account: Account.find(1) -}) - -# -``` - -### Failing transaction - -```ruby -class Profile::Create < Opera::Operation::Base - configure do |config| - config.transaction_class = Profile - end - - # DEPRECATED - # context_accessor :profile - context do - attr_accessor :profile - end - # DEPRECATED - # dependencies_reader :current_account, :mailer - dependencies do - attr_reader :current_account, :mailer - end - - validate :profile_schema - - transaction do - step :create - step :update - end - - step :send_email - step :output - - def profile_schema - Dry::Validation.Schema do - required(:first_name).filled - end.call(params) - end - - def create - self.profile = current_account.profiles.create(params) - end - - def update - profile.update(example_attr: :Example) - end - - def send_email - return true unless mailer - - mailer.send_mail(profile: profile) - end - - def output - result.output = { model: profile } - end -end -``` - -#### Example with non-existing attribute - -```ruby -Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - mailer: MyMailer, - current_account: Account.find(1) -}) - -D, [2020-08-14T16:13:30.946466 #2504] DEBUG -- : Account Load (0.5ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]] -D, [2020-08-14T16:13:30.960254 #2504] DEBUG -- : (0.2ms) BEGIN -D, [2020-08-14T16:13:30.983981 #2504] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-14 16:13:30.982289"], ["updated_at", "2020-08-14 16:13:30.982289"], ["account_id", 1]] -D, [2020-08-14T16:13:30.986233 #2504] DEBUG -- : (0.2ms) ROLLBACK -D, [2020-08-14T16:13:30.988231 #2504] DEBUG -- : unknown attribute 'example_attr' for Profile. (ActiveModel::UnknownAttributeError) -``` - -### Passing transaction - -```ruby -class Profile::Create < Opera::Operation::Base - configure do |config| - config.transaction_class = Profile - end - - # DEPRECATED - # context_accessor :profile - context do - attr_accessor :profile - end - # DEPRECATED - # dependencies_reader :current_account, :mailer - dependencies do - attr_reader :current_account, :mailer - end - - validate :profile_schema - - transaction do - step :create - step :update - end - - step :send_email - step :output - - def profile_schema - Dry::Validation.Schema do - required(:first_name).filled - end.call(params) - end - - def create - self.profile = current_account.profiles.create(params) - end - - def update - profile.update(updated_at: 1.day.ago) - end - - def send_email - return true unless mailer - - mailer.send_mail(profile: profile) - end - - def output - result.output = { model: profile } - end -end -``` - -#### Example with updating timestamp - -```ruby -Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - mailer: MyMailer, - current_account: Account.find(1) -}) -D, [2020-08-17T12:10:44.842392 #2741] DEBUG -- : Account Load (0.7ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]] -D, [2020-08-17T12:10:44.856964 #2741] DEBUG -- : (0.2ms) BEGIN -D, [2020-08-17T12:10:44.881332 #2741] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-17 12:10:44.879684"], ["updated_at", "2020-08-17 12:10:44.879684"], ["account_id", 1]] -D, [2020-08-17T12:10:44.886168 #2741] DEBUG -- : SQL (0.6ms) UPDATE "profiles" SET "updated_at" = $1 WHERE "profiles"."id" = $2 [["updated_at", "2020-08-16 12:10:44.883164"], ["id", 47]] -D, [2020-08-17T12:10:44.898132 #2741] DEBUG -- : (10.3ms) COMMIT -##}> -``` - -### Success - -```ruby -class Profile::Create < Opera::Operation::Base - # DEPRECATED - # context_accessor :profile - context do - attr_accessor :profile - end - # DEPRECATED - # dependencies_reader :current_account, :mailer - dependencies do - attr_reader :current_account, :mailer - end - - validate :profile_schema - - success :populate - - step :create - step :update - - success do - step :send_email - step :output - end - - def profile_schema - Dry::Validation.Schema do - required(:first_name).filled - end.call(params) - end - - def populate - context[:attributes] = {} - context[:valid] = false - end - - def create - self.profile = current_account.profiles.create(params) - end - - def update - profile.update(updated_at: 1.day.ago) - end +## Installation - # NOTE: We can add an error in this step and it won't break the execution - def send_email - result.add_error('mailer', 'Missing dependency') - mailer&.send_mail(profile: profile) - end +Add to your Gemfile: - def output - result.output = { model: context[:profile] } - end -end +```ruby +gem 'opera' ``` -#### Example output for success block +Then run `bundle install`. -```ruby -Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - current_account: Account.find(1) -}) -#["Missing dependency"]}, @information={}, @executions=[:profile_schema, :populate, :create, :update, :send_email, :output], @output={:model=>#}> -``` +> Requires Ruby >= 3.1. For Ruby 2.x use Opera 0.2.x. -### Finish If +## Quick Start ```ruby class Profile::Create < Opera::Operation::Base - # DEPRECATED - # context_accessor :profile context do attr_accessor :profile end - # DEPRECATED - # dependencies_reader :current_account, :mailer + dependencies do attr_reader :current_account, :mailer end @@ -677,13 +34,8 @@ class Profile::Create < Opera::Operation::Base validate :profile_schema step :create - finish_if :profile_create_only - step :update - - success do - step :send_email - step :output - end + step :send_email + step :output def profile_schema Dry::Validation.Schema do @@ -695,466 +47,164 @@ class Profile::Create < Opera::Operation::Base self.profile = current_account.profiles.create(params) end - def profile_create_only - dependencies[:create_only].present? - end - - def update - profile.update(updated_at: 1.day.ago) - end - - # NOTE: We can add an error in this step and it won't break the execution def send_email - result.add_error('mailer', 'Missing dependency') mailer&.send_mail(profile: profile) end def output - result.output = { model: context[:profile] } + result.output = { model: profile } end end ``` ```ruby -Profile::Create.call(params: { - first_name: :foo, - last_name: :bar -}, dependencies: { - create_only: true, - current_account: Account.find(1) -}) -# -``` - -### Inner Operation - -```ruby -class Profile::Find < Opera::Operation::Base - step :find - - def find - result.output = Profile.find(params[:id]) - end -end - -class Profile::Create < Opera::Operation::Base - validate :profile_schema - - operation :find - - step :create - - step :output - - def profile_schema - Dry::Validation.Schema do - optional(:id).filled - end.call(params) - end +result = Profile::Create.call( + params: { first_name: "Jane", last_name: "Doe" }, + dependencies: { current_account: Account.find(1), mailer: MyMailer } +) - def find - Profile::Find.call(params: params, dependencies: dependencies) - end - - def create - return if context[:find_output] - puts 'not found' - end - - def output - result.output = { model: context[:find_output] } - end -end +result.success? # => true +result.output # => { model: # } ``` -#### Example with inner operation doing the find +## Configuration ```ruby -Profile::Create.call(params: { - id: 1 -}, dependencies: { - current_account: Account.find(1) -}) -#{:id=>1, :user_id=>1, :linkedin_uid=>nil, ...}}> +Opera::Operation::Config.configure do |config| + config.transaction_class = ActiveRecord::Base + config.transaction_method = :transaction # default + config.transaction_options = { requires_new: true } # optional + config.instrumentation_class = MyInstrumentationAdapter # optional + config.mode = :development # or :production + config.reporter = Rails.logger # optional +end ``` -### Inner Operations - -Expects that method returns array of `Opera::Operation::Result` +Override per operation: ```ruby -class Profile::Create < Opera::Operation::Base - step :validate - step :create - - def validate; end - - def create - result.output = { model: "Profile #{Kernel.rand(100)}" } - end -end - -class Profile::CreateMultiple < Opera::Operation::Base - operations :create_multiple - - step :output - - def create_multiple - (0..params[:number]).map do - Profile::Create.call - end - end - - def output - result.output = context[:create_multiple_output] +class MyOperation < Opera::Operation::Base + configure do |config| + config.transaction_class = Profile + config.reporter = Rollbar end end ``` -```ruby -Profile::CreateMultiple.call(params: { number: 3 }) - -#[[:validate, :create], [:validate, :create], [:validate, :create], [:validate, :create]]}, :output], @output=[{:model=>"Profile 1"}, {:model=>"Profile 7"}, {:model=>"Profile 69"}, {:model=>"Profile 92"}]> -``` +Setting `mode: :production` skips storing execution traces for lower memory usage. -### Within +## Instrumentation -`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. +To instrument operations, create an adapter inheriting from `Opera::Operation::Instrumentation::Base`: ```ruby -class Profile::Create < Opera::Operation::Base - context do - attr_accessor :profile - end - - dependencies do - attr_reader :current_account - end - - step :build - - within :read_from_replica do - step :check_duplicate - step :validate_quota - end - - step :create - step :output - - def build - self.profile = current_account.profiles.build(params) - end - - def check_duplicate - result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email]) - end - - def validate_quota - result.add_error(:base, 'quota exceeded') if current_account.profiles.count >= 100 - end - - def create - profile.save! - end - - def output - result.output = { model: profile } - end - - private - - def read_from_replica(&block) - ActiveRecord::Base.connected_to(role: :reading, &block) +class MyInstrumentation < Opera::Operation::Instrumentation::Base + def self.instrument(operation, name:, level:) + # level is :operation or :step + Datadog::Tracing.trace(name, service: :opera) { yield } end end -``` - -`within`-method can also be used inline inside any step method when you need the wrapper for only part of that method's logic: - -```ruby -def some_step - value = read_from_replica { Profile.count } - result.output = { count: value } -end -private - -def read_from_replica(&block) - ActiveRecord::Base.connected_to(role: :reading, &block) +Opera::Operation::Config.configure do |config| + config.instrumentation_class = MyInstrumentation end ``` -#### Mixing step and operation inside within - -`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. - -```ruby -class Profile::Create < Opera::Operation::Base - context do - attr_accessor :profile - end - - dependencies do - attr_reader :current_account, :quota_checker - end - - within :read_from_replica do - step :check_duplicate - operation :fetch_quota - end - - step :create - step :output - - def check_duplicate - result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email]) - end - - def fetch_quota - quota_checker.call(params: params) - end - - def create - self.profile = current_account.profiles.create(params) - end - - def output - result.output = { model: profile, quota: context[:fetch_quota_output] } - end - - private - - def read_from_replica(&block) - ActiveRecord::Base.connected_to(role: :reading, &block) - end -end -``` +## DSL Reference -#### Nesting within inside a transaction +| Instruction | Description | +|---|---| +| `step :method` | Executes a method. Returns falsy to stop execution. | +| `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. | +| `transaction do ... end` | Wraps steps in a database transaction. Rolls back on error. | +| `success :method` or `success do ... end` | Like `step`, but a falsy return does **not** stop execution. Use for side effects. | +| `finish_if :method` | Stops execution (successfully) if the method returns truthy. | +| `operation :method` | Calls an inner operation. Must return `Opera::Operation::Result`. Propagates errors on failure. Output stored in `context[:_output]`. | +| `operations :method` | Like `operation`, but the method must return an array of `Opera::Operation::Result`. | +| `within :method do ... end` | Wraps nested steps with a custom method that must `yield`. If it doesn't yield, nested steps are skipped. | -`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. +### Combining instructions ```ruby -class Profile::Create < Opera::Operation::Base - configure do |config| - config.transaction_class = ActiveRecord::Base - end - - context do - attr_accessor :profile - end +class MyOperation < Opera::Operation::Base + validate :schema - dependencies do - attr_reader :current_account, :quota_checker, :audit_logger - end + step :prepare + finish_if :already_done? transaction do + step :create + step :update + within :read_from_replica do step :check_duplicate - operation :fetch_quota end - operation :write_audit_log - end - - step :output - - def check_duplicate - result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email]) - end - - def fetch_quota - quota_checker.call(params: params) - end - - def write_audit_log - audit_logger.call(params: params) - end - - def output - result.output = { quota: context[:fetch_quota_output] } end - private - - def read_from_replica(&block) - ActiveRecord::Base.connected_to(role: :reading, &block) + success do + step :send_notification + step :log_audit end -end -``` - -## Opera::Operation::Result - Instance Methods - -Sometimes it may be useful to be able to create an instance of the `Result` with preset `output`. -It can be handy especially in specs. Then just include it in the initializer: -``` -Opera::Operation::Result.new(output: 'success') -``` - -> - - - success? - [true, false] - Return true if no errors - - failure? - [true, false] - Return true if any error - - output - [Anything] - Return Anything - - output=(Anything) - Sets content of operation output - - output! - Return Anything if Success, raise exception if Failure - - add_error(key, value) - Adds new error message - - add_errors(Hash) - Adds multiple error messages - - add_information(Hash) - Adss new information - Useful informations for developers - -## Opera::Operation::Base - Instance Methods - -> - - - context [Hash] - used to pass information between steps - only for internal usage - - params [Hash] - immutable and received in call method - - dependencies [Hash] - immutable and received in call method - - finish! - this method interrupts the execution of steps after is invoked - -## Opera::Operation::Base - Class Methods - -#### `context_reader` - -The `context_reader` helper method is designed to facilitate easy access to specified keys within a `context` hash. It dynamically defines a method that acts as a getter for the value associated with a specified key, simplifying data retrieval. - -#### Parameters - -**key (Symbol):** The key(s) for which the getter and setter methods are to be created. These symbols should correspond to keys in the context hash. - -**default (Proc, optional):** A lambda or proc that returns a default value for the key if it is not present in the context hash. This proc is lazily evaluated only when the getter is invoked and the key is not present in the hash. - -#### Usage - -**GOOD** - -```ruby -# USE context_reader to read steps outputs from the context hash - -context_reader :schema_output - -validate :schema # context = { schema_output: { id: 1 } } -step :do_something - -def do_something - puts schema_output # outputs: { id: 1 } + step :output end ``` -```ruby -# USE context_reader with 'default' option to provide default value when key is missing in the context hash - -context_reader :profile, default: -> { Profile.new } +## Result API -step :fetch_profile -step :do_something - -def fetch_profile - return if App.http_disabled? - - context[:profile] = ProfileFetcher.call -end - -def update_profile - profile.name = 'John' - profile.save! -end -``` - -**BAD** +| Method | Returns | Description | +|---|---|---| +| `success?` | `Boolean` | `true` if no errors | +| `failure?` | `Boolean` | `true` if any errors | +| `output` | `Object` | The operation's return value | +| `output!` | `Object` | Returns output if success, raises `OutputError` if failure | +| `output=` | | Sets the output | +| `errors` | `Hash` | Accumulated error messages | +| `failures` | `Hash` | Alias for `errors` | +| `information` | `Hash` | Developer-facing metadata | +| `executions` | `Array` | Ordered list of executed steps (development mode only) | +| `add_error(key, value)` | | Adds a single error | +| `add_errors(hash)` | | Merges multiple errors | +| `add_information(hash)` | | Merges metadata | ```ruby -# Using `context_reader` to create read-only methods that instantiate objects, -# especially when these objects are not stored or updated in the `context` hash, is not recommended. -# This approach can lead to confusion and misuse of the context hash, -# as it suggests that the object might be part of the persistent state. -context_reader :serializer, default: -> { ProfileSerializer.new } - -step :output - -def output - self.result = serializer.to_json({...}) -end - - -# A better practice is to use private methods to define read-only access to resources -# that are instantiated on the fly and not intended for storage in any state context. - -step :output - -def output - self.result = serializer.to_json({...}) -end - -private - -def serializer - ProfileSerializer.new -end +# Pre-set output (useful in specs) +Opera::Operation::Result.new(output: 'success') ``` -**Conclusion** - -For creating instance methods that are meant to be read-only and not stored within a context hash, defining these methods as private is a more suitable and clear approach compared to using context_reader with a default. This method ensures that transient dependencies remain well-encapsulated and are not confused with persistent application state. - -### `context|params|depenencies` - -The `context|params|depenencies` helper method is designed to enable easy access to and modification of values for specified keys within a `context` hash. This method dynamically defines both getter and setter methods for the designated keys, facilitating straightforward retrieval and update of values. - -#### attr_reader, attr_accessor Parameters - -**key (Symbol):** The key(s) for which the getter and setter methods are to be created. These symbols will correspond to keys in the context hash. +## Operation Instance Methods -**default (Proc, optional):** A lambda or proc that returns a default value for the key if it is not present in the context hash. This proc is lazily evaluated only when the getter is invoked and the key is not present in the hash. +| Method | Description | +|---|---| +| `context` | Mutable `Hash` for passing data between steps | +| `params` | Immutable `Hash` received via `call` | +| `dependencies` | Immutable `Hash` received via `call` | +| `result` | The `Opera::Operation::Result` instance | +| `finish!` | Halts step execution (operation is still successful) | -#### Usage +## Testing -```ruby -context do - attr_accessor :profile -end - -step :fetch_profile -step :update_profile - -def fetch_profile - self.profile = ProfileFetcher.call # sets context[:profile] -end - -def update_profile - profile.update!(name: 'John') # reads profile from context[:profile] -end -``` +When using Opera inside a Rails engine, configure the transaction class in your test helper: ```ruby -context do - attr_accessor :profile, default: -> { Profile.new } -end -``` - -```ruby -context do - attr_accessor :profile, :account +# spec_helper.rb or rails_helper.rb +Opera::Operation::Config.configure do |config| + config.transaction_class = ActiveRecord::Base end ``` -#### Other methods +## Examples -> +Detailed examples with full input/output are available in the [`docs/examples/`](docs/examples/) directory: - - step(Symbol) - single instruction - - return [Truthly] - continue operation execution - - return [False] - stops operation execution - - operation(Symbol) - single instruction - requires to return Opera::Operation::Result object - - return [Opera::Operation::Result] - stops operation STEPS execution if failure - - validate(Symbol) - single dry-validations - requires to return Dry::Validation::Result object - - return [Dry::Validation::Result] - stops operation STEPS execution if any error but continue with other validations - - transaction(*Symbols) - list of instructions to be wrapped in transaction - - return [Truthly] - continue operation execution - - return [False] - stops operation execution and breaks transaction/do rollback - - within(Symbol, &block) - wraps nested steps with a custom method that must yield - - the named method receives a block and must yield to execute the nested steps - - call(params: Hash, dependencies: Hash?) - - return [Opera::Operation::Result] +- [Basic Operation](docs/examples/basic-operation.md) +- [Validations](docs/examples/validations.md) +- [Transactions](docs/examples/transactions.md) +- [Success Blocks](docs/examples/success-blocks.md) +- [Finish If](docs/examples/finish-if.md) +- [Inner Operations](docs/examples/inner-operations.md) +- [Within](docs/examples/within.md) +- [Context, Params & Dependencies](docs/examples/context-params-dependencies.md) ## Development @@ -1164,12 +214,8 @@ To install this gem onto your local machine, run `bundle exec rake install`. To ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/profinda/opera. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/opera/blob/master/CODE_OF_CONDUCT.md). +Bug reports and pull requests are welcome on GitHub at https://github.com/profinda/opera. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/profinda/opera/blob/master/CODE_OF_CONDUCT.md). ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). - -## Code of Conduct - -Everyone interacting in the Opera project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/profinda/opera/blob/master/CODE_OF_CONDUCT.md). diff --git a/docs/examples/basic-operation.md b/docs/examples/basic-operation.md new file mode 100644 index 0000000..07d6f35 --- /dev/null +++ b/docs/examples/basic-operation.md @@ -0,0 +1,79 @@ +# Basic Operation + +A simple operation that validates input, creates a record, sends an email, and returns output. + +```ruby +class Profile::Create < Opera::Operation::Base + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :mailer + end + + validate :profile_schema + + step :create + step :send_email + step :output + + def profile_schema + Dry::Validation.Schema do + required(:first_name).filled + end.call(params) + end + + def create + self.profile = current_account.profiles.create(params) + end + + def send_email + mailer&.send_mail(profile: profile) + end + + def output + result.output = { model: profile } + end +end +``` + +## Call with valid parameters + +```ruby +Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + mailer: MyMailer, + current_account: Account.find(1) +}) + +##}> +``` + +## Call with INVALID parameters - missing first_name + +```ruby +Profile::Create.call(params: { + last_name: :bar +}, dependencies: { + mailer: MyMailer, + current_account: Account.find(1) +}) + +#["is missing"]}, @information={}, @executions=[:profile_schema]> +``` + +## Call with MISSING dependencies + +```ruby +Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + current_account: Account.find(1) +}) + +##}> +``` diff --git a/docs/examples/context-params-dependencies.md b/docs/examples/context-params-dependencies.md new file mode 100644 index 0000000..c8b2364 --- /dev/null +++ b/docs/examples/context-params-dependencies.md @@ -0,0 +1,122 @@ +# Context, Params & Dependencies + +Opera provides typed accessor blocks for managing state within an operation. + +## context + +Mutable hash for passing data between steps. Supports `attr_reader`, `attr_writer`, and `attr_accessor`. + +```ruby +context do + attr_accessor :profile + attr_accessor :account, default: -> { Account.new } + attr_reader :schema_output +end +``` + +- `attr_accessor` defines getter and setter methods that read/write to the `context` hash +- `attr_reader` defines only a getter +- `default` accepts a lambda, evaluated lazily on first access when the key is missing + +```ruby +context do + attr_accessor :profile, :account +end + +step :fetch_profile +step :update_profile + +def fetch_profile + self.profile = ProfileFetcher.call # sets context[:profile] +end + +def update_profile + profile.update!(name: 'John') # reads profile from context[:profile] +end +``` + +## params + +Immutable hash received in the `call` method. Only supports `attr_reader`. + +```ruby +params do + attr_reader :activity, :requester +end +``` + +## dependencies + +Immutable hash received in the `call` method. Only supports `attr_reader`. + +```ruby +dependencies do + attr_reader :current_account, :mailer +end +``` + +## context_reader with defaults + +Use `context_reader` to read step outputs from the context hash: + +```ruby +context_reader :schema_output + +validate :schema # context = { schema_output: { id: 1 } } +step :do_something + +def do_something + puts schema_output # outputs: { id: 1 } +end +``` + +Use `default` to provide a fallback value when the key is missing: + +```ruby +context_reader :profile, default: -> { Profile.new } + +step :fetch_profile +step :do_something + +def fetch_profile + return if App.http_disabled? + + context[:profile] = ProfileFetcher.call +end + +def update_profile + profile.name = 'John' + profile.save! +end +``` + +## Best practices + +**Good** -- Use `context_reader` for step outputs and shared state: + +```ruby +context_reader :schema_output +``` + +**Bad** -- Don't use `context_reader` with `default` for transient objects that aren't stored in context: + +```ruby +# BAD: suggests serializer is part of persistent state +context_reader :serializer, default: -> { ProfileSerializer.new } +``` + +**Better** -- Use private methods for transient dependencies: + +```ruby +step :output + +def output + self.result = serializer.to_json({...}) +end + +private + +def serializer + ProfileSerializer.new +end +``` diff --git a/docs/examples/finish-if.md b/docs/examples/finish-if.md new file mode 100644 index 0000000..9123782 --- /dev/null +++ b/docs/examples/finish-if.md @@ -0,0 +1,67 @@ +# Finish If + +`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. + +```ruby +class Profile::Create < Opera::Operation::Base + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :mailer + end + + validate :profile_schema + + step :create + finish_if :profile_create_only + step :update + + success do + step :send_email + step :output + end + + def profile_schema + Dry::Validation.Schema do + required(:first_name).filled + end.call(params) + end + + def create + self.profile = current_account.profiles.create(params) + end + + def profile_create_only + dependencies[:create_only].present? + end + + def update + profile.update(updated_at: 1.day.ago) + end + + # NOTE: We can add an error in this step and it won't break the execution + def send_email + result.add_error('mailer', 'Missing dependency') + mailer&.send_mail(profile: profile) + end + + def output + result.output = { model: context[:profile] } + end +end +``` + +## Example + +```ruby +Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + create_only: true, + current_account: Account.find(1) +}) +# +``` diff --git a/docs/examples/inner-operations.md b/docs/examples/inner-operations.md new file mode 100644 index 0000000..f2c2b76 --- /dev/null +++ b/docs/examples/inner-operations.md @@ -0,0 +1,94 @@ +# Inner Operations + +## Single operation + +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 `:_output`. + +```ruby +class Profile::Find < Opera::Operation::Base + step :find + + def find + result.output = Profile.find(params[:id]) + end +end + +class Profile::Create < Opera::Operation::Base + validate :profile_schema + + operation :find + + step :create + + step :output + + def profile_schema + Dry::Validation.Schema do + optional(:id).filled + end.call(params) + end + + def find + Profile::Find.call(params: params, dependencies: dependencies) + end + + def create + return if context[:find_output] + puts 'not found' + end + + def output + result.output = { model: context[:find_output] } + end +end +``` + +### Example with inner operation doing the find + +```ruby +Profile::Create.call(params: { + id: 1 +}, dependencies: { + current_account: Account.find(1) +}) +#{:id=>1, :user_id=>1, :linkedin_uid=>nil, ...}}> +``` + +## Multiple operations + +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. + +```ruby +class Profile::Create < Opera::Operation::Base + step :validate + step :create + + def validate; end + + def create + result.output = { model: "Profile #{Kernel.rand(100)}" } + end +end + +class Profile::CreateMultiple < Opera::Operation::Base + operations :create_multiple + + step :output + + def create_multiple + (0..params[:number]).map do + Profile::Create.call + end + end + + def output + result.output = context[:create_multiple_output] + end +end +``` + +```ruby +Profile::CreateMultiple.call(params: { number: 3 }) + +#[[:validate, :create], [:validate, :create], [:validate, :create], [:validate, :create]]}, :output], @output=[{:model=>"Profile 1"}, {:model=>"Profile 7"}, {:model=>"Profile 69"}, {:model=>"Profile 92"}]> +``` diff --git a/docs/examples/success-blocks.md b/docs/examples/success-blocks.md new file mode 100644 index 0000000..0f7ba8c --- /dev/null +++ b/docs/examples/success-blocks.md @@ -0,0 +1,68 @@ +# Success Blocks + +Steps inside a `success` block continue executing even if they return `false`. Errors added inside a success block **do** stop execution of subsequent non-success steps. Use success blocks for side effects that shouldn't halt the pipeline on falsy returns (e.g., sending emails, logging). + +```ruby +class Profile::Create < Opera::Operation::Base + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :mailer + end + + validate :profile_schema + + success :populate + + step :create + step :update + + success do + step :send_email + step :output + end + + def profile_schema + Dry::Validation.Schema do + required(:first_name).filled + end.call(params) + end + + def populate + context[:attributes] = {} + context[:valid] = false + end + + def create + self.profile = current_account.profiles.create(params) + end + + def update + profile.update(updated_at: 1.day.ago) + end + + # NOTE: We can add an error in this step and it won't break the execution + def send_email + result.add_error('mailer', 'Missing dependency') + mailer&.send_mail(profile: profile) + end + + def output + result.output = { model: context[:profile] } + end +end +``` + +## Example output + +```ruby +Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + current_account: Account.find(1) +}) +#["Missing dependency"]}, @information={}, @executions=[:profile_schema, :populate, :create, :update, :send_email, :output], @output={:model=>#}> +``` diff --git a/docs/examples/transactions.md b/docs/examples/transactions.md new file mode 100644 index 0000000..90ed243 --- /dev/null +++ b/docs/examples/transactions.md @@ -0,0 +1,227 @@ +# Transactions + +Wrap multiple steps in a database transaction. If any step adds an error or raises an exception, the transaction is rolled back. + +## Configuration + +Set the transaction class either globally or per-operation: + +```ruby +# Global +Opera::Operation::Config.configure do |config| + config.transaction_class = ActiveRecord::Base + config.transaction_method = :transaction # default + config.transaction_options = { requires_new: true } # optional +end + +# Per-operation +class MyOperation < Opera::Operation::Base + configure do |config| + config.transaction_class = Profile + end +end +``` + +## Failing transaction + +When a step inside a transaction fails, the entire transaction is rolled back: + +```ruby +class Profile::Create < Opera::Operation::Base + configure do |config| + config.transaction_class = Profile + end + + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :mailer + end + + validate :profile_schema + + transaction do + step :create + step :update + end + + step :send_email + step :output + + def profile_schema + Dry::Validation.Schema do + required(:first_name).filled + end.call(params) + end + + def create + self.profile = current_account.profiles.create(params) + end + + def update + profile.update(example_attr: :Example) + end + + def send_email + return true unless mailer + + mailer.send_mail(profile: profile) + end + + def output + result.output = { model: profile } + end +end +``` + +### Example with non-existing attribute + +```ruby +Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + mailer: MyMailer, + current_account: Account.find(1) +}) + +D, [2020-08-14T16:13:30.946466 #2504] DEBUG -- : Account Load (0.5ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]] +D, [2020-08-14T16:13:30.960254 #2504] DEBUG -- : (0.2ms) BEGIN +D, [2020-08-14T16:13:30.983981 #2504] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-14 16:13:30.982289"], ["updated_at", "2020-08-14 16:13:30.982289"], ["account_id", 1]] +D, [2020-08-14T16:13:30.986233 #2504] DEBUG -- : (0.2ms) ROLLBACK +D, [2020-08-14T16:13:30.988231 #2504] DEBUG -- : unknown attribute 'example_attr' for Profile. (ActiveModel::UnknownAttributeError) +``` + +## Passing transaction + +```ruby +class Profile::Create < Opera::Operation::Base + configure do |config| + config.transaction_class = Profile + end + + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :mailer + end + + validate :profile_schema + + transaction do + step :create + step :update + end + + step :send_email + step :output + + def profile_schema + Dry::Validation.Schema do + required(:first_name).filled + end.call(params) + end + + def create + self.profile = current_account.profiles.create(params) + end + + def update + profile.update(updated_at: 1.day.ago) + end + + def send_email + return true unless mailer + + mailer.send_mail(profile: profile) + end + + def output + result.output = { model: profile } + end +end +``` + +### Example with updating timestamp + +```ruby +Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + mailer: MyMailer, + current_account: Account.find(1) +}) +D, [2020-08-17T12:10:44.842392 #2741] DEBUG -- : Account Load (0.7ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]] +D, [2020-08-17T12:10:44.856964 #2741] DEBUG -- : (0.2ms) BEGIN +D, [2020-08-17T12:10:44.881332 #2741] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-17 12:10:44.879684"], ["updated_at", "2020-08-17 12:10:44.879684"], ["account_id", 1]] +D, [2020-08-17T12:10:44.886168 #2741] DEBUG -- : SQL (0.6ms) UPDATE "profiles" SET "updated_at" = $1 WHERE "profiles"."id" = $2 [["updated_at", "2020-08-16 12:10:44.883164"], ["id", 47]] +D, [2020-08-17T12:10:44.898132 #2741] DEBUG -- : (10.3ms) COMMIT +##}> +``` + +## Using finish! inside a transaction + +Calling `finish!` inside a transaction stops execution without rolling back -- the transaction commits successfully: + +```ruby +class Profile::Create < Opera::Operation::Base + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :mailer + end + + validate :profile_schema + + step :build_record + step :create + step :send_email + step :output + + def profile_schema + Dry::Validation.Schema do + required(:first_name).filled + end.call(params) + end + + def build_record + self.profile = current_account.profiles.build(params) + self.profile.force_name_validation = true + end + + def create + self.profile = profile.save + finish! + end + + def send_email + return true unless mailer + + mailer.send_mail(profile: profile) + end + + def output + result.output(model: profile) + end +end +``` + +### Call + +```ruby +result = Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + current_account: Account.find(1) +}) + +# +``` diff --git a/docs/examples/validations.md b/docs/examples/validations.md new file mode 100644 index 0000000..351ca7f --- /dev/null +++ b/docs/examples/validations.md @@ -0,0 +1,139 @@ +# Validations + +Opera supports `Dry::Validation::Result` and `Opera::Operation::Result` as return types from validate steps. Validations accumulate errors -- all validations run even if earlier ones fail, so the caller gets all errors at once. + +## Example with sanitizing parameters + +```ruby +class Profile::Create < Opera::Operation::Base + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :mailer + end + + validate :profile_schema + + step :create + step :send_email + step :output + + def profile_schema + Dry::Validation.Schema do + configure { config.input_processor = :sanitizer } + + required(:first_name).filled + end.call(params) + end + + def create + self.profile = current_account.profiles.create(context[:profile_schema_output]) + end + + def send_email + return true unless mailer + + mailer.send_mail(profile: profile) + end + + def output + result.output = { model: profile } + end +end +``` + +```ruby +Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + mailer: MyMailer, + current_account: Account.find(1) +}) + +# NOTE: Last name is missing in output model +##}> +``` + +## Example with old (ActiveModel) validations + +```ruby +class Profile::Create < Opera::Operation::Base + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :mailer + end + + validate :profile_schema + + step :build_record + step :old_validation + step :create + step :send_email + step :output + + def profile_schema + Dry::Validation.Schema do + required(:first_name).filled + end.call(params) + end + + def build_record + self.profile = current_account.profiles.build(params) + self.profile.force_name_validation = true + end + + def old_validation + return true if profile.valid? + + result.add_information(missing_validations: "Please check dry validations") + result.add_errors(profile.errors.messages) + + false + end + + def create + profile.save + end + + def send_email + mailer.send_mail(profile: profile) + end + + def output + result.output = { model: profile } + end +end +``` + +### Call with valid parameters + +```ruby +Profile::Create.call(params: { + first_name: :foo, + last_name: :bar +}, dependencies: { + mailer: MyMailer, + current_account: Account.find(1) +}) + +##}> +``` + +### Call with INVALID parameters + +```ruby +Profile::Create.call(params: { + first_name: :foo +}, dependencies: { + mailer: MyMailer, + current_account: Account.find(1) +}) + +#["can't be blank"]}, @information={:missing_validations=>"Please check dry validations"}, @executions=[:build_record, :old_validation]> +``` diff --git a/docs/examples/within.md b/docs/examples/within.md new file mode 100644 index 0000000..03bdace --- /dev/null +++ b/docs/examples/within.md @@ -0,0 +1,166 @@ +# Within + +`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. + +```ruby +class Profile::Create < Opera::Operation::Base + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account + end + + step :build + + within :read_from_replica do + step :check_duplicate + step :validate_quota + end + + step :create + step :output + + def build + self.profile = current_account.profiles.build(params) + end + + def check_duplicate + result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email]) + end + + def validate_quota + result.add_error(:base, 'quota exceeded') if current_account.profiles.count >= 100 + end + + def create + profile.save! + end + + def output + result.output = { model: profile } + end + + private + + def read_from_replica(&block) + ActiveRecord::Base.connected_to(role: :reading, &block) + end +end +``` + +## Inline usage + +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: + +```ruby +def some_step + value = read_from_replica { Profile.count } + result.output = { count: value } +end + +private + +def read_from_replica(&block) + ActiveRecord::Base.connected_to(role: :reading, &block) +end +``` + +## Mixing step and operation inside within + +`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. + +```ruby +class Profile::Create < Opera::Operation::Base + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :quota_checker + end + + within :read_from_replica do + step :check_duplicate + operation :fetch_quota + end + + step :create + step :output + + def check_duplicate + result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email]) + end + + def fetch_quota + quota_checker.call(params: params) + end + + def create + self.profile = current_account.profiles.create(params) + end + + def output + result.output = { model: profile, quota: context[:fetch_quota_output] } + end + + private + + def read_from_replica(&block) + ActiveRecord::Base.connected_to(role: :reading, &block) + end +end +``` + +## Nesting within inside a transaction + +`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. + +```ruby +class Profile::Create < Opera::Operation::Base + configure do |config| + config.transaction_class = ActiveRecord::Base + end + + context do + attr_accessor :profile + end + + dependencies do + attr_reader :current_account, :quota_checker, :audit_logger + end + + transaction do + within :read_from_replica do + step :check_duplicate + operation :fetch_quota + end + operation :write_audit_log + end + + step :output + + def check_duplicate + result.add_error(:base, 'already exists') if Profile.exists?(email: params[:email]) + end + + def fetch_quota + quota_checker.call(params: params) + end + + def write_audit_log + audit_logger.call(params: params) + end + + def output + result.output = { quota: context[:fetch_quota_output] } + end + + private + + def read_from_replica(&block) + ActiveRecord::Base.connected_to(role: :reading, &block) + end +end +``` From e6363b1e59fe6fef59c3d22de737722acd58b7c4 Mon Sep 17 00:00:00 2001 From: Francisco Ruiz Date: Tue, 14 Apr 2026 16:13:08 +0200 Subject: [PATCH 5/8] Add benchmark script for operation performance testing Exercises the full execution path across four operation types: ComplexOperation (nested ops + transaction + within + validate + success + finish_if), BatchOperation (operations plural), ValidationOperation, and LeafOperation (minimal). Runs 1000 iterations of each, spawning ~7000 total operation instances for the complex scenario. --- benchmarks/operation_benchmark.rb | 240 ++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 benchmarks/operation_benchmark.rb diff --git a/benchmarks/operation_benchmark.rb b/benchmarks/operation_benchmark.rb new file mode 100644 index 0000000..0d1a831 --- /dev/null +++ b/benchmarks/operation_benchmark.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +# Performance benchmark for Opera operations. +# +# Exercises the full execution path: step dispatch, instruction iteration, +# context accessors, validate, transaction, success, finish_if, operation, +# operations, and within -- with nested inner operations and loops to +# simulate realistic workloads. +# +# Usage: +# ruby benchmarks/operation_benchmark.rb + +require 'bundler/setup' +require 'benchmark' +require 'opera' + +# --------------------------------------------------------------------------- +# Fake transaction class (no DB, just yields) +# --------------------------------------------------------------------------- +FakeTransaction = Class.new do + def self.transaction + yield + end +end + +Opera::Operation::Config.configure do |config| + config.transaction_class = FakeTransaction + config.mode = :production # skip execution traces, like real production +end + +# --------------------------------------------------------------------------- +# Leaf operation — called many times from within loops +# --------------------------------------------------------------------------- +LeafOperation = Class.new(Opera::Operation::Base) do + step :compute + step :output + + def compute + context[:value] = params.fetch(:n, 1) * 2 + end + + def output + result.output = { value: context[:value] } + end +end + +# --------------------------------------------------------------------------- +# Inner operation — calls LeafOperation in a loop +# --------------------------------------------------------------------------- +InnerOperation = Class.new(Opera::Operation::Base) do + context do + attr_accessor :results + end + + step :process_batch + step :output + + def process_batch + self.results = (1..params.fetch(:batch_size, 5)).map do |n| + LeafOperation.call(params: { n: n }) + end + end + + def output + result.output = { batch: results.map(&:output) } + end +end + +# --------------------------------------------------------------------------- +# Validation-heavy operation +# --------------------------------------------------------------------------- +ValidationOperation = Class.new(Opera::Operation::Base) do + validate :schema + + step :transform + step :output + + def schema + # Return a successful Opera::Operation::Result (simulates dry-validation) + Opera::Operation::Result.new(output: params) + end + + def transform + context[:transformed] = params.transform_values { |v| v.to_s.upcase } + end + + def output + result.output = context[:transformed] + end +end + +# --------------------------------------------------------------------------- +# Complex operation — combines everything +# --------------------------------------------------------------------------- +ComplexOperation = Class.new(Opera::Operation::Base) do + configure do |config| + config.transaction_class = FakeTransaction + end + + context do + attr_accessor :profile, :batch_results, :validated + end + + validate :schema + + step :prepare + finish_if :skip_processing? + + transaction do + step :create_record + step :update_record + end + + operation :run_inner + + within :with_timing do + step :heavy_computation + end + + success do + step :notify + step :log_audit + end + + step :output + + def schema + Opera::Operation::Result.new(output: params) + end + + def prepare + self.validated = context[:schema_output] + context[:counter] = 0 + end + + def skip_processing? + params[:skip] == true + end + + def create_record + self.profile = { id: rand(1000), name: validated[:name] } + end + + def update_record + profile[:updated_at] = Time.now.to_i + end + + def run_inner + InnerOperation.call(params: { batch_size: params.fetch(:batch_size, 5) }) + end + + def heavy_computation + # Simulate CPU work: string operations in a loop + 50.times do |i| + context[:counter] += i + "operation-#{i}-#{context[:counter]}".hash + end + end + + def notify + context[:notified] = true + end + + def log_audit + context[:audited] = true + end + + def output + result.output = { + profile: profile, + counter: context[:counter], + batch: context[:run_inner_output] + } + end + + def with_timing + yield + end +end + +# --------------------------------------------------------------------------- +# Operations (plural) consumer — calls multiple inner operations +# --------------------------------------------------------------------------- +BatchOperation = Class.new(Opera::Operation::Base) do + operations :run_all + step :output + + def run_all + (1..params.fetch(:count, 3)).map do |n| + LeafOperation.call(params: { n: n }) + end + end + + def output + result.output = context[:run_all_output] + end +end + +# --------------------------------------------------------------------------- +# Benchmark +# --------------------------------------------------------------------------- +ITERATIONS = 1000 +PARAMS = { name: 'benchmark', batch_size: 5 }.freeze +BATCH_PARAMS = { count: 5 }.freeze +VALIDATION_PARAMS = { first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com' }.freeze + +# Warm up +3.times do + ComplexOperation.call(params: PARAMS) + BatchOperation.call(params: BATCH_PARAMS) + ValidationOperation.call(params: VALIDATION_PARAMS) +end + +puts "Opera v#{Opera::VERSION} — #{ITERATIONS} iterations each" +puts "Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})" +puts '-' * 60 + +Benchmark.bm(35) do |x| + x.report('ComplexOperation (nested + tx):') do + ITERATIONS.times { ComplexOperation.call(params: PARAMS) } + end + + x.report('BatchOperation (operations):') do + ITERATIONS.times { BatchOperation.call(params: BATCH_PARAMS) } + end + + x.report('ValidationOperation (validate):') do + ITERATIONS.times { ValidationOperation.call(params: VALIDATION_PARAMS) } + end + + x.report('LeafOperation (minimal):') do + ITERATIONS.times { LeafOperation.call(params: { n: 42 }) } + end + + # Total operations executed in ComplexOperation run: + # 1 complex + 1 inner + 5 leaf = 7 operations per iteration + # = 7000 total operation instantiations for ComplexOperation alone + total_ops = ITERATIONS * 7 + puts "\nComplexOperation spawns ~#{total_ops} total operation instances across #{ITERATIONS} calls" +end From faa02cdbb3ee6447726b2d38249bb226984f24a0 Mon Sep 17 00:00:00 2001 From: Francisco Ruiz Date: Tue, 14 Apr 2026 17:11:37 +0200 Subject: [PATCH 6/8] Add WithinOperation to benchmark script Exercises within nested inside a transaction with multiple steps and an inner operation, giving a dedicated measurement for the within executor path which benefits most from the Marshal.dump removal due to its deeper instruction tree nesting. --- benchmarks/operation_benchmark.rb | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/benchmarks/operation_benchmark.rb b/benchmarks/operation_benchmark.rb index 0d1a831..134bb94 100644 --- a/benchmarks/operation_benchmark.rb +++ b/benchmarks/operation_benchmark.rb @@ -178,6 +178,90 @@ def with_timing end end +# --------------------------------------------------------------------------- +# Within operation — wraps steps and inner operations with a custom method +# --------------------------------------------------------------------------- +WithinOperation = Class.new(Opera::Operation::Base) do + configure do |config| + config.transaction_class = FakeTransaction + end + + context do + attr_accessor :log, default: -> { [] } + end + + step :prepare + + within :with_connection do + step :query_one + step :query_two + operation :fetch_leaf + end + + transaction do + within :with_lock do + step :write_one + step :write_two + end + step :write_three + end + + step :output + + def prepare + context[:counter] = 0 + end + + def query_one + context[:counter] += 1 + log << :query_one + end + + def query_two + context[:counter] += 1 + log << :query_two + end + + def fetch_leaf + LeafOperation.call(params: { n: context[:counter] }) + end + + def write_one + context[:counter] += 10 + log << :write_one + end + + def write_two + context[:counter] += 10 + log << :write_two + end + + def write_three + context[:counter] += 1 + log << :write_three + end + + def output + result.output = { + counter: context[:counter], + log: log, + leaf: context[:fetch_leaf_output] + } + end + + def with_connection + log << :connect + yield + log << :disconnect + end + + def with_lock + log << :lock + yield + log << :unlock + end +end + # --------------------------------------------------------------------------- # Operations (plural) consumer — calls multiple inner operations # --------------------------------------------------------------------------- @@ -203,12 +287,14 @@ def output PARAMS = { name: 'benchmark', batch_size: 5 }.freeze BATCH_PARAMS = { count: 5 }.freeze VALIDATION_PARAMS = { first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com' }.freeze +WITHIN_PARAMS = {}.freeze # Warm up 3.times do ComplexOperation.call(params: PARAMS) BatchOperation.call(params: BATCH_PARAMS) ValidationOperation.call(params: VALIDATION_PARAMS) + WithinOperation.call(params: WITHIN_PARAMS) end puts "Opera v#{Opera::VERSION} — #{ITERATIONS} iterations each" @@ -228,6 +314,10 @@ def output ITERATIONS.times { ValidationOperation.call(params: VALIDATION_PARAMS) } end + x.report('WithinOperation (within + tx):') do + ITERATIONS.times { WithinOperation.call(params: WITHIN_PARAMS) } + end + x.report('LeafOperation (minimal):') do ITERATIONS.times { LeafOperation.call(params: { n: 42 }) } end From 12a44d282d7ad9795be61a71d3bc476f69db338a Mon Sep 17 00:00:00 2001 From: Francisco Ruiz Date: Tue, 14 Apr 2026 17:14:37 +0200 Subject: [PATCH 7/8] Make Within executor call evaluate_instructions directly Replace super delegation with an explicit evaluate_instructions call, consistent with how all other executors now work after the execute_step refactor. No behavioral change -- just removes the last indirect super call from the executor hierarchy. --- lib/opera/operation/instructions/executors/within.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/opera/operation/instructions/executors/within.rb b/lib/opera/operation/instructions/executors/within.rb index b38672b..6d90893 100644 --- a/lib/opera/operation/instructions/executors/within.rb +++ b/lib/opera/operation/instructions/executors/within.rb @@ -13,7 +13,7 @@ def call(instruction) raise ArgumentError, 'within requires a block with at least one instruction' if nested_instructions.nil? operation.send(wrapper_method) do - super + evaluate_instructions(nested_instructions) end end end From 70af2bc979863f4e2ad8ff35a5a34ebd97cd2350 Mon Sep 17 00:00:00 2001 From: Francisco Ruiz Date: Wed, 15 Apr 2026 11:01:39 +0200 Subject: [PATCH 8/8] Bump version to 0.5.1 and update CHANGELOG --- CHANGELOG.md | 8 ++++++++ Gemfile.lock | 2 +- lib/opera/version.rb | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c457725..4321ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Opera Changelog +### 0.5.1 - Apr 15, 2026 + +- Remove `Marshal.dump` from instruction execution for ~40-55% throughput improvement +- Fix `Config.development_mode?` referencing non-existent constant +- Remove implicit `Rails.application.config.x.reporter` lookup from Config +- Restructure README into quick-start guide with examples moved to `docs/examples/` +- Add benchmark script for operation performance testing + ### 0.5.0 - Apr 13, 2026 - Add `within` executor for wrapping one or more steps with a custom block method diff --git a/Gemfile.lock b/Gemfile.lock index b34cf8f..9497433 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - opera (0.5.0) + opera (0.5.1) GEM remote: https://rubygems.org/ diff --git a/lib/opera/version.rb b/lib/opera/version.rb index 91032fb..b23731e 100644 --- a/lib/opera/version.rb +++ b/lib/opera/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Opera - VERSION = '0.5.0' + VERSION = '0.5.1' end