-
-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add extension for ActiveRecord transactions (#18)
* 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
Showing
4 changed files
with
276 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |