dry-transaction 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +49 -0
- data/LICENSE.md +9 -0
- data/README.md +201 -0
- data/Rakefile +6 -0
- data/lib/dry-transaction.rb +1 -0
- data/lib/dry/transaction.rb +54 -0
- data/lib/dry/transaction/dsl.rb +44 -0
- data/lib/dry/transaction/result_matcher.rb +26 -0
- data/lib/dry/transaction/sequence.rb +296 -0
- data/lib/dry/transaction/step.rb +42 -0
- data/lib/dry/transaction/step_adapters.rb +27 -0
- data/lib/dry/transaction/step_adapters/base.rb +20 -0
- data/lib/dry/transaction/step_adapters/map.rb +14 -0
- data/lib/dry/transaction/step_adapters/raw.rb +14 -0
- data/lib/dry/transaction/step_adapters/tee.rb +15 -0
- data/lib/dry/transaction/step_adapters/try.rb +21 -0
- data/lib/dry/transaction/step_failure.rb +20 -0
- data/lib/dry/transaction/version.rb +6 -0
- data/spec/examples.txt +41 -0
- data/spec/integration/passing_step_arguments_spec.rb +50 -0
- data/spec/integration/publishing_step_events_spec.rb +65 -0
- data/spec/integration/transaction_spec.rb +139 -0
- data/spec/spec_helper.rb +99 -0
- data/spec/support/test_module_constants.rb +11 -0
- data/spec/unit/sequence_spec.rb +173 -0
- metadata +169 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: dd63f3d5d018054cf586e579ccef6a9850ecce0a
|
4
|
+
data.tar.gz: 1c0028e352968ef521ff95b14d94e91adbbd4c6f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1e6c390997dc3f2273d2c2cbb55dc395a7f29011319f6f636a0ff3235f4f2110843ddb4dd2a200dda19a474cebf7228b0a55d85839fe0f243eef9b9d8e159bba
|
7
|
+
data.tar.gz: 50b11cf0a69818f259881bfbc6b7fb50d048d932457dfdb3fd87e6d38f188772534d5810b1d59a428f76697e4838fa0760897f38c6aafee35b5ca89c287c658c
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
dry-transaction (0.4.0)
|
5
|
+
kleisli
|
6
|
+
wisper (>= 1.6.0)
|
7
|
+
|
8
|
+
GEM
|
9
|
+
remote: https://rubygems.org/
|
10
|
+
specs:
|
11
|
+
diff-lcs (1.2.5)
|
12
|
+
docile (1.1.5)
|
13
|
+
json (1.8.3)
|
14
|
+
kleisli (0.2.7)
|
15
|
+
rake (10.4.2)
|
16
|
+
rspec (3.3.0)
|
17
|
+
rspec-core (~> 3.3.0)
|
18
|
+
rspec-expectations (~> 3.3.0)
|
19
|
+
rspec-mocks (~> 3.3.0)
|
20
|
+
rspec-core (3.3.2)
|
21
|
+
rspec-support (~> 3.3.0)
|
22
|
+
rspec-expectations (3.3.1)
|
23
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
24
|
+
rspec-support (~> 3.3.0)
|
25
|
+
rspec-mocks (3.3.2)
|
26
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
27
|
+
rspec-support (~> 3.3.0)
|
28
|
+
rspec-support (3.3.0)
|
29
|
+
simplecov (0.10.0)
|
30
|
+
docile (~> 1.1.0)
|
31
|
+
json (~> 1.8)
|
32
|
+
simplecov-html (~> 0.10.0)
|
33
|
+
simplecov-html (0.10.0)
|
34
|
+
wisper (1.6.1)
|
35
|
+
yard (0.8.7.6)
|
36
|
+
|
37
|
+
PLATFORMS
|
38
|
+
ruby
|
39
|
+
|
40
|
+
DEPENDENCIES
|
41
|
+
bundler (~> 1.10)
|
42
|
+
dry-transaction!
|
43
|
+
rake (~> 10.4.2)
|
44
|
+
rspec (~> 3.3.0)
|
45
|
+
simplecov (~> 0.10.0)
|
46
|
+
yard
|
47
|
+
|
48
|
+
BUNDLED WITH
|
49
|
+
1.11.2
|
data/LICENSE.md
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright © 2015-2016 [Icelab](http://icelab.com.au/).
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
6
|
+
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
8
|
+
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,201 @@
|
|
1
|
+
[gitter]: https://gitter.im/dry-rb/chat
|
2
|
+
[gem]: https://rubygems.org/gems/dry-transaction
|
3
|
+
[code_climate]: https://codeclimate.com/github/dry-rb/dry-transaction
|
4
|
+
[inch]: http://inch-ci.org/github/dry-rb/dry-transaction
|
5
|
+
|
6
|
+
# dry-transaction [![Join the Gitter chat](https://badges.gitter.im/Join%20Chat.svg)][gitter]
|
7
|
+
|
8
|
+
[![Gem Version](https://img.shields.io/gem/v/dry-transaction.svg)][gem]
|
9
|
+
[![Code Climate](https://img.shields.io/codeclimate/github/dry-rb/dry-transaction.svg)][code_climate]
|
10
|
+
[![API Documentation Coverage](http://inch-ci.org/github/dry-rb/dry-transaction.svg)][inch]
|
11
|
+
|
12
|
+
dry-transaction is a business transaction DSL. It provides a simple way to define a complex business transaction that includes processing by many different objects. It makes error handling a primary concern by using a “[Railway Oriented Programming](http://fsharpforfunandprofit.com/rop/)” approach for capturing and returning errors from any step in the transaction.
|
13
|
+
|
14
|
+
dry-transaction is based on the following ideas:
|
15
|
+
|
16
|
+
* A business transaction is a series of operations where each can fail and stop processing.
|
17
|
+
* A business transaction resolves its dependencies using an external container object and it doesn’t know any details about the individual operation objects except their identifiers.
|
18
|
+
* A business transaction can describe its steps on an abstract level without being coupled to any details about how individual operations work.
|
19
|
+
* A business transaction doesn’t have any state.
|
20
|
+
* Each operation shouldn’t accumulate state, instead it should receive an input and return an output without causing any side-effects.
|
21
|
+
* The only interface of a an operation is `#call(input)`.
|
22
|
+
* Each operation provides a meaningful functionality and can be reused.
|
23
|
+
* Errors in any operation can be easily caught and handled as part of the normal application flow.
|
24
|
+
|
25
|
+
## Why?
|
26
|
+
|
27
|
+
Requiring a business transaction’s steps to exist as independent operations directly addressable via a container means that they can be tested in isolation and easily reused throughout your application. Following from this, keeping the business transaction to a series of high-level, declarative steps ensures that it’s easy to understand at a glance.
|
28
|
+
|
29
|
+
The output of each step is wrapped in a [Kleisli](https://github.com/txus/kleisli) `Either` object (`Right` for success or `Left` for failure). This allows the steps to be chained together and ensures that processing stops in the case of a failure. Returning an `Either` from the overall transaction also allows for error handling to remain a primary concern without it getting in the way of tidy, straightforward operation logic. Wrapping the step output also means that you can work with a wide variety of operations within your application – they don’t need to return an `Either` already.
|
30
|
+
|
31
|
+
## Usage
|
32
|
+
|
33
|
+
### Container
|
34
|
+
|
35
|
+
All you need to use dry-transaction is a container to hold your application’s operations. Each operation must respond to `#call(input)`.
|
36
|
+
|
37
|
+
The operations will be resolved from the container via `#[]`. For our examples, we’ll use a plain hash:
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
container = {
|
41
|
+
process: -> input { {name: input["name"], email: input["email"]} },
|
42
|
+
validate: -> input { input[:email].nil? ? raise(ValidationFailure, "not valid") : input },
|
43
|
+
persist: -> input { DB << input and true }
|
44
|
+
}
|
45
|
+
```
|
46
|
+
|
47
|
+
For larger apps, you may like to consider something like [dry-container](https://github.com/dryrb/dry-container).
|
48
|
+
|
49
|
+
### Defining a transaction
|
50
|
+
|
51
|
+
Define a transaction to bring your opererations together:
|
52
|
+
|
53
|
+
```ruby
|
54
|
+
save_user = Dry.Transaction(container: container) do
|
55
|
+
map :process
|
56
|
+
try :validate, catch: ValidationFailure
|
57
|
+
tee :persist
|
58
|
+
end
|
59
|
+
```
|
60
|
+
|
61
|
+
Operations are formed into steps using _step adapters._ Step adapters wrap the output of your operations to make them easy to integrate into a transaction. The following adapters are available:
|
62
|
+
|
63
|
+
* `step` – the operation already returns an `Either` object (`Right(output)` for success and `Left(output)` for failure), and needs no special handling.
|
64
|
+
* `map` – any output is considered successful and returned as `Right(output)`
|
65
|
+
* `try` – the operation may raise an exception in an error case. This is caught and returned as `Left(exception)`. The output is otherwise returned as `Right(output)`.
|
66
|
+
* `tee` – the operation interacts with some external system and has no meaningful output. The original input is passed through and returned as `Right(input)`.
|
67
|
+
|
68
|
+
### Calling a transaction
|
69
|
+
|
70
|
+
Calling a transaction will run its operations in their specified order, with the output of each operation becoming the input for the next.
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
DB = []
|
74
|
+
|
75
|
+
save_user.call("name" => "Jane", "email" => "jane@doe.com")
|
76
|
+
# => Right({:name=>"Jane", :email=>"jane@doe.com"})
|
77
|
+
|
78
|
+
DB
|
79
|
+
# => [{:name=>"Jane", :email=>"jane@doe.com"}]
|
80
|
+
```
|
81
|
+
|
82
|
+
Each transaction returns a result value wrapped in a `Left` or `Right` object (based on the output of its final step). You can handle these results (including errors arising from particular steps) with a match block:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
save_user.call(name: "Jane", email: "jane@doe.com") do |m|
|
86
|
+
m.success do |value|
|
87
|
+
puts "Succeeded!"
|
88
|
+
end
|
89
|
+
|
90
|
+
m.failure :validate do |error|
|
91
|
+
# In a more realistic example, you’d loop through a list of messages in `errors`.
|
92
|
+
puts "Please provide an email address."
|
93
|
+
end
|
94
|
+
|
95
|
+
m.failure do |error|
|
96
|
+
puts "Couldn’t save this user."
|
97
|
+
end
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
101
|
+
### Passing additional step arguments
|
102
|
+
|
103
|
+
Additional arguments for step operations can be passed at the time of calling your transaction. Provide these arguments as an array, and they’ll be [splatted](https://endofline.wordpress.com/2011/01/21/the-strange-ruby-splat/) into the front of the operation’s arguments. This means that transactions can effectively support operations with any sort of `#call(*args, input)` interface.
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
DB = []
|
107
|
+
|
108
|
+
container = {
|
109
|
+
process: -> input { {name: input["name"], email: input["email"]} },
|
110
|
+
validate: -> allowed, input { input[:email].include?(allowed) ? raise(ValidationFailure, "not allowed") : input },
|
111
|
+
persist: -> input { DB << input and true }
|
112
|
+
}
|
113
|
+
|
114
|
+
save_user = Dry.Transaction(container: container) do
|
115
|
+
map :process
|
116
|
+
try :validate, catch: ValidationFailure
|
117
|
+
tee :persist
|
118
|
+
end
|
119
|
+
|
120
|
+
input = {"name" => "Jane", "email" => "jane@doe.com"}
|
121
|
+
save_user.call(input, validate: ["doe.com"])
|
122
|
+
# => Right({:name=>"Jane", :email=>"jane@doe.com"})
|
123
|
+
|
124
|
+
save_user.call(input, validate: ["smith.com"])
|
125
|
+
# => Left("not allowed")
|
126
|
+
```
|
127
|
+
|
128
|
+
### Subscribing to step notifications
|
129
|
+
|
130
|
+
As well as pattern matching on the final transaction result, you can subscribe to individual steps and trigger specific behaviour based on their success or failure:
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
NOTIFICATIONS = []
|
134
|
+
|
135
|
+
module UserPersistListener
|
136
|
+
extend self
|
137
|
+
|
138
|
+
def persist_success(user)
|
139
|
+
NOTIFICATIONS << "#{user[:email]} persisted"
|
140
|
+
end
|
141
|
+
|
142
|
+
def persist_failure(user)
|
143
|
+
NOTIFICATIONS << "#{user[:email]} failed to persist"
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
|
148
|
+
input = {"name" => "Jane", "email" => "jane@doe.com"}
|
149
|
+
|
150
|
+
save_user.subscribe(persist: UserPersistListener)
|
151
|
+
save_user.call(input, validate: ["doe.com"])
|
152
|
+
|
153
|
+
NOTIFICATIONS
|
154
|
+
# => ["jane@doe.com persisted"]
|
155
|
+
```
|
156
|
+
|
157
|
+
This pub/sub mechanism is provided by the [Wisper](https://github.com/krisleech/wisper) gem. You can subscribe to specific steps using the `#subscribe(step_name: listener)` API, or subscribe to all steps via `#subscribe(listener)`.
|
158
|
+
|
159
|
+
### Extending transactions
|
160
|
+
|
161
|
+
You can extend existing transactions by inserting or removing steps. See the [API docs](http://www.rubydoc.info/github/dry-rb/dry-transaction/Dry/Transaction/Sequence) for more information.
|
162
|
+
|
163
|
+
### Working with a larger container
|
164
|
+
|
165
|
+
In practice, your container won’t be a trivial collection of generically named operations. You can keep your transaction step names simple by using the `with:` option to provide the identifiers for the operations within your container:
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
save_user = Dry.Transaction(container: large_whole_app_container) do
|
169
|
+
map :process, with: "attributes.user"
|
170
|
+
try :validate, with: "validations.user", catch: ValidationFailure
|
171
|
+
tee :persist, with: "persistance.commands.update_user"
|
172
|
+
end
|
173
|
+
```
|
174
|
+
|
175
|
+
## Installation
|
176
|
+
|
177
|
+
Add this line to your application’s `Gemfile`:
|
178
|
+
|
179
|
+
```ruby
|
180
|
+
gem "dry-transaction"
|
181
|
+
```
|
182
|
+
|
183
|
+
Run `bundle` to install the gem.
|
184
|
+
|
185
|
+
## Documentation
|
186
|
+
|
187
|
+
View the [full API documentation](http://www.rubydoc.info/github/dry-rb/dry-transaction) on RubyDoc.info.
|
188
|
+
|
189
|
+
## Contributing
|
190
|
+
|
191
|
+
Bug reports and pull requests are welcome on [GitHub](http://github.com/dry-rb/dry-transaction).
|
192
|
+
|
193
|
+
## Credits
|
194
|
+
|
195
|
+
dry-transaction is developed and maintained by [Icelab](http://icelab.com.au/).
|
196
|
+
|
197
|
+
dry-transaction’s error handling is based on Scott Wlaschin’s [Railway Oriented Programming](http://fsharpforfunandprofit.com/rop/), found via Zohaib Rauf’s [Railway Oriented Programming in Elixir](http://zohaib.me/railway-programming-pattern-in-elixir/) blog post. dry-transaction’s behavior as a business transaction library draws heavy inspiration from Piotr Solnica’s [Transflow](http://github.com/solnic/transflow) and Gilbert B Garza’s [Solid Use Case](https://github.com/mindeavor/solid_use_case). Josep M. Bach’s [Kleisli](https://github.com/txus/kleisli) gem makes functional programming patterns in Ruby accessible and fun. Thank you all!
|
198
|
+
|
199
|
+
## License
|
200
|
+
|
201
|
+
Copyright © 2015-2016 [Icelab](http://icelab.com.au/). dry-transaction is free software, and may be redistributed under the terms specified in the [license](LICENSE.md).
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "dry/transaction"
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require "kleisli"
|
2
|
+
require "dry/transaction/version"
|
3
|
+
require "dry/transaction/dsl"
|
4
|
+
|
5
|
+
module Dry
|
6
|
+
# Define a business transaction.
|
7
|
+
#
|
8
|
+
# A business transaction is a series of callable operation objects that
|
9
|
+
# receive input and produce an output.
|
10
|
+
#
|
11
|
+
# The operations should be addressable via `#[]` in a container object that
|
12
|
+
# you pass when creating the transaction. The operations must respond to
|
13
|
+
# `#call(*args, input)`.
|
14
|
+
#
|
15
|
+
# Each operation will be called in the order it was specified in your
|
16
|
+
# transaction, with its output is passed as the input to the next operation.
|
17
|
+
# Operations will only be called if the previous step was a success.
|
18
|
+
#
|
19
|
+
# A step is successful when it returns a [Kleisli](kleisli) `Right` object
|
20
|
+
# wrapping its output value. A step is a failure when it returns a `Left`
|
21
|
+
# object. If your operations already return a `Right` or `Left`, they can be
|
22
|
+
# added to your operation as plain `step` steps.
|
23
|
+
#
|
24
|
+
# If your operations don't already return `Right` or `Left`, then they can be
|
25
|
+
# added to the transaction with the following steps:
|
26
|
+
#
|
27
|
+
# * `map` --- wrap the output of the operation in a `Right`
|
28
|
+
# * `try` --- wrap the output of the operation in a `Right`, unless a certain
|
29
|
+
# exception is raised, which will be caught and returned as a `Left`.
|
30
|
+
# * `tee` --- ignore the output of the operation and pass through its original
|
31
|
+
# input as a `Right`.
|
32
|
+
#
|
33
|
+
# [kleisli]: https://rubygems.org/gems/kleisli
|
34
|
+
#
|
35
|
+
# @example
|
36
|
+
# container = {do_first: some_obj, do_second: some_obj}
|
37
|
+
#
|
38
|
+
# my_transaction = Dry.Transaction(container: container) do
|
39
|
+
# step :do_first
|
40
|
+
# step :do_second
|
41
|
+
# end
|
42
|
+
#
|
43
|
+
# my_transaction.call(some_input)
|
44
|
+
#
|
45
|
+
# @param options [Hash] the options hash
|
46
|
+
# @option options [#[]] :container the operations container
|
47
|
+
#
|
48
|
+
# @return [Dry::Transaction::Steps] the transaction object
|
49
|
+
#
|
50
|
+
# @api public
|
51
|
+
def self.Transaction(options = {}, &block)
|
52
|
+
Transaction::DSL.new(options, &block).call
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require "dry/transaction/step"
|
2
|
+
require "dry/transaction/step_adapters"
|
3
|
+
require "dry/transaction/step_adapters/base"
|
4
|
+
require "dry/transaction/step_adapters/map"
|
5
|
+
require "dry/transaction/step_adapters/raw"
|
6
|
+
require "dry/transaction/step_adapters/tee"
|
7
|
+
require "dry/transaction/step_adapters/try"
|
8
|
+
require "dry/transaction/sequence"
|
9
|
+
|
10
|
+
module Dry
|
11
|
+
module Transaction
|
12
|
+
class DSL
|
13
|
+
# @api private
|
14
|
+
attr_reader :options
|
15
|
+
|
16
|
+
# @api private
|
17
|
+
attr_reader :container
|
18
|
+
|
19
|
+
# @api private
|
20
|
+
attr_reader :steps
|
21
|
+
|
22
|
+
# @api private
|
23
|
+
def initialize(options, &block)
|
24
|
+
@options = options
|
25
|
+
@container = options.fetch(:container)
|
26
|
+
@steps = []
|
27
|
+
|
28
|
+
instance_exec(&block)
|
29
|
+
end
|
30
|
+
|
31
|
+
StepAdapters.each do |adapter_name, adapter_class|
|
32
|
+
define_method adapter_name do |step_name, options = {}|
|
33
|
+
operation = container[options.fetch(:with, step_name)]
|
34
|
+
steps << Step.new(step_name, adapter_class.new(operation, options))
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# @api private
|
39
|
+
def call
|
40
|
+
Sequence.new(steps)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Dry
|
2
|
+
module Transaction
|
3
|
+
class ResultMatcher
|
4
|
+
attr_reader :result
|
5
|
+
attr_reader :output
|
6
|
+
|
7
|
+
def initialize(result)
|
8
|
+
@result = result
|
9
|
+
end
|
10
|
+
|
11
|
+
def success(&block)
|
12
|
+
return output unless result.is_a?(Kleisli::Either::Right)
|
13
|
+
|
14
|
+
@output = block.call(result.value)
|
15
|
+
end
|
16
|
+
|
17
|
+
def failure(step_name = nil, &block)
|
18
|
+
return output unless result.is_a?(Kleisli::Either::Left)
|
19
|
+
|
20
|
+
if step_name.nil? || step_name == result.value.__step_name
|
21
|
+
@output = block.call(result.value)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,296 @@
|
|
1
|
+
require "dry/transaction/result_matcher"
|
2
|
+
|
3
|
+
module Dry
|
4
|
+
module Transaction
|
5
|
+
class Sequence
|
6
|
+
# @api private
|
7
|
+
attr_reader :steps
|
8
|
+
|
9
|
+
# @api private
|
10
|
+
def initialize(steps)
|
11
|
+
@steps = steps
|
12
|
+
end
|
13
|
+
|
14
|
+
# Run the transaction.
|
15
|
+
#
|
16
|
+
# Each operation will be called in the order it was specified, with its
|
17
|
+
# output passed as input to the next operation. Operations will only be
|
18
|
+
# called if the previous step was a success.
|
19
|
+
#
|
20
|
+
# If any of the operations require extra arguments beyond the main input
|
21
|
+
# e.g. with a signature like `#call(something_else, input)`, then you
|
22
|
+
# must pass the extra arguments as arrays for each step in the options
|
23
|
+
# hash.
|
24
|
+
#
|
25
|
+
# @example Running a transaction
|
26
|
+
# my_transaction.call(some_input)
|
27
|
+
#
|
28
|
+
# @example Running a transaction with extra step arguments
|
29
|
+
# my_transaction.call(some_input, step_name: [extra_argument])
|
30
|
+
#
|
31
|
+
# The return value will be the output from the last operation, wrapped
|
32
|
+
# in a [Kleisli](kleisli) `Either` object, a `Right` for a successful
|
33
|
+
# transaction or a `Left` for a failed transaction.
|
34
|
+
#
|
35
|
+
# [kleisli]: https://rubygems.org/gems/kleisli
|
36
|
+
#
|
37
|
+
# @param input
|
38
|
+
# @param options [Hash] extra step arguments
|
39
|
+
#
|
40
|
+
# @return [Right, Left] output from the final step
|
41
|
+
#
|
42
|
+
# @api public
|
43
|
+
def call(input, options = {}, &block)
|
44
|
+
assert_valid_options(options)
|
45
|
+
assert_options_satisfy_step_arity(options)
|
46
|
+
|
47
|
+
steps = steps_with_options_applied(options)
|
48
|
+
result = steps.inject(Right(input), :>>)
|
49
|
+
|
50
|
+
if block
|
51
|
+
block.call(ResultMatcher.new(result))
|
52
|
+
else
|
53
|
+
result
|
54
|
+
end
|
55
|
+
end
|
56
|
+
alias_method :[], :call
|
57
|
+
|
58
|
+
# Subscribe to notifications from steps.
|
59
|
+
#
|
60
|
+
# When each step completes, it will send a `[step_name]_success` or
|
61
|
+
# `[step_name]_failure` message to any subscribers.
|
62
|
+
#
|
63
|
+
# For example, if you had a step called `persist`, then it would send
|
64
|
+
# either `persist_success` or `persist_failure` messages to subscribers
|
65
|
+
# after the operation completes.
|
66
|
+
#
|
67
|
+
# Pass a single object to subscribe to notifications from all steps, or
|
68
|
+
# pass a hash with step names as keys to subscribe to notifications from
|
69
|
+
# specific steps.
|
70
|
+
#
|
71
|
+
# @example Subscribing to notifications from all steps
|
72
|
+
# my_transaction.subscribe(my_listener)
|
73
|
+
#
|
74
|
+
# @example Subscribing to notifications from specific steps
|
75
|
+
# my_transaction.subscirbe(some_step: my_listener, another_step: another_listener)
|
76
|
+
#
|
77
|
+
# Notifications are implemented using the [Wisper](wisper) gem.
|
78
|
+
#
|
79
|
+
# [wisper]: https://rubygems.org/gems/wisper
|
80
|
+
#
|
81
|
+
# @param listeners [Object, Hash{Symbol => Object}] the listener object or
|
82
|
+
# hash of steps and listeners
|
83
|
+
#
|
84
|
+
# @api public
|
85
|
+
def subscribe(listeners)
|
86
|
+
if listeners.is_a?(Hash)
|
87
|
+
listeners.each do |step_name, listener|
|
88
|
+
steps.detect { |step| step.step_name == step_name }.subscribe(listener)
|
89
|
+
end
|
90
|
+
else
|
91
|
+
steps.each do |step|
|
92
|
+
step.subscribe(listeners)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
self
|
97
|
+
end
|
98
|
+
|
99
|
+
# Return a transaction with the steps from the provided transaction
|
100
|
+
# prepended onto the beginning of the steps in `self`.
|
101
|
+
#
|
102
|
+
# @example Prepend an existing transaction
|
103
|
+
# my_transaction = Dry.Transaction(container: container) do
|
104
|
+
# step :first
|
105
|
+
# step :second
|
106
|
+
# end
|
107
|
+
#
|
108
|
+
# other_transaction = Dry.Transaction(container: container) do
|
109
|
+
# step :another
|
110
|
+
# end
|
111
|
+
#
|
112
|
+
# my_transaction.prepend(other_transaction)
|
113
|
+
#
|
114
|
+
# @example Prepend a transaction defined inline
|
115
|
+
# my_transaction = Dry.Transaction(container: container) do
|
116
|
+
# step :first
|
117
|
+
# step :second
|
118
|
+
# end
|
119
|
+
#
|
120
|
+
# my_transaction.prepend(container: container) do
|
121
|
+
# step :another
|
122
|
+
# end
|
123
|
+
#
|
124
|
+
# @param other [Dry::Transaction::Sequence] the transaction to prepend.
|
125
|
+
# Optional if you will define a transaction inline via a block.
|
126
|
+
# @param options [Hash] the options hash for defining a transaction inline
|
127
|
+
# via a block. Optional if the transaction is passed directly as
|
128
|
+
# `other`.
|
129
|
+
# @option options [#[]] :container the operations container
|
130
|
+
#
|
131
|
+
# @return [Dry::Transaction::Sequence] the modified transaction object
|
132
|
+
#
|
133
|
+
# @api public
|
134
|
+
def prepend(other = nil, **options, &block)
|
135
|
+
other = accept_or_build_transaction(other, **options, &block)
|
136
|
+
|
137
|
+
self.class.new(other.steps + steps)
|
138
|
+
end
|
139
|
+
|
140
|
+
# Return a transaction with the steps from the provided transaction
|
141
|
+
# appended onto the end of the steps in `self`.
|
142
|
+
#
|
143
|
+
# @example Append an existing transaction
|
144
|
+
# my_transaction = Dry.Transaction(container: container) do
|
145
|
+
# step :first
|
146
|
+
# step :second
|
147
|
+
# end
|
148
|
+
#
|
149
|
+
# other_transaction = Dry.Transaction(container: container) do
|
150
|
+
# step :another
|
151
|
+
# end
|
152
|
+
#
|
153
|
+
# my_transaction.append(other_transaction)
|
154
|
+
#
|
155
|
+
# @example Append a transaction defined inline
|
156
|
+
# my_transaction = Dry.Transaction(container: container) do
|
157
|
+
# step :first
|
158
|
+
# step :second
|
159
|
+
# end
|
160
|
+
#
|
161
|
+
# my_transaction.append(container: container) do
|
162
|
+
# step :another
|
163
|
+
# end
|
164
|
+
#
|
165
|
+
# @param other [Dry::Transaction::Sequence] the transaction to append.
|
166
|
+
# Optional if you will define a transaction inline via a block.
|
167
|
+
# @param options [Hash] the options hash for defining a transaction inline
|
168
|
+
# via a block. Optional if the transaction is passed directly as
|
169
|
+
# `other`.
|
170
|
+
# @option options [#[]] :container the operations container
|
171
|
+
#
|
172
|
+
# @return [Dry::Transaction::Sequence] the modified transaction object
|
173
|
+
#
|
174
|
+
# @api public
|
175
|
+
def append(other = nil, **options, &block)
|
176
|
+
other = accept_or_build_transaction(other, **options, &block)
|
177
|
+
|
178
|
+
self.class.new(steps + other.steps)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Return a transaction with the steps from the provided transaction
|
182
|
+
# inserted into a specific place among the steps in `self`.
|
183
|
+
#
|
184
|
+
# Transactions can be inserted either before or after a named step.
|
185
|
+
#
|
186
|
+
# @example Insert an existing transaction (before a step)
|
187
|
+
# my_transaction = Dry.Transaction(container: container) do
|
188
|
+
# step :first
|
189
|
+
# step :second
|
190
|
+
# end
|
191
|
+
#
|
192
|
+
# other_transaction = Dry.Transaction(container: container) do
|
193
|
+
# step :another
|
194
|
+
# end
|
195
|
+
#
|
196
|
+
# my_transaction.insert(other_transaction, before: :second)
|
197
|
+
#
|
198
|
+
# @example Append a transaction defined inline (after a step)
|
199
|
+
# my_transaction = Dry.Transaction(container: container) do
|
200
|
+
# step :first
|
201
|
+
# step :second
|
202
|
+
# end
|
203
|
+
#
|
204
|
+
# my_transaction.insert(after: :first, container: container) do
|
205
|
+
# step :another
|
206
|
+
# end
|
207
|
+
#
|
208
|
+
# @param other [Dry::Transaction::Sequence] the transaction to append.
|
209
|
+
# Optional if you will define a transaction inline via a block.
|
210
|
+
# @param before [Symbol] the name of the step before which the
|
211
|
+
# transaction should be inserted (provide either this or `after`)
|
212
|
+
# @param after [Symbol] the name of the step after which the transaction
|
213
|
+
# should be inserted (provide either this or `before`)
|
214
|
+
# @param options [Hash] the options hash for defining a transaction
|
215
|
+
# inline via a block. Optional if the transaction is passed directly
|
216
|
+
# as `other`.
|
217
|
+
# @option options [#[]] :container the operations container
|
218
|
+
#
|
219
|
+
# @return [Dry::Transaction::Sequence] the modified transaction object
|
220
|
+
#
|
221
|
+
# @api public
|
222
|
+
def insert(other = nil, before: nil, after: nil, **options, &block)
|
223
|
+
insertion_step = before || after
|
224
|
+
unless steps.map(&:step_name).include?(insertion_step)
|
225
|
+
raise ArgumentError, "+#{insertion_step}+ is not a valid step name"
|
226
|
+
end
|
227
|
+
|
228
|
+
other = accept_or_build_transaction(other, **options, &block)
|
229
|
+
index = steps.index { |step| step.step_name == insertion_step } + (!!after ? 1 : 0)
|
230
|
+
|
231
|
+
self.class.new(steps.dup.insert(index, *other.steps))
|
232
|
+
end
|
233
|
+
|
234
|
+
# @overload remove(step, ...)
|
235
|
+
# Return a transaction with steps removed.
|
236
|
+
#
|
237
|
+
# @example
|
238
|
+
# my_transaction = Dry.Transaction(container: container) do
|
239
|
+
# step :first
|
240
|
+
# step :second
|
241
|
+
# step :third
|
242
|
+
# end
|
243
|
+
#
|
244
|
+
# my_transaction.remove(:first, :third)
|
245
|
+
#
|
246
|
+
# @param step [Symbol] the names of a step to remove
|
247
|
+
# @param ... [Symbol] more names of steps to remove
|
248
|
+
#
|
249
|
+
# @return [Dry::Transaction::Sequence] the modified transaction object
|
250
|
+
#
|
251
|
+
# @api public
|
252
|
+
def remove(*steps_to_remove)
|
253
|
+
self.class.new(steps.reject { |step| steps_to_remove.include?(step.step_name) })
|
254
|
+
end
|
255
|
+
|
256
|
+
private
|
257
|
+
|
258
|
+
def assert_valid_options(options)
|
259
|
+
options.each_key do |step_name|
|
260
|
+
unless steps.map(&:step_name).include?(step_name)
|
261
|
+
raise ArgumentError, "+#{step_name}+ is not a valid step name"
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
def assert_options_satisfy_step_arity(options)
|
267
|
+
steps.each do |step|
|
268
|
+
args_required = step.arity >= 0 ? step.arity : ~step.arity
|
269
|
+
args_supplied = options.fetch(step.step_name, []).length + 1 # add 1 for main `input`
|
270
|
+
|
271
|
+
if args_required > args_supplied
|
272
|
+
raise ArgumentError, "not enough options for step +#{step.step_name}+"
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
def steps_with_options_applied(options)
|
278
|
+
steps.map { |step|
|
279
|
+
if (args = options[step.step_name])
|
280
|
+
step.with_call_args(*args)
|
281
|
+
else
|
282
|
+
step
|
283
|
+
end
|
284
|
+
}
|
285
|
+
end
|
286
|
+
|
287
|
+
def accept_or_build_transaction(other_transaction = nil, **options, &block)
|
288
|
+
unless other_transaction || block
|
289
|
+
raise ArgumentError, "a transaction must be provided or defined in a block"
|
290
|
+
end
|
291
|
+
|
292
|
+
other_transaction || DSL.new(**options, &block).call
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|