dry-operation 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +14 -0
- data/dry-operation.gemspec +31 -0
- data/lib/dry/operation/class_context/prepend_manager.rb +58 -0
- data/lib/dry/operation/class_context/steps_method_prepender.rb +57 -0
- data/lib/dry/operation/class_context.rb +72 -0
- data/lib/dry/operation/errors.rb +60 -0
- data/lib/dry/operation/extensions/active_record.rb +133 -0
- data/lib/dry/operation/extensions/rom.rb +125 -0
- data/lib/dry/operation/extensions/sequel.rb +111 -0
- data/lib/dry/operation/version.rb +7 -0
- data/lib/dry/operation.rb +208 -0
- metadata +91 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e5b39cd9624408d00958a649070927f493af015cd79d64815edeae30e2b82993
|
4
|
+
data.tar.gz: 9d12151f5a48051502dc88a7c7152819dd7a7baed354088c414f4e698e7ef28a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c57e54507bcf2f601a2f172a125da654f788b5481aa8d4b60e3025129cc6c88c270853947998ebf997fdae4f52833f6a06288115be9c3bd1678a34f4bc52f86f
|
7
|
+
data.tar.gz: 5baa6f1508f60b8bf64b3ea8409caec2fc58707e153373fbcecbd0dec6b2bcc8dc9e280d641d84501dbe52145c0be3237018fa99f05be958626aecdbf718081e
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015-2024 dry-rb team
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
[gem]: https://rubygems.org/gems/dry-operation
|
2
|
+
[actions]: https://github.com/dry-rb/dry-operation/actions
|
3
|
+
|
4
|
+
# dry-operation [][gem] [][actions]
|
5
|
+
|
6
|
+
## Links
|
7
|
+
|
8
|
+
* [User documentation](https://dry-rb.org/gems/dry-operation)
|
9
|
+
* [API documentation](http://rubydoc.info/gems/dry-operation)
|
10
|
+
* [Forum](https://discourse.dry-rb.org)
|
11
|
+
|
12
|
+
## License
|
13
|
+
|
14
|
+
See [`LICENSE`](LICENSE).
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
4
|
+
$LOAD_PATH.prepend(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require "dry/operation/version"
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = "dry-operation"
|
9
|
+
spec.version = Dry::Operation::VERSION
|
10
|
+
spec.authors = ["dry-rb team"]
|
11
|
+
spec.email = ["gems@dry-rb.org"]
|
12
|
+
spec.homepage = "https://dry-rb.org/gems/dry-operation"
|
13
|
+
spec.summary = "A domain specific language for composable business transaction workflows."
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.metadata = {
|
17
|
+
"bug_tracker_uri" => "https://github.com/dry-rb/dry-operation/issues",
|
18
|
+
"changelog_uri" => "https://github.com/dry-rb/dry-operation/blob/main/CHANGELOG.md",
|
19
|
+
"documentation_uri" => "https://dry-rb.org/gems/dry-operation",
|
20
|
+
"funding_uri" => "https://github.com/sponsors/hanami",
|
21
|
+
"label" => "dry-operation",
|
22
|
+
"source_code_uri" => "https://github.com/dry-rb/dry-operation"
|
23
|
+
}
|
24
|
+
|
25
|
+
spec.required_ruby_version = ">= 3.1.0"
|
26
|
+
spec.add_dependency "zeitwerk", "~> 2.6"
|
27
|
+
spec.add_dependency "dry-monads", "~> 1.6"
|
28
|
+
|
29
|
+
spec.extra_rdoc_files = Dir["README*", "LICENSE*"]
|
30
|
+
spec.files = Dir["*.gemspec", "lib/**/*"]
|
31
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry/operation/errors"
|
4
|
+
|
5
|
+
module Dry
|
6
|
+
class Operation
|
7
|
+
module ClassContext
|
8
|
+
# @api private
|
9
|
+
class PrependManager
|
10
|
+
def initialize(klass:, methods_to_prepend:, prepended_methods: [])
|
11
|
+
@klass = klass
|
12
|
+
@methods_to_prepend = methods_to_prepend
|
13
|
+
@prepended_methods = prepended_methods
|
14
|
+
end
|
15
|
+
|
16
|
+
def register(*methods)
|
17
|
+
ensure_pristine
|
18
|
+
|
19
|
+
already_defined_methods = methods & @klass.instance_methods(false)
|
20
|
+
if already_defined_methods.any?
|
21
|
+
raise MethodsToPrependAlreadyDefinedError.new(methods: already_defined_methods)
|
22
|
+
else
|
23
|
+
@methods_to_prepend = methods
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def void
|
28
|
+
ensure_pristine
|
29
|
+
|
30
|
+
@methods_to_prepend = []
|
31
|
+
end
|
32
|
+
|
33
|
+
def call(method:)
|
34
|
+
return self unless @methods_to_prepend.include?(method)
|
35
|
+
|
36
|
+
@klass.include(StepsMethodPrepender.new(method: method))
|
37
|
+
@prepended_methods += [method]
|
38
|
+
end
|
39
|
+
|
40
|
+
def for_subclass(subclass)
|
41
|
+
self.class.new(
|
42
|
+
klass: subclass,
|
43
|
+
methods_to_prepend: @methods_to_prepend.dup,
|
44
|
+
prepended_methods: []
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def ensure_pristine
|
51
|
+
return if @prepended_methods.empty?
|
52
|
+
|
53
|
+
raise PrependConfigurationError.new(methods: @prepended_methods)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry/operation/errors"
|
4
|
+
|
5
|
+
module Dry
|
6
|
+
class Operation
|
7
|
+
module ClassContext
|
8
|
+
# @api private
|
9
|
+
class StepsMethodPrepender < Module
|
10
|
+
FAILURE_HOOK_METHOD_NAME = :on_failure
|
11
|
+
|
12
|
+
RESULT_HANDLER = lambda do |instance, method, result|
|
13
|
+
return if result.success? ||
|
14
|
+
!(instance.methods + instance.private_methods).include?(
|
15
|
+
FAILURE_HOOK_METHOD_NAME
|
16
|
+
)
|
17
|
+
|
18
|
+
failure_hook = instance.method(FAILURE_HOOK_METHOD_NAME)
|
19
|
+
case failure_hook.arity
|
20
|
+
when 1
|
21
|
+
failure_hook.(result.failure)
|
22
|
+
when 2
|
23
|
+
failure_hook.(result.failure, method)
|
24
|
+
else
|
25
|
+
raise FailureHookArityError.new(hook: failure_hook)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize(method:, result_handler: RESULT_HANDLER)
|
30
|
+
super()
|
31
|
+
@method = method
|
32
|
+
@result_handler = result_handler
|
33
|
+
end
|
34
|
+
|
35
|
+
def included(klass)
|
36
|
+
klass.prepend(mod)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def mod
|
42
|
+
@module ||= Module.new.tap do |mod|
|
43
|
+
module_exec(@result_handler) do |result_handler|
|
44
|
+
mod.define_method(@method) do |*args, **kwargs, &block|
|
45
|
+
steps do
|
46
|
+
super(*args, **kwargs, &block)
|
47
|
+
end.tap do |result|
|
48
|
+
result_handler.(self, __method__, result)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dry
|
4
|
+
class Operation
|
5
|
+
# {Dry::Operation} class context
|
6
|
+
module ClassContext
|
7
|
+
# Default methods to be prepended unless changed via {.operate_on}
|
8
|
+
DEFAULT_METHODS_TO_PREPEND = [:call].freeze
|
9
|
+
|
10
|
+
# Configures the instance methods to be prepended
|
11
|
+
#
|
12
|
+
# The given methods will be prepended with a wrapper that calls {#steps}
|
13
|
+
# before calling the original method.
|
14
|
+
#
|
15
|
+
# This method must be called before defining any of the methods to be
|
16
|
+
# prepended or before prepending any other method.
|
17
|
+
#
|
18
|
+
# @param methods [Array<Symbol>] methods to prepend
|
19
|
+
# @raise [MethodsToPrependAlreadyDefinedError] if any of the methods have
|
20
|
+
# already been defined in self
|
21
|
+
# @raise [PrependConfigurationError] if there's already a prepended method
|
22
|
+
def operate_on(*methods)
|
23
|
+
@_prepend_manager.register(*methods)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Skips prepending any method
|
27
|
+
#
|
28
|
+
# This method must be called before any method is prepended.
|
29
|
+
#
|
30
|
+
# @raise [PrependConfigurationError] if there's already a prepended method
|
31
|
+
def skip_prepending
|
32
|
+
@_prepend_manager.void
|
33
|
+
end
|
34
|
+
|
35
|
+
# @api private
|
36
|
+
def inherited(klass)
|
37
|
+
super
|
38
|
+
if klass.superclass == Dry::Operation
|
39
|
+
ClassContext.directly_inherited(klass)
|
40
|
+
else
|
41
|
+
ClassContext.indirectly_inherited(klass)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# @api private
|
46
|
+
def self.directly_inherited(klass)
|
47
|
+
klass.extend(MethodAddedHook)
|
48
|
+
klass.instance_variable_set(
|
49
|
+
:@_prepend_manager,
|
50
|
+
PrependManager.new(klass: klass, methods_to_prepend: DEFAULT_METHODS_TO_PREPEND)
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
# @api private
|
55
|
+
def self.indirectly_inherited(klass)
|
56
|
+
klass.instance_variable_set(
|
57
|
+
:@_prepend_manager,
|
58
|
+
klass.superclass.instance_variable_get(:@_prepend_manager).for_subclass(klass)
|
59
|
+
)
|
60
|
+
end
|
61
|
+
|
62
|
+
# @api private
|
63
|
+
module MethodAddedHook
|
64
|
+
def method_added(method)
|
65
|
+
super
|
66
|
+
|
67
|
+
@_prepend_manager.call(method: method)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dry
|
4
|
+
class Operation
|
5
|
+
class Error < ::StandardError; end
|
6
|
+
|
7
|
+
# Methods to prepend have already been defined
|
8
|
+
class MethodsToPrependAlreadyDefinedError < Error
|
9
|
+
def initialize(methods:)
|
10
|
+
super <<~MSG
|
11
|
+
'.operate_on' must be called before the given methods are defined.
|
12
|
+
The following methods have already been defined: #{methods.join(", ")}
|
13
|
+
MSG
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Configuring prepending after a method has already been prepended
|
18
|
+
class PrependConfigurationError < Error
|
19
|
+
def initialize(methods:)
|
20
|
+
super <<~MSG
|
21
|
+
'.operate_on' and '.skip_prepending' can't be called after any methods\
|
22
|
+
in the class have already been prepended.
|
23
|
+
The following methods have already been prepended: #{methods.join(", ")}
|
24
|
+
MSG
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Missing dependency required by an extension
|
29
|
+
class MissingDependencyError < Error
|
30
|
+
def initialize(gem:, extension:)
|
31
|
+
super <<~MSG
|
32
|
+
To use the #{extension} extension, you first need to install the \
|
33
|
+
#{gem} gem. Please, add it to your Gemfile and run bundle install
|
34
|
+
MSG
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
class InvalidStepResultError < Error
|
39
|
+
def initialize(result:)
|
40
|
+
super <<~MSG
|
41
|
+
Your step must return `Success(..)` or `Failure(..)`, \
|
42
|
+
from `Dry::Monads::Result`. Instead, it was `#{result.inspect}`.
|
43
|
+
MSG
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# An error related to an extension
|
48
|
+
class ExtensionError < ::StandardError; end
|
49
|
+
|
50
|
+
# Defined failure hook has wrong arity
|
51
|
+
class FailureHookArityError < ::StandardError
|
52
|
+
def initialize(hook:)
|
53
|
+
super <<~MSG
|
54
|
+
##{hook.name} must accept 1 (failure) or 2 (failure, method name) \
|
55
|
+
arguments, but its arity is #{hook.arity}
|
56
|
+
MSG
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require "active_record"
|
5
|
+
rescue LoadError
|
6
|
+
raise Dry::Operation::MissingDependencyError.new(gem: "activerecord", extension: "ActiveRecord")
|
7
|
+
end
|
8
|
+
|
9
|
+
module Dry
|
10
|
+
class Operation
|
11
|
+
module Extensions
|
12
|
+
# Add ActiveRecord transaction support to operations
|
13
|
+
#
|
14
|
+
# When this extension is included, you can use a `#transaction` method
|
15
|
+
# to wrap the desired steps in an ActiveRecord transaction. If any of the steps
|
16
|
+
# returns a `Dry::Monads::Result::Failure`, the transaction will be rolled
|
17
|
+
# back and, as usual, the rest of the flow will be skipped.
|
18
|
+
#
|
19
|
+
# ```ruby
|
20
|
+
# require "dry/operation/extensions/active_record"
|
21
|
+
#
|
22
|
+
# class MyOperation < Dry::Operation
|
23
|
+
# include Dry::Operation::Extensions::ActiveRecord
|
24
|
+
#
|
25
|
+
# def call(input)
|
26
|
+
# attrs = step validate(input)
|
27
|
+
# user = transaction do
|
28
|
+
# new_user = step persist(attrs)
|
29
|
+
# step assign_initial_role(new_user)
|
30
|
+
# new_user
|
31
|
+
# end
|
32
|
+
# step notify(user)
|
33
|
+
# user
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# # ...
|
37
|
+
# end
|
38
|
+
# ```
|
39
|
+
#
|
40
|
+
# By default, the `ActiveRecord::Base` class will be used to initiate the transaction.
|
41
|
+
# You can change this when including the extension:
|
42
|
+
#
|
43
|
+
# ```ruby
|
44
|
+
# include Dry::Operation::Extensions::ActiveRecord[User]
|
45
|
+
# ```
|
46
|
+
#
|
47
|
+
# Or you can change it at runtime:
|
48
|
+
#
|
49
|
+
# ```ruby
|
50
|
+
# user = transaction(user) do
|
51
|
+
# # ...
|
52
|
+
# end
|
53
|
+
# ```
|
54
|
+
#
|
55
|
+
# This is useful when you use multiple databases with ActiveRecord.
|
56
|
+
#
|
57
|
+
# The extension can be initiated with default options for the transaction.
|
58
|
+
# It will be applied to all transactions:
|
59
|
+
#
|
60
|
+
# ```ruby
|
61
|
+
# include Dry::Operation::Extensions::ActiveRecord[requires_new: true]
|
62
|
+
# ```
|
63
|
+
#
|
64
|
+
# You can override these options at runtime:
|
65
|
+
#
|
66
|
+
# ```ruby
|
67
|
+
# transaction(requires_new: false) do
|
68
|
+
# # ...
|
69
|
+
# end
|
70
|
+
#
|
71
|
+
# WARNING: Be aware that the `:requires_new` option is not yet supported.
|
72
|
+
#
|
73
|
+
# @see https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html
|
74
|
+
# @see https://guides.rubyonrails.org/active_record_multiple_databases.html
|
75
|
+
module ActiveRecord
|
76
|
+
DEFAULT_CONNECTION = ::ActiveRecord::Base
|
77
|
+
|
78
|
+
def self.included(klass)
|
79
|
+
klass.include(self[])
|
80
|
+
end
|
81
|
+
|
82
|
+
# Include the extension providing a custom class/object to initialize the transaction
|
83
|
+
# and default options.
|
84
|
+
#
|
85
|
+
# @param connection [ActiveRecord::Base, #transaction] the class/object to use
|
86
|
+
# @param options [Hash] additional options for the ActiveRecord transaction
|
87
|
+
def self.[](connection = DEFAULT_CONNECTION, **options)
|
88
|
+
Builder.new(connection, **options)
|
89
|
+
end
|
90
|
+
|
91
|
+
# @api private
|
92
|
+
class Builder < Module
|
93
|
+
def initialize(connection, **options)
|
94
|
+
super()
|
95
|
+
@connection = connection
|
96
|
+
@options = options
|
97
|
+
end
|
98
|
+
|
99
|
+
def included(klass)
|
100
|
+
class_exec(@connection, @options) do |default_connection, options|
|
101
|
+
# @!method transaction(connection = ActiveRecord::Base, **options, &steps)
|
102
|
+
# Wrap the given steps in an ActiveRecord transaction.
|
103
|
+
#
|
104
|
+
# If any of the steps returns a `Dry::Monads::Result::Failure`, the
|
105
|
+
# transaction will be rolled back and `:halt` will be thrown with the
|
106
|
+
# failure as its value.
|
107
|
+
#
|
108
|
+
# @param connection [#transaction] The class/object to use
|
109
|
+
# @param options [Hash] Additional options for the ActiveRecord transaction
|
110
|
+
# @yieldreturn [Object] the result of the block
|
111
|
+
# @see Dry::Operation#steps
|
112
|
+
# @see https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-transaction
|
113
|
+
klass.define_method(:transaction) do |connection = default_connection, **opts, &steps|
|
114
|
+
intercepting_failure do
|
115
|
+
result = nil
|
116
|
+
connection.transaction(**options.merge(opts)) do
|
117
|
+
intercepting_failure(->(failure) {
|
118
|
+
result = failure
|
119
|
+
raise ::ActiveRecord::Rollback
|
120
|
+
}) do
|
121
|
+
result = steps.()
|
122
|
+
end
|
123
|
+
end
|
124
|
+
result
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require "rom-sql"
|
5
|
+
rescue LoadError
|
6
|
+
raise Dry::Operation::MissingDependencyError.new(gem: "rom-sql", extension: "ROM")
|
7
|
+
end
|
8
|
+
|
9
|
+
module Dry
|
10
|
+
class Operation
|
11
|
+
module Extensions
|
12
|
+
# Add rom transaction support to operations
|
13
|
+
#
|
14
|
+
# When this extension is included, you can use a `#transaction` method
|
15
|
+
# to wrap the desired steps in a rom transaction. If any of the steps
|
16
|
+
# returns a `Dry::Monads::Result::Failure`, the transaction will be rolled
|
17
|
+
# back and, as usual, the rest of the flow will be skipped.
|
18
|
+
#
|
19
|
+
# The extension expects the including class to give access to the rom
|
20
|
+
# container via a `#rom` method.
|
21
|
+
#
|
22
|
+
# ```ruby
|
23
|
+
# require "dry/operation/extensions/rom"
|
24
|
+
#
|
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
|
+
# @raise [Dry::Operation::ExtensionError] if the including
|
77
|
+
# class doesn't define a `#rom` method.
|
78
|
+
# @see Dry::Operation#steps
|
79
|
+
|
80
|
+
def self.included(klass)
|
81
|
+
klass.include(self[])
|
82
|
+
end
|
83
|
+
|
84
|
+
# Include the extension providing a custom gateway
|
85
|
+
#
|
86
|
+
# @param gateway [Symbol] the rom gateway to use
|
87
|
+
def self.[](gateway: DEFAULT_GATEWAY)
|
88
|
+
Builder.new(gateway: gateway)
|
89
|
+
end
|
90
|
+
|
91
|
+
# @api private
|
92
|
+
class Builder < Module
|
93
|
+
def initialize(gateway:)
|
94
|
+
super()
|
95
|
+
@gateway = gateway
|
96
|
+
end
|
97
|
+
|
98
|
+
def included(klass)
|
99
|
+
class_exec(@gateway) do |default_gateway|
|
100
|
+
klass.define_method(:transaction) do |gateway: default_gateway, &steps|
|
101
|
+
raise Dry::Operation::ExtensionError, <<~MSG unless respond_to?(:rom)
|
102
|
+
When using the ROM extension, you need to define a #rom method \
|
103
|
+
that returns the ROM container
|
104
|
+
MSG
|
105
|
+
|
106
|
+
intercepting_failure do
|
107
|
+
result = nil
|
108
|
+
rom.gateways[gateway].transaction do |t|
|
109
|
+
intercepting_failure(->(failure) {
|
110
|
+
result = failure
|
111
|
+
t.rollback!
|
112
|
+
}) do
|
113
|
+
result = steps.()
|
114
|
+
end
|
115
|
+
end
|
116
|
+
result
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require "sequel"
|
5
|
+
rescue LoadError
|
6
|
+
raise Dry::Operation::MissingDependencyError.new(gem: "sequel", extension: "Sequel")
|
7
|
+
end
|
8
|
+
|
9
|
+
module Dry
|
10
|
+
class Operation
|
11
|
+
module Extensions
|
12
|
+
# Add Sequel transaction support to operations
|
13
|
+
#
|
14
|
+
# When this extension is included, you can use a `#transaction` method
|
15
|
+
# to wrap the desired steps in a Sequel transaction. If any of the steps
|
16
|
+
# returns a `Dry::Monads::Result::Failure`, the transaction will be rolled
|
17
|
+
# back and, as usual, the rest of the flow will be skipped.
|
18
|
+
#
|
19
|
+
# The extension expects the including class to give access to the Sequel
|
20
|
+
# database object via a `#db` method.
|
21
|
+
#
|
22
|
+
# ```ruby
|
23
|
+
# class MyOperation < Dry::Operation
|
24
|
+
# include Dry::Operation::Extensions::Sequel
|
25
|
+
#
|
26
|
+
# attr_reader :db
|
27
|
+
#
|
28
|
+
# def initialize(db:)
|
29
|
+
# @db = db
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# def call(input)
|
33
|
+
# attrs = step validate(input)
|
34
|
+
# user = transaction do
|
35
|
+
# new_user = step persist(attrs)
|
36
|
+
# step assign_initial_role(new_user)
|
37
|
+
# new_user
|
38
|
+
# end
|
39
|
+
# step notify(user)
|
40
|
+
# user
|
41
|
+
# end
|
42
|
+
#
|
43
|
+
# # ...
|
44
|
+
# end
|
45
|
+
# ```
|
46
|
+
#
|
47
|
+
# By default, no options are passed to the Sequel transaction. You can
|
48
|
+
# change this when including the extension:
|
49
|
+
#
|
50
|
+
# ```ruby
|
51
|
+
# include Dry::Operation::Extensions::Sequel[isolation: :serializable]
|
52
|
+
# ```
|
53
|
+
#
|
54
|
+
# Or you can change it at runtime:
|
55
|
+
#
|
56
|
+
# ```ruby
|
57
|
+
# transaction(isolation: :serializable) do
|
58
|
+
# # ...
|
59
|
+
# end
|
60
|
+
# ```
|
61
|
+
#
|
62
|
+
# WARNING: Be aware that the `:savepoint` option is not yet supported.
|
63
|
+
#
|
64
|
+
# @see http://sequel.jeremyevans.net/rdoc/files/doc/transactions_rdoc.html
|
65
|
+
module Sequel
|
66
|
+
def self.included(klass)
|
67
|
+
klass.include(self[])
|
68
|
+
end
|
69
|
+
|
70
|
+
# Include the extension providing default options for the transaction.
|
71
|
+
#
|
72
|
+
# @param options [Hash] additional options for the Sequel transaction
|
73
|
+
def self.[](options = {})
|
74
|
+
Builder.new(**options)
|
75
|
+
end
|
76
|
+
|
77
|
+
# @api private
|
78
|
+
class Builder < Module
|
79
|
+
def initialize(**options)
|
80
|
+
super()
|
81
|
+
@options = options
|
82
|
+
end
|
83
|
+
|
84
|
+
def included(klass)
|
85
|
+
class_exec(@options) do |default_options|
|
86
|
+
klass.define_method(:transaction) do |**opts, &steps|
|
87
|
+
raise Dry::Operation::ExtensionError, <<~MSG unless respond_to?(:db)
|
88
|
+
When using the Sequel extension, you need to define a #db method \
|
89
|
+
that returns the Sequel database object
|
90
|
+
MSG
|
91
|
+
|
92
|
+
intercepting_failure do
|
93
|
+
result = nil
|
94
|
+
db.transaction(**default_options.merge(opts)) do
|
95
|
+
intercepting_failure(->(failure) {
|
96
|
+
result = failure
|
97
|
+
raise ::Sequel::Rollback
|
98
|
+
}) do
|
99
|
+
result = steps.()
|
100
|
+
end
|
101
|
+
end
|
102
|
+
result
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,208 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "zeitwerk"
|
4
|
+
require "dry/monads"
|
5
|
+
require "dry/operation/errors"
|
6
|
+
|
7
|
+
module Dry
|
8
|
+
# DSL for chaining operations that can fail
|
9
|
+
#
|
10
|
+
# {Dry::Operation} is a thin DSL wrapping dry-monads that allows you to chain
|
11
|
+
# operations by focusing on the happy path and short-circuiting on failure.
|
12
|
+
#
|
13
|
+
# The canonical way of using it is to subclass {Dry::Operation} and define
|
14
|
+
# your flow in the `#call` method. Individual operations can be called with
|
15
|
+
# {#step}. They need to return either a success or a failure result.
|
16
|
+
# Successful results will be automatically unwrapped, while a failure will
|
17
|
+
# stop further execution of the method.
|
18
|
+
#
|
19
|
+
# ```ruby
|
20
|
+
# class MyOperation < Dry::Operation
|
21
|
+
# def call(input)
|
22
|
+
# attrs = step validate(input)
|
23
|
+
# user = step persist(attrs)
|
24
|
+
# step notify(user)
|
25
|
+
# user
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# def validate(input)
|
29
|
+
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# def persist(attrs)
|
33
|
+
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# def notify(user)
|
37
|
+
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
|
38
|
+
# end
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# include Dry::Monads[:result]
|
42
|
+
#
|
43
|
+
# case MyOperation.new.call(input)
|
44
|
+
# in Success(user)
|
45
|
+
# puts "User #{user.name} created"
|
46
|
+
# in Failure[:invalid_input, validation_errors]
|
47
|
+
# puts "Invalid input: #{validation_errors}"
|
48
|
+
# in Failure(:database_error)
|
49
|
+
# puts "Database error"
|
50
|
+
# in Failure(:email_error)
|
51
|
+
# puts "Email error"
|
52
|
+
# end
|
53
|
+
# ```
|
54
|
+
#
|
55
|
+
# Under the hood, the `#call` method is decorated to allow skipping the rest
|
56
|
+
# of its execution when a failure is encountered. You can choose to use another
|
57
|
+
# method with {ClassContext#operate_on} (which also accepts a list of methods):
|
58
|
+
#
|
59
|
+
# ```ruby
|
60
|
+
# class MyOperation < Dry::Operation
|
61
|
+
# operate_on :run # or operate_on :run, :call
|
62
|
+
#
|
63
|
+
# def run(input)
|
64
|
+
# attrs = step validate(input)
|
65
|
+
# user = step persist(attrs)
|
66
|
+
# step notify(user)
|
67
|
+
# user
|
68
|
+
# end
|
69
|
+
#
|
70
|
+
# # ...
|
71
|
+
# end
|
72
|
+
# ```
|
73
|
+
#
|
74
|
+
# As you can see, the aforementioned behavior allows you to write your flow
|
75
|
+
# in a linear fashion. Failures are mostly handled locally by each individual
|
76
|
+
# operation. However, you can also define a global failure handler by defining
|
77
|
+
# an `#on_failure` method. It will be called with the wrapped failure value
|
78
|
+
# and, in the case of accepting a second argument, the name of the method that
|
79
|
+
# defined the flow:
|
80
|
+
#
|
81
|
+
# ```ruby
|
82
|
+
# class MyOperation < Dry::Operation
|
83
|
+
# def call(input)
|
84
|
+
# attrs = step validate(input)
|
85
|
+
# user = step persist(attrs)
|
86
|
+
# step notify(user)
|
87
|
+
# user
|
88
|
+
# end
|
89
|
+
#
|
90
|
+
# def on_failure(user) # or def on_failure(failure_value, method_name)
|
91
|
+
# log_failure(user)
|
92
|
+
# end
|
93
|
+
# end
|
94
|
+
# ```
|
95
|
+
#
|
96
|
+
# You can opt out altogether of this behavior via {ClassContext#skip_prepending}. If so,
|
97
|
+
# you manually need to wrap your flow within the {#steps} method and manually
|
98
|
+
# handle global failures.
|
99
|
+
#
|
100
|
+
# ```ruby
|
101
|
+
# class MyOperation < Dry::Operation
|
102
|
+
# skip_prepending
|
103
|
+
#
|
104
|
+
# def call(input)
|
105
|
+
# steps do
|
106
|
+
# attrs = step validate(input)
|
107
|
+
# user = step persist(attrs)
|
108
|
+
# step notify(user)
|
109
|
+
# user
|
110
|
+
# end.tap do |result|
|
111
|
+
# log_failure(result.failure) if result.failure?
|
112
|
+
# end
|
113
|
+
# end
|
114
|
+
#
|
115
|
+
# # ...
|
116
|
+
# end
|
117
|
+
# ```
|
118
|
+
#
|
119
|
+
# The behavior configured by {ClassContext#operate_on} and {ClassContext#skip_prepending} is
|
120
|
+
# inherited by subclasses.
|
121
|
+
#
|
122
|
+
# Some extensions are available under the `Dry::Operation::Extensions`
|
123
|
+
# namespace, providing additional functionality that can be included in your
|
124
|
+
# operation classes.
|
125
|
+
class Operation
|
126
|
+
def self.loader
|
127
|
+
@loader ||= Zeitwerk::Loader.new.tap do |loader|
|
128
|
+
root = File.expand_path "..", __dir__
|
129
|
+
loader.inflector = Zeitwerk::GemInflector.new("#{root}/dry/operation.rb")
|
130
|
+
loader.tag = "dry-operation"
|
131
|
+
loader.push_dir root
|
132
|
+
loader.ignore(
|
133
|
+
"#{root}/dry/operation/errors.rb",
|
134
|
+
"#{root}/dry/operation/extensions/*.rb"
|
135
|
+
)
|
136
|
+
loader.inflector.inflect("rom" => "ROM")
|
137
|
+
end
|
138
|
+
end
|
139
|
+
loader.setup
|
140
|
+
|
141
|
+
FAILURE_TAG = :halt
|
142
|
+
private_constant :FAILURE_TAG
|
143
|
+
|
144
|
+
extend ClassContext
|
145
|
+
include Dry::Monads::Result::Mixin
|
146
|
+
|
147
|
+
# Wraps block's return value in a {Dry::Monads::Result::Success}
|
148
|
+
#
|
149
|
+
# Catches `:halt` and returns it
|
150
|
+
#
|
151
|
+
# @yieldreturn [Object]
|
152
|
+
# @return [Dry::Monads::Result::Success]
|
153
|
+
# @see #step
|
154
|
+
def steps(&block)
|
155
|
+
catching_failure { Success(block.call) }
|
156
|
+
end
|
157
|
+
|
158
|
+
# Unwraps a {Dry::Monads::Result::Success}
|
159
|
+
#
|
160
|
+
# Throws `:halt` with a {Dry::Monads::Result::Failure} on failure.
|
161
|
+
#
|
162
|
+
# @param result [Dry::Monads::Result]
|
163
|
+
# @return [Object] wrapped value
|
164
|
+
# @see #steps
|
165
|
+
def step(result)
|
166
|
+
if result.is_a?(Dry::Monads::Result)
|
167
|
+
result.value_or { throw_failure(result) }
|
168
|
+
else
|
169
|
+
raise InvalidStepResultError.new(result: result)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Invokes a callable in case of block's failure
|
174
|
+
#
|
175
|
+
# This method is useful when you want to perform some side-effect when a
|
176
|
+
# failure is encountered. It's meant to be used within the {#steps} block
|
177
|
+
# commonly wrapping a sub-set of {#step} calls.
|
178
|
+
#
|
179
|
+
# @param handler [#call] a callable that will be called with the encountered failure.
|
180
|
+
# By default, it throws `FAILURE_TAG` with the failure.
|
181
|
+
# @yieldreturn [Object]
|
182
|
+
# @return [Object] the block's return value when it's not a failure or the handler's
|
183
|
+
# return value when the block returns a failure
|
184
|
+
def intercepting_failure(handler = method(:throw_failure), &block)
|
185
|
+
output = catching_failure(&block)
|
186
|
+
|
187
|
+
case output
|
188
|
+
when Failure
|
189
|
+
handler.(output)
|
190
|
+
else
|
191
|
+
output
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
# Throws `:halt` with a failure
|
196
|
+
#
|
197
|
+
# @param failure [Dry::Monads::Result::Failure]
|
198
|
+
def throw_failure(failure)
|
199
|
+
throw FAILURE_TAG, failure
|
200
|
+
end
|
201
|
+
|
202
|
+
private
|
203
|
+
|
204
|
+
def catching_failure(&block)
|
205
|
+
catch(FAILURE_TAG, &block)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
metadata
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dry-operation
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- dry-rb team
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-11-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: zeitwerk
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.6'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.6'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: dry-monads
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.6'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.6'
|
41
|
+
description:
|
42
|
+
email:
|
43
|
+
- gems@dry-rb.org
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files:
|
47
|
+
- README.md
|
48
|
+
- LICENSE
|
49
|
+
files:
|
50
|
+
- LICENSE
|
51
|
+
- README.md
|
52
|
+
- dry-operation.gemspec
|
53
|
+
- lib/dry/operation.rb
|
54
|
+
- lib/dry/operation/class_context.rb
|
55
|
+
- lib/dry/operation/class_context/prepend_manager.rb
|
56
|
+
- lib/dry/operation/class_context/steps_method_prepender.rb
|
57
|
+
- lib/dry/operation/errors.rb
|
58
|
+
- lib/dry/operation/extensions/active_record.rb
|
59
|
+
- lib/dry/operation/extensions/rom.rb
|
60
|
+
- lib/dry/operation/extensions/sequel.rb
|
61
|
+
- lib/dry/operation/version.rb
|
62
|
+
homepage: https://dry-rb.org/gems/dry-operation
|
63
|
+
licenses:
|
64
|
+
- MIT
|
65
|
+
metadata:
|
66
|
+
bug_tracker_uri: https://github.com/dry-rb/dry-operation/issues
|
67
|
+
changelog_uri: https://github.com/dry-rb/dry-operation/blob/main/CHANGELOG.md
|
68
|
+
documentation_uri: https://dry-rb.org/gems/dry-operation
|
69
|
+
funding_uri: https://github.com/sponsors/hanami
|
70
|
+
label: dry-operation
|
71
|
+
source_code_uri: https://github.com/dry-rb/dry-operation
|
72
|
+
post_install_message:
|
73
|
+
rdoc_options: []
|
74
|
+
require_paths:
|
75
|
+
- lib
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
77
|
+
requirements:
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: 3.1.0
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - ">="
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
requirements: []
|
87
|
+
rubygems_version: 3.5.22
|
88
|
+
signing_key:
|
89
|
+
specification_version: 4
|
90
|
+
summary: A domain specific language for composable business transaction workflows.
|
91
|
+
test_files: []
|