diff --git a/CHANGELOG.md b/CHANGELOG.md index df4a9cb..fdc8d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Opera Changelog +### 0.7.0 - Apr 30, 2026 + +- Add `:if` / `:unless` options to `step`, `operation`, and `operations` for declarative conditional execution. Conditions accept a Symbol (method name) or a Proc/Lambda (evaluated via `instance_exec` in the operation instance scope). Skipped steps do not execute and are not recorded in `result.executions`. For `operation` / `operations`, the conventional `_output` slot in context is set to `nil` when skipped, matching the historical `return Opera::Operation::Result.new` early-exit behavior. Passing both `:if` and `:unless` on the same step raises `ArgumentError` at class load time. + ### 0.6.0 - Apr 15, 2026 - Add `always` executor: runs its step unconditionally after all regular steps, regardless of failure or an early finish diff --git a/Gemfile.lock b/Gemfile.lock index 5a1c296..5f722d7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - opera (0.6.0) + opera (0.7.0) GEM remote: https://rubygems.org/ diff --git a/README.md b/README.md index b3fa708..26d20eb 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,40 @@ end | `within :method do ... end` | Wraps nested steps with a custom method that must `yield`. If it doesn't yield, nested steps are skipped. | | `always :method` | Executes a step unconditionally after all regular steps, even after a failure or an early finish. Must appear at the end of the operation — only other `always` steps may follow. Cannot be used inside blocks. Use `result.success?` / `result.failure?` inside the method to branch on outcome. | +### Conditional execution (`:if` / `:unless`) + +`step`, `operation`, and `operations` accept `:if` and `:unless` keyword +arguments for declarative conditional execution. The condition is evaluated +**before** the step's method is called -- if the condition is not met the +step is skipped entirely (no method invocation, no side effects, not recorded +in `result.executions`). + +The condition value can be a **Symbol** (method name on the operation) or a +**Proc/Lambda** (evaluated via `instance_exec` in the operation instance +scope). + +```ruby +# Symbol form +step :notify_user, if: :notifications_enabled? +operation :create_internal_experience, if: :internal_experience_authorized? + +# Lambda form +step :recalculate, unless: -> { params[:skip_recalculation] } +operation :reopen_and_reset, if: -> { profile_ids.present? } +``` + +When an `operation` or `operations` step is skipped, its +`context[:_output]` slot is set to `nil` (matching the historical +`return Opera::Operation::Result.new` early-exit behavior). When a plain +`step` is skipped, no context output is set. + +Passing both `:if` and `:unless` on the same step raises `ArgumentError` at +class load time. + +`:if` / `:unless` are not supported on `validate`, `success`, `finish_if`, +`transaction`, `within`, or `always` -- conditional containers and validation +have ambiguous semantics. + ### Combining instructions ```ruby diff --git a/lib/opera/operation.rb b/lib/opera/operation.rb index 4911e6c..7d4dbbe 100644 --- a/lib/opera/operation.rb +++ b/lib/opera/operation.rb @@ -2,6 +2,7 @@ require 'opera/operation/attributes_dsl' require 'opera/operation/builder' +require 'opera/operation/builder/options_builder' require 'opera/operation/base' require 'opera/operation/executor' require 'opera/operation/instrumentation' diff --git a/lib/opera/operation/builder.rb b/lib/opera/operation/builder.rb index 90d2467..17ff415 100644 --- a/lib/opera/operation/builder.rb +++ b/lib/opera/operation/builder.rb @@ -16,7 +16,7 @@ def instructions end INNER_INSTRUCTIONS.each do |instruction| - define_method instruction do |method = nil, &blk| + define_method instruction do |method = nil, **opts, &blk| if instructions.any? { |i| i[:kind] == :always } raise ArgumentError, "`#{instruction}` cannot appear after `always`. " \ @@ -24,7 +24,7 @@ def instructions end check_method_availability!(method) if method - instructions.concat(InnerBuilder.new.send(instruction, method, &blk)) + instructions.concat(InnerBuilder.new.send(instruction, method, **opts, &blk)) end end @@ -43,19 +43,13 @@ def initialize(&block) end INNER_INSTRUCTIONS.each do |instruction| - define_method instruction do |method = nil, &blk| - instructions << if !blk.nil? - { - kind: instruction, - label: method, - instructions: InnerBuilder.new(&blk).instructions - } - else - { - kind: instruction, - method: method - } - end + define_method instruction do |method = nil, **opts, &blk| + entry = if blk + { kind: instruction, label: method, instructions: InnerBuilder.new(&blk).instructions } + else + { kind: instruction, method: method } + end + instructions << entry.merge(OptionsBuilder.build(opts)) end end diff --git a/lib/opera/operation/builder/options_builder.rb b/lib/opera/operation/builder/options_builder.rb new file mode 100644 index 0000000..35ef75f --- /dev/null +++ b/lib/opera/operation/builder/options_builder.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Opera + module Operation + module Builder + # Parses keyword options passed to a Builder instruction (`step`, + # `operation`, `transaction`, etc.) into a normalized hash that is merged + # into the instruction entry. + # + # Currently understands `:if` and `:unless`. New options can be added by + # extending ALLOWED_OPTIONS and the build logic. + class OptionsBuilder + ALLOWED_OPTIONS = %i[if unless].freeze + + def self.build(opts) + return {} if opts.empty? + + unknown = opts.keys - ALLOWED_OPTIONS + raise ArgumentError, "Unknown option(s): #{unknown.inspect}. Allowed: #{ALLOWED_OPTIONS}" if unknown.any? + + { predicate: build_predicate(opts) }.compact + end + + # Translates `:if` / `:unless` (Symbol or Proc) into a single Proc that + # returns true when the step should run. Returns nil when neither is + # given. Raises if both are given. + def self.build_predicate(opts) + return nil unless opts[:if] || opts[:unless] + raise ArgumentError, 'Cannot use both :if and :unless on the same step' if opts[:if] && opts[:unless] + + cond = opts[:if] || opts[:unless] + body = cond.is_a?(Symbol) ? proc { send(cond) } : cond + opts.key?(:if) ? body : proc { !instance_exec(&body) } + end + end + end + end +end diff --git a/lib/opera/operation/executor.rb b/lib/opera/operation/executor.rb index eb245fc..750151c 100644 --- a/lib/opera/operation/executor.rb +++ b/lib/opera/operation/executor.rb @@ -41,6 +41,11 @@ def execute_step(instruction) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity def evaluate_instruction(instruction) + if instruction[:predicate] && !condition_met?(instruction) + add_instruction_output(instruction, nil) if %i[operation operations].include?(instruction[:kind]) + return + end + case instruction[:kind] when :step Instructions::Executors::Step.new(operation).call(instruction) @@ -66,6 +71,14 @@ def evaluate_instruction(instruction) end # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity + # Evaluates the `:predicate` Proc stored on a conditionable instruction. + # The predicate is built at class-load time from `:if` / `:unless` and + # already encodes the negation for `:unless`, so the executor only needs + # to call it in the operation instance scope. + def condition_met?(instruction) + operation.instance_exec(&instruction[:predicate]) + end + def result operation.result end diff --git a/lib/opera/version.rb b/lib/opera/version.rb index 5767368..627a1e8 100644 --- a/lib/opera/version.rb +++ b/lib/opera/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Opera - VERSION = '0.6.0' + VERSION = '0.7.0' end diff --git a/spec/opera/operation/base_spec.rb b/spec/opera/operation/base_spec.rb index 08e4288..b5994f7 100644 --- a/spec/opera/operation/base_spec.rb +++ b/spec/opera/operation/base_spec.rb @@ -1998,5 +1998,440 @@ def step_5 end end end + + describe 'conditional execution (:if / :unless)' do + context 'for step' do + context 'with `if:` symbol' do + let(:operation_class) do + Class.new(Operation::Base) do + step :step_1, if: :run_step_1? + step :step_2 + + def run_step_1? + params[:enabled] + end + + def step_1 + context[:step_1_called] = true + end + + def step_2 + result.output = context[:step_1_called] + end + end + end + + context 'when condition is truthy' do + subject { operation_class.call(params: { enabled: true }) } + + it 'runs the step' do + expect_any_instance_of(operation_class).to receive(:step_1).and_call_original + expect(subject.output).to be(true) + expect(subject.executions).to include(:step_1, :step_2) + end + end + + context 'when condition is falsy' do + subject { operation_class.call(params: { enabled: false }) } + + it 'skips the step entirely' do + expect_any_instance_of(operation_class).not_to receive(:step_1) + expect(subject.output).to be_nil + end + + it 'does not record the skipped step in executions' do + expect(subject.executions).not_to include(:step_1) + expect(subject.executions).to include(:step_2) + end + end + end + + context 'with `unless:` symbol' do + let(:operation_class) do + Class.new(Operation::Base) do + step :step_1, unless: :skip_step_1? + step :step_2 + + def skip_step_1? + params[:skip] + end + + def step_1 + context[:step_1_called] = true + end + + def step_2 + result.output = context[:step_1_called] + end + end + end + + context 'when condition is truthy (skip)' do + subject { operation_class.call(params: { skip: true }) } + + it 'skips the step' do + expect_any_instance_of(operation_class).not_to receive(:step_1) + expect(subject.output).to be_nil + expect(subject.executions).not_to include(:step_1) + end + end + + context 'when condition is falsy (run)' do + subject { operation_class.call(params: { skip: false }) } + + it 'runs the step' do + expect_any_instance_of(operation_class).to receive(:step_1).and_call_original + expect(subject.output).to be(true) + end + end + end + + context 'with `if:` lambda' do + let(:operation_class) do + Class.new(Operation::Base) do + step :step_1, if: -> { params[:value].to_i > 0 } + step :step_2 + + def step_1 + context[:step_1_called] = true + end + + def step_2 + result.output = context[:step_1_called] + end + end + end + + it 'runs the step when lambda is truthy' do + expect(operation_class.call(params: { value: 1 }).output).to be(true) + end + + it 'skips the step when lambda is falsy' do + expect(operation_class.call(params: { value: 0 }).output).to be_nil + end + end + + context 'with `unless:` lambda' do + let(:operation_class) do + Class.new(Operation::Base) do + step :step_1, unless: -> { params[:skip] } + step :step_2 + + def step_1 + context[:step_1_called] = true + end + + def step_2 + result.output = context[:step_1_called] + end + end + end + + it 'runs the step when lambda is falsy' do + expect(operation_class.call(params: { skip: false }).output).to be(true) + end + + it 'skips the step when lambda is truthy' do + expect(operation_class.call(params: { skip: true }).output).to be_nil + end + end + end + + context 'for operation' do + let(:nested_operation_class) do + Class.new(Operation::Base) do + step :step_1 + + def step_1 + result.output = 'nested' + end + end + end + + context 'with `if:` symbol' do + let(:operation_class_with_op) do + nested = nested_operation_class + Class.new(Operation::Base) do + operation :run_nested, if: :enabled? + step :output + + define_method(:enabled?) do + params[:enabled] + end + + define_method(:run_nested) do + nested.call + end + + def output + # Skipped operation sets context[:run_nested_output] = nil, + # successful operation stores its output there. + result.output = context[:run_nested_output] + end + end + end + + context 'when condition is truthy' do + subject { operation_class_with_op.call(params: { enabled: true }) } + + it 'runs the operation and stores its output in context' do + expect(subject.output).to eq('nested') + end + end + + context 'when condition is falsy' do + subject { operation_class_with_op.call(params: { enabled: false }) } + + it 'skips the operation' do + expect_any_instance_of(operation_class_with_op).not_to receive(:run_nested) + expect(subject.output).to be_nil + end + + it 'does not record skipped operation in executions' do + expect(subject.executions.flatten).not_to include(:run_nested) + end + end + end + + context 'with `unless:` lambda' do + let(:operation_class_with_op) do + nested = nested_operation_class + Class.new(Operation::Base) do + operation :run_nested, unless: -> { params[:skip] } + step :output + + define_method(:run_nested) do + nested.call + end + + def output + result.output = context[:run_nested_output] + end + end + end + + it 'runs when lambda is falsy' do + expect(operation_class_with_op.call(params: { skip: false }).output).to eq('nested') + end + + it 'skips when lambda is truthy' do + expect(operation_class_with_op.call(params: { skip: true }).output).to be_nil + end + end + end + + context 'for operations (plural)' do + let(:nested_operation_class) do + Class.new(Operation::Base) do + step :step_1 + + def step_1 + result.output = 'one' + end + end + end + + let(:operation_class_with_ops) do + nested = nested_operation_class + Class.new(Operation::Base) do + operations :run_batch, if: :enabled? + step :output + + define_method(:enabled?) do + params[:enabled] + end + + define_method(:run_batch) do + [nested.call, nested.call] + end + + def output + result.output = context[:run_batch_output] + end + end + end + + context 'when condition is truthy' do + subject { operation_class_with_ops.call(params: { enabled: true }) } + + it 'runs the batch and stores outputs' do + expect(subject.output).to eq(%w[one one]) + end + end + + context 'when condition is falsy' do + subject { operation_class_with_ops.call(params: { enabled: false }) } + + it 'skips the batch and stores nil in context' do + expect_any_instance_of(operation_class_with_ops).not_to receive(:run_batch) + # The `output` step reads context[:run_batch_output]; skipped op + # sets it to nil so result.output is nil. + expect(subject.output).to be_nil + end + end + end + + context 'inside a transaction block' do + let(:operation_class) do + Class.new(Operation::Base) do + transaction do + step :persist + step :side_effect, if: :enabled? + end + step :output + + def enabled? + params[:enabled] + end + + def persist + context[:persisted] = true + end + + def side_effect + context[:side_effect_called] = true + end + + def output + result.output = { + persisted: context[:persisted], + side_effect_called: context[:side_effect_called] + } + end + end + end + + before do + Operation::Config.configure do |config| + config.transaction_class = Class.new do + def self.transaction + yield + end + end + end + end + + it 'runs the conditional step when condition is truthy' do + result = operation_class.call(params: { enabled: true }) + expect(result.output).to eq(persisted: true, side_effect_called: true) + end + + it 'skips the conditional step when condition is falsy' do + result = operation_class.call(params: { enabled: false }) + expect(result.output).to eq(persisted: true, side_effect_called: nil) + end + end + + context 'inside a within block' do + let(:operation_class) do + Class.new(Operation::Base) do + within :wrapper do + step :inner_step, if: :enabled? + end + + def enabled? + params[:enabled] + end + + def wrapper + yield + end + + def inner_step + result.output = :ran + end + end + end + + it 'runs when condition is truthy' do + expect(operation_class.call(params: { enabled: true }).output).to eq(:ran) + end + + it 'skips when condition is falsy' do + expect(operation_class.call(params: { enabled: false }).output).to be_nil + end + end + + context 'when both :if and :unless are passed' do + it 'raises ArgumentError at class definition time' do + expect do + Class.new(Operation::Base) do + step :step_1, if: :foo?, unless: :bar? + end + end.to raise_error(ArgumentError, /both :if and :unless/) + end + + it 'raises ArgumentError for operation' do + expect do + Class.new(Operation::Base) do + operation :op_1, if: :foo?, unless: :bar? + end + end.to raise_error(ArgumentError, /both :if and :unless/) + end + + it 'raises ArgumentError inside a block' do + expect do + Class.new(Operation::Base) do + transaction do + step :step_1, if: :foo?, unless: :bar? + end + end + end.to raise_error(ArgumentError, /both :if and :unless/) + end + end + + context 'when condition references undefined symbol' do + let(:operation_class) do + Class.new(Operation::Base) do + step :step_1, if: :no_such_method? + + def step_1 + true + end + end + end + + it 'raises NoMethodError at evaluation time' do + expect { operation_class.call }.to raise_error(NoMethodError, /no_such_method\?/) + end + end + + context 'instance_exec semantics for lambda' do + let(:operation_class) do + Class.new(Operation::Base) do + step :step_1, if: -> { context[:flag] } + + def step_1 + result.output = :ran + end + end + end + + it 'evaluates lambda in operation instance scope' do + op = operation_class.new + op.context[:flag] = true + # Run via .call where context is empty -> skipped + expect(operation_class.call.output).to be_nil + end + end + + context 'across multiple invocations' do + let(:operation_class) do + Class.new(Operation::Base) do + step :step_1, if: -> { params[:enabled] } + + def step_1 + result.output = :ran + end + end + end + + it 'is stateless and re-evaluates conditions per call' do + expect(operation_class.call(params: { enabled: true }).output).to eq(:ran) + expect(operation_class.call(params: { enabled: false }).output).to be_nil + expect(operation_class.call(params: { enabled: true }).output).to eq(:ran) + end + end + end end end