Skip to content

Commit

Permalink
feat: Add extension for ActiveRecord transactions (#18)
Browse files Browse the repository at this point in the history
* feat: Add extention for ActiveRecord transactions

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.

It also supports additional options for the ActiveRecord transaction.
These options can be set as default for all transactions or overridden
at runtime.
  • Loading branch information
tiev committed Jul 31, 2024
1 parent f1aacf4 commit d234c20
Show file tree
Hide file tree
Showing 4 changed files with 276 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
120 changes: 120 additions & 0 deletions lib/dry/operation/extensions/active_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# 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.
#
# The extension can be initiated with default options for the transaction.
# It will be applied to all transactions:
#
# ```ruby
# include Dry::Operation::Extensions::ActiveRecord[requires_new: true]
# ```
#
# You can override these options at runtime:
#
# ```ruby
# transaction(requires_new: false) do
# # ...
# end
#
# @see https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html
# @see https://guides.rubyonrails.org/active_record_multiple_databases.html
module ActiveRecord
DEFAULT_CONNECTION = ::ActiveRecord::Base

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

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

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

def included(klass)
class_exec(@connection, @options) do |default_connection, options|
# @!method transaction(connection = ActiveRecord::Base, **options, &steps)
# Wrap the given steps in an 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.
#
# @param connection [#transaction] The class/object to use
# @param options [Hash] Additional options for the ActiveRecord transaction
# @yieldreturn [Object] the result of the block
# @see Dry::Operation#steps
# @see https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-transaction
klass.define_method(:transaction) do |connection = default_connection, **opts, &steps|
connection.transaction(**options.merge(opts)) do
intercepting_failure(-> { raise ::ActiveRecord::Rollback }, &steps)
end
end
end
end
end
end
end
end
end
125 changes: 125 additions & 0 deletions spec/integration/extensions/active_record_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# frozen_string_literal: true

require "spec_helper"

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

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

after :all do
ActiveRecord::Schema.define do
ActiveRecord::Migration.suppress_messages do
drop_table :x_ar_foo
end
end
end

after :each do
model.delete_all
end

let(:model) do
Class.new(ActiveRecord::Base) do
self.table_name = :x_ar_foo
end
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 initialize(model)
@model = model
super()
end

def call
transaction do
step create_record
step failure
end
end

def create_record
Success(@model.create(bar: "bar"))
end

def failure
Failure(:failure)
end
end.new(model)

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

it "acts transparently for the regular flow" do
instance = Class.new(base) do
def initialize(model)
@model = model
super()
end

def call
transaction do
step create_record
step count_records
end
end

def create_record
Success(@model.create(bar: "bar"))
end

def count_records
Success(@model.count)
end
end.new(model)

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

it "accepts options for ActiveRecord transaction method" do
instance = Class.new(base) do
def initialize(model)
@model = model
super()
end

def call
transaction do
step create_record
transaction(requires_new: true) do
step failure
end
end
end

def create_record
Success(@model.create(bar: "bar"))
end

def failure
@model.create(bar: "bar")
Failure(:failure)
end
end.new(model)

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

require "spec_helper"

RSpec.describe Dry::Operation::Extensions::ActiveRecord do
describe "#transaction" do
it "forwards options to ActiveRecord transaction call" do
instance = Class.new.include(Dry::Operation::Extensions::ActiveRecord).new

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

it "accepts custom initiator and options" do
instance = Class.new.include(Dry::Operation::Extensions::ActiveRecord).new
record = double(:transaction)

expect(record).to receive(:transaction)
instance.transaction(record) {}
end

it "merges options with default options" do
instance = Class.new.include(Dry::Operation::Extensions::ActiveRecord[requires_new: true]).new

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

0 comments on commit d234c20

Please sign in to comment.