cuprum 0.8.0 → 0.10.0

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