stannum 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|