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

feat: Add extension for ActiveRecord transactions #18

Merged
merged 5 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
tiev marked this conversation as resolved.
Show resolved Hide resolved
# @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.
tiev marked this conversation as resolved.
Show resolved Hide resolved
#
# 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
waiting-for-dev marked this conversation as resolved.
Show resolved Hide resolved
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
tiev marked this conversation as resolved.
Show resolved Hide resolved
Class.new(ActiveRecord::Base) do
self.table_name = :foo
end
end

before :all do
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
tiev marked this conversation as resolved.
Show resolved Hide resolved
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
tiev marked this conversation as resolved.
Show resolved Hide resolved

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
Loading