stannum 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/DEVELOPMENT.md +105 -0
  5. data/LICENSE +22 -0
  6. data/README.md +1327 -0
  7. data/config/locales/en.rb +47 -0
  8. data/lib/stannum/attribute.rb +115 -0
  9. data/lib/stannum/constraint.rb +65 -0
  10. data/lib/stannum/constraints/absence.rb +42 -0
  11. data/lib/stannum/constraints/anything.rb +28 -0
  12. data/lib/stannum/constraints/base.rb +285 -0
  13. data/lib/stannum/constraints/boolean.rb +33 -0
  14. data/lib/stannum/constraints/delegator.rb +71 -0
  15. data/lib/stannum/constraints/enum.rb +64 -0
  16. data/lib/stannum/constraints/equality.rb +47 -0
  17. data/lib/stannum/constraints/hashes/extra_keys.rb +126 -0
  18. data/lib/stannum/constraints/hashes/indifferent_key.rb +74 -0
  19. data/lib/stannum/constraints/hashes.rb +11 -0
  20. data/lib/stannum/constraints/identity.rb +46 -0
  21. data/lib/stannum/constraints/nothing.rb +28 -0
  22. data/lib/stannum/constraints/presence.rb +42 -0
  23. data/lib/stannum/constraints/signature.rb +92 -0
  24. data/lib/stannum/constraints/signatures/map.rb +17 -0
  25. data/lib/stannum/constraints/signatures/tuple.rb +17 -0
  26. data/lib/stannum/constraints/signatures.rb +11 -0
  27. data/lib/stannum/constraints/tuples/extra_items.rb +84 -0
  28. data/lib/stannum/constraints/tuples.rb +10 -0
  29. data/lib/stannum/constraints/type.rb +113 -0
  30. data/lib/stannum/constraints/types/array_type.rb +148 -0
  31. data/lib/stannum/constraints/types/big_decimal_type.rb +16 -0
  32. data/lib/stannum/constraints/types/date_time_type.rb +16 -0
  33. data/lib/stannum/constraints/types/date_type.rb +16 -0
  34. data/lib/stannum/constraints/types/float_type.rb +14 -0
  35. data/lib/stannum/constraints/types/hash_type.rb +205 -0
  36. data/lib/stannum/constraints/types/hash_with_indifferent_keys.rb +21 -0
  37. data/lib/stannum/constraints/types/hash_with_string_keys.rb +21 -0
  38. data/lib/stannum/constraints/types/hash_with_symbol_keys.rb +21 -0
  39. data/lib/stannum/constraints/types/integer_type.rb +14 -0
  40. data/lib/stannum/constraints/types/nil_type.rb +20 -0
  41. data/lib/stannum/constraints/types/proc_type.rb +14 -0
  42. data/lib/stannum/constraints/types/string_type.rb +14 -0
  43. data/lib/stannum/constraints/types/symbol_type.rb +14 -0
  44. data/lib/stannum/constraints/types/time_type.rb +14 -0
  45. data/lib/stannum/constraints/types.rb +25 -0
  46. data/lib/stannum/constraints/union.rb +85 -0
  47. data/lib/stannum/constraints.rb +26 -0
  48. data/lib/stannum/contract.rb +243 -0
  49. data/lib/stannum/contracts/array_contract.rb +108 -0
  50. data/lib/stannum/contracts/base.rb +597 -0
  51. data/lib/stannum/contracts/builder.rb +72 -0
  52. data/lib/stannum/contracts/definition.rb +74 -0
  53. data/lib/stannum/contracts/hash_contract.rb +136 -0
  54. data/lib/stannum/contracts/indifferent_hash_contract.rb +78 -0
  55. data/lib/stannum/contracts/map_contract.rb +199 -0
  56. data/lib/stannum/contracts/parameters/arguments_contract.rb +185 -0
  57. data/lib/stannum/contracts/parameters/keywords_contract.rb +174 -0
  58. data/lib/stannum/contracts/parameters/signature_contract.rb +29 -0
  59. data/lib/stannum/contracts/parameters.rb +15 -0
  60. data/lib/stannum/contracts/parameters_contract.rb +530 -0
  61. data/lib/stannum/contracts/tuple_contract.rb +213 -0
  62. data/lib/stannum/contracts.rb +19 -0
  63. data/lib/stannum/errors.rb +730 -0
  64. data/lib/stannum/messages/default_strategy.rb +124 -0
  65. data/lib/stannum/messages.rb +25 -0
  66. data/lib/stannum/parameter_validation.rb +216 -0
  67. data/lib/stannum/rspec/match_errors.rb +17 -0
  68. data/lib/stannum/rspec/match_errors_matcher.rb +93 -0
  69. data/lib/stannum/rspec/validate_parameter.rb +23 -0
  70. data/lib/stannum/rspec/validate_parameter_matcher.rb +506 -0
  71. data/lib/stannum/rspec.rb +8 -0
  72. data/lib/stannum/schema.rb +131 -0
  73. data/lib/stannum/struct.rb +444 -0
  74. data/lib/stannum/support/coercion.rb +114 -0
  75. data/lib/stannum/support/optional.rb +69 -0
  76. data/lib/stannum/support.rb +8 -0
  77. data/lib/stannum/version.rb +57 -0
  78. data/lib/stannum.rb +27 -0
  79. metadata +216 -0
@@ -0,0 +1,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