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 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 Version](https://badge.fury.io/rb/dry-operation.svg)][gem] [![Continuous Integration](https://github.com/dry-rb/dry-operation/actions/workflows/ci.yml/badge.svg)][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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class Operation
5
+ VERSION = "1.0.0"
6
+ end
7
+ 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: []