Skip to content

Commit e9d26d9

Browse files
authored
Add in_transaction helper method to avoid nesting transactions blocks (#23)
It takes advantage of the `in_transaction?` helper method to determine if the current code is already wrapped in a transaction. If we're in a transaction, just yield the block. If we aren't, wrap the block in a transaction using the provided or default connection. This allows the block to guarantee it is running in a transaction without incurring the downside of rollbacks being swallowed because of nested transaction blocks (see https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-transaction for details)
1 parent a162325 commit e9d26d9

File tree

3 files changed

+149
-4
lines changed

3 files changed

+149
-4
lines changed

README.md

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,80 @@ Will be executed right after transaction in which it have been declared was roll
111111

112112
If called outside transaction will raise an exception!
113113

114-
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).
114+
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.
115115

116116
### Available helper methods
117117

118+
#### `in_transaction`
119+
120+
Makes sure the provided block is running in a transaction.
121+
122+
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.
123+
124+
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.
125+
126+
If no transaction is present, the provided block will open a new transaction.
127+
128+
```rb
129+
class ServiceObjectBtw
130+
include AfterCommitEverywhere
131+
132+
def call
133+
in_transaction do
134+
an_update
135+
another_update
136+
after_commit { puts "We're all done!" }
137+
end
138+
end
139+
end
140+
```
141+
142+
Our service object can run its database operations safely when run in isolation.
143+
144+
```rb
145+
ServiceObjectBtw.new.call # This opens a new #transaction block
146+
```
147+
148+
If it is later called from code already wrapped in a transaction, the existing transaction will be utilized without any nesting:
149+
150+
```rb
151+
ActiveRecord::Base.transaction do
152+
new_update
153+
next_update
154+
# This no longer opens a new #transaction block, because one is already present
155+
ServiceObjectBtw.new.call
156+
end
157+
```
158+
159+
This can be called directly on the module as well:
160+
161+
```rb
162+
AfterCommitEverywhere.in_transaction do
163+
AfterCommitEverywhere.after_commit { puts "We're all done!" }
164+
end
165+
```
166+
118167
#### `in_transaction?`
119168

120-
Returns `true` when called inside open transaction, `false` otherwise.
169+
Returns `true` when called inside an open transaction, `false` otherwise.
170+
171+
```rb
172+
def check_for_transaction
173+
if in_transaction?
174+
puts "We're in a transaction!"
175+
else
176+
puts "We're not in a transaction..."
177+
end
178+
end
179+
180+
check_for_transaction
181+
# => prints "We're not in a transaction..."
182+
183+
in_transaction do
184+
check_for_transaction
185+
end
186+
# => prints "We're in a transaction!"
187+
```
121188

122189
### Available callback options
123190

lib/after_commit_everywhere.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ module AfterCommitEverywhere
1414
class NotInTransaction < RuntimeError; end
1515

1616
delegate :after_commit, :before_commit, :after_rollback, to: AfterCommitEverywhere
17-
delegate :in_transaction?, to: AfterCommitEverywhere
17+
delegate :in_transaction?, :in_transaction, to: AfterCommitEverywhere
1818

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

135+
# Makes sure the provided block runs in a transaction. If we are not currently in a transaction, a new transaction is started.
136+
#
137+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection to operate in. Defaults to +ActiveRecord::Base.connection+
138+
# @return void
139+
def in_transaction(connection = nil)
140+
connection ||= default_connection
141+
142+
if in_transaction?(connection)
143+
yield
144+
else
145+
connection.transaction { yield }
146+
end
147+
end
148+
135149
private
136150

137151
def default_connection

spec/after_commit_everywhere_spec.rb

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@
133133
expect(handler).to have_received(:call)
134134
end
135135

136-
it "doesn't execute callback when rollback issued" do
136+
it "doesn't execute callback when rollback issued from :requires_new transaction" do
137137
outer_handler = spy("outer")
138138
ActiveRecord::Base.transaction do
139139
example_class.new.after_commit { outer_handler.call }
@@ -145,6 +145,19 @@
145145
expect(outer_handler).to have_received(:call)
146146
expect(handler).not_to have_received(:call)
147147
end
148+
149+
it "executes callbacks when rollback issued from default nested transaction" do
150+
outer_handler = spy("outer")
151+
ActiveRecord::Base.transaction do
152+
described_class.after_commit { outer_handler.call }
153+
ActiveRecord::Base.transaction do
154+
raise ActiveRecord::Rollback
155+
end
156+
end
157+
158+
expect(outer_handler).to have_received(:call)
159+
expect(handler).not_to have_received(:call)
160+
end
148161
end
149162

150163
context "with transactions to different databases" do
@@ -515,4 +528,55 @@
515528
is_expected.to be_falsey
516529
end
517530
end
531+
532+
shared_examples "verify in_transaction behavior" do
533+
it "rollbacks propogate up to the top level transaction block" do
534+
outer_handler = spy("outer")
535+
ActiveRecord::Base.transaction do
536+
described_class.after_commit { outer_handler.call }
537+
receiver.in_transaction do
538+
raise ActiveRecord::Rollback
539+
end
540+
end
541+
542+
expect(outer_handler).not_to have_received(:call)
543+
expect(handler).not_to have_received(:call)
544+
end
545+
546+
it "runs in a new transaction if no wrapping transaction is available" do
547+
expect(ActiveRecord::Base.connection.transaction_open?).to be_falsey
548+
receiver.in_transaction do
549+
expect(ActiveRecord::Base.connection.transaction_open?).to be_truthy
550+
end
551+
end
552+
553+
context "when rolling back, the rollback propogates to the parent transaction block" do
554+
subject { receiver.after_rollback { handler.call } }
555+
556+
it "executes all after_rollback calls, even when raising an ActiveRecord::Rollback" do
557+
outer_handler = spy("outer")
558+
ActiveRecord::Base.transaction do
559+
receiver.after_rollback { outer_handler.call }
560+
described_class.in_transaction do
561+
subject
562+
# ActiveRecord::Rollback works here because `in_transaction` yields without creating a new nested transaction
563+
raise ActiveRecord::Rollback
564+
end
565+
end
566+
567+
expect(handler).to have_received(:call)
568+
expect(outer_handler).to have_received(:call)
569+
end
570+
end
571+
end
572+
573+
describe "#in_transaction" do
574+
let(:receiver) { example_class.new }
575+
include_examples "verify in_transaction behavior"
576+
end
577+
578+
describe ".in_transaction" do
579+
let(:receiver) { described_class }
580+
include_examples "verify in_transaction behavior"
581+
end
518582
end

0 commit comments

Comments
 (0)