Skip to content

Commit ce38bc3

Browse files
Add extension for ROM transactions
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
1 parent d8813a8 commit ce38bc3

File tree

7 files changed

+305
-4
lines changed

7 files changed

+305
-4
lines changed

Gemfile

+5
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,8 @@ group :test do
2424
gem "rspec"
2525
gem "simplecov"
2626
end
27+
28+
group :development, :test do
29+
gem "rom-sql"
30+
gem "sqlite3"
31+
end

lib/dry/operation.rb

+45-4
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ module Dry
9292
#
9393
# The behavior configured by {ClassContext#operate_on} and {ClassContext#skip_prepending} is
9494
# inherited by subclasses.
95+
#
96+
# Some extensions are available under the `Dry::Operation::Extensions`
97+
# namespace, providing additional functionality that can be included in your
98+
# operation classes.
9599
class Operation
96100
def self.loader
97101
@loader ||= Zeitwerk::Loader.new.tap do |loader|
@@ -102,33 +106,70 @@ def self.loader
102106
loader.ignore(
103107
"#{root}/dry/operation/errors.rb"
104108
)
109+
loader.inflector.inflect("rom" => "ROM")
105110
end
106111
end
107112
loader.setup
108113

114+
FAILURE_TAG = :halt
115+
private_constant :FAILURE_TAG
116+
109117
extend ClassContext
110118
include Dry::Monads::Result::Mixin
111119

112120
# Wraps block's return value in a {Dry::Monads::Result::Success}
113121
#
114-
# Catches :halt and returns it
122+
# Catches `:halt` and returns it
115123
#
116124
# @yieldreturn [Object]
117125
# @return [Dry::Monads::Result::Success]
118126
# @see #step
119127
def steps(&block)
120-
catch(:halt) { Success(block.call) }
128+
catching_failure { Success(block.call) }
121129
end
122130

123131
# Unwraps a {Dry::Monads::Result::Success}
124132
#
125-
# Throws :halt with a {Dry::Monads::Result::Failure} on failure.
133+
# Throws `:halt` with a {Dry::Monads::Result::Failure} on failure.
126134
#
127135
# @param result [Dry::Monads::Result]
128136
# @return [Object] wrapped value
129137
# @see #steps
130138
def step(result)
131-
result.value_or { throw :halt, result }
139+
result.value_or { throw_failure(result) }
140+
end
141+
142+
# Invokes a callable in case of block's failure
143+
#
144+
# Throws `:halt` with a {Dry::Monads::Result::Failure} on failure.
145+
#
146+
# This method is useful when you want to perform some side-effect when a
147+
# failure is encountered. It's meant to be used within the {#steps} block
148+
# commonly wrapping a sub-set of {#step} calls.
149+
#
150+
# @param handler [#call] a callable that will be called when a failure is encountered
151+
# @yieldreturn [Object]
152+
# @return [Object] the block's return value
153+
def intercepting_failure(handler, &block)
154+
output = catching_failure(&block)
155+
156+
case output
157+
when Failure
158+
handler.()
159+
throw_failure(output)
160+
else
161+
output
162+
end
163+
end
164+
165+
private
166+
167+
def catching_failure(&block)
168+
catch(FAILURE_TAG) { block.() }
169+
end
170+
171+
def throw_failure(failure)
172+
throw FAILURE_TAG, failure
132173
end
133174
end
134175
end

lib/dry/operation/errors.rb

+13
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,18 @@ def initialize(methods:)
2222
MSG
2323
end
2424
end
25+
26+
# Missing dependency required by an extension
27+
class MissingDependencyError < ::StandardError
28+
def initialize(gem:, extension:)
29+
super <<~MSG
30+
To use the #{extension} extension, you first need to install the \
31+
#{gem} gem. Please, add it to your Gemfile and run bundle install
32+
MSG
33+
end
34+
end
35+
36+
# An expected interface for an extension is not implemented
37+
class InterfaceNotImplementedError < ::StandardError; end
2538
end
2639
end

lib/dry/operation/extensions/rom.rb

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
require "dry/operation/errors"
4+
5+
begin
6+
require "rom-sql"
7+
rescue LoadError
8+
raise Dry::Operation::MissingDependencyError.new(gem: "rom-sql", extension: "ROM")
9+
end
10+
11+
module Dry
12+
class Operation
13+
module Extensions
14+
# Add rom transaction support to operations
15+
#
16+
# When this extension is included, you can use a `#transaction` method
17+
# to wrap the desired steps in a rom transaction. If any of the steps
18+
# returns a `Dry::Monads::Result::Failure`, the transaction will be rolled
19+
# back and, as usual, the rest of the flow will be skipped.
20+
#
21+
# The extension expects the including class to give access to the rom
22+
# container via a `#rom` method.
23+
#
24+
# ```ruby
25+
# class MyOperation < Dry::Operation
26+
# include Dry::Operation::Extensions::ROM
27+
#
28+
# attr_reader :rom
29+
#
30+
# def initialize(rom:)
31+
# @rom = rom
32+
# end
33+
#
34+
# def call(input)
35+
# attrs = step validate(input)
36+
# user = transaction do
37+
# new_user = step persist(attrs)
38+
# step assign_initial_role(new_user)
39+
# new_user
40+
# end
41+
# step notify(user)
42+
# user
43+
# end
44+
#
45+
# # ...
46+
# end
47+
# ```
48+
#
49+
# By default, the `:default` gateway will be used. You can change this
50+
# when including the extension:
51+
#
52+
# ```ruby
53+
# include Dry::Operation::Extensions::ROM[gateway: :my_gateway]
54+
# ```
55+
#
56+
# Or you can change it at runtime:
57+
#
58+
# ```ruby
59+
# user = transaction(gateway: :my_gateway) do
60+
# # ...
61+
# end
62+
# ```
63+
#
64+
# @see https://rom-rb.org
65+
module ROM
66+
DEFAULT_GATEWAY = :default
67+
68+
# @!method transaction(gateway: DEFAULT_GATEWAY, &steps)
69+
# Wrap the given steps in a rom transaction.
70+
#
71+
# If any of the steps returns a `Dry::Monads::Result::Failure`, the
72+
# transaction will be rolled back and `:halt` will be thrown with the
73+
# failure as its value.
74+
#
75+
# @yieldreturn [Object] the result of the block
76+
# @see Dry::Operation#steps
77+
78+
def self.included(klass)
79+
klass.include(self[])
80+
end
81+
82+
# Include the extension providing a custom gateway
83+
#
84+
# @param gateway [Symbol] the rom gateway to use
85+
def self.[](gateway: DEFAULT_GATEWAY)
86+
Builder.new(gateway: gateway)
87+
end
88+
89+
# @api private
90+
class Builder < Module
91+
def initialize(gateway:)
92+
super()
93+
@gateway = gateway
94+
end
95+
96+
def included(klass)
97+
class_exec(@gateway) do |default_gateway|
98+
klass.define_method(:transaction) do |gateway: default_gateway, &steps|
99+
raise Dry::Operation::InterfaceNotImplementedError, <<~MSG unless respond_to?(:rom)
100+
When using the ROM extension, you need to define a #rom method \
101+
that returns the ROM container
102+
MSG
103+
104+
rom.gateways[gateway].transaction do |t|
105+
intercepting_failure(-> { raise t.rollback! }) do
106+
steps.()
107+
end
108+
end
109+
end
110+
end
111+
end
112+
end
113+
end
114+
end
115+
end
116+
end
+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Dry::Operation::Extensions::ROM do
6+
include Dry::Monads[:result]
7+
8+
let(:rom) do
9+
ROM.container(:sql, "sqlite:memory") do |config|
10+
config.default.create_table(:foo) do
11+
column :bar, :string
12+
end
13+
14+
config.relation(:foo)
15+
end
16+
end
17+
18+
let(:base) do
19+
Class.new(Dry::Operation) do
20+
include Dry::Operation::Extensions::ROM
21+
22+
attr_reader :rom
23+
24+
def initialize(rom:)
25+
@rom = rom
26+
super()
27+
end
28+
end
29+
end
30+
31+
it "rolls transaction back on failure" do
32+
instance = Class.new(base) do
33+
def call
34+
transaction do
35+
step create_record
36+
step failure
37+
end
38+
end
39+
40+
def create_record
41+
Success(rom.relations[:foo].command(:create).(bar: "bar"))
42+
end
43+
44+
def failure
45+
Failure(:failure)
46+
end
47+
end.new(rom: rom)
48+
49+
instance.()
50+
51+
expect(rom.relations[:foo].count).to be(0)
52+
end
53+
54+
it "acts transparently for the regular flow" do
55+
instance = Class.new(base) do
56+
def call
57+
transaction do
58+
step create_record
59+
step count_records
60+
end
61+
end
62+
63+
def create_record
64+
Success(rom.relations[:foo].command(:create).(bar: "bar"))
65+
end
66+
67+
def count_records
68+
Success(rom.relations[:foo].count)
69+
end
70+
end.new(rom: rom)
71+
72+
expect(
73+
instance.()
74+
).to eql(Success(1))
75+
end
76+
end

spec/unit/extensions/rom_spec.rb

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Dry::Operation::Extensions::ROM do
6+
describe "#transaction" do
7+
it "raises a meaningful error when #rom method is not implemented" do
8+
instance = Class.new.include(Dry::Operation::Extensions::ROM).new
9+
10+
expect { instance.transaction {} }.to raise_error(
11+
Dry::Operation::InterfaceNotImplementedError,
12+
/you need to define a #rom method/
13+
)
14+
end
15+
end
16+
end

spec/unit/operation_spec.rb

+34
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,38 @@ def foo(value)
5555
}.to throw_symbol(:halt, failure)
5656
end
5757
end
58+
59+
describe "#intercepting_failure" do
60+
it "forwards the block's output when it's not a failure" do
61+
expect(
62+
described_class.new.intercepting_failure(-> {}) { :foo }
63+
).to be(:foo)
64+
end
65+
66+
it "doesn't call the handler when the block doesn't return a failure" do
67+
called = false
68+
69+
catch(:halt) {
70+
described_class.new.intercepting_failure(-> { called = true }) { :foo }
71+
}
72+
73+
expect(called).to be(false)
74+
end
75+
76+
it "throws :halt with the result when the block returns a failure" do
77+
expect {
78+
described_class.new.intercepting_failure(-> {}) { Failure(:foo) }
79+
}.to throw_symbol(:halt, Failure(:foo))
80+
end
81+
82+
it "calls the handler when the block returns a failure" do
83+
called = false
84+
85+
catch(:halt) {
86+
described_class.new.intercepting_failure(-> { called = true }) { Failure(:foo) }
87+
}
88+
89+
expect(called).to be(true)
90+
end
91+
end
5892
end

0 commit comments

Comments
 (0)