dry-operation 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []