-
-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
We add a `Dry::Operation::Extensions::ROM` module that, when included, gives access to a `#transaction` method. This method wraps the yielded steps in a ROM [1] transaction, rolling back in case one of them returns a failure. We lean on a new `Dry::Operation#intercepting_failure` method, which allows running a callback before the failure is re-thrown again to be managed by the wrapping `#steps` call. Besides providing clarity, this method will be reused by future extensions. The extension expects the including class to define a `#rom` method giving access to the ROM container. ```ruby class MyOperation < Dry::Operation include Dry::Operation::Extensions::ROM attr_reader :rom def initialize(rom:) @rom = rom 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 ``` The extension uses the `:default` gateway by default, but it can be changed both at include time with `include Dry::Operation::Extensions::ROM[gateway: :my_gateway]`, and at runtime with `#transaction(gateway: :my_gateway)`. This commit also establishes the dry-operation's convention for database transactions. Instead of wrapping the whole flow, we require the user to be conscious of the transaction boundaries (not including, e.g., external requests or notifications). That encourages using individual operations when thinking about composition instead of the whole flow. [1] - https://rom-rb.org
- Loading branch information
1 parent
d8813a8
commit 5a96ae4
Showing
7 changed files
with
307 additions
and
4 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 |
---|---|---|
|
@@ -24,3 +24,8 @@ group :test do | |
gem "rspec" | ||
gem "simplecov" | ||
end | ||
|
||
group :development, :test do | ||
gem "rom-sql" | ||
gem "sqlite3" | ||
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
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
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,118 @@ | ||
# frozen_string_literal: true | ||
|
||
require "dry/operation/errors" | ||
|
||
begin | ||
require "rom-sql" | ||
rescue LoadError | ||
raise Dry::Operation::MissingDependencyError.new(gem: "rom-sql", extension: "ROM") | ||
end | ||
|
||
module Dry | ||
class Operation | ||
module Extensions | ||
# Add rom transaction support to operations | ||
# | ||
# When this extension is included, you can use a `#transaction` method | ||
# to wrap the desired steps in a rom 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 rom | ||
# container via a `#rom` method. | ||
# | ||
# ```ruby | ||
# class MyOperation < Dry::Operation | ||
# include Dry::Operation::Extensions::ROM | ||
# | ||
# attr_reader :rom | ||
# | ||
# def initialize(rom:) | ||
# @rom = rom | ||
# 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, the `:default` gateway will be used. You can change this | ||
# when including the extension: | ||
# | ||
# ```ruby | ||
# include Dry::Operation::Extensions::ROM[gateway: :my_gateway] | ||
# ``` | ||
# | ||
# Or you can change it at runtime: | ||
# | ||
# ```ruby | ||
# user = transaction(gateway: :my_gateway) do | ||
# # ... | ||
# end | ||
# ``` | ||
# | ||
# @see https://rom-rb.org | ||
module ROM | ||
DEFAULT_GATEWAY = :default | ||
|
||
# @!method transaction(gateway: DEFAULT_GATEWAY, &steps) | ||
# Wrap the given steps in a rom transaction. | ||
# | ||
# If any of the steps returns a `Dry::Monads::Result::Failure`, the | ||
# transaction will be rolled back and `:halt` will be thrown with the | ||
# failure as its value. | ||
# | ||
# @yieldreturn [Object] the result of the block | ||
# @raise [Dry::Operation::InterfaceNotImplementedError] if the including | ||
# class doesn't define a `#rom` method. | ||
# @see Dry::Operation#steps | ||
|
||
def self.included(klass) | ||
klass.include(self[]) | ||
end | ||
|
||
# Include the extension providing a custom gateway | ||
# | ||
# @param gateway [Symbol] the rom gateway to use | ||
def self.[](gateway: DEFAULT_GATEWAY) | ||
Builder.new(gateway: gateway) | ||
end | ||
|
||
# @api private | ||
class Builder < Module | ||
def initialize(gateway:) | ||
super() | ||
@gateway = gateway | ||
end | ||
|
||
def included(klass) | ||
class_exec(@gateway) do |default_gateway| | ||
klass.define_method(:transaction) do |gateway: default_gateway, &steps| | ||
raise Dry::Operation::InterfaceNotImplementedError, <<~MSG unless respond_to?(:rom) | ||
When using the ROM extension, you need to define a #rom method \ | ||
that returns the ROM container | ||
MSG | ||
|
||
rom.gateways[gateway].transaction do |t| | ||
intercepting_failure(-> { raise t.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,76 @@ | ||
# frozen_string_literal: true | ||
|
||
require "spec_helper" | ||
|
||
RSpec.describe Dry::Operation::Extensions::ROM do | ||
include Dry::Monads[:result] | ||
|
||
let(:rom) do | ||
ROM.container(:sql, "sqlite:memory") do |config| | ||
config.default.create_table(:foo) do | ||
column :bar, :string | ||
end | ||
|
||
config.relation(:foo) | ||
end | ||
end | ||
|
||
let(:base) do | ||
Class.new(Dry::Operation) do | ||
include Dry::Operation::Extensions::ROM | ||
|
||
attr_reader :rom | ||
|
||
def initialize(rom:) | ||
@rom = rom | ||
super() | ||
end | ||
end | ||
end | ||
|
||
it "rolls transaction back on failure" do | ||
instance = Class.new(base) do | ||
def call | ||
transaction do | ||
step create_record | ||
step failure | ||
end | ||
end | ||
|
||
def create_record | ||
Success(rom.relations[:foo].command(:create).(bar: "bar")) | ||
end | ||
|
||
def failure | ||
Failure(:failure) | ||
end | ||
end.new(rom: rom) | ||
|
||
instance.() | ||
|
||
expect(rom.relations[:foo].count).to be(0) | ||
end | ||
|
||
it "acts transparently for the regular flow" do | ||
instance = Class.new(base) do | ||
def call | ||
transaction do | ||
step create_record | ||
step count_records | ||
end | ||
end | ||
|
||
def create_record | ||
Success(rom.relations[:foo].command(:create).(bar: "bar")) | ||
end | ||
|
||
def count_records | ||
Success(rom.relations[:foo].count) | ||
end | ||
end.new(rom: rom) | ||
|
||
expect( | ||
instance.() | ||
).to eql(Success(1)) | ||
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,16 @@ | ||
# frozen_string_literal: true | ||
|
||
require "spec_helper" | ||
|
||
RSpec.describe Dry::Operation::Extensions::ROM do | ||
describe "#transaction" do | ||
it "raises a meaningful error when #rom method is not implemented" do | ||
instance = Class.new.include(Dry::Operation::Extensions::ROM).new | ||
|
||
expect { instance.transaction {} }.to raise_error( | ||
Dry::Operation::InterfaceNotImplementedError, | ||
/you need to define a #rom method/ | ||
) | ||
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