Skip to content

Commit

Permalink
feat: Add extention for ActiveRecord transactions
Browse files Browse the repository at this point in the history
This commit introduces a new extension to Dry::Operation that allows
operations to be wrapped in ActiveRecord transactions. This is useful
for ensuring atomicity of operations that involve multiple steps that
need to be rolled back in case of failure. The extension provides a
`transaction` method that can be used to wrap steps in a transaction.
The transaction will be rolled back if any of the steps return a
`Dry::Monads::Result::Failure`.

The extension also supports specifying a custom ActiveRecord class to
initiate the transaction, which is useful when working with multiple
databases.
  • Loading branch information
tiev committed Jul 22, 2024
1 parent f1aacf4 commit 3592922
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 1 deletion.
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ group :test do
end

group :development, :test do
gem "activerecord"
gem "rom-sql"
gem "sqlite3"
gem "sqlite3", "~> 1.4"
end
101 changes: 101 additions & 0 deletions lib/dry/operation/extensions/active_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# frozen_string_literal: true

begin
require "active_record"
rescue LoadError
raise Dry::Operation::MissingDependencyError.new(gem: "activerecord", extension: "ActiveRecord")
end

module Dry
class Operation
module Extensions
# Add ActiveRecord transaction support to operations
#
# When this extension is included, you can use a `#transaction` method
# to wrap the desired steps in an ActiveRecord transaction. If any of the steps
# returns a `Dry::Monads::Result::Failure`, the transaction will be rolled
# back and, as usual, the rest of the flow will be skipped.
#
# ```ruby
# class MyOperation < Dry::Operation
# include Dry::Operation::Extensions::ActiveRecord
#
# def call(input)
# attrs = step validate(input)
# user = transaction do
# new_user = step persist(attrs)
# step assign_initial_role(new_user)
# new_user
# end
# step notify(user)
# user
# end
#
# # ...
# end
# ```
#
# By default, the `ActiveRecord::Base` class will be used to initiate the transaction.
# You can change this when including the extension:
#
# ```ruby
# include Dry::Operation::Extensions::ActiveRecord[User]
# ```
#
# Or you can change it at runtime:
#
# ```ruby
# user = transaction(user) do
# # ...
# end
# ```
#
# This is useful when you use multiple databases with ActiveRecord.
#
# @see https://rom-rb.org
# @see https://guides.rubyonrails.org/active_record_multiple_databases.html
module ActiveRecord
DEFAULT_CONNECTION = ::ActiveRecord::Base

# @!method transaction(connection = DEFAULT_CONNECTION, &steps)
# Wrap the given steps in a ActiveRecord transaction.
#
# If any of the steps returns a `Dry::Monads::Result::Failure`, the
# transaction will be rolled back and `:halt` will be thrown with the
# failure as its value.
#
# @yieldreturn [Object] the result of the block
# @see Dry::Operation#steps

def self.included(klass)
klass.include(self[])
end

# Include the extension providing a custom class/object to initialize the transaction
#
# @param connection [ActiveRecord::Base, #transaction] the class/object to use
def self.[](connection = DEFAULT_CONNECTION)
Builder.new(connection)
end

# @api private
class Builder < Module
def initialize(connection)
super()
@connection = connection
end

def included(klass)
class_exec(@connection) do |default_connection|
klass.define_method(:transaction) do |connection = default_connection, &steps|
connection.transaction(requires_new: true) do
intercepting_failure(-> { raise ::ActiveRecord::Rollback }, &steps)
end
end
end
end
end
end
end
end
end
102 changes: 102 additions & 0 deletions spec/integration/extensions/active_record_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Dry::Operation::Extensions::ActiveRecord do
include Dry::Monads[:result]

let!(:model) do
Class.new(ActiveRecord::Base) do
self.table_name = :foo
end
end

before :all do
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.define do
create_table :foo do |t|
t.string :bar
end
end
end

after :each do
model.delete_all
end

let(:base) do
Class.new(Dry::Operation) do
include Dry::Operation::Extensions::ActiveRecord
end
end

it "rolls transaction back on failure" do
instance = Class.new(base) do
def call
transaction do
step create_record
step failure
end
end

def create_record
Success(ActiveRecord::Base.descendants.first.create(bar: "bar"))
end

def failure
Failure(:failure)
end
end.new

instance.()
expect(model.count).to be(0)
end

it "acts transparently for the regular flow" do
instance = Class.new(base) do
def call
transaction do
step create_record
step count_records
end
end

def create_record
Success(ActiveRecord::Base.descendants.first.create(bar: "bar"))
end

def count_records
Success(ActiveRecord::Base.descendants.first.count)
end
end.new

expect(
instance.()
).to eql(Success(1))
end

it "ensures new savepoints for nested transactions" do
instance = Class.new(base) do
def call
transaction do
step create_record
transaction do
step failure
end
end
end

def create_record
Success(ActiveRecord::Base.descendants.first.create(bar: "bar"))
end

def failure
ActiveRecord::Base.descendants.first.create(bar: "bar")
Failure(:failure)
end
end.new

instance.()
expect(model.count).to be(1)
end
end
14 changes: 14 additions & 0 deletions spec/unit/extensions/active_record_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Dry::Operation::Extensions::ActiveRecord do
describe "#transaction" do
it "ensures sub-transaction for nested transaction" do
instance = Class.new.include(Dry::Operation::Extensions::ActiveRecord).new

expect(ActiveRecord::Base).to receive(:transaction).with(requires_new: true)
instance.transaction {}
end
end
end

0 comments on commit 3592922

Please sign in to comment.