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,597 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stannum/contracts'
|
4
|
+
|
5
|
+
module Stannum::Contracts
|
6
|
+
# A Contract aggregates constraints about the given object.
|
7
|
+
#
|
8
|
+
# @example Creating A Contract With Constraints
|
9
|
+
# numeric_constraint =
|
10
|
+
# Stannum::Constraint.new(type: 'not_numeric', negated_type: 'numeric') do |actual|
|
11
|
+
# actual.is_a?(Numeric)
|
12
|
+
# end
|
13
|
+
# integer_constraint =
|
14
|
+
# Stannum::Constraint.new(type: 'not_integer', negated_type: 'integer') do |actual|
|
15
|
+
# actual.is_a?(Integer)
|
16
|
+
# end
|
17
|
+
# range_constraint =
|
18
|
+
# Stannum::Constraint.new(type: 'not_in_range', negated_type: 'in_range') do |actual|
|
19
|
+
# actual >= 0 && actual <= 10 rescue false
|
20
|
+
# end
|
21
|
+
# contract =
|
22
|
+
# Stannum::Contracts::Base.new
|
23
|
+
# .add_constraint(numeric_constraint)
|
24
|
+
# .add_constraint(integer_constraint)
|
25
|
+
# .add_constraint(range_constraint)
|
26
|
+
#
|
27
|
+
# @example With An Object That Matches None Of The Constraints
|
28
|
+
# contract.matches?(nil) #=> false
|
29
|
+
# errors = contract.errors_for(nil) #=> Cuprum::Errors
|
30
|
+
# errors.to_a
|
31
|
+
# #=> [
|
32
|
+
# { type: 'not_numeric', data: {}, path: [], message: nil },
|
33
|
+
# { type: 'not_integer', data: {}, path: [], message: nil },
|
34
|
+
# { type: 'not_in_range', data: {}, path: [], message: nil }
|
35
|
+
# ]
|
36
|
+
#
|
37
|
+
# contract.does_not_match?(nil) #=> true
|
38
|
+
# errors = contract.negated_errors_for(nil) #=> Cuprum::Errors
|
39
|
+
# errors.to_a
|
40
|
+
# #=> []
|
41
|
+
#
|
42
|
+
# @example With An Object That Matches Some Of The Constraints
|
43
|
+
# contract.matches?(11) #=> false
|
44
|
+
# contract.errors_for(11).to_a
|
45
|
+
# #=> [
|
46
|
+
# { type: 'not_in_range', data: {}, path: [], message: nil }
|
47
|
+
# ]
|
48
|
+
#
|
49
|
+
# contract.does_not_match?(11) #=> true
|
50
|
+
# contract.negated_errors_for(11).to_a
|
51
|
+
# #=> [
|
52
|
+
# { type: 'numeric', data: {}, path: [], message: nil },
|
53
|
+
# { type: 'integer', data: {}, path: [], message: nil }
|
54
|
+
# ]
|
55
|
+
#
|
56
|
+
# @example With An Object That Matches All Of The Constraints
|
57
|
+
# contract.matches?(5) #=> true
|
58
|
+
# contract.errors_for(5).to_a #=> []
|
59
|
+
#
|
60
|
+
# contract.does_not_match?(5) #=> false
|
61
|
+
# contract.negated_errors_for(5)
|
62
|
+
# #=> [
|
63
|
+
# { type: 'numeric', data: {}, path: [], message: nil },
|
64
|
+
# { type: 'integer', data: {}, path: [], message: nil },
|
65
|
+
# { type: 'in_range', data: {}, path: [], message: nil }
|
66
|
+
# ]
|
67
|
+
#
|
68
|
+
# @example Creating A Contract With A Sanity Constraint
|
69
|
+
# format_constraint =
|
70
|
+
# Stannum::Constraint.new(type: 'invalid_format', negated_type: 'valid_format') do |actual|
|
71
|
+
# actual =~ /\A0x[0-9A-Fa-f]*\z/
|
72
|
+
# end
|
73
|
+
# length_constraint =
|
74
|
+
# Stannum::Constraint.new(type: 'invalid_length', negated_type: 'valid_length') do |actual|
|
75
|
+
# actual.length > 2
|
76
|
+
# end
|
77
|
+
# string_constraint = Stannum::Constraints::Type.new(String)
|
78
|
+
# contract =
|
79
|
+
# Stannum::Contracts::Base.new
|
80
|
+
# .add_constraint(string_constraint, sanity: true)
|
81
|
+
# .add_constraint(format_constraint)
|
82
|
+
# .add_constraint(length_constraint)
|
83
|
+
#
|
84
|
+
# @example With An Object That Does Not Match The Sanity Constraint
|
85
|
+
# contract.matches?(nil) #=> false
|
86
|
+
# errors = contract.errors_for(nil) #=> Cuprum::Errors
|
87
|
+
# errors.to_a
|
88
|
+
# #=> [
|
89
|
+
# {
|
90
|
+
# data: { type: String},
|
91
|
+
# message: nil,
|
92
|
+
# path: [],
|
93
|
+
# type: 'stannum.constraints.is_not_type'
|
94
|
+
# }
|
95
|
+
# ]
|
96
|
+
#
|
97
|
+
# contract.does_not_match?(nil) #=> true
|
98
|
+
# errors = contract.negated_errors_for(nil) #=> Cuprum::Errors
|
99
|
+
# errors.to_a
|
100
|
+
# #=> []
|
101
|
+
class Base < Stannum::Constraints::Base # rubocop:disable Metrics/ClassLength
|
102
|
+
STOP_ITERATION = Object.new.freeze
|
103
|
+
private_constant :STOP_ITERATION
|
104
|
+
|
105
|
+
# @param options [Hash<Symbol, Object>] Configuration options for the
|
106
|
+
# contract. Defaults to an empty Hash.
|
107
|
+
def initialize(**options, &block)
|
108
|
+
@constraints = []
|
109
|
+
@concatenated = []
|
110
|
+
|
111
|
+
super(**options)
|
112
|
+
|
113
|
+
define_constraints(&block)
|
114
|
+
end
|
115
|
+
|
116
|
+
# Performs an equality comparison.
|
117
|
+
#
|
118
|
+
# @param other [Object] The object to compare.
|
119
|
+
#
|
120
|
+
# @return [true, false] true if the other object has the same class,
|
121
|
+
# options, and constraints; otherwise false.
|
122
|
+
def ==(other)
|
123
|
+
super && equal_definitions?(other)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Adds a constraint to the contract.
|
127
|
+
#
|
128
|
+
# When the contract is matched with an object, the constraint will be
|
129
|
+
# evaluated with the object and the errors updated accordingly.
|
130
|
+
#
|
131
|
+
# @param constraint [Stannum::Constraints::Base] The constraint to add.
|
132
|
+
# @param sanity [true, false] Marks the constraint as a sanity constraint,
|
133
|
+
# which is always matched first and will always short-circuit on a failed
|
134
|
+
# match.
|
135
|
+
# @param options [Hash<Symbol, Object>] Options for the constraint. These
|
136
|
+
# can be used by subclasses to define the value and error mappings for the
|
137
|
+
# constraint.
|
138
|
+
#
|
139
|
+
# @return [self] the contract.
|
140
|
+
def add_constraint(constraint, sanity: false, **options)
|
141
|
+
validate_constraint(constraint)
|
142
|
+
|
143
|
+
@constraints << Stannum::Contracts::Definition.new(
|
144
|
+
constraint: constraint,
|
145
|
+
contract: self,
|
146
|
+
options: options.merge(sanity: sanity)
|
147
|
+
)
|
148
|
+
|
149
|
+
self
|
150
|
+
end
|
151
|
+
|
152
|
+
# Concatenate the constraints from the given other contract.
|
153
|
+
#
|
154
|
+
# Merges the constraints from the concatenated contract into the original.
|
155
|
+
# This is a dynamic process - if constraints are added to the concatenated
|
156
|
+
# contract at a later point, they will also be added to the original. This
|
157
|
+
# is also recursive - concatenating a contract will also merge the
|
158
|
+
# constraints from any contracts that were themselves concatenated in the
|
159
|
+
# concatenated contract.
|
160
|
+
#
|
161
|
+
# There are two approaches for adding one contract to another. The first and
|
162
|
+
# simplest is to take advantage of the fact that each contract is, itself, a
|
163
|
+
# constraint. Adding the new contract to the original via #add_constraint
|
164
|
+
# works in most cases - the new contract will be called during #matches? and
|
165
|
+
# when generating errors. However, functionality that inspects the
|
166
|
+
# constraints directly (such as the :allow_extra_keys functionality in
|
167
|
+
# HashContract) will fail.
|
168
|
+
#
|
169
|
+
# Concatenating a contract in another is a much closer relationship. Each
|
170
|
+
# time the constraints on the original contract are enumerated, it will also
|
171
|
+
# yield the constraints from the concatenated contract (and from any
|
172
|
+
# contracts that are concatenated in that contract, recursively).
|
173
|
+
#
|
174
|
+
# To sum up, use #add_constraint when you want to constrain a property of
|
175
|
+
# the actual object with a contract. Use #concat when you want to add more
|
176
|
+
# constraints about the object itself.
|
177
|
+
#
|
178
|
+
# @example Concatenating A Contract
|
179
|
+
# concatenated_contract = Stannum::Contract.new
|
180
|
+
# .add_constraint(Stannum::Constraint.new { |int| int < 10 })
|
181
|
+
#
|
182
|
+
# original_contract = Stannum::Contract.new
|
183
|
+
# .add_constraint(Stannum::Constraint.new { |int| int >= 0 })
|
184
|
+
# .concat(concatenated_contract)
|
185
|
+
#
|
186
|
+
# original_contract.matches?(-1) #=> a failing result
|
187
|
+
# original_contract.matches?(0) #=> a passing result
|
188
|
+
# original_contract.matches?(5) #=> a passing result
|
189
|
+
# original_contract.matches?(10) #=> a failing result
|
190
|
+
#
|
191
|
+
# @param other [Stannum::Contract] the other contract.
|
192
|
+
#
|
193
|
+
# @return [Stannum::Contract] the original contract.
|
194
|
+
#
|
195
|
+
# @see #add_constraint
|
196
|
+
def concat(other)
|
197
|
+
validate_contract(other)
|
198
|
+
|
199
|
+
@concatenated << other
|
200
|
+
|
201
|
+
self
|
202
|
+
end
|
203
|
+
|
204
|
+
# Checks that none of the added constraints match the object.
|
205
|
+
#
|
206
|
+
# If the contract defines sanity constraints, the sanity constraints will be
|
207
|
+
# matched first. If any of the sanity constraints fail (#does_not_match?
|
208
|
+
# for the constraint returns true), then this method will immediately return
|
209
|
+
# true and all subsequent constraints will be skipped.
|
210
|
+
#
|
211
|
+
# @param actual [Object] The object to match.
|
212
|
+
#
|
213
|
+
# @return [true, false] True if none of the constraints match the given
|
214
|
+
# object; otherwise false. If there are no constraints, returns true.
|
215
|
+
#
|
216
|
+
# @see #each_pair
|
217
|
+
# @see #matches?
|
218
|
+
# @see #negated_match
|
219
|
+
def does_not_match?(actual)
|
220
|
+
each_pair(actual) do |definition, value|
|
221
|
+
if definition.contract.match_negated_constraint(definition, value)
|
222
|
+
next unless definition.sanity?
|
223
|
+
|
224
|
+
return true
|
225
|
+
end
|
226
|
+
|
227
|
+
return false
|
228
|
+
end
|
229
|
+
|
230
|
+
true
|
231
|
+
end
|
232
|
+
|
233
|
+
# Iterates through the constraints defined for the contract.
|
234
|
+
#
|
235
|
+
# Any constraints defined on concatenated contracts are yielded, followed by
|
236
|
+
# any constraints defined on the contract itself.
|
237
|
+
#
|
238
|
+
# Each constraint is represented as a Stannum::Contracts::Definition, which
|
239
|
+
# encapsulates the constraint, the original contract, and the options
|
240
|
+
# specified by #add_constraint.
|
241
|
+
#
|
242
|
+
# If the contract defines sanity constraints, the sanity constraints will be
|
243
|
+
# returned or yielded first, followed by the remaining constraints.
|
244
|
+
#
|
245
|
+
# @overload each_constraint
|
246
|
+
# @return [Enumerator] An enumerator for the constraint definitions.
|
247
|
+
#
|
248
|
+
# @overload each_constraint
|
249
|
+
# @yieldparam definition [Stannum::Contracts::Definition] Each definition
|
250
|
+
# from the contract or concatenated contracts.
|
251
|
+
#
|
252
|
+
# @see #concat
|
253
|
+
# @see #each_pair
|
254
|
+
def each_constraint
|
255
|
+
return enum_for(:each_constraint) unless block_given?
|
256
|
+
|
257
|
+
each_unscoped_constraint do |definition|
|
258
|
+
yield definition if definition.sanity?
|
259
|
+
end
|
260
|
+
|
261
|
+
each_unscoped_constraint do |definition| # rubocop:disable Style/CombinableLoops
|
262
|
+
yield definition unless definition.sanity?
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Iterates through the constraints and mapped values.
|
267
|
+
#
|
268
|
+
# For each constraint defined for the contract, the contract defines a data
|
269
|
+
# mapping representing the object or property that the constraint will
|
270
|
+
# match. Calling #each_pair for an object yields the constraint and the
|
271
|
+
# mapped object or property for that constraint and object.
|
272
|
+
#
|
273
|
+
# If the contract defines sanity constraints, the sanity constraints will be
|
274
|
+
# returned or yielded first, followed by the remaining constraints.
|
275
|
+
#
|
276
|
+
# By default, this mapping returns the object itself; however, this can be
|
277
|
+
# overriden in subclasses based on the constraint options, such as matching
|
278
|
+
# constraints against the properties of an object rather than the object
|
279
|
+
# itself.
|
280
|
+
#
|
281
|
+
# This enumerator is used internally to implement the Constraint interface
|
282
|
+
# for subclasses of Contract.
|
283
|
+
#
|
284
|
+
# @param actual [Object] The object to match.
|
285
|
+
#
|
286
|
+
# @overload each_pair(actual)
|
287
|
+
# @return [Enumerator] An enumerator for the constraints and values.
|
288
|
+
#
|
289
|
+
# @overload each_pair(actual)
|
290
|
+
# @yieldparam definition [Stannum::Contracts::Definition] Each definition
|
291
|
+
# from the contract or concatenated contracts.
|
292
|
+
# @yieldparam value [Object] The mapped value for that constraint.
|
293
|
+
#
|
294
|
+
# @see #each_constraint
|
295
|
+
def each_pair(actual)
|
296
|
+
return enum_for(:each_pair, actual) unless block_given?
|
297
|
+
|
298
|
+
each_constraint do |definition|
|
299
|
+
value = definition.contract.map_value(actual, **definition.options)
|
300
|
+
|
301
|
+
yield definition, value
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
# Aggregates errors for each constraint that does not match the object.
|
306
|
+
#
|
307
|
+
# For each defined constraint, the constraint is matched against the mapped
|
308
|
+
# value for that constraint and the object. If the constraint does not match
|
309
|
+
# the mapped value, the corresponding errors will be added to the errors
|
310
|
+
# object.
|
311
|
+
#
|
312
|
+
# If the contract defines sanity constraints, the sanity constraints will be
|
313
|
+
# matched first. If any of the sanity constraints fail, #errors_for will
|
314
|
+
# immediately return the errors for the failed constraint.
|
315
|
+
#
|
316
|
+
# @param actual [Object] The object to match.
|
317
|
+
# @param errors [Stannum::Errors] The errors object to append errors to. If
|
318
|
+
# an errors object is not given, a new errors object will be created.
|
319
|
+
#
|
320
|
+
# @return [Stannum::Errors] the given or generated errors object.
|
321
|
+
#
|
322
|
+
# @see #each_pair
|
323
|
+
# @see #match
|
324
|
+
# @see #negated_errors_for
|
325
|
+
def errors_for(actual, errors: nil)
|
326
|
+
errors ||= Stannum::Errors.new
|
327
|
+
|
328
|
+
each_pair(actual) do |definition, value|
|
329
|
+
next if match_constraint(definition, value)
|
330
|
+
|
331
|
+
definition.contract.add_errors_for(definition, value, errors)
|
332
|
+
|
333
|
+
return errors if definition.sanity?
|
334
|
+
end
|
335
|
+
|
336
|
+
errors
|
337
|
+
end
|
338
|
+
|
339
|
+
# Matches and generates errors for each constraint.
|
340
|
+
#
|
341
|
+
# For each defined constraint, the constraint is matched against the
|
342
|
+
# mapped value for that constraint and the object. If the constraint does
|
343
|
+
# not match the mapped value, the corresponding errors will be added to
|
344
|
+
# the errors object.
|
345
|
+
#
|
346
|
+
# Finally, if all of the constraints match the mapped value, #match will
|
347
|
+
# return true and the errors object. Otherwise, #match will return false and
|
348
|
+
# the errors object.
|
349
|
+
#
|
350
|
+
# If the contract defines sanity constraints, the sanity constraints will be
|
351
|
+
# matched first. If any of the sanity constraints fail (#matches? for the
|
352
|
+
# constraint returns false), then this method will immediately return
|
353
|
+
# false and the errors for the failed sanity constraint; and all subsequent
|
354
|
+
# constraints will be skipped.
|
355
|
+
#
|
356
|
+
# @param actual [Object] The object to match.
|
357
|
+
#
|
358
|
+
# @return [<Array(Boolean, Stannum::Errors)>] the status (true or false) and
|
359
|
+
# the generated errors object.
|
360
|
+
#
|
361
|
+
# @see #each_pair
|
362
|
+
# @see #errors_for
|
363
|
+
# @see #matches?
|
364
|
+
# @see #negated_match
|
365
|
+
def match(actual)
|
366
|
+
status = true
|
367
|
+
errors = Stannum::Errors.new
|
368
|
+
|
369
|
+
each_pair(actual) do |definition, value|
|
370
|
+
next if definition.contract.match_constraint(definition, value)
|
371
|
+
|
372
|
+
status = false
|
373
|
+
|
374
|
+
definition.contract.send(:add_errors_for, definition, value, errors)
|
375
|
+
|
376
|
+
return [status, errors] if definition.sanity?
|
377
|
+
end
|
378
|
+
|
379
|
+
[status, errors]
|
380
|
+
end
|
381
|
+
|
382
|
+
# Checks that all of the added constraints match the object.
|
383
|
+
#
|
384
|
+
# If the contract defines sanity constraints, the sanity constraints will be
|
385
|
+
# matched first. If any of the sanity constraints fail (#does_not_match?
|
386
|
+
# for the constraint returns true), then this method will immediately return
|
387
|
+
# false and all subsequent constraints will be skipped.
|
388
|
+
#
|
389
|
+
# @param actual [Object] The object to match.
|
390
|
+
#
|
391
|
+
# @return [true, false] True if all of the constraints match the given
|
392
|
+
# object; otherwise false. If there are no constraints, returns true.
|
393
|
+
#
|
394
|
+
# @see #does_not_match?
|
395
|
+
# @see #each_pair
|
396
|
+
# @see #match
|
397
|
+
def matches?(actual)
|
398
|
+
each_pair(actual) do |definition, value|
|
399
|
+
unless definition.contract.match_constraint(definition, value)
|
400
|
+
return false
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
true
|
405
|
+
end
|
406
|
+
alias match? matches?
|
407
|
+
|
408
|
+
# Aggregates errors for each constraint that matches the object.
|
409
|
+
#
|
410
|
+
# For each defined constraint, the constraint is matched against the mapped
|
411
|
+
# value for that constraint and the object. If the constraint matches the
|
412
|
+
# mapped value, the corresponding errors will be added to the errors object.
|
413
|
+
#
|
414
|
+
# If the contract defines sanity constraints, the sanity constraints will be
|
415
|
+
# matched first. If any of the sanity constraints fail, #errors_for will
|
416
|
+
# immediately return any errors already added to the errors object.
|
417
|
+
#
|
418
|
+
# @param actual [Object] The object to match.
|
419
|
+
# @param errors [Stannum::Errors] The errors object to append errors to. If
|
420
|
+
# an errors object is not given, a new errors object will be created.
|
421
|
+
#
|
422
|
+
# @return [Stannum::Errors] the given or generated errors object.
|
423
|
+
#
|
424
|
+
# @see #each_pair
|
425
|
+
# @see #errors_for
|
426
|
+
# @see #negated_match
|
427
|
+
def negated_errors_for(actual, errors: nil)
|
428
|
+
errors ||= Stannum::Errors.new
|
429
|
+
|
430
|
+
each_pair(actual) do |definition, value|
|
431
|
+
if match_negated_constraint(definition, value)
|
432
|
+
next unless definition.sanity?
|
433
|
+
|
434
|
+
return errors
|
435
|
+
end
|
436
|
+
|
437
|
+
definition.contract.add_negated_errors_for(definition, value, errors)
|
438
|
+
end
|
439
|
+
|
440
|
+
errors
|
441
|
+
end
|
442
|
+
|
443
|
+
# Matches and generates errors for each constraint.
|
444
|
+
#
|
445
|
+
# For each defined constraint, the constraint is matched against the
|
446
|
+
# mapped value for that constraint and the object. If the constraint
|
447
|
+
# matches the mapped value, the corresponding errors will be added to
|
448
|
+
# the errors object.
|
449
|
+
#
|
450
|
+
# Finally, if none of the constraints match the mapped value, #match will
|
451
|
+
# return true and the errors object. Otherwise, #match will return false and
|
452
|
+
# the errors object.
|
453
|
+
#
|
454
|
+
# If the contract defines sanity constraints, the sanity constraints will be
|
455
|
+
# matched first. If any of the sanity constraints fail (#does_not_match?
|
456
|
+
# for the constraint returns true), then this method will immediately return
|
457
|
+
# true and any errors already set; and all subsequent constraints will be
|
458
|
+
# skipped.
|
459
|
+
#
|
460
|
+
# @param actual [Object] The object to match.
|
461
|
+
#
|
462
|
+
# @return [<Array(Boolean, Stannum::Errors)>] the status (true or false) and
|
463
|
+
# the generated errors object.
|
464
|
+
#
|
465
|
+
# @see #does_not_match?
|
466
|
+
# @see #each_pair
|
467
|
+
# @see #match
|
468
|
+
# @see #negated_errors_for
|
469
|
+
def negated_match(actual) # rubocop:disable Metrics/MethodLength
|
470
|
+
status = true
|
471
|
+
errors = Stannum::Errors.new
|
472
|
+
|
473
|
+
each_pair(actual) do |definition, value|
|
474
|
+
if definition.contract.match_negated_constraint(definition, value)
|
475
|
+
next unless definition.sanity?
|
476
|
+
|
477
|
+
return [true, errors]
|
478
|
+
end
|
479
|
+
|
480
|
+
status = false
|
481
|
+
|
482
|
+
definition.contract.add_negated_errors_for(definition, value, errors)
|
483
|
+
end
|
484
|
+
|
485
|
+
[status, errors]
|
486
|
+
end
|
487
|
+
|
488
|
+
protected
|
489
|
+
|
490
|
+
attr_accessor :concatenated
|
491
|
+
|
492
|
+
attr_accessor :constraints
|
493
|
+
|
494
|
+
def add_errors_for(definition, value, errors)
|
495
|
+
definition
|
496
|
+
.constraint
|
497
|
+
.errors_for(
|
498
|
+
value,
|
499
|
+
errors: map_errors(errors, **definition.options)
|
500
|
+
)
|
501
|
+
end
|
502
|
+
|
503
|
+
def add_negated_errors_for(definition, value, errors)
|
504
|
+
definition
|
505
|
+
.constraint
|
506
|
+
.negated_errors_for(
|
507
|
+
value,
|
508
|
+
errors: map_errors(errors, **definition.options)
|
509
|
+
)
|
510
|
+
end
|
511
|
+
|
512
|
+
def copy_properties(source, options: nil, **_)
|
513
|
+
super
|
514
|
+
|
515
|
+
self.constraints = source.constraints.dup
|
516
|
+
self.concatenated = source.concatenated.dup
|
517
|
+
|
518
|
+
self
|
519
|
+
end
|
520
|
+
|
521
|
+
def each_unscoped_constraint(&block)
|
522
|
+
return enum_for(:each_unscoped_constraint) unless block_given?
|
523
|
+
|
524
|
+
each_concatenated_contract do |contract|
|
525
|
+
contract.each_constraint do |definition|
|
526
|
+
yield definition if definition.concatenatable?
|
527
|
+
end
|
528
|
+
end
|
529
|
+
|
530
|
+
@constraints.each(&block)
|
531
|
+
end
|
532
|
+
|
533
|
+
def match_constraint(definition, value)
|
534
|
+
definition.constraint.matches?(value)
|
535
|
+
end
|
536
|
+
|
537
|
+
def match_negated_constraint(definition, value)
|
538
|
+
definition.constraint.does_not_match?(value)
|
539
|
+
end
|
540
|
+
|
541
|
+
def map_errors(errors, **_options)
|
542
|
+
errors
|
543
|
+
end
|
544
|
+
|
545
|
+
def map_value(actual, **_options)
|
546
|
+
actual
|
547
|
+
end
|
548
|
+
|
549
|
+
private
|
550
|
+
|
551
|
+
def define_constraints(&block)
|
552
|
+
self.class::Builder.new(self).instance_exec(&block) if block_given?
|
553
|
+
end
|
554
|
+
|
555
|
+
def each_concatenated_contract(&block)
|
556
|
+
return enum_for(:each_concatenated_contract) unless block_given?
|
557
|
+
|
558
|
+
@concatenated.each(&block)
|
559
|
+
end
|
560
|
+
|
561
|
+
def equal_definitions?(other) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
562
|
+
own_defns = each_unscoped_constraint
|
563
|
+
other_defns = other.each_unscoped_constraint
|
564
|
+
|
565
|
+
loop do
|
566
|
+
# rubocop:disable Layout/EmptyLinesAroundExceptionHandlingKeywords, Lint/RedundantCopDisableDirective
|
567
|
+
u = begin; own_defns.next; rescue StopIteration; STOP_ITERATION; end
|
568
|
+
v = begin; other_defns.next; rescue StopIteration; STOP_ITERATION; end
|
569
|
+
# rubocop:enable Layout/EmptyLinesAroundExceptionHandlingKeywords, Lint/RedundantCopDisableDirective
|
570
|
+
|
571
|
+
return true if u == STOP_ITERATION && v == STOP_ITERATION
|
572
|
+
|
573
|
+
return false if u == STOP_ITERATION || v == STOP_ITERATION
|
574
|
+
|
575
|
+
unless u.constraint == v.constraint && u.options == v.options
|
576
|
+
return false
|
577
|
+
end
|
578
|
+
end
|
579
|
+
end
|
580
|
+
|
581
|
+
def validate_constraint(constraint)
|
582
|
+
return if constraint.is_a?(Stannum::Constraints::Base)
|
583
|
+
|
584
|
+
raise ArgumentError,
|
585
|
+
'must be an instance of Stannum::Constraints::Base',
|
586
|
+
caller(1..-1)
|
587
|
+
end
|
588
|
+
|
589
|
+
def validate_contract(constraint)
|
590
|
+
return if constraint.is_a?(Stannum::Contracts::Base)
|
591
|
+
|
592
|
+
raise ArgumentError,
|
593
|
+
'must be an instance of Stannum::Contract',
|
594
|
+
caller(1..-1)
|
595
|
+
end
|
596
|
+
end
|
597
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stannum/contracts'
|
4
|
+
|
5
|
+
module Stannum::Contracts
|
6
|
+
# Abstract base class for contract builder classes.
|
7
|
+
#
|
8
|
+
# A contract builder provides a Domain-Specific Language for defining
|
9
|
+
# constraints on a contract. They are typically used during initialization if
|
10
|
+
# a block is passed to Contract#new.
|
11
|
+
class Builder
|
12
|
+
# @param contract [Stannum::Contract] The contract to which constraints are
|
13
|
+
# added.
|
14
|
+
def initialize(contract)
|
15
|
+
@contract = contract
|
16
|
+
end
|
17
|
+
|
18
|
+
# @return [Stannum::Contract] The contract to which constraints are added.
|
19
|
+
attr_reader :contract
|
20
|
+
|
21
|
+
# Adds a constraint to the contract.
|
22
|
+
#
|
23
|
+
# @overload constraint(constraint, **options)
|
24
|
+
# Adds the given constraint to the contract.
|
25
|
+
#
|
26
|
+
# @param constraint [Stannum::Constraints::Base] The constraint to add to
|
27
|
+
# the contract.
|
28
|
+
# @param options [Hash<Symbol, Object>] Options for the constraint.
|
29
|
+
#
|
30
|
+
# @overload constraint(**options, &block)
|
31
|
+
# Creates an instance of Stannum::Constraint using the given block and
|
32
|
+
# adds it to the contract.
|
33
|
+
#
|
34
|
+
# @param options [Hash<Symbol, Object>] Options for the constraint.
|
35
|
+
# @option options negated_type [String] The error type generated for a
|
36
|
+
# matching object.
|
37
|
+
# @option options type [String] The error type generated for a
|
38
|
+
# non-matching object.
|
39
|
+
def constraint(constraint = nil, **options, &block)
|
40
|
+
constraint = resolve_constraint(constraint, **options, &block)
|
41
|
+
|
42
|
+
contract.add_constraint(constraint, **options)
|
43
|
+
|
44
|
+
self
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def ambiguous_values_error(constraint)
|
50
|
+
'expected either a block or a constraint instance, but received both a' \
|
51
|
+
" block and #{constraint.inspect}"
|
52
|
+
end
|
53
|
+
|
54
|
+
def resolve_constraint(constraint = nil, **options, &block)
|
55
|
+
if block_given? && constraint
|
56
|
+
raise ArgumentError, ambiguous_values_error(constraint), caller(1..-1)
|
57
|
+
end
|
58
|
+
|
59
|
+
return constraint if valid_constraint?(constraint)
|
60
|
+
|
61
|
+
return Stannum::Constraint.new(**options, &block) if block
|
62
|
+
|
63
|
+
raise ArgumentError,
|
64
|
+
"invalid constraint #{constraint.inspect}",
|
65
|
+
caller(1..-1)
|
66
|
+
end
|
67
|
+
|
68
|
+
def valid_constraint?(constraint)
|
69
|
+
constraint.is_a?(Stannum::Constraints::Base)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|