Skip to content

Commit

Permalink
Add extension for Sequel transactions
Browse files Browse the repository at this point in the history
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)`.

As with the ActiveRecord extension, savepoints on nested transactions
are not supported yet
  • Loading branch information
waiting-for-dev committed Oct 27, 2024
1 parent a6ea211 commit 4195339
Show file tree
Hide file tree
Showing 6 changed files with 306 additions and 1 deletion.
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
111 changes: 111 additions & 0 deletions lib/dry/operation/extensions/sequel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 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
# ```
#
# WARNING: Be aware that the `:savepoint` option is not yet supported.
#
# @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

intercepting_failure do
result = nil
db.transaction(**default_options.merge(opts)) do
intercepting_failure(->(failure) {
result = failure
raise ::Sequel::Rollback
}) do
result = steps.()
end
end
result
end
end
end
end
end
end
end
end
end
2 changes: 1 addition & 1 deletion spec/integration/extensions/active_record_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def initialize(model)
def call
transaction do
step create_record
transaction(requires_new: true) do
transaction(savepoint: true) do
step failure
end
end
Expand Down
142 changes: 142 additions & 0 deletions spec/integration/extensions/sequel_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# 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 for a success" 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 "acts transparently for the regular flow for a 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)

expect(instance.()).to eql(Failure(:failure))
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

xit "works with `requires_new` for nested transactions" do
instance = Class.new(base) do
def call
transaction do
step create_user
transaction(requires_new: true) do
step failure
end
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(1)
end
end
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
require "dry/operation"
require "dry/operation/extensions/active_record"
require "dry/operation/extensions/rom"
require "dry/operation/extensions/sequel"

SPEC_ROOT = Pathname(__dir__).realpath.freeze

Expand Down
50 changes: 50 additions & 0 deletions spec/unit/extensions/sequel_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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(Dry::Operation).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(Dry::Operation) do
include Dry::Operation::Extensions::Sequel

attr_reader :db

def initialize(db)
super()
@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(Dry::Operation) do
include Dry::Operation::Extensions::Sequel[savepoint: true]

attr_reader :db

def initialize(db)
super()
@db = db
end
end.new(db)

expect(db).to receive(:transaction).with(savepoint: true, isolation: :serializable)
instance.transaction(isolation: :serializable) {}
end
end
end

0 comments on commit 4195339

Please sign in to comment.