statefully 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/statefully.rb +2 -0
- data/lib/statefully/change.rb +52 -0
- data/lib/statefully/diff.rb +261 -61
- data/lib/statefully/errors.rb +32 -0
- data/lib/statefully/inspect.rb +11 -0
- data/lib/statefully/state.rb +338 -20
- data/spec/diff_spec.rb +31 -23
- data/spec/errors_spec.rb +13 -0
- data/spec/spec_helper.rb +6 -4
- data/spec/state_spec.rb +58 -31
- metadata +204 -3
@@ -0,0 +1,32 @@
|
|
1
|
+
# rubocop:disable Metrics/LineLength
|
2
|
+
module Statefully
|
3
|
+
module Errors
|
4
|
+
# StateMissing represents an error being thrown when a member of {State} is
|
5
|
+
# being accessed using an unsafe accessor (eg. #member!). It is technically
|
6
|
+
# a NoMethodError, but it is introduced to allow users to differentiate
|
7
|
+
# between failing state accessors and other code that may fail in a similar
|
8
|
+
# way.
|
9
|
+
class StateMissing < ::RuntimeError
|
10
|
+
# Stores the name of the missing {State} field
|
11
|
+
#
|
12
|
+
# @return [Symbol] the name of the field.
|
13
|
+
# @api public
|
14
|
+
# @example
|
15
|
+
# Statefully::Errors::StateMissing.new(:bacon).field
|
16
|
+
# => :bacon
|
17
|
+
attr_reader :field
|
18
|
+
|
19
|
+
# Error constructor for {StateMissing}
|
20
|
+
#
|
21
|
+
# @param field [Symbol] name of the missing field.
|
22
|
+
# @api public
|
23
|
+
# @example
|
24
|
+
# Statefully::Errors::StateMissing.new(:bacon)
|
25
|
+
# => #<Statefully::Errors::StateMissing: field 'bacon' missing from state>
|
26
|
+
def initialize(field)
|
27
|
+
@field = field
|
28
|
+
super("field '#{field}' missing from state")
|
29
|
+
end
|
30
|
+
end # class StateMissing
|
31
|
+
end # module Errors
|
32
|
+
end # module Statefully
|
data/lib/statefully/inspect.rb
CHANGED
@@ -1,10 +1,21 @@
|
|
1
1
|
module Statefully
|
2
|
+
# {Inspect} provides helpers for human-readable object inspection.
|
2
3
|
module Inspect
|
4
|
+
# Inspect a [Hash] of values in `key: val` format
|
5
|
+
# @param input [Hash] input values
|
6
|
+
#
|
7
|
+
# @return [String]
|
8
|
+
# @api private
|
3
9
|
def from_hash(input)
|
4
10
|
'{' + input.map { |key, val| "#{key}: #{val.inspect}" }.join(', ') + '}'
|
5
11
|
end
|
6
12
|
module_function :from_hash
|
7
13
|
|
14
|
+
# Inspect a [Hash] of values in `key=val` format
|
15
|
+
# @param input [Hash] input values
|
16
|
+
#
|
17
|
+
# @return [String]
|
18
|
+
# @api private
|
8
19
|
def from_fields(input)
|
9
20
|
input.map { |key, val| "#{key}=#{val.inspect}" }.join(', ')
|
10
21
|
end
|
data/lib/statefully/state.rb
CHANGED
@@ -1,56 +1,214 @@
|
|
1
1
|
require 'forwardable'
|
2
2
|
require 'singleton'
|
3
3
|
|
4
|
+
# rubocop:disable Metrics/LineLength
|
4
5
|
module Statefully
|
6
|
+
# {State} is an immutable collection of fields with some convenience methods.
|
7
|
+
# @abstract
|
5
8
|
class State
|
6
9
|
include Enumerable
|
7
10
|
extend Forwardable
|
8
11
|
|
12
|
+
# Return the previous {State}
|
13
|
+
#
|
14
|
+
# @return [State]
|
15
|
+
# @api public
|
16
|
+
# @example
|
17
|
+
# Statefully::State.create.previous
|
18
|
+
# => #<Statefully::State::None>
|
19
|
+
#
|
20
|
+
# Statefully::State.create.succeed.previous
|
21
|
+
# => #<Statefully::State::Success>
|
9
22
|
attr_reader :previous
|
10
|
-
def_delegators :@_members, :each, :key?, :keys, :fetch
|
11
23
|
|
24
|
+
# @!method each
|
25
|
+
# @return [Enumerator]
|
26
|
+
# @see https://docs.ruby-lang.org/en/2.0.0/Hash.html#method-i-each Hash#each
|
27
|
+
# @api public
|
28
|
+
# @example
|
29
|
+
# Statefully::State.create(key: 'val').each { |key, val| puts("#{key} => #{val}") }
|
30
|
+
# key => val
|
31
|
+
# @!method fetch
|
32
|
+
# @return [Object]
|
33
|
+
# @see https://docs.ruby-lang.org/en/2.0.0/Hash.html#method-i-fetch Hash#fetch
|
34
|
+
# @api public
|
35
|
+
# @example
|
36
|
+
# Statefully::State.create(key: 'val').fetch(:key)
|
37
|
+
# => 'val'
|
38
|
+
# @!method key?
|
39
|
+
# @return [Boolean]
|
40
|
+
# @see https://docs.ruby-lang.org/en/2.0.0/Hash.html#method-i-key-3F Hash#key?
|
41
|
+
# @api public
|
42
|
+
# @example
|
43
|
+
# state = Statefully::State.create(key: 'val')
|
44
|
+
# state.key?(:key)
|
45
|
+
# => true
|
46
|
+
# state.key?(:other)
|
47
|
+
# => false
|
48
|
+
# @!method keys
|
49
|
+
# @return [Array<Symbol>]
|
50
|
+
# @see https://docs.ruby-lang.org/en/2.0.0/Hash.html#method-i-keys Hash#keys
|
51
|
+
# @api public
|
52
|
+
# @example
|
53
|
+
# Statefully::State.create(key: 'val').keys
|
54
|
+
# => [:key]
|
55
|
+
def_delegators :@_members, :each, :fetch, :key?, :keys
|
56
|
+
|
57
|
+
# Create an instance of {State} object
|
58
|
+
#
|
59
|
+
# This is meant as the only valid way of creating {State} objects.
|
60
|
+
#
|
61
|
+
# @param values [Hash<Symbol, Object>] keyword arguments
|
62
|
+
#
|
63
|
+
# @return [type] [description]
|
64
|
+
# @api public
|
65
|
+
# @example
|
66
|
+
# Statefully::State.create(key: 'val')
|
67
|
+
# => #<Statefully::State::Success key="val">
|
12
68
|
def self.create(**values)
|
13
69
|
Success.send(:new, values, previous: None.instance).freeze
|
14
70
|
end
|
15
71
|
|
72
|
+
# Return a {Diff} between current and previous {State}
|
73
|
+
#
|
74
|
+
# @return [Diff]
|
75
|
+
# @api public
|
76
|
+
# @example
|
77
|
+
# Statefully::State.create.succeed(key: 'val').diff
|
78
|
+
# => #<Statefully::Diff::Changed added={key: "val"}>
|
16
79
|
def diff
|
17
|
-
Diff.create(self, previous)
|
80
|
+
Diff.create(current: self, previous: previous)
|
18
81
|
end
|
19
82
|
|
83
|
+
# Return all historical changes to this {State}
|
84
|
+
#
|
85
|
+
# @return [Array<Diff>]
|
86
|
+
# @api public
|
87
|
+
# @example
|
88
|
+
# Statefully::State.create.succeed(key: 'val').history
|
89
|
+
# => [#<Statefully::Diff::Changed added={key: "val"}>, #<Statefully::Diff::Created>]
|
20
90
|
def history
|
21
91
|
([diff] + previous.history).freeze
|
22
92
|
end
|
23
93
|
|
94
|
+
# Check if the current {State} is successful
|
95
|
+
#
|
96
|
+
# @return [Boolean]
|
97
|
+
# @api public
|
98
|
+
# @example
|
99
|
+
# state = Statefully::State.create
|
100
|
+
# state.successful?
|
101
|
+
# => true
|
102
|
+
#
|
103
|
+
# state.fail(RuntimeError.new('Boom!')).successful?
|
104
|
+
# => false
|
24
105
|
def successful?
|
25
106
|
true
|
26
107
|
end
|
27
108
|
|
109
|
+
# Check if the current {State} is failed
|
110
|
+
#
|
111
|
+
# @return [Boolean]
|
112
|
+
# @api public
|
113
|
+
# @example
|
114
|
+
# state = Statefully::State.create
|
115
|
+
# state.failed?
|
116
|
+
# => false
|
117
|
+
#
|
118
|
+
# state.fail(RuntimeError.new('Boom!')).failed?
|
119
|
+
# => true
|
28
120
|
def failed?
|
29
121
|
!successful?
|
30
122
|
end
|
31
123
|
|
124
|
+
# Check if the current {State} is finished
|
125
|
+
#
|
126
|
+
# @return [Boolean]
|
127
|
+
# @api public
|
128
|
+
# @example
|
129
|
+
# state = Statefully::State.create
|
130
|
+
# state.finished?
|
131
|
+
# => false
|
132
|
+
#
|
133
|
+
# state.finish.finished?
|
134
|
+
# => true
|
32
135
|
def finished?
|
33
136
|
false
|
34
137
|
end
|
35
138
|
|
139
|
+
# Check if the current {State} is none (a null-object of {State})
|
140
|
+
#
|
141
|
+
# @return [Boolean]
|
142
|
+
# @api public
|
143
|
+
# @example
|
144
|
+
# state = Statefully::State.create
|
145
|
+
# state.none?
|
146
|
+
# => false
|
147
|
+
#
|
148
|
+
# state.previous.none?
|
149
|
+
# => true
|
150
|
+
def none?
|
151
|
+
false
|
152
|
+
end
|
153
|
+
|
154
|
+
# Resolve the current {State}
|
155
|
+
#
|
156
|
+
# Resolving will return the current {State} if successful, but raise an
|
157
|
+
# error wrapped in a {State::Failure}. This is a convenience method inspired
|
158
|
+
# by monadic composition from functional languages.
|
159
|
+
#
|
160
|
+
# @return [State] if the receiver is {#successful?}
|
161
|
+
# @raise [StandardError] if the receiver is {#failed?}
|
162
|
+
# @api public
|
163
|
+
# @example
|
164
|
+
# Statefully::State.create(key: 'val').resolve
|
165
|
+
# => #<Statefully::State::Success key="val">
|
166
|
+
#
|
167
|
+
# Statefully::State.create.fail(RuntimeError.new('Boom!')).resolve
|
168
|
+
# RuntimeError: Boom!
|
169
|
+
# [STACK TRACE]
|
36
170
|
def resolve
|
37
171
|
self
|
38
172
|
end
|
39
173
|
|
174
|
+
# Show the current {State} in a human-readable form
|
175
|
+
#
|
176
|
+
# @return [String]
|
177
|
+
# @api public
|
178
|
+
# @example
|
179
|
+
# Statefully::State.create(key: 'val')
|
180
|
+
# => #<Statefully::State::Success key="val">
|
40
181
|
def inspect
|
41
182
|
_inspect_details({})
|
42
183
|
end
|
43
184
|
|
44
185
|
private
|
45
186
|
|
187
|
+
# State fields
|
188
|
+
#
|
189
|
+
# @return [Hash]
|
190
|
+
# @api private
|
46
191
|
attr_reader :_members
|
47
192
|
|
193
|
+
# Constructor for the {State} object
|
194
|
+
#
|
195
|
+
# @param values [Hash<Symbol, Object>] values to store
|
196
|
+
# @param previous [State] previous {State}
|
197
|
+
#
|
198
|
+
# @return [State]
|
199
|
+
# @api private
|
48
200
|
def initialize(values, previous:)
|
49
201
|
@_members = values.freeze
|
50
202
|
@previous = previous
|
51
203
|
end
|
52
204
|
private_class_method :new
|
53
205
|
|
206
|
+
# Inspect {State} fields, with extras
|
207
|
+
#
|
208
|
+
# @param extras [Hash] Non-member values to include
|
209
|
+
#
|
210
|
+
# @return [String]
|
211
|
+
# @api private
|
54
212
|
def _inspect_details(extras)
|
55
213
|
details = [self.class.name]
|
56
214
|
fields = _members.merge(extras)
|
@@ -58,7 +216,47 @@ module Statefully
|
|
58
216
|
"#<#{details.join(' ')}>"
|
59
217
|
end
|
60
218
|
|
219
|
+
# Dynamically pass unknown messages to the underlying state storage
|
220
|
+
#
|
221
|
+
# State fields become accessible through readers, like in an
|
222
|
+
# {http://ruby-doc.org/stdlib-2.0.0/libdoc/ostruct/rdoc/OpenStruct.html OpenStruct}.
|
223
|
+
# A single state field can be questioned for existence by having its name
|
224
|
+
# followed by a question mark - eg. bacon?.
|
225
|
+
# A single state field can be force-accessed by having its name followed by
|
226
|
+
# an exclamation mark - eg. bacon!.
|
227
|
+
#
|
61
228
|
# This method reeks of :reek:TooManyStatements.
|
229
|
+
#
|
230
|
+
# @param name [Symbol|String]
|
231
|
+
# @param args [Array<Object>]
|
232
|
+
# @param block [Proc]
|
233
|
+
#
|
234
|
+
# @return [Object]
|
235
|
+
# @raise [NoMethodError]
|
236
|
+
# @raise [Errors::StateMissing]
|
237
|
+
# @api private
|
238
|
+
# @example
|
239
|
+
# state = Statefully::State.create(bacon: 'tasty')
|
240
|
+
#
|
241
|
+
# state.bacon
|
242
|
+
# => "tasty"
|
243
|
+
#
|
244
|
+
# state.bacon?
|
245
|
+
# => true
|
246
|
+
#
|
247
|
+
# state.bacon!
|
248
|
+
# => "tasty"
|
249
|
+
#
|
250
|
+
# state.cabbage
|
251
|
+
# NoMethodError: undefined method `cabbage' for #<Statefully::State::Success bacon="tasty">
|
252
|
+
# [STACK TRACE]
|
253
|
+
#
|
254
|
+
# state.cabbage?
|
255
|
+
# => false
|
256
|
+
#
|
257
|
+
# state.cabbage!
|
258
|
+
# Statefully::Errors::StateMissing: field 'cabbage' missing from state
|
259
|
+
# [STACK TRACE]
|
62
260
|
def method_missing(name, *args, &block)
|
63
261
|
sym_name = name.to_sym
|
64
262
|
return fetch(sym_name) if key?(sym_name)
|
@@ -69,95 +267,215 @@ module Statefully
|
|
69
267
|
known = key?(base)
|
70
268
|
return known if modifier == '?'
|
71
269
|
return fetch(base) if known
|
72
|
-
raise
|
270
|
+
raise Errors::StateMissing, base
|
73
271
|
end
|
74
272
|
|
273
|
+
# Companion to `method_missing`
|
274
|
+
#
|
75
275
|
# This method reeks of :reek:BooleanParameter.
|
276
|
+
#
|
277
|
+
# @param name [Symbol|String]
|
278
|
+
# @param _include_private [Boolean]
|
279
|
+
#
|
280
|
+
# @return [Boolean]
|
281
|
+
# @api private
|
76
282
|
def respond_to_missing?(name, _include_private = false)
|
77
283
|
str_name = name.to_s
|
78
284
|
key?(name.to_sym) || %w[? !].any?(&str_name.method(:end_with?)) || super
|
79
285
|
end
|
80
286
|
|
81
|
-
|
82
|
-
attr_reader :field
|
83
|
-
|
84
|
-
def initialize(field)
|
85
|
-
@field = field
|
86
|
-
super("field '#{field}' missing from state")
|
87
|
-
end
|
88
|
-
end # class Missing
|
89
|
-
|
287
|
+
# {None} is a null-value of {State}
|
90
288
|
class None < State
|
91
289
|
include Singleton
|
92
290
|
|
291
|
+
# Return all historical changes to this {State}
|
292
|
+
#
|
293
|
+
# @return [Array<Diff>]
|
294
|
+
# @api public
|
295
|
+
# @example
|
296
|
+
# Statefully::State.create.succeed(key: 'val').history
|
297
|
+
# => [#<Statefully::Diff::Changed added={key: "val"}>, #<Statefully::Diff::Created>]
|
93
298
|
def history
|
94
299
|
[]
|
95
300
|
end
|
96
301
|
|
302
|
+
# Check if the current {State} is none (a null-object of {State})
|
303
|
+
#
|
304
|
+
# @return [Boolean]
|
305
|
+
# @api public
|
306
|
+
# @example
|
307
|
+
# state = Statefully::State.create
|
308
|
+
# state.none?
|
309
|
+
# => false
|
310
|
+
#
|
311
|
+
# state.previous.none?
|
312
|
+
# => true
|
313
|
+
def none?
|
314
|
+
true
|
315
|
+
end
|
316
|
+
|
97
317
|
private
|
98
318
|
|
319
|
+
# Constructor for the {None} object
|
320
|
+
# @api private
|
99
321
|
def initialize
|
100
322
|
@_members = {}.freeze
|
101
323
|
@previous = self
|
102
324
|
end
|
103
325
|
end # class None
|
104
|
-
private_constant :None
|
105
326
|
|
106
|
-
# Success is a not-yet failed State.
|
327
|
+
# {Success} is a not-yet failed {State}.
|
107
328
|
class Success < State
|
329
|
+
# Return the next, successful {State} with new values merged in (if any)
|
330
|
+
#
|
331
|
+
# @param values [Hash<Symbol, Object>] New values of the {State}
|
332
|
+
#
|
333
|
+
# @return [State::Success] new successful {State}
|
334
|
+
# @api public
|
335
|
+
# @example
|
336
|
+
# Statefully::State.create.succeed(key: 'val')
|
337
|
+
# => #<Statefully::State::Success key="val">
|
108
338
|
def succeed(**values)
|
109
339
|
self.class.send(:new, _members.merge(values).freeze, previous: self)
|
110
340
|
end
|
111
341
|
|
342
|
+
# Return the next, failed {State} with a stored error
|
343
|
+
#
|
344
|
+
# @param error [StandardError] error to store
|
345
|
+
#
|
346
|
+
# @return [State::Failure] new failed {State}
|
347
|
+
# @api public
|
348
|
+
# @example
|
349
|
+
# Statefully::State.create(key: 'val').fail(RuntimeError.new('Boom!'))
|
350
|
+
# => #<Statefully::State::Failure key="val", error="#<RuntimeError: Boom!>">
|
112
351
|
def fail(error)
|
113
352
|
Failure.send(:new, _members, error, previous: self).freeze
|
114
353
|
end
|
115
354
|
|
355
|
+
# Return the next, finished? {State}
|
356
|
+
#
|
357
|
+
# @return [State::State] new finished {State}
|
358
|
+
# @api public
|
359
|
+
# @example
|
360
|
+
# Statefully::State.create(key: 'val').finish
|
361
|
+
# => #<Statefully::State::Finished key="val">
|
116
362
|
def finish
|
117
363
|
Finished.send(:new, _members, previous: self).freeze
|
118
364
|
end
|
119
365
|
end # class Success
|
120
|
-
private_constant :Success
|
121
366
|
|
122
|
-
# Failure is a failed State.
|
367
|
+
# {Failure} is a failed {State}.
|
123
368
|
class Failure < State
|
369
|
+
# Error stored in the current {State}
|
370
|
+
#
|
371
|
+
# @return [StandardError]
|
372
|
+
# @api public
|
373
|
+
# @example
|
374
|
+
# state = Statefully::State.create(key: 'val').fail(RuntimeError.new('Boom!'))
|
375
|
+
# state.error
|
376
|
+
# => #<RuntimeError: Boom!>
|
124
377
|
attr_reader :error
|
125
378
|
|
379
|
+
# Constructor for the {Failure} object
|
380
|
+
#
|
381
|
+
# @param values [Hash<Symbol, Object>] fields to be stored
|
382
|
+
# @param error [StandardError] error to be wrapped
|
383
|
+
# @param previous [State] previous state
|
384
|
+
# @api private
|
126
385
|
def initialize(values, error, previous:)
|
127
386
|
super(values, previous: previous)
|
128
387
|
@error = error
|
129
388
|
end
|
130
389
|
|
390
|
+
# Return a {Diff} between current and previous {State}
|
391
|
+
#
|
392
|
+
# @return [Diff::Failed]
|
393
|
+
# @api public
|
394
|
+
# @example
|
395
|
+
# state = Statefully::State.create(key: 'val').fail(RuntimeError.new('Boom!'))
|
396
|
+
# state.diff
|
397
|
+
# => #<Statefully::Diff::Failed error=#<RuntimeError: Boom!>>
|
131
398
|
def diff
|
132
399
|
Diff::Failed.new(error).freeze
|
133
400
|
end
|
134
401
|
|
402
|
+
# Check if the current {State} is successful
|
403
|
+
#
|
404
|
+
# @return [Boolean]
|
405
|
+
# @api public
|
406
|
+
# @example
|
407
|
+
# state = Statefully::State.create
|
408
|
+
# state.successful?
|
409
|
+
# => true
|
410
|
+
#
|
411
|
+
# state.fail(RuntimeError.new('Boom!')).successful?
|
412
|
+
# => false
|
135
413
|
def successful?
|
136
414
|
false
|
137
415
|
end
|
138
416
|
|
417
|
+
# Resolve the current {State}
|
418
|
+
#
|
419
|
+
# Resolving will return the current {State} if successful, but raise an
|
420
|
+
# error wrapped in a {State::Failure}. This is a convenience method inspired
|
421
|
+
# by monadic composition from functional languages.
|
422
|
+
#
|
423
|
+
# @return [State] if the receiver is {#successful?}
|
424
|
+
# @raise [StandardError] if the receiver is {#failed?}
|
425
|
+
# @api public
|
426
|
+
# @example
|
427
|
+
# Statefully::State.create(key: 'val').resolve
|
428
|
+
# => #<Statefully::State::Success key="val">
|
429
|
+
#
|
430
|
+
# Statefully::State.create.fail(RuntimeError.new('Boom!')).resolve
|
431
|
+
# RuntimeError: Boom!
|
432
|
+
# [STACK TRACE]
|
139
433
|
def resolve
|
140
434
|
raise error
|
141
435
|
end
|
142
436
|
|
437
|
+
# Show the current {State} in a human-readable form
|
438
|
+
#
|
439
|
+
# @return [String]
|
440
|
+
# @api public
|
441
|
+
# @example
|
442
|
+
# Statefully::State.create.fail(RuntimeError.new('Boom!'))
|
443
|
+
# => #<Statefully::State::Failure error="#<RuntimeError: Boom!>">
|
143
444
|
def inspect
|
144
445
|
_inspect_details(error: error.inspect)
|
145
446
|
end
|
146
447
|
end # class Failure
|
147
|
-
private_constant :Failure
|
148
448
|
|
149
|
-
# Finished state is a state which is successful, but should not be
|
150
|
-
# any further. This could be useful for things like early returns.
|
449
|
+
# {Finished} state is a state which is successful, but should not be
|
450
|
+
# processed any further. This could be useful for things like early returns.
|
151
451
|
class Finished < State
|
452
|
+
# Return a {Diff} between current and previous {State}
|
453
|
+
#
|
152
454
|
# This method reeks of :reek:UtilityFunction - just implementing an API.
|
455
|
+
#
|
456
|
+
# @return [Diff::Finished]
|
457
|
+
# @api public
|
458
|
+
# @example
|
459
|
+
# Statefully::State.create(key: 'val').finish.diff
|
460
|
+
# => #<Statefully::Diff::Finished>
|
153
461
|
def diff
|
154
462
|
Diff::Finished.instance
|
155
463
|
end
|
156
464
|
|
465
|
+
# Check if the current {State} is finished
|
466
|
+
#
|
467
|
+
# @return [Boolean]
|
468
|
+
# @api public
|
469
|
+
# @example
|
470
|
+
# state = Statefully::State.create
|
471
|
+
# state.finished?
|
472
|
+
# => false
|
473
|
+
#
|
474
|
+
# state.finish.finished?
|
475
|
+
# => true
|
157
476
|
def finished?
|
158
477
|
true
|
159
478
|
end
|
160
479
|
end # class Finished
|
161
|
-
private_constant :Finished
|
162
480
|
end # class State
|
163
481
|
end # module Statefully
|