-
-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add extension for Sequel transactions
We add a `Dry::Operation::Extensions::Sequel` module that, when included, gives access to a `#transaction` method. This method wraps the yielded steps in a [Sequel](https://sequel.jeremyevans.net/) transaction, rolling back in case one of them returns a failure. The extension expects the including class to define a `#db` method giving access to the Sequel database definition: ```ruby class MyOperation < Dry::Operation include Dry::Operation::Extensions::Sequel attr_reader :db def initialize(db:) @db = db end 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 ``` Default options for the `#transaction` options (which delegates to Sequel [transaction method](https://sequel.jeremyevans.net/rdoc/files/doc/transactions_rdoc.html)) can be given both at include time with `include Dry::Operation::Extensions::Sequel[isolation: :serializable]`, and at runtime with `#transaction(isolation: :serializable)`.
- Loading branch information
1 parent
d234c20
commit be0e182
Showing
4 changed files
with
247 additions
and
0 deletions.
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
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,5 +28,6 @@ end | |
group :development, :test do | ||
gem "activerecord" | ||
gem "rom-sql" | ||
gem "sequel" | ||
gem "sqlite3", "~> 1.4" | ||
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,102 @@ | ||
# frozen_string_literal: true | ||
|
||
begin | ||
require "sequel" | ||
rescue LoadError | ||
raise Dry::Operation::MissingDependencyError.new(gem: "sequel", extension: "Sequel") | ||
end | ||
|
||
module Dry | ||
class Operation | ||
module Extensions | ||
# Add Sequel transaction support to operations | ||
# | ||
# When this extension is included, you can use a `#transaction` method | ||
# to wrap the desired steps in a Sequel 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. | ||
# | ||
# The extension expects the including class to give access to the Sequel | ||
# database object via a `#db` method. | ||
# | ||
# ```ruby | ||
# class MyOperation < Dry::Operation | ||
# include Dry::Operation::Extensions::Sequel | ||
# | ||
# attr_reader :db | ||
# | ||
# def initialize(db:) | ||
# @db = db | ||
# end | ||
# | ||
# 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, no options are passed to the Sequel transaction. You can | ||
# change this when including the extension: | ||
# | ||
# ```ruby | ||
# include Dry::Operation::Extensions::Sequel[isolation: :serializable] | ||
# ``` | ||
# | ||
# Or you can change it at runtime: | ||
# | ||
# ```ruby | ||
# transaction(isolation: :serializable) do | ||
# # ... | ||
# end | ||
# ``` | ||
# | ||
# @see http://sequel.jeremyevans.net/rdoc/files/doc/transactions_rdoc.html | ||
module Sequel | ||
def self.included(klass) | ||
klass.include(self[]) | ||
end | ||
|
||
# Include the extension providing default options for the transaction. | ||
# | ||
# @param options [Hash] additional options for the Sequel transaction | ||
def self.[](options = {}) | ||
Builder.new(**options) | ||
end | ||
|
||
# @api private | ||
class Builder < Module | ||
def initialize(**options) | ||
super() | ||
@options = options | ||
end | ||
|
||
def included(klass) | ||
class_exec(@options) do |default_options| | ||
klass.define_method(:transaction) do |**opts, &steps| | ||
raise Dry::Operation::ExtensionError, <<~MSG unless respond_to?(:db) | ||
When using the Sequel extension, you need to define a #db method \ | ||
that returns the Sequel database object | ||
MSG | ||
|
||
db.transaction(**default_options.merge(opts)) do | ||
intercepting_failure(-> { raise ::Sequel::Rollback }) do | ||
steps.() | ||
end | ||
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,96 @@ | ||
# frozen_string_literal: true | ||
|
||
require "spec_helper" | ||
|
||
RSpec.describe Dry::Operation::Extensions::Sequel do | ||
include Dry::Monads[:result] | ||
|
||
let(:db) do | ||
Sequel.sqlite | ||
end | ||
|
||
before do | ||
db.create_table(:users) do | ||
primary_key :id | ||
String :name | ||
end | ||
end | ||
|
||
after do | ||
db.drop_table(:users) | ||
end | ||
|
||
let(:base) do | ||
Class.new(Dry::Operation) do | ||
include Dry::Operation::Extensions::Sequel | ||
|
||
attr_reader :db | ||
|
||
def initialize(db:) | ||
@db = db | ||
super() | ||
end | ||
end | ||
end | ||
|
||
it "rolls transaction back on failure" do | ||
instance = Class.new(base) do | ||
def call | ||
transaction do | ||
step create_user | ||
step failure | ||
end | ||
end | ||
|
||
def create_user | ||
Success(db[:users].insert(name: "John")) | ||
end | ||
|
||
def failure | ||
Failure(:failure) | ||
end | ||
end.new(db: db) | ||
|
||
instance.() | ||
expect(db[:users].count).to be(0) | ||
end | ||
|
||
it "acts transparently for the regular flow" do | ||
instance = Class.new(base) do | ||
def call | ||
transaction do | ||
step create_user | ||
step count_users | ||
end | ||
end | ||
|
||
def create_user | ||
Success(db[:users].insert(name: "John")) | ||
end | ||
|
||
def count_users | ||
Success(db[:users].count) | ||
end | ||
end.new(db: db) | ||
|
||
expect(instance.()).to eql(Success(1)) | ||
end | ||
|
||
it "accepts options for Sequel transaction method" do | ||
instance = Class.new(base) do | ||
def call | ||
transaction(isolation: :serializable) do | ||
step create_user | ||
end | ||
end | ||
|
||
def create_user | ||
Success(db[:users].insert(name: "John")) | ||
end | ||
end.new(db: db) | ||
|
||
expect(db).to receive(:transaction).with(isolation: :serializable) | ||
|
||
instance.() | ||
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,48 @@ | ||
# frozen_string_literal: true | ||
|
||
require "spec_helper" | ||
|
||
RSpec.describe Dry::Operation::Extensions::Sequel do | ||
describe "#transaction" do | ||
it "raises a meaningful error when #db method is not implemented" do | ||
instance = Class.new.include(Dry::Operation::Extensions::Sequel).new | ||
|
||
expect { instance.transaction {} }.to raise_error( | ||
Dry::Operation::ExtensionError, | ||
/you need to define a #db method/ | ||
) | ||
end | ||
|
||
it "forwards options to Sequel transaction call" do | ||
db = double(:db) | ||
instance = Class.new do | ||
include Dry::Operation::Extensions::Sequel | ||
|
||
attr_reader :db | ||
|
||
def initialize(db) | ||
@db = db | ||
end | ||
end.new(db) | ||
|
||
expect(db).to receive(:transaction).with(isolation: :serializable) | ||
instance.transaction(isolation: :serializable) {} | ||
end | ||
|
||
it "merges options with default options" do | ||
db = double(:db) | ||
instance = Class.new do | ||
include Dry::Operation::Extensions::Sequel[savepoint: true] | ||
|
||
attr_reader :db | ||
|
||
def initialize(db) | ||
@db = db | ||
end | ||
end.new(db) | ||
|
||
expect(db).to receive(:transaction).with(savepoint: true, isolation: :serializable) | ||
instance.transaction(isolation: :serializable) {} | ||
end | ||
end | ||
end |