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

Add extension for Sequel transactions #21

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ end
group :development, :test do
gem "activerecord"
gem "rom-sql"
gem "sequel"
gem "sqlite3", "~> 1.4"
end
1 change: 1 addition & 0 deletions lib/dry/operation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "dry/operation/errors"
require "dry/operation/extensions/active_record"
require "dry/operation/extensions/rom"
require "dry/operation/extensions/sequel"

module Dry
# DSL for chaining operations that can fail
Expand Down
102 changes: 102 additions & 0 deletions lib/dry/operation/extensions/sequel.rb
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
96 changes: 96 additions & 0 deletions spec/integration/extensions/sequel_spec.rb
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
48 changes: 48 additions & 0 deletions spec/unit/extensions/sequel_spec.rb
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
Loading