From c74658a7842ea5763a647d1f7aaa2807c48e1529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Busqu=C3=A9?= Date: Fri, 13 Sep 2024 15:52:25 +0200 Subject: [PATCH] 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)`. --- Gemfile | 1 + lib/dry/operation.rb | 1 + lib/dry/operation/extensions/sequel.rb | 102 +++++++++++++++++++++ spec/integration/extensions/sequel_spec.rb | 96 +++++++++++++++++++ spec/unit/extensions/sequel_spec.rb | 48 ++++++++++ 5 files changed, 248 insertions(+) create mode 100644 lib/dry/operation/extensions/sequel.rb create mode 100644 spec/integration/extensions/sequel_spec.rb create mode 100644 spec/unit/extensions/sequel_spec.rb diff --git a/Gemfile b/Gemfile index 7a94f15..38fc49f 100644 --- a/Gemfile +++ b/Gemfile @@ -28,5 +28,6 @@ end group :development, :test do gem "activerecord" gem "rom-sql" + gem "sequel" gem "sqlite3", "~> 1.4" end diff --git a/lib/dry/operation.rb b/lib/dry/operation.rb index 20ed60e..feffd1c 100644 --- a/lib/dry/operation.rb +++ b/lib/dry/operation.rb @@ -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 diff --git a/lib/dry/operation/extensions/sequel.rb b/lib/dry/operation/extensions/sequel.rb new file mode 100644 index 0000000..ce3f578 --- /dev/null +++ b/lib/dry/operation/extensions/sequel.rb @@ -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 diff --git a/spec/integration/extensions/sequel_spec.rb b/spec/integration/extensions/sequel_spec.rb new file mode 100644 index 0000000..20bd943 --- /dev/null +++ b/spec/integration/extensions/sequel_spec.rb @@ -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 diff --git a/spec/unit/extensions/sequel_spec.rb b/spec/unit/extensions/sequel_spec.rb new file mode 100644 index 0000000..01bbe42 --- /dev/null +++ b/spec/unit/extensions/sequel_spec.rb @@ -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