stannum 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/DEVELOPMENT.md +105 -0
  5. data/LICENSE +22 -0
  6. data/README.md +1327 -0
  7. data/config/locales/en.rb +47 -0
  8. data/lib/stannum/attribute.rb +115 -0
  9. data/lib/stannum/constraint.rb +65 -0
  10. data/lib/stannum/constraints/absence.rb +42 -0
  11. data/lib/stannum/constraints/anything.rb +28 -0
  12. data/lib/stannum/constraints/base.rb +285 -0
  13. data/lib/stannum/constraints/boolean.rb +33 -0
  14. data/lib/stannum/constraints/delegator.rb +71 -0
  15. data/lib/stannum/constraints/enum.rb +64 -0
  16. data/lib/stannum/constraints/equality.rb +47 -0
  17. data/lib/stannum/constraints/hashes/extra_keys.rb +126 -0
  18. data/lib/stannum/constraints/hashes/indifferent_key.rb +74 -0
  19. data/lib/stannum/constraints/hashes.rb +11 -0
  20. data/lib/stannum/constraints/identity.rb +46 -0
  21. data/lib/stannum/constraints/nothing.rb +28 -0
  22. data/lib/stannum/constraints/presence.rb +42 -0
  23. data/lib/stannum/constraints/signature.rb +92 -0
  24. data/lib/stannum/constraints/signatures/map.rb +17 -0
  25. data/lib/stannum/constraints/signatures/tuple.rb +17 -0
  26. data/lib/stannum/constraints/signatures.rb +11 -0
  27. data/lib/stannum/constraints/tuples/extra_items.rb +84 -0
  28. data/lib/stannum/constraints/tuples.rb +10 -0
  29. data/lib/stannum/constraints/type.rb +113 -0
  30. data/lib/stannum/constraints/types/array_type.rb +148 -0
  31. data/lib/stannum/constraints/types/big_decimal_type.rb +16 -0
  32. data/lib/stannum/constraints/types/date_time_type.rb +16 -0
  33. data/lib/stannum/constraints/types/date_type.rb +16 -0
  34. data/lib/stannum/constraints/types/float_type.rb +14 -0
  35. data/lib/stannum/constraints/types/hash_type.rb +205 -0
  36. data/lib/stannum/constraints/types/hash_with_indifferent_keys.rb +21 -0
  37. data/lib/stannum/constraints/types/hash_with_string_keys.rb +21 -0
  38. data/lib/stannum/constraints/types/hash_with_symbol_keys.rb +21 -0
  39. data/lib/stannum/constraints/types/integer_type.rb +14 -0
  40. data/lib/stannum/constraints/types/nil_type.rb +20 -0
  41. data/lib/stannum/constraints/types/proc_type.rb +14 -0
  42. data/lib/stannum/constraints/types/string_type.rb +14 -0
  43. data/lib/stannum/constraints/types/symbol_type.rb +14 -0
  44. data/lib/stannum/constraints/types/time_type.rb +14 -0
  45. data/lib/stannum/constraints/types.rb +25 -0
  46. data/lib/stannum/constraints/union.rb +85 -0
  47. data/lib/stannum/constraints.rb +26 -0
  48. data/lib/stannum/contract.rb +243 -0
  49. data/lib/stannum/contracts/array_contract.rb +108 -0
  50. data/lib/stannum/contracts/base.rb +597 -0
  51. data/lib/stannum/contracts/builder.rb +72 -0
  52. data/lib/stannum/contracts/definition.rb +74 -0
  53. data/lib/stannum/contracts/hash_contract.rb +136 -0
  54. data/lib/stannum/contracts/indifferent_hash_contract.rb +78 -0
  55. data/lib/stannum/contracts/map_contract.rb +199 -0
  56. data/lib/stannum/contracts/parameters/arguments_contract.rb +185 -0
  57. data/lib/stannum/contracts/parameters/keywords_contract.rb +174 -0
  58. data/lib/stannum/contracts/parameters/signature_contract.rb +29 -0
  59. data/lib/stannum/contracts/parameters.rb +15 -0
  60. data/lib/stannum/contracts/parameters_contract.rb +530 -0
  61. data/lib/stannum/contracts/tuple_contract.rb +213 -0
  62. data/lib/stannum/contracts.rb +19 -0
  63. data/lib/stannum/errors.rb +730 -0
  64. data/lib/stannum/messages/default_strategy.rb +124 -0
  65. data/lib/stannum/messages.rb +25 -0
  66. data/lib/stannum/parameter_validation.rb +216 -0
  67. data/lib/stannum/rspec/match_errors.rb +17 -0
  68. data/lib/stannum/rspec/match_errors_matcher.rb +93 -0
  69. data/lib/stannum/rspec/validate_parameter.rb +23 -0
  70. data/lib/stannum/rspec/validate_parameter_matcher.rb +506 -0
  71. data/lib/stannum/rspec.rb +8 -0
  72. data/lib/stannum/schema.rb +131 -0
  73. data/lib/stannum/struct.rb +444 -0
  74. data/lib/stannum/support/coercion.rb +114 -0
  75. data/lib/stannum/support/optional.rb +69 -0
  76. data/lib/stannum/support.rb +8 -0
  77. data/lib/stannum/version.rb +57 -0
  78. data/lib/stannum.rb +27 -0
  79. metadata +216 -0
@@ -0,0 +1,730 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ require 'stannum'
6
+
7
+ module Stannum
8
+ # An errors object represents a collection of errors.
9
+ #
10
+ # Most of the time, an end user will not be creating an Errors object
11
+ # directly. Instead, an errors object may be returned by a process that
12
+ # validates or coerces data to an expected form. For one such example, see
13
+ # the Stannum::Constraint and its subclasses.
14
+ #
15
+ # Internally, an errors object is an Array of errors. Each error is
16
+ # represented by a Hash containing the keys :data, :message, :path and :type.
17
+ #
18
+ # - The :type of the error is a short, unique symbol or string that identifies
19
+ # the type of the error, such as 'invalid' or 'not_found'. The type is
20
+ # frequently namespaced, e.g. 'stannum.constraints.present'.
21
+ # - The :message of the error is a short string that provides a human-readable
22
+ # description of the error, such as 'is invalid' or 'is not found'. The
23
+ # message may include format directives for error data (see below). If the
24
+ # :message key is missing or the value is nil, use a default error message
25
+ # or generate the message from the :type.
26
+ # - The :data of the error stores additional information about the error and
27
+ # the expected behavior. For example, an out of range error might have
28
+ # type: 'out_of_range' and data { min: 0, max: 10 }, indicating that the
29
+ # expected values were between 0 and 10. If the data key is missing or the
30
+ # value is empty, there is no additional information about the error.
31
+ # - The :path of the error reflects the steps to resolve the relevant property
32
+ # from the given data object. The path is an Array with keys of either
33
+ # Symbols/Strings (for object properties or Hash keys) or Integers (for
34
+ # Array indices). For example, given the hash { companies: [{ teams: [] }] }
35
+ # and an expecation that a company's team must not be empty, the resulting
36
+ # error would have path: [:companies, 0, :teams]. if the path key is missing
37
+ # or the value is empty, the error refers to the root object.
38
+ #
39
+ # @example Creating An Errors Object
40
+ # errors = Stannum::Errors.new
41
+ #
42
+ # @example Adding Errors
43
+ # errors.add(:not_numeric)
44
+ #
45
+ # # Add an error with a custom message.
46
+ # errors.add(:invalid, message: 'is not valid')
47
+ #
48
+ # # Add an error with additional data.
49
+ # errors.add(:out_of_range, min: 0, max: 10)
50
+ #
51
+ # # Add multiple errors.
52
+ # errors.add(:first_error).add(:second_error).add(:third_error)
53
+ #
54
+ # @example Viewing The Errors
55
+ # errors.empty? #=> false
56
+ # errors.size #=> 6
57
+ #
58
+ # errors.each { |err| } #=> yields each error to the block
59
+ # errors.to_a #=> returns an array containing each error
60
+ #
61
+ # @example Accessing Nested Errors via a Key
62
+ # errors = Stannum::Errors.new
63
+ # child = errors[:spell]
64
+ # child.size #=> 0
65
+ # child.to_a #=> []
66
+ #
67
+ # child.add(:insufficient_mana)
68
+ # child.size # 1
69
+ # child.to_a # [{ type: :insufficient_mana, path: [] }]
70
+ #
71
+ # # Adding an error to a child makes it available on a parent.
72
+ # errors.size # 1
73
+ # errors.to_a # [{ type: :insufficient_mana, path: [:spell] }]
74
+ #
75
+ # @example Accessing Nested Errors via an Index
76
+ # errors = Stannum::Errors.new
77
+ # child = errors[1]
78
+ #
79
+ # child.size #=> 0
80
+ # child.to_a #=> []
81
+ #
82
+ # child.add(:unknown_monster)
83
+ # child.size # 1
84
+ # child.to_a # [{ type: :unknown_monster, path: [] }]
85
+ #
86
+ # # Adding an error to a child makes it available on a parent.
87
+ # errors.size # 1
88
+ # errors.to_a # [{ type: :unknown_monster, path: [1] }]
89
+ #
90
+ # @example Accessing Deeply Nested Errors
91
+ # errors = Stannum::Errors.new
92
+ #
93
+ # errors[:towns][1][:name].add(:unpronounceable)
94
+ # errors.size #=> 1
95
+ # errors.to_a #=> [{ type: :unpronounceable, path: [:towns, 1, :name] }]
96
+ #
97
+ # errors[:towns].size #=> 1
98
+ # errors[:towns].to_a #=> [{ type: :unpronounceable, path: [1, :name] }]
99
+ #
100
+ # errors[:towns][1].size #=> 1
101
+ # errors[:towns][1].to_a #=> [{ type: :unpronounceable, path: [:name] }]
102
+ #
103
+ # errors[:towns][1][:name].size #=> 1
104
+ # errors[:towns][1][:name].to_a #=> [{ type: :unpronounceable, path: [] }]
105
+ #
106
+ # # Can also access nested properties via #dig.
107
+ # errors.dig(:towns, 1, :name).to_a #=> [{ type: :unpronounceable, path: [] }]
108
+ #
109
+ # @example Replacing Errors
110
+ # errors = Cuprum::Errors.new
111
+ # errors[:potions][:ingredients].add(:missing_rabbits_foot)
112
+ # errors.size #=> 1
113
+ #
114
+ # other = Cuprum::Errors.new.add(:too_hot, :brew_longer, :foul_smelling)
115
+ # errors[:potions] = other
116
+ # errors.size #=> 3
117
+ # errors.to_a
118
+ # #=> [
119
+ # # { type: :brew_longer, path: [:potions] },
120
+ # # { type: :foul_smelling, path: [:potions] },
121
+ # # { type: :too_hot, path: [:potions] }
122
+ # # ]
123
+ #
124
+ # @example Replacing Nested Errors
125
+ # errors = Cuprum::Errors.new
126
+ # errors[:armory].add(:empty)
127
+ #
128
+ # other = Cuprum::Errors.new
129
+ # other.dig(:weapons, 0).add(:needs_sharpening)
130
+ # other.dig(:weapons, 1).add(:rusty).add(:out_of_ammo)
131
+ #
132
+ # errors[:armory] = other
133
+ # errors.size #=> 3
134
+ # errors.to_a
135
+ # #=> [
136
+ # # { type: needs_sharpening, path: [:armory, :weapons, 0] },
137
+ # # { type: out_of_ammo, path: [:armory, :weapons, 1] },
138
+ # # { type: rusty, path: [:armory, :weapons, 1] }
139
+ # # ]
140
+ class Errors # rubocop:disable Metrics/ClassLength
141
+ include Enumerable
142
+
143
+ def initialize
144
+ @children = Hash.new { |hsh, key| hsh[key] = self.class.new }
145
+ @cache = Set.new
146
+ @errors = []
147
+ end
148
+
149
+ # Checks if the other errors object contains the same errors.
150
+ #
151
+ # @return [true, false] true if the other object is an errors object or an
152
+ # array with the same class and errors, otherwise false.
153
+ def ==(other)
154
+ return false unless other.is_a?(Array) || other.is_a?(self.class)
155
+
156
+ return false unless empty? == other.empty?
157
+
158
+ compare_hashed_errors(other)
159
+ end
160
+ alias eql? ==
161
+
162
+ # Accesses a nested errors object.
163
+ #
164
+ # Each errors object can have one or more children, each of which is itself
165
+ # an errors object. These nested errors represent errors on some subset of
166
+ # the main object - for example, a failed validation of a named property,
167
+ # of the value in a key-value pair, or of an indexed value in an ordered
168
+ # collection.
169
+ #
170
+ # The children are created as needed and are stored with either an integer
171
+ # or a symbol key. Calling errors[1] multiple times will always return the
172
+ # same errors object. Likewise, calling errors[:some_key] multiple times
173
+ # will return the same object, and calling errors['some_key'] will return
174
+ # that same errors object as well.
175
+ #
176
+ # @param key [Integer, String, Symbol] The key or index of the referenced
177
+ # value, item, or property.
178
+ #
179
+ # @return [Stannum::Errors] an Errors object.
180
+ #
181
+ # @raise [ArgumentError] if the key is not a String, Symbol or Integer.
182
+ #
183
+ # @example Accessing Nested Errors via a Key
184
+ # errors = Stannum::Errors.new
185
+ # child = errors[:spell]
186
+ # child.size #=> 0
187
+ # child.to_a #=> []
188
+ #
189
+ # child.add(:insufficient_mana)
190
+ # child.size # 1
191
+ # child.to_a # [{ type: :insufficient_mana, path: [] }]
192
+ #
193
+ # # Adding an error to a child makes it available on a parent.
194
+ # errors.size # 1
195
+ # errors.to_a # [{ type: :insufficient_mana, path: [:spell] }]
196
+ #
197
+ # @example Accessing Nested Errors via an Index
198
+ # errors = Stannum::Errors.new
199
+ # child = errors[1]
200
+ #
201
+ # child.size #=> 0
202
+ # child.to_a #=> []
203
+ #
204
+ # child.add(:unknown_monster)
205
+ # child.size # 1
206
+ # child.to_a # [{ type: :unknown_monster, path: [] }]
207
+ #
208
+ # # Adding an error to a child makes it available on a parent.
209
+ # errors.size # 1
210
+ # errors.to_a # [{ type: :unknown_monster, path: [1] }]
211
+ #
212
+ # @example Accessing Deeply Nested Errors
213
+ # errors = Stannum::Errors.new
214
+ #
215
+ # errors[:towns][1][:name].add(:unpronounceable)
216
+ # errors.size #=> 1
217
+ # errors.to_a #=> [{ type: :unpronounceable, path: [:towns, 1, :name] }]
218
+ #
219
+ # errors[:towns].size #=> 1
220
+ # errors[:towns].to_a #=> [{ type: :unpronounceable, path: [1, :name] }]
221
+ #
222
+ # errors[:towns][1].size #=> 1
223
+ # errors[:towns][1].to_a #=> [{ type: :unpronounceable, path: [:name] }]
224
+ #
225
+ # errors[:towns][1][:name].size #=> 1
226
+ # errors[:towns][1][:name].to_a #=> [{ type: :unpronounceable, path: [] }]
227
+ #
228
+ # @see #[]=
229
+ #
230
+ # @see #dig
231
+ def [](key)
232
+ validate_key(key)
233
+
234
+ @children[key]
235
+ end
236
+
237
+ # Replaces the child errors with the specified errors object or Array.
238
+ #
239
+ # If the given value is nil or an empty array, the #[]= operator will remove
240
+ # the child errors object at the given key, removing all errors within that
241
+ # namespace and all namespaces nested inside it.
242
+ #
243
+ # If the given value is an errors object or an Array of errors object, the
244
+ # #[]= operation will replace the child errors object at the given key,
245
+ # removing all existing errors and adding the new errors. Each added error
246
+ # will use its nested path (if any) as a relative path from the given key.
247
+ #
248
+ # @param key [Integer, String, Symbol] The key or index of the referenced
249
+ # value, item, or property.
250
+ #
251
+ # @param value [Stannum::Errors, Array[Hash], nil] The errors to insert with
252
+ # the specified path.
253
+ #
254
+ # @return [Object] the value passed in.
255
+ #
256
+ # @raise [ArgumentError] if the key is not a String, Symbol or Integer.
257
+ #
258
+ # @raise [ArgumentError] if the value is not a valid errors object, Array of
259
+ # errors hashes, empty Array, or nil.
260
+ #
261
+ # @example Replacing Errors
262
+ # errors = Cuprum::Errors.new
263
+ # errors[:potions][:ingredients].add(:missing_rabbits_foot)
264
+ # errors.size #=> 1
265
+ #
266
+ # other = Cuprum::Errors.new.add(:too_hot, :brew_longer, :foul_smelling)
267
+ # errors[:potions] = other
268
+ # errors.size #=> 3
269
+ # errors.to_a
270
+ # #=> [
271
+ # # { type: :brew_longer, path: [:potions] },
272
+ # # { type: :foul_smelling, path: [:potions] },
273
+ # # { type: :too_hot, path: [:potions] }
274
+ # # ]
275
+ #
276
+ # @example Replacing Nested Errors
277
+ # errors = Cuprum::Errors.new
278
+ # errors[:armory].add(:empty)
279
+ #
280
+ # other = Cuprum::Errors.new
281
+ # other.dig(:weapons, 0).add(:needs_sharpening)
282
+ # other.dig(:weapons, 1).add(:rusty).add(:out_of_ammo)
283
+ #
284
+ # errors[:armory] = other
285
+ # errors.size #=> 3
286
+ # errors.to_a
287
+ # #=> [
288
+ # # { type: needs_sharpening, path: [:armory, :weapons, 0] },
289
+ # # { type: out_of_ammo, path: [:armory, :weapons, 1] },
290
+ # # { type: rusty, path: [:armory, :weapons, 1] }
291
+ # # ]
292
+ #
293
+ # @see #[]
294
+ def []=(key, value)
295
+ validate_key(key)
296
+
297
+ value = normalize_value(value, allow_nil: true)
298
+
299
+ @children[key] = value
300
+ end
301
+
302
+ # Adds an error of the specified type.
303
+ #
304
+ # @param type [String, Symbol] The error type. This should be a string or
305
+ # symbol with one or more underscored, dot-separated values.
306
+ # @param message [String] A custom error message to display. Optional;
307
+ # defaults to nil.
308
+ # @param data [Hash<Symbol, Object>] Additional data to store about the
309
+ # error, such as the expected type or the min/max values of the expected
310
+ # range. Optional; defaults to an empty Hash.
311
+ #
312
+ # @return [Stannum::Errors] the errors object.
313
+ #
314
+ # @raise [ArgumentError] if the type or message are invalid.
315
+ #
316
+ # @example Adding An Error
317
+ # errors = Stannum::Errors.new.add(:not_found)
318
+ #
319
+ # @example Adding An Error With A Message
320
+ # errors = Stannum::Errors.new.add(:not_found, message: 'is missing')
321
+ #
322
+ # @example Adding Multiple Errors
323
+ # errors = Stannum::Errors.new
324
+ # errors
325
+ # .add(:not_numeric)
326
+ # .add(:not_integer, message: 'is outside the range')
327
+ # .add(:not_in_range)
328
+ def add(type, message: nil, **data)
329
+ error = build_error(data: data, message: message, type: type)
330
+ hashed = error.hash
331
+
332
+ return self if @cache.include?(hashed)
333
+
334
+ @errors << error
335
+ @cache << hashed
336
+
337
+ self
338
+ end
339
+
340
+ # Accesses a (possibly deeply) nested errors object.
341
+ #
342
+ # Similiar to the #[] method, but can access a deeply nested errors object
343
+ # as well. The #dig method can take either a list of one or more keys
344
+ # (Integers, Strings, and Symbols) as arguments, or an Array of keys.
345
+ # Calling errors.dig is equivalent to calling errors[] with each key in
346
+ # sequence.
347
+ #
348
+ # @return [Stannum::Errors] the nested error object at the specified path.
349
+ #
350
+ # @raise [ArgumentError] if the keys are not Strings, Symbols or Integers.
351
+ #
352
+ # @overload dig(keys)
353
+ # @param keys [Array<Integer, String, Symbol>] The path to the nested
354
+ # errors object, as an array of Integers, Strings, and Symbols.
355
+ #
356
+ # @overload dig(*keys)
357
+ # @param keys [Array<Integer, String, Symbol>] The path to the nested
358
+ # errors object, as individual Integers, Strings, and Symbols.
359
+ #
360
+ # @example Accessing Nested Errors via a Key
361
+ # errors = Stannum::Errors.new
362
+ # child = errors.dig(:spell)
363
+ # child.size #=> 0
364
+ # child.to_a #=> []
365
+ #
366
+ # child.add(:insufficient_mana)
367
+ # child.size # 1
368
+ # child.to_a # [{ type: :insufficient_mana, path: [] }]
369
+ #
370
+ # # Adding an error to a child makes it available on a parent.
371
+ # errors.size # 1
372
+ # errors.to_a # [{ type: :insufficient_mana, path: [:spell] }]
373
+ #
374
+ # @example Accessing Nested Errors via an Index
375
+ # errors = Stannum::Errors.new
376
+ # child = errors.dig(1)
377
+ #
378
+ # child.size #=> 0
379
+ # child.to_a #=> []
380
+ #
381
+ # child.add(:unknown_monster)
382
+ # child.size # 1
383
+ # child.to_a # [{ type: :unknown_monster, path: [] }]
384
+ #
385
+ # # Adding an error to a child makes it available on a parent.
386
+ # errors.size # 1
387
+ # errors.to_a # [{ type: :unknown_monster, path: [1] }]
388
+ #
389
+ # @example Accessing Deeply Nested Errors
390
+ # errors = Stannum::Errors.new
391
+ #
392
+ # errors.dig(:towns, 1, :name).add(:unpronounceable)
393
+ # errors.size #=> 1
394
+ # errors.to_a #=> [{ type: :unpronounceable, path: [:towns, 1, :name] }]
395
+ #
396
+ # errors.dig(:towns).size #=> 1
397
+ # errors.dig(:towns).to_a #=> [{ type: :unpronounceable, path: [1, :name] }]
398
+ #
399
+ # errors.dig(:towns, 1).size #=> 1
400
+ # errors.dig(:towns, 1).to_a #=> [{ type: :unpronounceable, path: [:name] }]
401
+ #
402
+ # errors.dig(:towns, 1, :name).size #=> 1
403
+ # errors.dig(:towns, 1, :name).to_a #=> [{ type: :unpronounceable, path: [] }]
404
+ #
405
+ # @see #[]
406
+ def dig(first, *rest)
407
+ path = first.is_a?(Array) ? first : [first, *rest]
408
+
409
+ path.reduce(self) { |errors, segment| errors[segment] }
410
+ end
411
+
412
+ # Creates a deep copy of the errors object.
413
+ #
414
+ # @return [Stannum::Errors] the copy of the errors object.
415
+ def dup # rubocop:disable Metrics/MethodLength
416
+ child = self.class.new
417
+
418
+ each do |error|
419
+ child # rubocop:disable Style/SingleArgumentDig
420
+ .dig(error.fetch(:path, []))
421
+ .add(
422
+ error.fetch(:type),
423
+ message: error[:message],
424
+ **error.fetch(:data, {})
425
+ )
426
+ end
427
+
428
+ child
429
+ end
430
+
431
+ # @overload each
432
+ # Returns an Enumerator that iterates through the errors.
433
+ #
434
+ # @return [Enumerator]
435
+ #
436
+ # @overload each
437
+ # Iterates through the errors, yielding each error to the provided block.
438
+ #
439
+ # @yieldparam error [Hash<Symbol=>Object>] The error object. Each error
440
+ # is a hash containing the keys :data, :message, :path and :type.
441
+ def each
442
+ return to_enum(:each) { size } unless block_given?
443
+
444
+ @errors.each { |item| yield item.merge(path: []) }
445
+
446
+ @children.each do |path, child|
447
+ child.each do |item|
448
+ yield item.merge(path: item.fetch(:path, []).dup.unshift(path))
449
+ end
450
+ end
451
+ end
452
+
453
+ # Checks if the errors object contains any errors.
454
+ #
455
+ # @return [true, false] true if the errors object has no errors, otherwise
456
+ # false.
457
+ def empty?
458
+ @errors.empty? && @children.all?(&:empty?)
459
+ end
460
+ alias blank? empty?
461
+
462
+ # Groups the errors by the error path.
463
+ #
464
+ # Generates a Hash whose keys are the unique error :path values. For each
465
+ # path, the corresponding value is the Array of all errors with that path.
466
+ #
467
+ # This will flatten paths: an error with path [:parts] will be grouped in a
468
+ # separate array from a part with path [:parts, :assemblies].
469
+ #
470
+ # Errors with an empty path will be grouped with a key of an empty Array.
471
+ #
472
+ # @return [Hash<Array, Array>] the errors grouped by the error path.
473
+ #
474
+ # @overload group_by_path
475
+ #
476
+ # @overload group_by_path(&block)
477
+ # Groups the values returned by the block by the error path.
478
+ #
479
+ # @yieldparam error [Hash<Symbol>] the error Hash.
480
+ def group_by_path
481
+ grouped = Hash.new { |hsh, key| hsh[key] = [] }
482
+
483
+ each do |error|
484
+ path = error[:path]
485
+ value = block_given? ? yield(error) : error
486
+
487
+ grouped[path] << value
488
+ end
489
+
490
+ grouped
491
+ end
492
+
493
+ # @return [String] a human-readable representation of the object.
494
+ def inspect
495
+ oid = super[2...-1].split.first.split(':').last
496
+
497
+ "#<#{self.class.name}:#{oid} @summary=%{#{summary}}>"
498
+ end
499
+
500
+ # Adds the given errors to a copy of the errors object.
501
+ #
502
+ # Creates a copy of the errors object, and then adds each error in the
503
+ # passed in errors object or array to the copy. The copy will thus contain
504
+ # all of the errors from the original object and all of the errors from the
505
+ # passed in object. The original object is not changed.
506
+ #
507
+ # @param value [Stannum::Errors, Array[Hash]] The errors to add to the
508
+ # copied errors object.
509
+ #
510
+ # @return [Stannum::Errors] the copied errors object.
511
+ #
512
+ # @raise [ArgumentError] if the value is not a valid errors object or Array
513
+ # of errors hashes.
514
+ #
515
+ # @see #update.
516
+ def merge(value)
517
+ value = normalize_value(value, allow_nil: false)
518
+
519
+ dup.update_errors(value)
520
+ end
521
+
522
+ # The number of errors in the errors object.
523
+ #
524
+ # @return [Integer] the number of errors.
525
+ def size
526
+ @errors.size + @children.each_value.reduce(0) do |total, child|
527
+ total + child.size
528
+ end
529
+ end
530
+ alias count size
531
+
532
+ # Generates a text summary of the errors.
533
+ #
534
+ # @return [String] the text summary.
535
+ def summary
536
+ with_messages
537
+ .map { |error| generate_summary_item(error) }
538
+ .join(', ')
539
+ end
540
+
541
+ # Generates an array of error objects.
542
+ #
543
+ # Each error is a hash containing the keys :data, :message, :path and :type.
544
+ #
545
+ # @return [Array<Hash>] the error objects.
546
+ def to_a
547
+ each.to_a
548
+ end
549
+
550
+ # Adds the given errors to the errors object.
551
+ #
552
+ # Adds each error in the passed in errors object or array to the current
553
+ # errors object. It will then contain all of the original errors and all of
554
+ # the errors from the passed in object. This changes the current object.
555
+ #
556
+ # @param value [Stannum::Errors, Array[Hash]] The errors to add to the
557
+ # current errors object.
558
+ #
559
+ # @return [self] the current errors object.
560
+ #
561
+ # @raise [ArgumentError] if the value is not a valid errors object or Array
562
+ # of errors hashes.
563
+ #
564
+ # @see #merge.
565
+ def update(value)
566
+ value = normalize_value(value, allow_nil: false)
567
+
568
+ update_errors(value)
569
+ end
570
+
571
+ # Creates a copy of the errors and generates error messages for each error.
572
+ #
573
+ # @param force [Boolean] If true, overrides any messages already defined for
574
+ # the errors.
575
+ # @param strategy [#call] The strategy to use to generate the error
576
+ # messages.
577
+ #
578
+ # @return [Stannum::Errors] the copy of the errors object.
579
+ def with_messages(force: false, strategy: nil)
580
+ strategy ||= Stannum::Messages.strategy
581
+
582
+ dup.tap do |errors|
583
+ errors.each_error do |error|
584
+ next unless force || error[:message].nil? || error[:message].empty?
585
+
586
+ message = strategy.call(error[:type], **(error[:data] || {}))
587
+
588
+ error[:message] = message
589
+ end
590
+ end
591
+ end
592
+
593
+ protected
594
+
595
+ def each_error(&block)
596
+ return enum_for(:each_error) unless block_given?
597
+
598
+ @errors.each(&block)
599
+
600
+ @children.each_value do |child|
601
+ child.each_error(&block)
602
+ end
603
+ end
604
+
605
+ def update_errors(other_errors)
606
+ other_errors.each do |error|
607
+ dig(error.fetch(:path, []))
608
+ .add(
609
+ error.fetch(:type),
610
+ message: error[:message],
611
+ **error.fetch(:data, {})
612
+ )
613
+ end
614
+
615
+ self
616
+ end
617
+
618
+ private
619
+
620
+ def build_error(data:, message:, type:)
621
+ type = normalize_type(type)
622
+ msg = normalize_message(message)
623
+
624
+ { data: data, message: msg, type: type }
625
+ end
626
+
627
+ def compare_hashed_errors(other_errors)
628
+ hashes = Set.new(map(&:hash))
629
+ other_hashes = Set.new(other_errors.map(&:hash))
630
+
631
+ hashes == other_hashes
632
+ end
633
+
634
+ def generate_summary_item(error)
635
+ path = generate_summary_path(error[:path])
636
+
637
+ return error[:message] if path.nil? || path.empty?
638
+
639
+ "#{path}: #{error[:message]}"
640
+ end
641
+
642
+ def generate_summary_path(path)
643
+ return nil if path.empty?
644
+
645
+ return path.first.to_s if path.size == 1
646
+
647
+ path[1..-1].reduce(path.first.to_s) do |str, item|
648
+ item.is_a?(Integer) ? "#{str}[#{item}]" : "#{str}.#{item}"
649
+ end
650
+ end
651
+
652
+ def invalid_value_error(allow_nil)
653
+ values = ['an instance of Stannum::Errors', 'an array of error hashes']
654
+ values << 'nil' if allow_nil
655
+
656
+ 'value must be ' + # rubocop:disable Style/StringConcatenation
657
+ tools.array_tools.humanize_list(values, last_separator: ' or ')
658
+ end
659
+
660
+ def normalize_array_item(item, allow_nil:)
661
+ unless item.is_a?(Hash) && item.key?(:type)
662
+ raise ArgumentError, invalid_value_error(allow_nil)
663
+ end
664
+
665
+ item
666
+ end
667
+
668
+ def normalize_array_value(ary, allow_nil:)
669
+ child = self.class.new
670
+
671
+ ary.each do |item|
672
+ err = normalize_array_item(item, allow_nil: allow_nil)
673
+ data = err.fetch(:data, {})
674
+ path = err.fetch(:path, [])
675
+
676
+ child.dig(path).add(err[:type], message: err[:message], **data) # rubocop:disable Style/SingleArgumentDig
677
+ end
678
+
679
+ child
680
+ end
681
+
682
+ def normalize_message(message)
683
+ return if message.nil?
684
+
685
+ unless message.is_a?(String)
686
+ raise ArgumentError, 'message must be a String'
687
+ end
688
+
689
+ raise ArgumentError, "message can't be blank" if message.empty?
690
+
691
+ message
692
+ end
693
+
694
+ def normalize_type(type)
695
+ raise ArgumentError, "error type can't be nil" if type.nil?
696
+
697
+ unless type.is_a?(String) || type.is_a?(Symbol)
698
+ raise ArgumentError, 'error type must be a String or Symbol'
699
+ end
700
+
701
+ raise ArgumentError, "error type can't be blank" if type.empty?
702
+
703
+ type.to_s
704
+ end
705
+
706
+ def normalize_value(value, allow_nil: false)
707
+ return self.class.new if value.nil? && allow_nil
708
+
709
+ return value.dup if value.is_a?(self.class)
710
+
711
+ if value.is_a?(Array)
712
+ return normalize_array_value(value, allow_nil: allow_nil)
713
+ end
714
+
715
+ raise ArgumentError, invalid_value_error(allow_nil)
716
+ end
717
+
718
+ def tools
719
+ SleepingKingStudios::Tools::Toolbelt.instance
720
+ end
721
+
722
+ def validate_key(key)
723
+ return if key.is_a?(Integer) || key.is_a?(Symbol) || key.is_a?(String)
724
+
725
+ raise ArgumentError,
726
+ 'key must be an Integer, a String or a Symbol',
727
+ caller(1..-1)
728
+ end
729
+ end
730
+ end