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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ {
4
+ en: {
5
+ stannum: {
6
+ constraints: {
7
+ absent: 'is nil or empty',
8
+ anything: 'is a value',
9
+ does_not_have_methods: 'does not respond to the methods',
10
+ has_methods: 'responds to the methods',
11
+ hashes: {
12
+ extra_keys: 'has extra keys',
13
+ is_not_string_or_symbol: 'is not a String or a Symbol',
14
+ is_string_or_symbol: 'is a String or a Symbol',
15
+ no_extra_keys: 'does not have extra keys'
16
+ },
17
+ invalid: 'is invalid',
18
+ is_boolean: 'is true or false',
19
+ is_in_list: 'is in the list',
20
+ is_in_union: 'matches one of the constraints',
21
+ is_equal_to: 'is equal to',
22
+ is_not_boolean: 'is not true or false',
23
+ is_not_equal_to: 'is not equal to',
24
+ is_not_in_list: 'is not in the list',
25
+ is_not_in_union: 'does not match any of the constraints',
26
+ is_not_type: ->(_type, data) { "is not a #{data[:type]}" },
27
+ is_not_value: 'is not the expected value',
28
+ is_type: ->(_type, data) { "is a #{data[:type]}" },
29
+ is_value: 'is the expected value',
30
+ parameters: {
31
+ extra_arguments: 'has extra arguments',
32
+ extra_keywords: 'has extra keywords'
33
+ },
34
+ tuples: {
35
+ extra_items: 'has extra items',
36
+ no_extra_items: 'does not have extra items'
37
+ },
38
+ types: {
39
+ is_nil: 'is nil',
40
+ is_not_nil: 'is not nil'
41
+ },
42
+ present: 'is not nil or empty',
43
+ valid: 'is valid'
44
+ }
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum'
4
+ require 'stannum/support/optional'
5
+
6
+ module Stannum
7
+ # Data object representing an attribute on a struct.
8
+ class Attribute
9
+ include Stannum::Support::Optional
10
+
11
+ # @param name [String, Symbol] The name of the attribute. Converted to a
12
+ # String.
13
+ # @param options [Hash, nil] Options for the attribute. Converted to a Hash
14
+ # with Symbol keys. Defaults to an empty Hash.
15
+ # @param type [Class, Module, String] The type of the attribute. Can be a
16
+ # Class, a Module, or the name of a class or module.
17
+ def initialize(name:, options:, type:)
18
+ validate_name(name)
19
+ validate_options(options)
20
+ validate_type(type)
21
+
22
+ @name = name.to_s
23
+ @options = tools.hash_tools.convert_keys_to_symbols(options || {})
24
+ @options = resolve_required_option(**@options)
25
+
26
+ @type, @resolved_type = resolve_type(type)
27
+ end
28
+
29
+ # @return [String] the name of the attribute.
30
+ attr_reader :name
31
+
32
+ # @return [Hash] the attribute options.
33
+ attr_reader :options
34
+
35
+ # @return [String] the name of the attribute type Class or Module.
36
+ attr_reader :type
37
+
38
+ # @return [Object] the default value for the attribute, if any.
39
+ def default
40
+ @options[:default]
41
+ end
42
+
43
+ # @return [Boolean] true if the attribute has a default value; otherwise
44
+ # false.
45
+ def default?
46
+ !@options[:default].nil?
47
+ end
48
+
49
+ # @return [Symbol] the name of the reader method for the attribute.
50
+ def reader_name
51
+ @reader_name ||= name.intern
52
+ end
53
+
54
+ # @return [Module] the type of the attribute.
55
+ def resolved_type
56
+ return @resolved_type if @resolved_type
57
+
58
+ @resolved_type = Object.const_get(type)
59
+
60
+ unless @resolved_type.is_a?(Module)
61
+ raise NameError, "constant #{type} is not a Class or Module"
62
+ end
63
+
64
+ @resolved_type
65
+ end
66
+
67
+ # @return [Symbol] the name of the writer method for the attribute.
68
+ def writer_name
69
+ @writer_name ||= :"#{name}="
70
+ end
71
+
72
+ private
73
+
74
+ def resolve_type(type)
75
+ return [type, nil] if type.is_a?(String)
76
+
77
+ [type.to_s, type]
78
+ end
79
+
80
+ def tools
81
+ SleepingKingStudios::Tools::Toolbelt.instance
82
+ end
83
+
84
+ def validate_name(name)
85
+ raise ArgumentError, "name can't be blank" if name.nil?
86
+
87
+ unless name.is_a?(String) || name.is_a?(Symbol)
88
+ raise ArgumentError, 'name must be a String or Symbol'
89
+ end
90
+
91
+ raise ArgumentError, "name can't be blank" if name.empty?
92
+ end
93
+
94
+ def validate_options(options)
95
+ return if options.nil? || options.is_a?(Hash)
96
+
97
+ raise ArgumentError, 'options must be a Hash or nil'
98
+ end
99
+
100
+ def validate_type(type)
101
+ raise ArgumentError, "type can't be blank" if type.nil?
102
+
103
+ return if type.is_a?(Module)
104
+
105
+ if type.is_a?(String)
106
+ return unless type.empty?
107
+
108
+ raise ArgumentError, "type can't be blank"
109
+ end
110
+
111
+ raise ArgumentError,
112
+ 'type must be a Class, a Module, or the name of a class or module'
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints/base'
4
+
5
+ module Stannum
6
+ # Constraint class for defining a custom or one-off constraint instance.
7
+ #
8
+ # The Stannum::Constraint class allows you to define a constraint instance
9
+ # with a block, and optionally a type and negated type when generating errors
10
+ # for non-matching objects.
11
+ #
12
+ # If your use case is more complicated, such as a constraint with multiple
13
+ # expectations and thus different errors depending on the given object, use
14
+ # a subclass of the Stannum::Constraints::Base class instead. For example, an
15
+ # is_odd constraint that checks if an object is an odd integer might have
16
+ # different errors when passed a non-integer object and when passed an even
17
+ # integer, even though both are failing matches.
18
+ #
19
+ # Likewise, if you want to define a custom constraint class, it is recommended
20
+ # that you use Stannum::Constraints::Base as the base class for all but the
21
+ # simplest constraints.
22
+ #
23
+ # @example Defining a Custom Constraint
24
+ # is_integer = Stannum::Constraint.new { |actual| actual.is_a?(Integer) }
25
+ # is_integer.matches?(nil) #=> false
26
+ # is_integer.matches?(3) #=> true
27
+ # is_integer.matches?(3.5) #=> false
28
+ #
29
+ # @example Defining a Custom Constraint With Errors
30
+ # is_even_integer = Stannum::Constraint.new(
31
+ # negated_type: 'examples.an_even_integer',
32
+ # type: 'examples.not_an_even_integer'
33
+ # ) { |actual| actual.is_a?(Integer) && actual.even? }
34
+ #
35
+ # is_even_integer.matches?(nil) #=> false
36
+ # is_even_integer.matches?(2) #=> true
37
+ # is_even_integer.matches?(3) #=> false
38
+ #
39
+ # @see Stannum::Constraints::Base
40
+ class Constraint < Stannum::Constraints::Base
41
+ # @overload initialize(**options)
42
+ # @param options [Hash<Symbol, Object>] Configuration options for the
43
+ # constraint. Defaults to an empty Hash.
44
+ #
45
+ # @yield The definition for the constraint. Each time #matches? is called
46
+ # for this constraint, the given object will be passed to this block and
47
+ # the result of the block will be returned.
48
+ # @yieldparam actual [Object] The object to check against the constraint.
49
+ # @yieldreturn [true, false] true if the given object matches the
50
+ # constraint, otherwise false.
51
+ #
52
+ # @see #matches?
53
+ def initialize(**options, &block)
54
+ @definition = block
55
+
56
+ super(**options)
57
+ end
58
+
59
+ # (see Stannum::Constraints::Base#matches?)
60
+ def matches?(actual)
61
+ @definition ? @definition.call(actual) : super
62
+ end
63
+ alias match? matches?
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints/base'
4
+
5
+ module Stannum::Constraints
6
+ # An absence constraint asserts that the object is nil or empty.
7
+ #
8
+ # @example Using an Absence constraint
9
+ # constraint = Stannum::Constraints::Absence.new
10
+ #
11
+ # constraint.matches?(nil) #=> true
12
+ # constraint.matches?(Object.new) #=> false
13
+ #
14
+ # @example Using a Absence constraint with an Array
15
+ # constraint.matches?([]) #=> true
16
+ # constraint.matches?([1, 2, 3]) #=> false
17
+ #
18
+ # @example Using a Absence constraint with an Hash
19
+ # constraint.matches?({}) #=> true
20
+ # constraint.matches?({ key: 'value' }) #=> false
21
+ class Absence < Stannum::Constraints::Base
22
+ # The :type of the error generated for a matching object.
23
+ NEGATED_TYPE = Stannum::Constraints::Presence::TYPE
24
+
25
+ # The :type of the error generated for a non-matching object.
26
+ TYPE = Stannum::Constraints::Presence::NEGATED_TYPE
27
+
28
+ # Checks that the object is nil or empty.
29
+ #
30
+ # @return [true, false] true if the object is nil or empty, otherwise false.
31
+ #
32
+ # @see Stannum::Constraint#matches?
33
+ def matches?(actual)
34
+ return true if actual.nil?
35
+
36
+ return true if actual.respond_to?(:empty?) && actual.empty?
37
+
38
+ false
39
+ end
40
+ alias match? matches?
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints/base'
4
+
5
+ module Stannum::Constraints
6
+ # An example constraint that matches any object, even nil.
7
+ #
8
+ # @example
9
+ # constraint = Stannum::Constraints::Anything.new
10
+ # constraint.matches?(Object.new)
11
+ # #=> true
12
+ # constraint.does_not_match?(Object.new)
13
+ # #=> false
14
+ class Anything < Stannum::Constraints::Base
15
+ # The :type of the error generated for a matching object.
16
+ NEGATED_TYPE = 'stannum.constraints.anything'
17
+
18
+ # Returns true for all objects.
19
+ #
20
+ # @return [true] in all cases.
21
+ #
22
+ # @see Stannum::Constraint#matches?
23
+ def matches?(_actual)
24
+ true
25
+ end
26
+ alias match? matches?
27
+ end
28
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints'
4
+
5
+ module Stannum::Constraints
6
+ # A constraint codifies a particular expectation about an object.
7
+ class Base
8
+ # Builder class for defining constraints for a Contract.
9
+ #
10
+ # This class should not be invoked directly. Instead, pass a block to the
11
+ # constructor for Contract.
12
+ #
13
+ # @api private
14
+ class Builder < Stannum::Contracts::Builder
15
+ end
16
+
17
+ # The :type of the error generated for a matching object.
18
+ NEGATED_TYPE = 'stannum.constraints.valid'
19
+
20
+ # The :type of the error generated for a non-matching object.
21
+ TYPE = 'stannum.constraints.invalid'
22
+
23
+ # @param options [Hash<Symbol, Object>] Configuration options for the
24
+ # constraint. Defaults to an empty Hash.
25
+ # @option options [String] :message The default error message generated for
26
+ # a non-matching object.
27
+ # @option options [String] :negated_message The default error message
28
+ # generated for a matching object.
29
+ # @option options [String] :negated_type The type of the error generated for
30
+ # a matching object.
31
+ # @option options [String] :type The type of the error generated for a
32
+ # non-matching object.
33
+ def initialize(**options)
34
+ self.options = options
35
+ end
36
+
37
+ # @return [Hash<Symbol, Object>] Configuration options for the constraint.
38
+ attr_reader :options
39
+
40
+ # Performs an equality comparison.
41
+ #
42
+ # @param other [Object] The object to compare.
43
+ #
44
+ # @return [true, false] true if the other object has the same class and
45
+ # options; otherwise false.
46
+ def ==(other)
47
+ other.class == self.class && options == other.options
48
+ end
49
+
50
+ # Produces a shallow copy of the constraint.
51
+ #
52
+ # @param freeze [true, false, nil] If true or false, sets the frozen status
53
+ # of the cloned constraint; otherwise, copies the frozen status of the
54
+ # original. Defaults to nil.
55
+ #
56
+ # @return [Stannum::Constraints::Base] the cloned constraint.
57
+ def clone(freeze: nil)
58
+ freeze = true if freeze.nil? && RUBY_VERSION <= '3.0.0'
59
+
60
+ super(freeze: freeze).copy_properties(self)
61
+ end
62
+
63
+ # Checks that the given object does not match the constraint.
64
+ #
65
+ # @example Checking a matching object.
66
+ # constraint = CustomConstraint.new
67
+ # object = MatchingObject.new
68
+ #
69
+ # constraint.does_not_match?(object) #=> false
70
+ #
71
+ # @example Checking a non-matching object.
72
+ # constraint = CustomConstraint.new
73
+ # object = NonMatchingObject.new
74
+ #
75
+ # constraint.does_not_match?(object) #=> true
76
+ #
77
+ # @return [true, false] false if the object matches the expected properties
78
+ # or behavior, otherwise true.
79
+ #
80
+ # @see #matches?
81
+ def does_not_match?(actual)
82
+ !matches?(actual)
83
+ end
84
+
85
+ # Produces a shallow copy of the constraint.
86
+ #
87
+ # @return [Stannum::Constraints::Base] the duplicated constraint.
88
+ def dup
89
+ super.copy_properties(self)
90
+ end
91
+
92
+ # Generates an errors object for the given object.
93
+ #
94
+ # The errors object represents the difference between the given object and
95
+ # the expected properties or behavior. It may be the same for all objects,
96
+ # or different based on the details of the object or the constraint.
97
+ #
98
+ # @param actual [Object] The object to generate errors for.
99
+ # @param errors [Stannum::Errors] The errors object to append errors to. If
100
+ # an errors object is not given, a new errors object will be created.
101
+ #
102
+ # @example Generating errors for a non-matching object.
103
+ # constraint = CustomConstraint.new
104
+ # object = NonMatchingObject.new
105
+ # errors = constraint.errors_for(object)
106
+ #
107
+ # errors.class #=> Stannum::Errors
108
+ # errors.to_a #=> [{ type: 'some_error', message: 'some error message' }]
109
+ #
110
+ # @note This method should only be called for an object that does not match
111
+ # the constraint. Generating errors for a matching object can result in
112
+ # undefined behavior.
113
+ #
114
+ # @return [Stannum::Errors] the given or generated errors object.
115
+ #
116
+ # @see #matches?
117
+ # @see #negated_errors_for
118
+ def errors_for(actual, errors: nil) # rubocop:disable Lint/UnusedMethodArgument
119
+ (errors || Stannum::Errors.new).add(type, message: message)
120
+ end
121
+
122
+ # Checks the given object against the constraint and returns errors, if any.
123
+ #
124
+ # This method checks the given object against the expected properties or
125
+ # behavior. If the object matches the constraint, #match will return true.
126
+ # If the object does not match the constraint, #match will return false and
127
+ # the generated errors for that object.
128
+ #
129
+ # @example Checking a matching object.
130
+ # constraint = CustomConstraint.new
131
+ # object = MatchingObject.new
132
+ #
133
+ # success, errors = constraint.match(object)
134
+ # success #=> true
135
+ # errors #=> nil
136
+ #
137
+ # @example Checking a non-matching object.
138
+ # constraint = CustomConstraint.new
139
+ # object = NonMatchingObject.new
140
+ #
141
+ # success, errors = constraint.match(object)
142
+ # success #=> false
143
+ # errors.class #=> Stannum::Errors
144
+ # errors.to_a #=> [{ type: 'some_error', message: 'some error message' }]
145
+ #
146
+ # @see #errors_for
147
+ # @see #matches?
148
+ def match(actual)
149
+ return [true, Stannum::Errors.new] if matches?(actual)
150
+
151
+ [false, errors_for(actual)]
152
+ end
153
+
154
+ # @overload matches?(actual)
155
+ #
156
+ # Checks that the given object matches the constraint.
157
+ #
158
+ # @example Checking a matching object.
159
+ # constraint = CustomConstraint.new
160
+ # object = MatchingObject.new
161
+ #
162
+ # constraint.matches?(object) #=> true
163
+ #
164
+ # @example Checking a non-matching object.
165
+ # constraint = CustomConstraint.new
166
+ # object = NonMatchingObject.new
167
+ #
168
+ # constraint.matches?(object) #=> false
169
+ #
170
+ # @return [true, false] true if the object matches the expected properties
171
+ # or behavior, otherwise false.
172
+ #
173
+ # @see #does_not_match?
174
+ def matches?(_actual)
175
+ false
176
+ end
177
+ alias match? matches?
178
+
179
+ # @return [String, nil] the default error message generated for a
180
+ # non-matching object.
181
+ def message
182
+ options[:message]
183
+ end
184
+
185
+ # Generates an errors object for the given object when negated.
186
+ #
187
+ # The errors object represents the difference between the given object and
188
+ # the expected properties or behavior when the constraint is negated. It may
189
+ # be the same for all objects, or different based on the details of the
190
+ # object or the constraint.
191
+ #
192
+ # @param actual [Object] The object to generate errors for.
193
+ # @param errors [Stannum::Errors] The errors object to append errors to. If
194
+ # an errors object is not given, a new errors object will be created.
195
+ #
196
+ # @example Generating errors for a matching object.
197
+ # constraint = CustomConstraint.new
198
+ # object = MatchingObject.new
199
+ # errors = constraint.negated_errors_for(object)
200
+ #
201
+ # errors.class #=> Stannum::Errors
202
+ # errors.to_a #=> [{ type: 'some_error', message: 'some error message' }]
203
+ #
204
+ # @note This method should only be called for an object that matches the
205
+ # constraint. Generating errors for a matching object can result in
206
+ # undefined behavior.
207
+ #
208
+ # @return [Stannum::Errors] the given or generated errors object.
209
+ #
210
+ # @see #does_not_match?
211
+ # @see #errors_for
212
+ def negated_errors_for(actual, errors: nil) # rubocop:disable Lint/UnusedMethodArgument
213
+ (errors || Stannum::Errors.new)
214
+ .add(negated_type, message: negated_message)
215
+ end
216
+
217
+ # Checks the given object against the constraint and returns errors, if any.
218
+ #
219
+ # This method checks the given object against the expected properties or
220
+ # behavior. If the object matches the constraint, #negated_match will return
221
+ # false and the generated errors for that object. If the object does not
222
+ # match the constraint, #negated_match will return true.
223
+ #
224
+ # @example Checking a matching object.
225
+ # constraint = CustomConstraint.new
226
+ # object = MatchingObject.new
227
+ #
228
+ # success, errors = constraint.negated_match(object)
229
+ # success #=> false
230
+ # errors.class #=> Stannum::Errors
231
+ # errors.to_a #=> [{ type: 'some_error', message: 'some error message' }]
232
+ #
233
+ # @example Checking a non-matching object.
234
+ # constraint = CustomConstraint.new
235
+ # object = NonMatchingObject.new
236
+ #
237
+ # success, errors = constraint.negated_match(object)
238
+ # success #=> true
239
+ # errors #=> nil
240
+ #
241
+ # @see #does_not_match?
242
+ # @see #match
243
+ # @see #negated_errors_for
244
+ def negated_match(actual)
245
+ return [true, Stannum::Errors.new] if does_not_match?(actual)
246
+
247
+ [false, negated_errors_for(actual)]
248
+ end
249
+
250
+ # @return [String, nil] The default error message generated for a matching
251
+ # object.
252
+ def negated_message
253
+ options[:negated_message]
254
+ end
255
+
256
+ # @return [String] the error type generated for a matching object.
257
+ def negated_type
258
+ options.fetch(:negated_type, self.class::NEGATED_TYPE)
259
+ end
260
+
261
+ # @return [String] the error type generated for a non-matching object.
262
+ def type
263
+ options.fetch(:type, self.class::TYPE)
264
+ end
265
+
266
+ # Creates a copy of the constraint and updates the copy's options.
267
+ #
268
+ # @param options [Hash] The options to update.
269
+ #
270
+ # @return [Stannum::Constraints::Base] the copied constraint.
271
+ def with_options(**options)
272
+ dup.copy_properties(self, options: self.options.merge(options))
273
+ end
274
+
275
+ protected
276
+
277
+ attr_writer :options
278
+
279
+ def copy_properties(source, options: nil, **_)
280
+ self.options = options || source.options.dup
281
+
282
+ self
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints'
4
+
5
+ module Stannum::Constraints
6
+ # A Boolean constraint matches only true or false.
7
+ #
8
+ # @example Using a Boolean constraint
9
+ # constraint = Stannum::Constraints::Boolean.new
10
+ #
11
+ # constraint.matches?(nil) #=> false
12
+ # constraint.matches?('a string') #=> false
13
+ # constraint.matches?(false) #=> true
14
+ # constraint.matches?(true) #=> true
15
+ class Boolean < Stannum::Constraints::Base
16
+ # The :type of the error generated for a matching object.
17
+ NEGATED_TYPE = 'stannum.constraints.is_boolean'
18
+
19
+ # The :type of the error generated for a non-matching object.
20
+ TYPE = 'stannum.constraints.is_not_boolean'
21
+
22
+ # Checks that the object is either true or false.
23
+ #
24
+ # @return [true, false] true if the object is true or false, otherwise
25
+ # false.
26
+ #
27
+ # @see Stannum::Constraint#matches?
28
+ def matches?(actual)
29
+ true.equal?(actual) || false.equal?(actual)
30
+ end
31
+ alias match? matches?
32
+ end
33
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ require 'stannum/constraints'
6
+
7
+ module Stannum::Constraints
8
+ # A Delegator constraint delegates the constraint methods to a receiver.
9
+ #
10
+ # Use the Delegator constraint when the behavior of a constraint needs to
11
+ # change based on the context. For example, a contract may use a Delegator
12
+ # constraint to wrap changes made after the contract is first initialized.
13
+ #
14
+ # @example Using a Delegator constraint
15
+ # receiver = Stannum::Constraints::Type.new(String)
16
+ # constraint = Stannum::Constraints::Delegator.new(receiver)
17
+ #
18
+ # constraint.matches?('a string') #=> true
19
+ # constraint.matches?(:a_symbol) #=> false
20
+ #
21
+ # constraint.receiver = Stannum::Constraints::Type.new(Symbol)
22
+ #
23
+ # constraint.matches?('a string') #=> false
24
+ # constraint.matches?(:a_symbol) #=> true
25
+ class Delegator < Stannum::Constraints::Base
26
+ extend Forwardable
27
+
28
+ # @param receiver [Stannum::Constraints::Base] The constraint that methods
29
+ # will be delegated to.
30
+ def initialize(receiver)
31
+ super()
32
+
33
+ self.receiver = receiver
34
+ end
35
+
36
+ def_delegators :@receiver,
37
+ :does_not_match?,
38
+ :errors_for,
39
+ :match,
40
+ :matches?,
41
+ :negated_errors_for,
42
+ :negated_match,
43
+ :negated_type,
44
+ :options,
45
+ :type
46
+
47
+ alias match? matches?
48
+
49
+ # @return [Stannum::Constraints::Base] the constraint that methods will be
50
+ # delegated to.
51
+ attr_reader :receiver
52
+
53
+ # @param value [Stannum::Constraints::Base] The constraint that methods
54
+ # will be delegated to.
55
+ def receiver=(value)
56
+ validate_receiver(value)
57
+
58
+ @receiver = value
59
+ end
60
+
61
+ private
62
+
63
+ def validate_receiver(receiver)
64
+ return if receiver.is_a?(Stannum::Constraints::Base)
65
+
66
+ raise ArgumentError,
67
+ 'receiver must be a Stannum::Constraints::Base',
68
+ caller(1..-1)
69
+ end
70
+ end
71
+ end