Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

in_transaction helper to avoid nesting transaction blocks #23

Merged
merged 3 commits into from
Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,80 @@ Will be executed right after transaction in which it have been declared was roll

If called outside transaction will raise an exception!

Please keep in mind ActiveRecord's [limitations for rolling back nested transactions](http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions).
Please keep in mind ActiveRecord's [limitations for rolling back nested transactions](http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions). See [`in_transaction`](#in_transaction) for a workaround to this limitation.

### Available helper methods

#### `in_transaction`

Makes sure the provided block is running in a transaction.

This method aims to provide clearer intention than a typical `ActiveRecord::Base.transaction` block - `in_transaction` only cares that _some_ transaction is present, not that a transaction is nested in any way.

If a transaction is present, it will yield without taking any action. Note that this means `ActiveRecord::Rollback` errors will not be trapped by `in_transaction` but will propagate up to the nearest parent transaction block.

If no transaction is present, the provided block will open a new transaction.

```rb
class ServiceObjectBtw
include AfterCommitEverywhere

def call
in_transaction do
an_update
another_update
after_commit { puts "We're all done!" }
end
end
end
```

Our service object can run its database operations safely when run in isolation.

```rb
ServiceObjectBtw.new.call # This opens a new #transaction block
```

If it is later called from code already wrapped in a transaction, the existing transaction will be utilized without any nesting:

```rb
ActiveRecord::Base.transaction do
new_update
next_update
# This no longer opens a new #transaction block, because one is already present
ServiceObjectBtw.new.call
end
```

This can be called directly on the module as well:

```rb
AfterCommitEverywhere.in_transaction do
AfterCommitEverywhere.after_commit { puts "We're all done!" }
end
```

#### `in_transaction?`

Returns `true` when called inside open transaction, `false` otherwise.
Returns `true` when called inside an open transaction, `false` otherwise.

```rb
def check_for_transaction
if in_transaction?
puts "We're in a transaction!"
else
puts "We're not in a transaction..."
end
end

check_for_transaction
# => prints "We're not in a transaction..."

in_transaction do
check_for_transaction
end
# => prints "We're in a transaction!"
```

### Available callback options

Expand Down
16 changes: 15 additions & 1 deletion lib/after_commit_everywhere.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ module AfterCommitEverywhere
class NotInTransaction < RuntimeError; end

delegate :after_commit, :before_commit, :after_rollback, to: AfterCommitEverywhere
delegate :in_transaction?, to: AfterCommitEverywhere
delegate :in_transaction?, :in_transaction, to: AfterCommitEverywhere

# Causes {before_commit} and {after_commit} to raise an exception when
# called outside a transaction.
Expand Down Expand Up @@ -132,6 +132,20 @@ def in_transaction?(connection = nil)
connection.transaction_open? && connection.current_transaction.joinable?
end

# Makes sure the provided block runs in a transaction. If we are not currently in a transaction, a new transaction is started.
#
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection to operate in. Defaults to +ActiveRecord::Base.connection+
# @return void
def in_transaction(connection = nil)
connection ||= default_connection

if in_transaction?(connection)
yield
else
connection.transaction { yield }
end
end

private

def default_connection
Expand Down
66 changes: 65 additions & 1 deletion spec/after_commit_everywhere_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
expect(handler).to have_received(:call)
end

it "doesn't execute callback when rollback issued" do
it "doesn't execute callback when rollback issued from :requires_new transaction" do
outer_handler = spy("outer")
ActiveRecord::Base.transaction do
example_class.new.after_commit { outer_handler.call }
Expand All @@ -145,6 +145,19 @@
expect(outer_handler).to have_received(:call)
expect(handler).not_to have_received(:call)
end

it "executes callbacks when rollback issued from default nested transaction" do
outer_handler = spy("outer")
ActiveRecord::Base.transaction do
described_class.after_commit { outer_handler.call }
ActiveRecord::Base.transaction do
raise ActiveRecord::Rollback
end
end

expect(outer_handler).to have_received(:call)
expect(handler).not_to have_received(:call)
end
end

context "with transactions to different databases" do
Expand Down Expand Up @@ -515,4 +528,55 @@
is_expected.to be_falsey
end
end

shared_examples "verify in_transaction behavior" do
it "rollbacks propogate up to the top level transaction block" do
outer_handler = spy("outer")
ActiveRecord::Base.transaction do
described_class.after_commit { outer_handler.call }
receiver.in_transaction do
raise ActiveRecord::Rollback
end
end

expect(outer_handler).not_to have_received(:call)
expect(handler).not_to have_received(:call)
end

it "runs in a new transaction if no wrapping transaction is available" do
expect(ActiveRecord::Base.connection.transaction_open?).to be_falsey
receiver.in_transaction do
expect(ActiveRecord::Base.connection.transaction_open?).to be_truthy
end
end

context "when rolling back, the rollback propogates to the parent transaction block" do
subject { receiver.after_rollback { handler.call } }

it "executes all after_rollback calls, even when raising an ActiveRecord::Rollback" do
outer_handler = spy("outer")
ActiveRecord::Base.transaction do
receiver.after_rollback { outer_handler.call }
described_class.in_transaction do
subject
# ActiveRecord::Rollback works here because `in_transaction` yields without creating a new nested transaction
raise ActiveRecord::Rollback
end
end

expect(handler).to have_received(:call)
expect(outer_handler).to have_received(:call)
end
end
end

describe "#in_transaction" do
let(:receiver) { example_class.new }
include_examples "verify in_transaction behavior"
end

describe ".in_transaction" do
let(:receiver) { described_class }
include_examples "verify in_transaction behavior"
end
end