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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/DEVELOPMENT.md +105 -0
- data/LICENSE +22 -0
- data/README.md +1327 -0
- data/config/locales/en.rb +47 -0
- data/lib/stannum/attribute.rb +115 -0
- data/lib/stannum/constraint.rb +65 -0
- data/lib/stannum/constraints/absence.rb +42 -0
- data/lib/stannum/constraints/anything.rb +28 -0
- data/lib/stannum/constraints/base.rb +285 -0
- data/lib/stannum/constraints/boolean.rb +33 -0
- data/lib/stannum/constraints/delegator.rb +71 -0
- data/lib/stannum/constraints/enum.rb +64 -0
- data/lib/stannum/constraints/equality.rb +47 -0
- data/lib/stannum/constraints/hashes/extra_keys.rb +126 -0
- data/lib/stannum/constraints/hashes/indifferent_key.rb +74 -0
- data/lib/stannum/constraints/hashes.rb +11 -0
- data/lib/stannum/constraints/identity.rb +46 -0
- data/lib/stannum/constraints/nothing.rb +28 -0
- data/lib/stannum/constraints/presence.rb +42 -0
- data/lib/stannum/constraints/signature.rb +92 -0
- data/lib/stannum/constraints/signatures/map.rb +17 -0
- data/lib/stannum/constraints/signatures/tuple.rb +17 -0
- data/lib/stannum/constraints/signatures.rb +11 -0
- data/lib/stannum/constraints/tuples/extra_items.rb +84 -0
- data/lib/stannum/constraints/tuples.rb +10 -0
- data/lib/stannum/constraints/type.rb +113 -0
- data/lib/stannum/constraints/types/array_type.rb +148 -0
- data/lib/stannum/constraints/types/big_decimal_type.rb +16 -0
- data/lib/stannum/constraints/types/date_time_type.rb +16 -0
- data/lib/stannum/constraints/types/date_type.rb +16 -0
- data/lib/stannum/constraints/types/float_type.rb +14 -0
- data/lib/stannum/constraints/types/hash_type.rb +205 -0
- data/lib/stannum/constraints/types/hash_with_indifferent_keys.rb +21 -0
- data/lib/stannum/constraints/types/hash_with_string_keys.rb +21 -0
- data/lib/stannum/constraints/types/hash_with_symbol_keys.rb +21 -0
- data/lib/stannum/constraints/types/integer_type.rb +14 -0
- data/lib/stannum/constraints/types/nil_type.rb +20 -0
- data/lib/stannum/constraints/types/proc_type.rb +14 -0
- data/lib/stannum/constraints/types/string_type.rb +14 -0
- data/lib/stannum/constraints/types/symbol_type.rb +14 -0
- data/lib/stannum/constraints/types/time_type.rb +14 -0
- data/lib/stannum/constraints/types.rb +25 -0
- data/lib/stannum/constraints/union.rb +85 -0
- data/lib/stannum/constraints.rb +26 -0
- data/lib/stannum/contract.rb +243 -0
- data/lib/stannum/contracts/array_contract.rb +108 -0
- data/lib/stannum/contracts/base.rb +597 -0
- data/lib/stannum/contracts/builder.rb +72 -0
- data/lib/stannum/contracts/definition.rb +74 -0
- data/lib/stannum/contracts/hash_contract.rb +136 -0
- data/lib/stannum/contracts/indifferent_hash_contract.rb +78 -0
- data/lib/stannum/contracts/map_contract.rb +199 -0
- data/lib/stannum/contracts/parameters/arguments_contract.rb +185 -0
- data/lib/stannum/contracts/parameters/keywords_contract.rb +174 -0
- data/lib/stannum/contracts/parameters/signature_contract.rb +29 -0
- data/lib/stannum/contracts/parameters.rb +15 -0
- data/lib/stannum/contracts/parameters_contract.rb +530 -0
- data/lib/stannum/contracts/tuple_contract.rb +213 -0
- data/lib/stannum/contracts.rb +19 -0
- data/lib/stannum/errors.rb +730 -0
- data/lib/stannum/messages/default_strategy.rb +124 -0
- data/lib/stannum/messages.rb +25 -0
- data/lib/stannum/parameter_validation.rb +216 -0
- data/lib/stannum/rspec/match_errors.rb +17 -0
- data/lib/stannum/rspec/match_errors_matcher.rb +93 -0
- data/lib/stannum/rspec/validate_parameter.rb +23 -0
- data/lib/stannum/rspec/validate_parameter_matcher.rb +506 -0
- data/lib/stannum/rspec.rb +8 -0
- data/lib/stannum/schema.rb +131 -0
- data/lib/stannum/struct.rb +444 -0
- data/lib/stannum/support/coercion.rb +114 -0
- data/lib/stannum/support/optional.rb +69 -0
- data/lib/stannum/support.rb +8 -0
- data/lib/stannum/version.rb +57 -0
- data/lib/stannum.rb +27 -0
- 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
|