cuprum 0.10.0 → 0.11.0.rc.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.
@@ -7,12 +7,12 @@ module Cuprum::Errors
7
7
  # Error class to be used when trying to access the result of an uncalled
8
8
  # Operation.
9
9
  class OperationNotCalled < Cuprum::Error
10
- COMPARABLE_PROPERTIES = %i[operation].freeze
11
- private_constant :COMPARABLE_PROPERTIES
12
-
13
10
  # Format for generating error message.
14
11
  MESSAGE_FORMAT = '%s was not called and does not have a result'
15
12
 
13
+ # Short string used to identify the type of error.
14
+ TYPE = 'cuprum.errors.operation_not_called'
15
+
16
16
  # @param operation [Cuprum::Operation] The uncalled operation.
17
17
  def initialize(operation:)
18
18
  @operation = operation
@@ -20,7 +20,7 @@ module Cuprum::Errors
20
20
  class_name = operation&.class&.name || 'operation'
21
21
  message = MESSAGE_FORMAT % class_name
22
22
 
23
- super(message: message)
23
+ super(message: message, operation: operation)
24
24
  end
25
25
 
26
26
  # @return [Cuprum::Operation] The uncalled operation.
@@ -28,8 +28,8 @@ module Cuprum::Errors
28
28
 
29
29
  private
30
30
 
31
- def comparable_properties
32
- COMPARABLE_PROPERTIES
31
+ def as_json_data
32
+ operation ? { 'class_name' => operation.class.name } : {}
33
33
  end
34
34
  end
35
35
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/error'
4
+ require 'cuprum/errors'
5
+
6
+ module Cuprum::Errors
7
+ # An error returned when a command encounters an unhandled exception.
8
+ class UncaughtException < Cuprum::Error
9
+ # Short string used to identify the type of error.
10
+ TYPE = 'cuprum.collections.errors.uncaught_exception'
11
+
12
+ # @param exception [StandardError] The exception that was raised.
13
+ # @param message [String] A message to display. Will be annotated with
14
+ # details on the exception and the exception's cause (if any).
15
+ def initialize(exception:, message: 'uncaught exception')
16
+ @exception = exception
17
+ @cause = exception.cause
18
+
19
+ super(message: generate_message(message))
20
+ end
21
+
22
+ # @return [StandardError] the exception that was raised.
23
+ attr_reader :exception
24
+
25
+ private
26
+
27
+ attr_reader :cause
28
+
29
+ def as_json_data # rubocop:disable Metrics/MethodLength
30
+ data = {
31
+ 'exception_backtrace' => exception.backtrace,
32
+ 'exception_class' => exception.class,
33
+ 'exception_message' => exception.message
34
+ }
35
+
36
+ return data unless cause
37
+
38
+ data.update(
39
+ {
40
+ 'cause_backtrace' => cause.backtrace,
41
+ 'cause_class' => cause.class,
42
+ 'cause_message' => cause.message
43
+ }
44
+ )
45
+ end
46
+
47
+ def generate_message(message)
48
+ message = "#{message} #{exception.class}: #{exception.message}"
49
+
50
+ return message unless cause
51
+
52
+ message + " caused by #{cause.class}: #{cause.message}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/errors/uncaught_exception'
4
+
5
+ module Cuprum
6
+ # Utility class for handling uncaught exceptions in commands.
7
+ #
8
+ # @example
9
+ # class UnsafeCommand < Cuprum::Command
10
+ # private
11
+ #
12
+ # def process
13
+ # raise 'Something went wrong.'
14
+ # end
15
+ # end
16
+ #
17
+ # class SafeCommand < UnsafeCommand
18
+ # include Cuprum::ExceptionHandling
19
+ # end
20
+ #
21
+ # UnsafeCommand.new.call
22
+ # #=> raises a StandardError
23
+ #
24
+ # result = SafeCommand.new.call
25
+ # #=> a Cuprum::Result
26
+ # result.error
27
+ # #=> a Cuprum::Errors::UncaughtException error.
28
+ # result.error.message
29
+ # #=> 'uncaught exception in SafeCommand -' \
30
+ # ' StandardError: Something went wrong.'
31
+ module ExceptionHandling
32
+ # Wraps the #call method with a rescue clause matching any StandardError.
33
+ #
34
+ # If a StandardError or subclass thereof is raised and not caught by #call,
35
+ # then ExceptionHandling will rescue the exception and return a failing
36
+ # Cuprum::Result with a Cuprum::Errors::UncaughtException error.
37
+ #
38
+ # @return [Cuprum::Result] the result of calling the superclass method, or
39
+ # a failing result if a StandardError is raised.
40
+ def call(*args, **kwargs, &block)
41
+ super
42
+ rescue StandardError => exception
43
+ error = Cuprum::Errors::UncaughtException.new(
44
+ exception: exception,
45
+ message: "uncaught exception in #{self.class.name} - "
46
+ )
47
+ failure(error)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum'
4
+ require 'cuprum/matching'
5
+
6
+ module Cuprum
7
+ # Provides result matching based on result status, error, and value.
8
+ #
9
+ # First, define match clauses using the .match DSL. Each match clause has a
10
+ # status and optionally a value class and/or error class. A result will only
11
+ # match the clause if the result status is the same as the clause's status.
12
+ # If the clause sets a value class, then the result value must be an instance
13
+ # of that class (or an instance of a subclass). If the clause sets an error
14
+ # class, then the result error must be an instance of that class (or an
15
+ # instance of a subclass).
16
+ #
17
+ # Once the matcher defines one or more match clauses, call #call with a result
18
+ # to match the result. The matcher will determine the best match with the same
19
+ # status (value and error match the result, only value or error match, or just
20
+ # status matches) and then call the match clause with the result. If no match
21
+ # clauses match the result, the matcher will instead raise a
22
+ # Cuprum::Matching::NoMatchError.
23
+ #
24
+ # @example Matching A Status
25
+ # matcher = Cuprum::Matcher.new do
26
+ # match(:failure) { 'Something went wrong' }
27
+ #
28
+ # match(:success) { 'Ok' }
29
+ # end
30
+ #
31
+ # matcher.call(Cuprum::Result.new(status: :failure))
32
+ # #=> 'Something went wrong'
33
+ #
34
+ # matcher.call(Cuprum::Result.new(status: :success))
35
+ # #=> 'Ok'
36
+ #
37
+ # @example Matching An Error
38
+ # matcher = Cuprum::Matcher.new do
39
+ # match(:failure) { 'Something went wrong' }
40
+ #
41
+ # match(:failure, error: CustomError) { |result| result.error.message }
42
+ #
43
+ # match(:success) { 'Ok' }
44
+ # end
45
+ #
46
+ # matcher.call(Cuprum::Result.new(status: :failure))
47
+ # #=> 'Something went wrong'
48
+ #
49
+ # error = CustomError.new(message: 'The magic smoke is escaping.')
50
+ # matcher.call(Cuprum::Result.new(error: error))
51
+ # #=> 'The magic smoke is escaping.'
52
+ #
53
+ # @example Using A Match Context
54
+ # context = Struct.new(:name).new('programs')
55
+ # matcher = Cuprum::Matcher.new(context) do
56
+ # match(:failure) { 'Something went wrong' }
57
+ #
58
+ # match(:success) { "Greetings, #{name}!" }
59
+ # end
60
+ #
61
+ # matcher.call(Cuprum::Result.new(status: :success)
62
+ # #=> 'Greetings, programs!'
63
+ class Matcher
64
+ include Cuprum::Matching
65
+
66
+ # @param match_context [Object] the execution context for a matching clause.
67
+ #
68
+ # @yield Executes the block in the context of the singleton class. This is
69
+ # used to define match clauses when instantiating a Matcher instance.
70
+ def initialize(match_context = nil, &block)
71
+ @match_context = match_context
72
+
73
+ singleton_class.instance_exec(&block) if block_given?
74
+ end
75
+
76
+ # Returns a copy of the matcher with the given execution context.
77
+ #
78
+ # @param match_context [Object] the execution context for a matching clause.
79
+ #
80
+ # @return [Cuprum::Matcher] the copied matcher.
81
+ def with_context(match_context)
82
+ clone.tap { |copy| copy.match_context = match_context }
83
+ end
84
+ alias_method :using_context, :with_context
85
+
86
+ protected
87
+
88
+ attr_writer :match_context
89
+ end
90
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum'
4
+
5
+ module Cuprum
6
+ # Handles matching a result against an ordered list of matchers.
7
+ #
8
+ # A MatcherList should be used when you have a series of matchers with a
9
+ # defined priority ordering. Within that ordering, the list will check for the
10
+ # most specific matching clause in each of the matchers. A clause matching
11
+ # both the value and error will match first, followed by a clause matching
12
+ # only the result value or error, and finally a clause matching only the
13
+ # result status will match. If none of the matchers have a clause that matches
14
+ # the result, a Cuprum::Matching::NoMatchError will be raised.
15
+ #
16
+ # @example Using A MatcherList
17
+ # generic_matcher = Cuprum::Matcher.new do
18
+ # match(:failure) { 'generic failure' }
19
+ #
20
+ # match(:failure, error: CustomError) { 'custom failure' }
21
+ # end
22
+ # specific_matcher = Cuprum::Matcher.new do
23
+ # match(:failure, error: Cuprum::Error) { 'specific failure' }
24
+ # end
25
+ # matcher_list = Cuprum::MatcherList.new(
26
+ # [
27
+ # specific_matcher,
28
+ # generic_matcher
29
+ # ]
30
+ # )
31
+ #
32
+ # # A failure without an error does not match the first matcher, so the
33
+ # # matcher list continues on to the next matcher in the list.
34
+ # result = Cuprum::Result.new(status: :failure)
35
+ # matcher_list.call(result)
36
+ # #=> 'generic failure'
37
+ #
38
+ # # A failure with an error matches the first matcher.
39
+ # error = Cuprum::Error.new(message: 'Something went wrong.')
40
+ # result = Cuprum::Result.new(error: error)
41
+ # matcher_list.call(result)
42
+ # #=> 'specific failure'
43
+ #
44
+ # # A failure with an error subclass still matches the first matcher, even
45
+ # # though the second matcher has a more exact match.
46
+ # error = CustomError.new(message: 'The magic smoke is escaping.')
47
+ # result = Cuprum::Result.new(error: error)
48
+ # matcher_list.call(result)
49
+ # #=> 'specific failure'
50
+ class MatcherList
51
+ # @param matchers [Array<Cuprum::Matching>] The matchers to match against a
52
+ # result, in order of descending priority.
53
+ def initialize(matchers)
54
+ @matchers = matchers
55
+ end
56
+
57
+ # @return [Array<Cuprum::Matching>] the matchers to match against a result.
58
+ attr_reader :matchers
59
+
60
+ # Finds and executes the best matching clause from the ordered matchers.
61
+ #
62
+ # When given a result, the matcher list will check through each of the
63
+ # matchers in the order they were given for match clauses that match the
64
+ # result. Each matcher is checked for a clause that matches the status,
65
+ # error, and value of the result. If no matching clause is found, the
66
+ # matchers are then checked for a clause matching the status and either the
67
+ # error or value of the result. Finally, if there are still no matching
68
+ # clauses, the matchers are checked for a clause that matches the result
69
+ # status.
70
+ #
71
+ # Once a matching clause is found, that clause is then called with the
72
+ # given result.
73
+ #
74
+ # If none of the matchers have a clause that matches the result, a
75
+ # Cuprum::Matching::NoMatchError will be raised.
76
+ #
77
+ # @param result [Cuprum::Result] The result to match.
78
+ #
79
+ # @raise Cuprum::Matching::NoMatchError if none of the matchers match the
80
+ # given result.
81
+ #
82
+ # @see Cuprum::Matcher#call.
83
+ def call(result)
84
+ unless result.respond_to?(:to_cuprum_result)
85
+ raise ArgumentError, 'result must be a Cuprum::Result'
86
+ end
87
+
88
+ result = result.to_cuprum_result
89
+ matcher = matcher_for(result)
90
+
91
+ return matcher.call(result) if matcher
92
+
93
+ raise Cuprum::Matching::NoMatchError,
94
+ "no match found for #{result.inspect}"
95
+ end
96
+
97
+ private
98
+
99
+ def error_match?(matcher:, result:)
100
+ matcher.matches?(
101
+ result.status,
102
+ error: result.error&.class
103
+ )
104
+ end
105
+
106
+ def exact_match?(matcher:, result:)
107
+ matcher.matches?(
108
+ result.status,
109
+ error: result.error&.class,
110
+ value: result.value&.class
111
+ )
112
+ end
113
+
114
+ def find_exact_match(result)
115
+ matchers.find do |matcher|
116
+ exact_match?(matcher: matcher, result: result)
117
+ end
118
+ end
119
+
120
+ def find_generic_match(result)
121
+ matchers.find do |matcher|
122
+ generic_match?(matcher: matcher, result: result)
123
+ end
124
+ end
125
+
126
+ def find_partial_match(result)
127
+ matchers.find do |matcher|
128
+ error_match?(matcher: matcher, result: result) ||
129
+ value_match?(matcher: matcher, result: result)
130
+ end
131
+ end
132
+
133
+ def generic_match?(matcher:, result:)
134
+ matcher.matches?(result.status)
135
+ end
136
+
137
+ def matcher_for(result)
138
+ find_exact_match(result) ||
139
+ find_partial_match(result) ||
140
+ find_generic_match(result)
141
+ end
142
+
143
+ def value_match?(matcher:, result:)
144
+ matcher.matches?(
145
+ result.status,
146
+ value: result.value&.class
147
+ )
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum'
4
+
5
+ module Cuprum
6
+ # Implements result matching based on result status, error, and value.
7
+ #
8
+ # @see Cuprum::Matcher.
9
+ module Matching
10
+ autoload :MatchClause, 'cuprum/matching/match_clause'
11
+
12
+ # Class methods extend-ed into a class when the module is included.
13
+ module ClassMethods
14
+ # Defines a match clause for the matcher.
15
+ #
16
+ # @param status [Symbol] The status to match. The clause will match a
17
+ # result only if the result has the same status as the match clause.
18
+ # @param error [Class] The type of error to match. If given, the clause
19
+ # will match a result only if the result error is an instance of the
20
+ # given class, or an instance of a subclass.
21
+ # @param value [Class] The type of value to match. If given, the clause
22
+ # will match a result only if the result value is an instance of the
23
+ # given class, or an instance of a subclass.
24
+ #
25
+ # @yield The code to execute on a successful match.
26
+ # @yieldparam result [Cuprum::Result] The matched result.
27
+ def match(status, error: nil, value: nil, &block)
28
+ validate_status!(status)
29
+ validate_error!(error)
30
+ validate_value!(value)
31
+
32
+ clause = MatchClause.new(block, error, status, value)
33
+ clauses = match_clauses[status]
34
+ index = clauses.bsearch_index { |item| clause <= item } || -1
35
+
36
+ # Clauses are sorted from most specific to least specific.
37
+ clauses.insert(index, clause)
38
+ end
39
+
40
+ # @private
41
+ def match_result(result:)
42
+ status_clauses(result.status).find do |clause|
43
+ clause.matches_result?(result: result)
44
+ end
45
+ end
46
+
47
+ # @private
48
+ def matches_result?(result:)
49
+ status_clauses(result.status).reverse_each.any? do |clause|
50
+ clause.matches_result?(result: result)
51
+ end
52
+ end
53
+
54
+ # @private
55
+ def matches_status?(error:, status:, value:)
56
+ status_clauses(status).reverse_each.any? do |clause|
57
+ clause.matches_details?(error: error, value: value)
58
+ end
59
+ end
60
+
61
+ protected
62
+
63
+ def match_clauses
64
+ @match_clauses ||= Hash.new { |hsh, key| hsh[key] = [] }
65
+ end
66
+
67
+ private
68
+
69
+ def status_clauses(status)
70
+ ancestors
71
+ .select { |ancestor| ancestor < Cuprum::Matching }
72
+ .map { |ancestor| ancestor.match_clauses[status] }
73
+ .reduce([], &:concat)
74
+ .sort
75
+ end
76
+
77
+ def validate_error!(error)
78
+ return if error.nil? || error.is_a?(Module)
79
+
80
+ raise ArgumentError,
81
+ 'error must be a Class or Module',
82
+ caller(1..-1)
83
+ end
84
+
85
+ def validate_status!(status)
86
+ if status.nil? || status.to_s.empty?
87
+ raise ArgumentError, "status can't be blank", caller(1..-1)
88
+ end
89
+
90
+ return if status.is_a?(Symbol)
91
+
92
+ raise ArgumentError, 'status must be a Symbol', caller(1..-1)
93
+ end
94
+
95
+ def validate_value!(value)
96
+ return if value.nil? || value.is_a?(Module)
97
+
98
+ raise ArgumentError,
99
+ 'value must be a Class or Module',
100
+ caller(1..-1)
101
+ end
102
+ end
103
+
104
+ # Exception raised when the matcher does not match a result.
105
+ class NoMatchError < StandardError; end
106
+
107
+ class << self
108
+ private
109
+
110
+ def included(other)
111
+ super
112
+
113
+ other.extend(ClassMethods)
114
+ end
115
+ end
116
+
117
+ # @return [Object, nil] the execution context for a matching clause.
118
+ attr_reader :match_context
119
+
120
+ # Finds the match clause matching the result and calls the stored block.
121
+ #
122
+ # Match clauses are defined using the .match DSL. When a result is matched,
123
+ # the defined clauses matching the result status are checked in descending
124
+ # order of specificity:
125
+ #
126
+ # - Clauses that expect both a value and an error.
127
+ # - Clauses that expect a value.
128
+ # - Clauses that expect an error.
129
+ # - Clauses that do not expect a value or an error.
130
+ #
131
+ # If there are multiple clauses that expect a value or an error, they are
132
+ # sorted by inheritance - a clause with a subclass value or error is checked
133
+ # before the clause with the parent class.
134
+ #
135
+ # Using that ordering, each potential clause is checked for a match with the
136
+ # result. If the clause defines a value, then the result will match the
137
+ # clause only if the result value is an instance of the expected value (or
138
+ # an instance of a subclass). Likewise, if the clause defines an error, then
139
+ # the result will match the clause only if the result error is an instance
140
+ # of the expected error class (or an instance of a subclass). Clauses that
141
+ # do not define either a value nor an error will match with any result with
142
+ # the same status, but as the least specific are always matched last.
143
+ #
144
+ # Matchers can also inherit clauses from a parent class or from an included
145
+ # module. Inherited or included clauses are checked after clauses defined on
146
+ # the matcher itself, so the matcher can override generic matches with more
147
+ # specific functionality.
148
+ #
149
+ # Finally, once the most specific matching clause is found, #call will
150
+ # call the block used to define the clause. If the block takes at least one
151
+ # argument, the result will be passed to the block; otherwise, it will be
152
+ # called with no parameters. If there is no clause matching the result,
153
+ # #call will instead raise a Cuprum::Matching::NoMatchError.
154
+ #
155
+ # The match clause is executed in the context of the matcher object. This
156
+ # allows instance methods defined for the matcher to be called as part of
157
+ # the match clause block. If the matcher defines a non-nil
158
+ # #matching_context, the block is instead executed in the context of the
159
+ # matching_context using #instance_exec.
160
+ #
161
+ # @param result [Cuprum::Result] The result to match.
162
+ #
163
+ # @return [Object] the value returned by the stored block.
164
+ #
165
+ # @raise [NoMatchError] if there is no clause matching the result.
166
+ #
167
+ # @see ClassMethods::match
168
+ # @see #match_context
169
+ def call(result)
170
+ unless result.respond_to?(:to_cuprum_result)
171
+ raise ArgumentError, 'result must be a Cuprum::Result'
172
+ end
173
+
174
+ result = result.to_cuprum_result
175
+ clause = singleton_class.match_result(result: result)
176
+
177
+ raise NoMatchError, "no match found for #{result.inspect}" if clause.nil?
178
+
179
+ call_match(block: clause.block, result: result)
180
+ end
181
+
182
+ # @return [Boolean] true if an execution context is defined for a matching
183
+ # clause; otherwise false.
184
+ def match_context?
185
+ !match_context.nil?
186
+ end
187
+
188
+ # @overload matches?(result)
189
+ # Checks if the matcher has any match clauses that match the given result.
190
+ #
191
+ # @param result [Cuprum::Result] The result to match.
192
+ #
193
+ # @return [Boolean] true if the matcher has at least one match clause that
194
+ # matches the result; otherwise false.
195
+ #
196
+ # @overload matches?(status, error: nil, value: nil)
197
+ # Checks if the matcher has any clauses matching the status and details.
198
+ #
199
+ # @param status [Symbol] The status to match.
200
+ # @param error [Class, nil] The class of error to match, if any.
201
+ # @param value [Class, nil] The class of value to match, if any.
202
+ #
203
+ # @return [Boolean] true if the matcher has at least one match clause that
204
+ # matches the status and details; otherwise false.
205
+ def matches?(result_or_status, error: nil, value: nil) # rubocop:disable Metrics/MethodLength
206
+ if result_or_status.respond_to?(:to_cuprum_result)
207
+ raise ArgumentError, 'error defined by result' unless error.nil?
208
+ raise ArgumentError, 'value defined by result' unless value.nil?
209
+
210
+ return singleton_class.matches_result?(
211
+ result: result_or_status.to_cuprum_result
212
+ )
213
+ elsif result_or_status.is_a?(Symbol)
214
+ return singleton_class.matches_status?(
215
+ error: error,
216
+ status: result_or_status,
217
+ value: value
218
+ )
219
+ end
220
+
221
+ raise ArgumentError, 'argument must be a result or a status'
222
+ end
223
+
224
+ private
225
+
226
+ def call_match(block:, result:)
227
+ args = block.arity.zero? ? [] : [result]
228
+
229
+ (match_context || self).instance_exec(*args, &block)
230
+ end
231
+ end
232
+ end