diff --git a/CHANGELOG.md b/CHANGELOG.md index 116bccf..c2b8632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [1.1.0] - 2025-05-25 +### Added +- Added `:if` and `:unless` options for `:transaction` and `:after_commit` methods at `:sequel_models` plugin + ## [1.0.0] - 2025-05-19 ### Changed - Removed support for `Ruby` versions older than 3.0 diff --git a/lib/pathway.rb b/lib/pathway.rb index fb302b5..7ff4def 100644 --- a/lib/pathway.rb +++ b/lib/pathway.rb @@ -201,7 +201,7 @@ def wrap(obj) = Result.result(obj) def _callable(callable) case callable - when Proc + when Proc # unless (callable.binding rescue nil)&.receiver == @operation ->(*args, **kwargs) { @operation.instance_exec(*args, **kwargs, &callable) } when Symbol ->(*args, **kwargs) { @operation.send(callable, *args, **kwargs) } diff --git a/lib/pathway/plugins/sequel_models.rb b/lib/pathway/plugins/sequel_models.rb index cbf31db..90b6a98 100644 --- a/lib/pathway/plugins/sequel_models.rb +++ b/lib/pathway/plugins/sequel_models.rb @@ -6,11 +6,11 @@ module Pathway module Plugins module SequelModels module DSLMethods - def transaction(step_name = nil, &dsl_bl) - raise 'must provide a step or a block but not both' if !step_name.nil? == block_given? + def transaction(step_name = nil, if: nil, unless: nil, &) + cond, dsl_bl = _transact_opts(step_name, *%i[if unless].map { binding.local_variable_get(_1) }, &) - if step_name - transaction { step step_name } + if cond + if_true(cond) { transaction(&dsl_bl) } else around(->(runner, _) { db.transaction(savepoint: true) do @@ -20,11 +20,11 @@ def transaction(step_name = nil, &dsl_bl) end end - def after_commit(step_name = nil, &dsl_bl) - raise 'must provide a step or a block but not both' if !step_name.nil? == block_given? + def after_commit(step_name = nil, if: nil, unless: nil, &) + cond, dsl_bl = _transact_opts(step_name, *%i[if unless].map { binding.local_variable_get(_1) }, &) - if step_name - after_commit { step step_name } + if cond + if_true(cond) { after_commit(&dsl_bl) } else around(->(runner, state) { dsl_copy = self.class::DSL.new(State.new(self, state.to_h.dup), self) @@ -35,6 +35,26 @@ def after_commit(step_name = nil, &dsl_bl) }, &dsl_bl) end end + + private + + def _transact_opts(step_name, if_cond, unless_cond, &bl) + dsl = if !step_name.nil? == block_given? + raise ArgumentError, 'must provide either a step or a block but not both' + else + bl || proc { step step_name } + end + + cond = if if_cond && unless_cond + raise ArgumentError, 'options :if and :unless are mutually exclusive' + elsif if_cond + if_cond + elsif unless_cond + _callable(unless_cond) >> :!.to_proc + end + + return cond, dsl + end end module ClassMethods diff --git a/spec/plugins/base_spec.rb b/spec/plugins/base_spec.rb index ca08feb..7d14063 100644 --- a/spec/plugins/base_spec.rb +++ b/spec/plugins/base_spec.rb @@ -84,8 +84,7 @@ def notify(state) allow(notifier).to receive(:call) end - let(:input) { { foo: 'FOO' } } - let(:result) { operation.call(input) } + let(:valid_input) { { foo: 'FOO' } } describe ".process" do it "defines a 'call' method wich saves operation argument into the :input key" do @@ -99,16 +98,19 @@ def notify(state) operation.call(:my_input_test_value) end + let(:result) { operation.call(valid_input) } + it "defines a 'call' method which returns a value using the key specified by 'result_at'" do expect(back_end).to receive(:call).and_return(:SOME_RETURN_VALUE) + expect(result).to be_a(Result) expect(result).to be_a_success expect(result.value).to eq(:SOME_RETURN_VALUE) end end describe ".call" do - let(:result) { OperationWithSteps.call(ctx, input) } + let(:result) { OperationWithSteps.call(ctx, valid_input) } it "creates a new instance an invokes the 'call' method on it" do expect(back_end).to receive(:call).and_return(:SOME_RETURN_VALUE) @@ -133,7 +135,7 @@ def notify(state) expect(state).to_not be_equal(old_state) end - operation.call(input) + operation.call(valid_input) end end @@ -146,7 +148,7 @@ def notify(state) expect(state.to_hash).to include(result_value: :SOME_VALUE) end - operation.call(input) + operation.call(valid_input) end it "defines an updating step which sets the specified key" do @@ -160,10 +162,13 @@ def notify(state) expect(state.to_hash).to include(aux_value: :RETURN_VALUE) end - operation.call(input) + operation.call(valid_input) end end + + let(:result) { operation.call(valid_input) } + describe "#step" do it "defines an non updating step" do expect(notifier).to receive(:call) { { result_value: 0 } } diff --git a/spec/plugins/dry_validation_spec.rb b/spec/plugins/dry_validation_spec.rb index c30e99c..e2b659b 100644 --- a/spec/plugins/dry_validation_spec.rb +++ b/spec/plugins/dry_validation_spec.rb @@ -201,30 +201,25 @@ class OperationWithAutoWire < Operation let(:ctx) { { user: double("User", role: role), repository: repository } } let(:role) { :root } let(:params) { { name: "Paul Smith", email: "psmith@email.com" } } - let(:result) { operation.call(params) } let(:repository) { double.tap { |repo| allow(repo).to receive(:fetch).and_return(double) } } context "when calling with valid params" do it "returns a successful result", :aggregate_failures do - expect(result).to be_a_success - expect(result.value).to_not be_nil + expect(operation).to succeed_on(params).returning(anything) end end context "when finding model fails" do let(:repository) { double.tap { |repo| allow(repo).to receive(:fetch).and_return(nil) } } it "returns a a failed result", :aggregate_failures do - expect(result).to be_a_failure - expect(result.error.type).to eq(:not_found) + expect(operation).to fail_on(params).with_type(:not_found) end end context "when calling with invalid params" do let(:params) { { email: "psmith@email.com" } } it "returns a failed result", :aggregate_failures do - expect(result).to be_a_failure - expect(result.error.type).to eq(:validation) - expect(result.error.details).to eq(name: ['is missing']) + expect(operation).to fail_on(params).with_details(name: ['is missing']) end end diff --git a/spec/plugins/sequel_models_spec.rb b/spec/plugins/sequel_models_spec.rb index 29d4e25..fc349ae 100644 --- a/spec/plugins/sequel_models_spec.rb +++ b/spec/plugins/sequel_models_spec.rb @@ -59,7 +59,6 @@ def chain_operation(state) end describe 'DSL' do - let(:result) { operation.call(params) } let(:params) { { email: 'asd@fgh.net' } } let(:model) { double } @@ -68,20 +67,58 @@ def chain_operation(state) describe '#transaction' do context 'when providing a block' do let(:operation) { MailerOperation.new(mailer: mailer) } - before { expect(DB).to receive(:transaction).and_call_original } + before { allow(DB).to receive(:transaction).and_call_original } it 'returns the result state provided by the inner transaction when successful' do allow(MyModel).to receive(:first).with(params).and_return(model) - expect(result).to be_a_success - expect(result.value).to eq(model: model) + expect(operation).to succeed_on(params).returning(model: model) end it "returns the error state provided by the inner transaction when there's a failure" do allow(MyModel).to receive(:first).with(params).and_return(nil) - expect(result).to be_a_failure - expect(result.error.type).to eq(:not_found) + expect(operation).to fail_on(params).with_type(:not_found) + end + + context 'a conditional,' do + class IfConditionalOperation < PkOperation + context :should_run + + process do + transaction(if: :should_run?) do + step :fetch_model + end + end + + private + def should_run?(state)= state[:should_run] + end + + let(:operation) { IfConditionalOperation.new(should_run: should_run) } + let(:params) { { pk: 77 } } + + context 'when the condition is true' do + let(:should_run) { true } + + it 'executes the transaction' do + expect(DB).to receive(:transaction).once.and_call_original + expect(MyModel).to receive(:first).with(params).and_return(model) + + expect(operation).to succeed_on(params).returning(model) + end + end + + context 'when the condition is false' do + let(:should_run) { false } + + it 'skips the transaction' do + expect(MyModel).to_not receive(:first) + expect(DB).to_not receive(:transaction) + + expect(operation).to succeed_on(params) + end + end end end @@ -93,25 +130,79 @@ class FetchStepOperation < MyOperation end let(:operation) { FetchStepOperation.new(mailer: mailer) } - before { expect(DB).to receive(:transaction).and_call_original } + before { allow(DB).to receive(:transaction).and_call_original } it 'returns the result state provided by the inner transaction when successful' do allow(MyModel).to receive(:first).with(params).and_return(model) - expect(result).to be_a_success - expect(result.value).to eq(model) + expect(operation).to succeed_on(params).returning(model) end it "returns the error state provided by the inner transaction when there's a failure" do allow(MyModel).to receive(:first).with(params).and_return(nil) - expect(result).to be_a_failure - expect(result.error.type).to eq(:not_found) + expect(operation).to fail_on(params).with_type(:not_found) + end + + context 'and conditional' do + class UnlessConditionalOperation < PkOperation + context :should_skip + + process do + transaction :create_model, unless: :should_skip? + end + + def create_model(state) + state[result_key] = model_class.create(state[:input]) + end + + private + def should_skip?(state)= state[:should_skip] + end + + let(:operation) { UnlessConditionalOperation.new(should_skip: should_skip) } + let(:params) { { pk: 99 } } + + context 'if the condition is true' do + let(:should_skip) { false } + + it 'executes the transaction' do + expect(DB).to receive(:transaction).once.and_call_original + expect(MyModel).to receive(:create).with(params).and_return(model) + + expect(operation).to succeed_on(params).returning(model) + end + end + + context 'if the condition is false' do + let(:should_skip) { true } + + it 'skips the transaction' do + expect(DB).to_not receive(:transaction) + expect(MyModel).to_not receive(:create) + + expect(operation).to succeed_on(params) + end + end + end + end + + context 'when both an :if and :unless conditional' do + class InvalidUseOfCondOperation < MyOperation + process do + transaction :perform_db_action, if: :is_good?, unless: :is_bad? + end + end + + let(:operation) { InvalidUseOfCondOperation.new } + + it 'raises an error' do + expect { operation.call(params) }.to raise_error.with_message('options :if and :unless are mutually exclusive') end end context 'when providing a block and a step' do - class InvalidOperation < MyOperation + class AmbivalentTransactOperation < MyOperation process do transaction :perform_db_action do step :perform_other_db_action @@ -119,24 +210,24 @@ class InvalidOperation < MyOperation end end - let(:operation) { InvalidOperation.new } + let(:operation) { AmbivalentTransactOperation.new } it 'raises an error' do - expect { result }.to raise_error.with_message('must provide a step or a block but not both') + expect { operation.call(params) }.to raise_error.with_message('must provide either a step or a block but not both') end end context 'when not providing a block nor a step' do - class InvalidOperation < MyOperation + class EmptyTransacOperation < MyOperation process do transaction end end - let(:operation) { InvalidOperation.new } + let(:operation) { EmptyTransacOperation.new } it 'raises an error' do - expect { result }.to raise_error.with_message('must provide a step or a block but not both') + expect { operation.call(params) }.to raise_error.with_message('must provide either a step or a block but not both') end end end @@ -151,7 +242,7 @@ class InvalidOperation < MyOperation expect(DB).to receive(:after_commit).and_call_original expect(mailer).to receive(:send_emails).with(model) - expect(result).to be_a_success + expect(operation).to succeed_on(params) end it 'does not call after_commit block when transaction fails' do @@ -160,7 +251,7 @@ class InvalidOperation < MyOperation expect(DB).to_not receive(:after_commit).and_call_original expect(mailer).to_not receive(:send_emails) - expect(result).to be_a_failure + expect(operation).to fail_on(params) end context 'when the execution state is changed bellow the after_commit callback' do @@ -170,8 +261,7 @@ class InvalidOperation < MyOperation allow(MyModel).to receive(:first).with(params).and_return(model) expect(mailer).to receive(:send_emails).with(model) - expect(result).to be_a_success - expect(result.value).to eq(model: model) + expect(operation).to succeed_on(params).returning(model: model) end end end @@ -198,7 +288,7 @@ def send_emails(state) expect(DB).to receive(:after_commit).and_call_original expect(mailer).to receive(:send_emails).with(model) - expect(result).to be_a_success + expect(operation).to succeed_on(params) end it 'does not call after_commit block when transaction fails' do @@ -206,12 +296,173 @@ def send_emails(state) expect(DB).to_not receive(:after_commit).and_call_original expect(mailer).to_not receive(:send_emails) - expect(result).to be_a_failure + expect(operation).to fail_on(params) + end + end + + context 'with conditional execution' do + context 'using :if with and a block' do + class IfConditionalAfterCommitOperation < MyOperation + context :should_run + + process do + transaction do + step :fetch_model + after_commit(if: :should_run?) do + step :send_emails + end + end + end + + def send_emails(state) + @mailer.send_emails(state[:my_model]) if @mailer + end + + private + def should_run?(state) = state[:should_run] + end + + let(:operation) { IfConditionalAfterCommitOperation.new(mailer: mailer, should_run: should_run) } + let(:params) { { email: 'asd@fgh.net' } } + + before { allow(MyModel).to receive(:first).with(params).and_return(model) } + + context 'when the condition is true' do + let(:should_run) { true } + + it 'executes the after_commit block' do + expect(DB).to receive(:after_commit).and_call_original + expect(mailer).to receive(:send_emails).with(model) + + expect(operation).to succeed_on(params) + end + end + + context 'when the condition is false' do + let(:should_run) { false } + + it 'skips the after_commit block' do + expect(DB).to_not receive(:after_commit) + expect(mailer).to_not receive(:send_emails) + + expect(operation).to succeed_on(params) + end + end + end + + context 'using :unless and a block' do + class UnlessConditionalAfterCommitOperation < MyOperation + context :should_skip + + process do + transaction do + step :fetch_model + after_commit(unless: :should_skip?) do + step :send_emails + end + end + end + + def send_emails(state) + @mailer.send_emails(state[:my_model]) if @mailer + end + + private + def should_skip?(state) = state[:should_skip] + end + + let(:operation) { UnlessConditionalAfterCommitOperation.new(mailer: mailer, should_skip: should_skip) } + let(:params) { { email: 'asd@fgh.net' } } + + before { allow(MyModel).to receive(:first).with(params).and_return(model) } + + context 'when the condition is false' do + let(:should_skip) { false } + + it 'executes the after_commit block' do + expect(DB).to receive(:after_commit).and_call_original + expect(mailer).to receive(:send_emails).with(model) + + expect(operation).to succeed_on(params) + end + end + + context 'when the condition is true' do + let(:should_skip) { true } + + it 'skips the after_commit block' do + expect(DB).to_not receive(:after_commit) + expect(mailer).to_not receive(:send_emails) + + expect(operation).to succeed_on(params) + end + end + end + + context 'using :if with step name' do + class IfStepConditionalAfterCommitOperation < MyOperation + context :should_run + + process do + transaction do + step :fetch_model + after_commit :send_emails, if: :should_run? + end + end + + def send_emails(state) + @mailer.send_emails(state[:my_model]) if @mailer + end + + private + def should_run?(state) = state[:should_run] + end + + before { allow(MyModel).to receive(:first).with(email: 'asd@fgh.net').and_return(model) } + let(:operation) { IfStepConditionalAfterCommitOperation.new(mailer: mailer, should_run: should_run) } + + context 'when the condition is true' do + let(:should_run) { true } + + it 'executes the after_commit step' do + expect(DB).to receive(:after_commit).and_call_original + expect(mailer).to receive(:send_emails).with(model) + + expect(operation).to succeed_on(params) + end + end + + context 'when the condition is false' do + let(:should_run) { false } + + it 'skips the after_commit step' do + expect(DB).to_not receive(:after_commit) + expect(mailer).to_not receive(:send_emails) + + expect(operation).to succeed_on(params) + end + end + end + + context 'when both :if and :unless are provided' do + class InvalidConditionalAfterCommitOperation < MyOperation + process do + transaction do + after_commit :send_emails, if: :is_good?, unless: :is_bad? + end + end + end + + let(:operation) { InvalidConditionalAfterCommitOperation.new } + + it 'raises an error' do + expect { operation.call(params) }.to raise_error.with_message('options :if and :unless are mutually exclusive') + end end end context 'when providing a block and a step' do - class InvalidOperation < MyOperation + class AmbivalentAfterCommitOperation < MyOperation process do transaction do after_commit :perform_db_action do @@ -221,15 +472,15 @@ class InvalidOperation < MyOperation end end - let(:operation) { InvalidOperation.new } + let(:operation) { AmbivalentAfterCommitOperation.new } it 'raises an error' do - expect { result }.to raise_error.with_message('must provide a step or a block but not both') + expect { operation.call(params) }.to raise_error.with_message('must provide either a step or a block but not both') end end context 'when not providing a block nor a step' do - class InvalidOperation < MyOperation + class InvalidAfterCommitOperation < MyOperation process do transaction do after_commit @@ -237,10 +488,10 @@ class InvalidOperation < MyOperation end end - let(:operation) { InvalidOperation.new } + let(:operation) { InvalidAfterCommitOperation.new } it 'raises an error' do - expect { result }.to raise_error.with_message('must provide a step or a block but not both') + expect { operation.call(params) }.to raise_error.with_message('must provide either a step or a block but not both') end end end diff --git a/spec/plugins/simple_auth_spec.rb b/spec/plugins/simple_auth_spec.rb index 0bb3493..4ddc382 100644 --- a/spec/plugins/simple_auth_spec.rb +++ b/spec/plugins/simple_auth_spec.rb @@ -72,8 +72,6 @@ class AuthOperationWithArray < Operation end describe "#call" do - let(:result) { operation.call({}) } - context "when the authorization blocks expects no params" do subject(:operation) { AuthOperation.new(context) } let(:context) { { user: double(role: role) } } @@ -81,15 +79,14 @@ class AuthOperationWithArray < Operation context "and calling with proper authorization" do let(:role) { :admin } it "returns a successful result", :aggregate_failures do - expect(result).to be_a_success + expect(operation).to succeed_on({}) end end context "and calling with without proper authorization" do let(:role) { :user } it "returns a failed result", :aggregate_failures do - expect(result).to be_a_failure - expect(result.error.type).to eq(:forbidden) + expect(operation).to fail_on({}).with_type(:forbidden) end end end @@ -98,15 +95,14 @@ class AuthOperationWithArray < Operation context "and calling with proper authorization" do subject(:operation) { AuthOperationParam.new } it "returns a successful result", :aggregate_failures do - expect(result).to be_a_success + expect(operation).to succeed_on({}) end end context "and calling without proper authorization" do subject(:operation) { AuthOperationParam.new(value: :OTHER) } it "returns a failed result", :aggregate_failures do - expect(result).to be_a_failure - expect(result.error.type).to eq(:forbidden) + expect(operation).to fail_on({}).with_type(:forbidden) end end end @@ -115,15 +111,14 @@ class AuthOperationWithArray < Operation context "and calling with proper authorization" do subject(:operation) { AuthOperationMultiParam.new(value1: 10, value2: 20) } it "returns a successful result", :aggregate_failures do - expect(result).to be_a_success + expect(operation).to succeed_on({}) end end context "and calling without proper authorization" do subject(:operation) { AuthOperationMultiParam.new(value1: -11, value2: 99) } it "returns a failed result", :aggregate_failures do - expect(result).to be_a_failure - expect(result.error.type).to eq(:forbidden) + expect(operation).to fail_on({}).with_type(:forbidden) end end end @@ -132,15 +127,14 @@ class AuthOperationWithArray < Operation context "and calling with proper authorization" do subject(:operation) { AuthOperationWithArray.new(values: [3, 5]) } it "returns a successful result", :aggregate_failures do - expect(result).to be_a_success + expect(operation).to succeed_on({}) end end context "and calling without proper authorization" do subject(:operation) { AuthOperationWithArray.new(values: [3, 4, 5]) } it "returns a failed result", :aggregate_failures do - expect(result).to be_a_failure - expect(result.error.type).to eq(:forbidden) + expect(operation).to fail_on({}).with_type(:forbidden) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3c569d8..2e1d075 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -18,6 +18,7 @@ end require 'pathway' +require 'pathway/rspec' require 'sequel' require 'pry' require 'pry-byebug'