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)`.
  • Loading branch information
waiting-for-dev committed Sep 13, 2024
1 parent d234c20 commit be0e182
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 0 deletions.
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
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

0 comments on commit be0e182

Please sign in to comment.