stannum 0.1.0

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