stannum 0.1.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.
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