cuprum 0.8.0 → 0.10.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.
@@ -1,113 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'cuprum'
2
4
 
3
5
  module Cuprum
4
- # Helper methods that delegate result methods to the currently processed
5
- # result.
6
- #
7
- # @example
8
- # class LogCommand
9
- # include Cuprum::Processing
10
- # include Cuprum::ResultHelpers
11
- #
12
- # private
13
- #
14
- # def process log
15
- # case log[:level]
16
- # when 'fatal'
17
- # halt!
18
- #
19
- # 'error'
20
- # when 'error' && log[:message]
21
- # errors << message
22
- #
23
- # 'error'
24
- # when 'error'
25
- # failure!
26
- #
27
- # 'error'
28
- # else
29
- # 'ok'
30
- # end # case
31
- # end # method process
32
- # end # class
33
- #
34
- # result = LogCommand.new.call(:level => 'info')
35
- # result.success? #=> true
36
- #
37
- # string = 'something went wrong'
38
- # result = LogCommand.new.call(:level => 'error', :message => string)
39
- # result.success? #=> false
40
- # result.errors #=> ['something went wrong']
41
- #
42
- # result = LogCommand.new.call(:level => 'error')
43
- # result.success? #=> false
44
- # result.errors #=> []
45
- #
46
- # result = LogCommand.new.call(:level => 'fatal')
47
- # result.halted? #=> true
48
- #
49
- # @see Cuprum::Command
6
+ # Helper methods for generating Cuprum result objects.
50
7
  module ResultHelpers
51
8
  private
52
9
 
53
- # @!visibility public
54
- #
55
- # Provides a reference to the current result's errors object. Messages or
56
- # error objects added to this will be included in the #errors method of the
57
- # returned result object.
58
- #
59
- # @return [Array, Object] The errors object.
60
- #
61
- # @see Cuprum::Result#errors.
62
- #
63
- # @note This is a private method, and only available when executing the
64
- # command implementation as defined in the constructor block or the
65
- # #process method.
66
- def errors
67
- result&.errors
68
- end # method errors
69
-
70
- # @!visibility public
71
- #
72
- # Marks the current result as failed. Calling #failure? on the returned
73
- # result object will evaluate to true, whether or not the result has any
74
- # errors.
75
- #
76
- # @see Cuprum::Result#failure!.
77
- #
78
- # @note This is a private method, and only available when executing the
79
- # command implementation as defined in the constructor block or the
80
- # #process method.
81
- def failure!
82
- result&.failure!
83
- end # method failure!
10
+ def build_result(error: nil, status: nil, value: nil)
11
+ Cuprum::Result.new(error: error, status: status, value: value)
12
+ end
84
13
 
85
- # @!visibility public
86
- #
87
- # Marks the current result as halted.
88
- #
89
- # @see Cuprum::Result#halt!.
90
- #
91
- # @note This is a private method, and only available when executing the
92
- # command implementation as defined in the constructor block or the
93
- # #process method.
94
- def halt!
95
- result&.halt!
96
- end # method halt!
14
+ def failure(error)
15
+ build_result(error: error)
16
+ end
97
17
 
98
- # @!visibility public
99
- #
100
- # Marks the current result as passing. Calling #success? on the returned
101
- # result object will evaluate to true, whether or not the result has any
102
- # errors.
103
- #
104
- # @see Cuprum::Result#success!.
105
- #
106
- # @note This is a private method, and only available when executing the
107
- # command implementation as defined in the constructor block or the
108
- # #process method.
109
- def success!
110
- result&.success!
111
- end # method success!
112
- end # module
113
- end # module
18
+ def success(value)
19
+ build_result(value: value)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum'
4
+
5
+ module Cuprum
6
+ # Namespace for RSpec extensions for testing Cuprum applications.
7
+ module RSpec; end
8
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/rspec/be_a_result_matcher'
4
+
5
+ module RSpec
6
+ module Matchers # rubocop:disable Style/Documentation
7
+ def be_a_failing_result
8
+ be_a_result.with_status(:failure)
9
+ end
10
+
11
+ def be_a_passing_result
12
+ be_a_result.with_status(:success).and_error(nil)
13
+ end
14
+
15
+ def be_a_result
16
+ Cuprum::RSpec::BeAResultMatcher.new
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/errors/operation_not_called'
4
+ require 'cuprum/rspec'
5
+
6
+ module Cuprum::RSpec
7
+ # Custom matcher that asserts the actual object is a Cuprum result object with
8
+ # the specified properties.
9
+ class BeAResultMatcher # rubocop:disable Metrics/ClassLength
10
+ DEFAULT_VALUE = Object.new.freeze
11
+ private_constant :DEFAULT_VALUE
12
+
13
+ RSPEC_MATCHER_METHODS = %i[description failure_message matches?].freeze
14
+ private_constant :RSPEC_MATCHER_METHODS
15
+
16
+ def initialize
17
+ @expected_error = DEFAULT_VALUE
18
+ @expected_value = DEFAULT_VALUE
19
+ end
20
+
21
+ # @return [String] a short description of the matcher and expected
22
+ # properties.
23
+ def description
24
+ message = 'be a Cuprum result'
25
+
26
+ return message unless expected_properties?
27
+
28
+ "#{message} #{properties_description}"
29
+ end
30
+
31
+ # Checks that the given actual object is not a Cuprum result.
32
+ #
33
+ # @param actual [Object] The actual object to match.
34
+ #
35
+ # @return [Boolean] false if the actual object is a result; otherwise true.
36
+ def does_not_match?(actual)
37
+ @actual = actual
38
+
39
+ raise ArgumentError, negated_matcher_warning if expected_properties?
40
+
41
+ !actual_is_result?
42
+ end
43
+
44
+ # @return [String] a summary message describing a failed expectation.
45
+ def failure_message
46
+ message = "expected #{actual.inspect} to #{description}"
47
+
48
+ if !actual_is_result?
49
+ message + ', but the object is not a result'
50
+ elsif actual_is_uncalled_operation?
51
+ message + ', but the object is an uncalled operation'
52
+ elsif !properties_match?
53
+ message + properties_failure_message
54
+ else
55
+ # :nocov:
56
+ message
57
+ # :nocov:
58
+ end
59
+ end
60
+
61
+ # @return [String] a summary message describing a failed negated
62
+ # expectation.
63
+ def failure_message_when_negated
64
+ "expected #{actual.inspect} not to #{description}"
65
+ end
66
+
67
+ # Checks that the given actual object is a Cuprum result or compatible
68
+ # object and has the specified properties.
69
+ #
70
+ # @param actual [Object] The actual object to match.
71
+ #
72
+ # @return [Boolean] true if the actual object is a result with the expected
73
+ # properties; otherwise false.
74
+ def matches?(actual)
75
+ @actual = actual
76
+
77
+ actual_is_result? && !actual_is_uncalled_operation? && properties_match?
78
+ end
79
+
80
+ # Sets an error expectation on the matcher. Calls to #matches? will fail
81
+ # unless the actual object has the specified error.
82
+ #
83
+ # @param error [Cuprum::Error, Object] The expected error.
84
+ #
85
+ # @return [BeAResultMatcher] the updated matcher.
86
+ def with_error(error)
87
+ @expected_error = error
88
+
89
+ self
90
+ end
91
+ alias_method :and_error, :with_error
92
+
93
+ # Sets a status expectation on the matcher. Calls to #matches? will fail
94
+ # unless the actual object has the specified status.
95
+ #
96
+ # @param status [Symbol] The expected status.
97
+ #
98
+ # @return [BeAResultMatcher] the updated matcher.
99
+ def with_status(status)
100
+ @expected_status = status
101
+
102
+ self
103
+ end
104
+ alias_method :and_status, :with_status
105
+
106
+ # Sets a value expectation on the matcher. Calls to #matches? will fail
107
+ # unless the actual object has the specified value.
108
+ #
109
+ # @param value [Object] The expected value.
110
+ #
111
+ # @return [BeAResultMatcher] the updated matcher.
112
+ def with_value(value)
113
+ @expected_value = value
114
+
115
+ self
116
+ end
117
+ alias_method :and_value, :with_value
118
+
119
+ private
120
+
121
+ attr_reader \
122
+ :actual,
123
+ :expected_error,
124
+ :expected_status,
125
+ :expected_value
126
+
127
+ def actual_is_result?
128
+ actual.respond_to?(:to_cuprum_result)
129
+ end
130
+
131
+ def actual_is_uncalled_operation?
132
+ result.error.is_a?(Cuprum::Errors::OperationNotCalled)
133
+ end
134
+
135
+ def compare_items(expected, actual)
136
+ return expected.matches?(actual) if expected.respond_to?(:matches?)
137
+
138
+ expected == actual
139
+ end
140
+
141
+ def error_failure_message
142
+ return '' if error_matches?
143
+
144
+ "\n expected error: #{inspect_expected(expected_error)}" \
145
+ "\n actual error: #{result.error.inspect}"
146
+ end
147
+
148
+ def error_matches?
149
+ return @error_matches unless @error_matches.nil?
150
+
151
+ return @error_matches = true unless expected_error?
152
+
153
+ @error_matches = compare_items(expected_error, result.error)
154
+ end
155
+
156
+ def expected_properties?
157
+ (expected_error? && !expected_error.nil?) ||
158
+ expected_status? ||
159
+ expected_value?
160
+ end
161
+
162
+ def expected_error?
163
+ expected_error != DEFAULT_VALUE
164
+ end
165
+
166
+ def expected_status?
167
+ !!expected_status
168
+ end
169
+
170
+ def expected_value?
171
+ expected_value != DEFAULT_VALUE
172
+ end
173
+
174
+ def inspect_expected(expected)
175
+ return expected.description if rspec_matcher?(expected)
176
+
177
+ expected.inspect
178
+ end
179
+
180
+ def negated_matcher_warning
181
+ "Using `expect().not_to be_a_result#{properties_warning}` risks false" \
182
+ ' positives, since any other result will match.'
183
+ end
184
+
185
+ # rubocop:disable Metrics/CyclomaticComplexity
186
+ # rubocop:disable Metrics/AbcSize
187
+ def properties_description
188
+ msg = ''
189
+ ary = []
190
+ ary << 'value' if expected_value?
191
+ ary << 'error' if expected_error? && !expected_error.nil?
192
+
193
+ unless ary.empty?
194
+ msg = "with the expected #{tools.array_tools.humanize_list(ary)}"
195
+ end
196
+
197
+ return msg unless expected_status?
198
+
199
+ return "with status: #{expected_status.inspect}" if msg.empty?
200
+
201
+ msg + " and status: #{expected_status.inspect}"
202
+ end
203
+ # rubocop:enable Metrics/CyclomaticComplexity
204
+ # rubocop:enable Metrics/AbcSize
205
+
206
+ def properties_failure_message
207
+ properties_short_message +
208
+ status_failure_message +
209
+ value_failure_message +
210
+ error_failure_message
211
+ end
212
+
213
+ def properties_match?
214
+ error_matches? && status_matches? && value_matches?
215
+ end
216
+
217
+ def properties_short_message
218
+ ary = []
219
+ ary << 'status' unless status_matches?
220
+ ary << 'value' unless value_matches?
221
+ ary << 'error' unless error_matches?
222
+
223
+ ", but the #{tools.array_tools.humanize_list(ary)}" \
224
+ " #{tools.integer_tools.pluralize(ary.size, 'does', 'do')} not match:"
225
+ end
226
+
227
+ def properties_warning
228
+ ary = []
229
+ ary << 'value' if expected_value?
230
+ ary << 'status' if expected_status?
231
+ ary << 'error' if expected_error?
232
+
233
+ return '' if ary.empty?
234
+
235
+ message = ".with_#{ary.first}()"
236
+
237
+ return message if ary.size == 1
238
+
239
+ message + ary[1..-1].map { |str| ".and_#{str}()" }.join
240
+ end
241
+
242
+ def result
243
+ @result ||= actual.to_cuprum_result
244
+ end
245
+
246
+ def rspec_matcher?(value)
247
+ RSPEC_MATCHER_METHODS.all? do |method_name|
248
+ value.respond_to?(method_name)
249
+ end
250
+ end
251
+
252
+ def status_failure_message
253
+ return '' if status_matches?
254
+
255
+ "\n expected status: #{expected_status.inspect}" \
256
+ "\n actual status: #{result.status.inspect}"
257
+ end
258
+
259
+ def status_matches?
260
+ return @status_matches unless @status_matches.nil?
261
+
262
+ return @status_matches = true unless expected_status?
263
+
264
+ @status_matches = result.status == expected_status
265
+ end
266
+
267
+ def tools
268
+ SleepingKingStudios::Tools::Toolbelt.instance
269
+ end
270
+
271
+ def value_failure_message
272
+ return '' if value_matches?
273
+
274
+ "\n expected value: #{inspect_expected(expected_value)}" \
275
+ "\n actual value: #{result.value.inspect}"
276
+ end
277
+
278
+ def value_matches?
279
+ return @value_matches unless @value_matches.nil?
280
+
281
+ return @value_matches = true unless expected_value?
282
+
283
+ @value_matches = compare_items(expected_value, result.value)
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/result_helpers'
4
+
5
+ module Cuprum
6
+ # The Steps supports step by step processes that halt on a failed step.
7
+ #
8
+ # After including Cuprum::Steps, use the #steps instance method to wrap a
9
+ # series of instructions. Each instruction is then defined using the #step
10
+ # method. Steps can be defined either as a block or as a method invocation.
11
+ #
12
+ # When the steps block is evaluated, each step is called in sequence. If the
13
+ # step resolves to a passing result, the result value is returned and
14
+ # execution continues to the next step. If all of the steps pass, then the
15
+ # result of the final step is returned from the #steps block.
16
+ #
17
+ # Conversely, if any step resolves to a failing result, that failing result is
18
+ # immediately returned from the #steps block. No further steps will be called.
19
+ #
20
+ # For example, consider updating a database record using a primary key and an
21
+ # attributes hash. Broken down into its basics, this requires the following
22
+ # instructions:
23
+ #
24
+ # - Using the primary key, find the existing record in the database.
25
+ # - Update the record object with the given attributes.
26
+ # - Save the updated record back to the database.
27
+ #
28
+ # Note that each of these steps can fail for different reasons. For example,
29
+ # if a record with the given primary key does not exist in the database, then
30
+ # the first instruction will fail, and the follow up steps should not be
31
+ # executed. Further, whatever context is executing these steps probably wants
32
+ # to know which step failed, and why.
33
+ #
34
+ # @example Defining Methods As Steps
35
+ # def assign_attributes(record, attributes); end
36
+ #
37
+ # def find_record(primary_key); end
38
+ #
39
+ # def save_record(record); end
40
+ #
41
+ # def update_record(primary_key, attributes)
42
+ # steps do
43
+ # record = step :find_record, primary_key
44
+ # record = step :assign_attributes, record, attributes
45
+ # step :save_record, record
46
+ # end
47
+ # end
48
+ #
49
+ # @example Defining Blocks As Steps
50
+ # class AssignAttributes < Cuprum::Command; end
51
+ #
52
+ # class FindRecord < Cuprum::Command; end
53
+ #
54
+ # class SaveRecord < Cuprum::Command; end
55
+ #
56
+ # def update_record(primary_key, attributes)
57
+ # steps do
58
+ # record = step { FindRecord.new.call(primary_key) }
59
+ # record = step { AssignAttributes.new.call(record, attributes) }
60
+ # step { SaveRecord.new.call(record) }
61
+ # end
62
+ # end
63
+ module Steps
64
+ include Cuprum::ResultHelpers
65
+
66
+ class << self
67
+ # @!visibility private
68
+ def execute_method(receiver, method_name, *args, **kwargs, &block)
69
+ if block_given? && kwargs.empty?
70
+ receiver.send(method_name, *args, &block)
71
+ elsif block_given?
72
+ receiver.send(method_name, *args, **kwargs, &block)
73
+ elsif kwargs.empty?
74
+ receiver.send(method_name, *args)
75
+ else
76
+ receiver.send(method_name, *args, **kwargs)
77
+ end
78
+ end
79
+
80
+ # @!visibility private
81
+ def extract_result_value(result)
82
+ return result unless result.respond_to?(:to_cuprum_result)
83
+
84
+ result = result.to_cuprum_result
85
+
86
+ return result.value if result.success?
87
+
88
+ throw :cuprum_failed_step, result
89
+ end
90
+
91
+ # rubocop:disable Metrics/MethodLength
92
+ # @!visibility private
93
+ def validate_method_name(method_name)
94
+ if method_name.nil?
95
+ raise ArgumentError,
96
+ 'expected a block or a method name',
97
+ caller(1..-1)
98
+ end
99
+
100
+ unless method_name.is_a?(String) || method_name.is_a?(Symbol)
101
+ raise ArgumentError,
102
+ 'expected method name to be a String or Symbol',
103
+ caller(1..-1)
104
+ end
105
+
106
+ return unless method_name.empty?
107
+
108
+ raise ArgumentError, "method name can't be blank", caller(1..-1)
109
+ end
110
+ # rubocop:enable Metrics/MethodLength
111
+ end
112
+
113
+ # @overload step()
114
+ # Executes the block and returns the value, or halts on a failure.
115
+ #
116
+ # @yield Called with no parameters.
117
+ #
118
+ # @return [Object] the #value of the result, or the returned object.
119
+ #
120
+ # The #step method is used to evaluate a sequence of processes, and to
121
+ # fail fast and halt processing if any of the steps returns a failing
122
+ # result. Each invocation of #step should be wrapped in a #steps block,
123
+ # or used inside the #process method of a Command.
124
+ #
125
+ # If the object returned by the block is a Cuprum result or compatible
126
+ # object (such as a called operation), the value is converted to a Cuprum
127
+ # result via the #to_cuprum_result method. Otherwise, the object is
128
+ # returned directly from #step.
129
+ #
130
+ # If the returned object is a passing result, the #value of the result is
131
+ # returned by #step.
132
+ #
133
+ # If the returned object is a failing result, then #step will throw
134
+ # :cuprum_failed_result and the failing result. This is caught by the
135
+ # #steps block, and halts execution of any subsequent steps.
136
+ #
137
+ # @example Calling a Step
138
+ # # The #do_something method returns the string 'some value'.
139
+ # step { do_something() } #=> 'some value'
140
+ #
141
+ # value = step { do_something() }
142
+ # value #=> 'some value'
143
+ #
144
+ # @example Calling a Step with a Passing Result
145
+ # # The #do_something_else method returns a Cuprum result with a value
146
+ # # of 'another value'.
147
+ # step { do_something_else() } #=> 'another value'
148
+ #
149
+ # # The result is passing, so the value is extracted and returned.
150
+ # value = step { do_something_else() }
151
+ # value #=> 'another value'
152
+ #
153
+ # @example Calling a Step with a Failing Result
154
+ # # The #do_something_wrong method returns a failing Cuprum result.
155
+ # step { do_something_wrong() } # Throws the :cuprum_failed_step symbol.
156
+ #
157
+ # @overload step(method_name, *arguments, **keywords)
158
+ # Calls the method and returns the value, or halts on a failure.
159
+ #
160
+ # @param method_name [String, Symbol] The name of the method to call. Must
161
+ # be the name of a method on the current object.
162
+ # @param arguments [Array] Positional arguments to pass to the method.
163
+ # @param keywords [Hash] Keyword arguments to pass to the method.
164
+ #
165
+ # @yield A block to pass to the method.
166
+ #
167
+ # @return [Object] the #value of the result, or the returned object.
168
+ #
169
+ # The #step method is used to evaluate a sequence of processes, and to
170
+ # fail fast and halt processing if any of the steps returns a failing
171
+ # result. Each invocation of #step should be wrapped in a #steps block,
172
+ # or used inside the #process method of a Command.
173
+ #
174
+ # If the object returned by the block is a Cuprum result or compatible
175
+ # object (such as a called operation), the value is converted to a Cuprum
176
+ # result via the #to_cuprum_result method. Otherwise, the object is
177
+ # returned directly from #step.
178
+ #
179
+ # If the returned object is a passing result, the #value of the result is
180
+ # returned by #step.
181
+ #
182
+ # If the returned object is a failing result, then #step will throw
183
+ # :cuprum_failed_result and the failing result. This is caught by the
184
+ # #steps block, and halts execution of any subsequent steps.
185
+ #
186
+ # @example Calling a Step
187
+ # # The #zero method returns the integer 0.
188
+ # step :zero #=> 0
189
+ #
190
+ # value = step :zero
191
+ # value #=> 0
192
+ #
193
+ # @example Calling a Step with a Passing Result
194
+ # # The #add method adds the numbers and returns a Cuprum result with a
195
+ # # value equal to the sum.
196
+ # step :add, 2, 2
197
+ # #=> 4
198
+ #
199
+ # # The result is passing, so the value is extracted and returned.
200
+ # value = step :add, 2, 2
201
+ # value #=> 4
202
+ #
203
+ # @example Calling a Step with a Failing Result
204
+ # # The #divide method returns a failing Cuprum result when the second
205
+ # # argument is zero.
206
+ # step :divide, 1, 0
207
+ # # Throws the :cuprum_failed_step symbol, which should be caught by the
208
+ # # enclosing #steps block.
209
+ def step(method_name = nil, *args, **kwargs, &block)
210
+ result =
211
+ if !block_given? || method_name || !args.empty? || !kwargs.empty?
212
+ Cuprum::Steps.validate_method_name(method_name)
213
+
214
+ Cuprum::Steps
215
+ .execute_method(self, method_name, *args, **kwargs, &block)
216
+ else
217
+ block.call
218
+ end
219
+
220
+ Cuprum::Steps.extract_result_value(result)
221
+ end
222
+
223
+ # Returns the first failing #step result, or the final result if none fail.
224
+ #
225
+ # The #steps method is used to wrap a series of #step calls. Each step is
226
+ # executed in sequence. If any of the steps returns a failing result, that
227
+ # result is immediately returned from #steps. Otherwise, #steps wraps the
228
+ # value returned by a block in a Cuprum result.
229
+ #
230
+ # @yield Called with no parameters.
231
+ #
232
+ # @yieldreturn A Cuprum result, or an object to be wrapped in a result.
233
+ #
234
+ # @return [Cuprum::Result] the result or object returned by the block,
235
+ # wrapped in a Cuprum result.
236
+ #
237
+ # @example With A Passing Step
238
+ # result = steps do
239
+ # step { success('some value') }
240
+ # end
241
+ # result.class #=> Cuprum::Result
242
+ # result.success? #=> true
243
+ # result.value #=> 'some value'
244
+ #
245
+ # @example With A Failing Step
246
+ # result = steps do
247
+ # step { failure('something went wrong') }
248
+ # end
249
+ # result.class #=> Cuprum::Result
250
+ # result.success? #=> false
251
+ # result.error #=> 'something went wrong'
252
+ #
253
+ # @example With Multiple Steps
254
+ # result = steps do
255
+ # # This step is passing, so execution continues on to the next step.
256
+ # step { success('first step') }
257
+ #
258
+ # # This step is failing, so execution halts and returns this result.
259
+ # step { failure('second step') }
260
+ #
261
+ # # This step will never be called.
262
+ # step { success('third step') }
263
+ # end
264
+ # result.class #=> Cuprum::Result
265
+ # result.success? #=> false
266
+ # result.error #=> 'second step'
267
+ def steps
268
+ result = catch(:cuprum_failed_step) { yield }
269
+
270
+ return result if result.respond_to?(:to_cuprum_result)
271
+
272
+ success(result)
273
+ end
274
+ end
275
+ end