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,506 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'rspec/sleeping_king_studios/matchers/core/deep_matcher'
5
+ rescue NameError
6
+ # Optional dependency.
7
+ end
8
+
9
+ require 'stannum/rspec'
10
+ require 'stannum/support/coercion'
11
+
12
+ module Stannum::RSpec
13
+ # Asserts that the command validates the given method parameter.
14
+ class ValidateParameterMatcher # rubocop:disable Metrics/ClassLength
15
+ include RSpec::Mocks::ExampleMethods
16
+
17
+ class InvalidParameterHandledError < StandardError; end
18
+ private_constant :InvalidParameterHandledError
19
+
20
+ class << self
21
+ # @private
22
+ def add_parameter_mapping(map:, match:)
23
+ raise ArgumentError, 'map must be a Proc' unless map.is_a?(Proc)
24
+ raise ArgumentError, 'match must be a Proc' unless match.is_a?(Proc)
25
+
26
+ parameter_mappings << { match: match, map: map }
27
+ end
28
+
29
+ # @private
30
+ def map_parameters(actual:, method_name:)
31
+ parameter_mappings.each do |keywords|
32
+ match = keywords.fetch(:match)
33
+ map = keywords.fetch(:map)
34
+
35
+ next unless match.call(actual: actual, method_name: method_name)
36
+
37
+ return map.call(actual: actual, method_name: method_name)
38
+ end
39
+
40
+ unwrapped_method(actual: actual, method_name: method_name).parameters
41
+ end
42
+
43
+ private
44
+
45
+ def default_parameter_mappings
46
+ [
47
+ {
48
+ match: lambda do |actual:, method_name:, **_|
49
+ actual.is_a?(Class) && method_name == :new
50
+ end,
51
+ map: lambda do |actual:, **_|
52
+ actual.instance_method(:initialize).parameters
53
+ end
54
+ }
55
+ ]
56
+ end
57
+
58
+ def parameter_mappings
59
+ @parameter_mappings ||= default_parameter_mappings
60
+ end
61
+
62
+ def unwrapped_method(actual:, method_name:)
63
+ method = actual.method(method_name)
64
+ validations = Stannum::ParameterValidation::MethodValidations
65
+
66
+ until method.nil?
67
+ return method unless method.owner.is_a?(validations)
68
+
69
+ method = method.super_method
70
+ end
71
+ end
72
+ end
73
+
74
+ # @param method_name [String, Symbol] The name of the method with validated
75
+ # parameters.
76
+ # @param parameter_name [String, Symbol] The name of the validated method
77
+ # parameter.
78
+ def initialize(method_name:, parameter_name:)
79
+ @method_name = method_name.intern
80
+ @parameter_name = parameter_name.intern
81
+ end
82
+
83
+ # @return [Stannum::Constraints::Base, nil] the constraint used to generate
84
+ # the expected error(s).
85
+ attr_reader :expected_constraint
86
+
87
+ # @return [String, Symbol] the name of the method with validated parameters.
88
+ attr_reader :method_name
89
+
90
+ # @return [Hash] the configured parameters to match.
91
+ attr_reader :parameters
92
+
93
+ # @return [String, Symbol] the name of the validated method parameter.
94
+ attr_reader :parameter_name
95
+
96
+ # @return [Object] the invalid value for the validated parameter.
97
+ attr_reader :parameter_value
98
+
99
+ # @return [String] a short description of the matcher and expected
100
+ # properties.
101
+ def description
102
+ "validate the #{parameter_name.inspect} #{parameter_type || 'parameter'}"
103
+ end
104
+
105
+ # Asserts that the object does not validate the specified method parameter.
106
+ #
107
+ # @param actual [Object] The object to match.
108
+ #
109
+ # @return [true, false] false if the object validates the parameter,
110
+ # otherwise true.
111
+ def does_not_match?(actual)
112
+ disallow_fluent_options!
113
+
114
+ @actual = actual
115
+ @failure_reason = nil
116
+
117
+ return true unless supports_parameter_validation?
118
+ return false unless responds_to_method?
119
+ return true unless validates_method?
120
+ return false unless method_has_parameter?
121
+
122
+ !validates_method_parameter?
123
+ end
124
+
125
+ # @return [String] a summary message describing a failed expectation.
126
+ def failure_message # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
127
+ message = "expected ##{method_name} to #{description}"
128
+ reason =
129
+ case @failure_reason
130
+ when :does_not_respond_to_method
131
+ "the object does not respond to ##{method_name}"
132
+ when :does_not_support_parameter_validation
133
+ 'the object does not implement parameter validation'
134
+ when :does_not_validate_method
135
+ "the object does not validate the parameters of ##{method_name}"
136
+ when :errors_do_not_match
137
+ "the errors do not match:\n\n#{equality_matcher_failure_message}"
138
+ when :method_does_not_have_parameter
139
+ "##{method_name} does not have a #{parameter_name.inspect} parameter"
140
+ when :parameter_not_validated
141
+ "##{method_name} does not expect a #{parameter_name.inspect}" \
142
+ " #{parameter_type}"
143
+ when :valid_parameter_value
144
+ "#{valid_value.inspect} is a valid value for the" \
145
+ " #{parameter_name.inspect} #{parameter_type}"
146
+ end
147
+
148
+ [message, reason].compact.join(', but ')
149
+ end
150
+
151
+ # @return [String] a summary message describing a failed negated
152
+ # expectation.
153
+ def failure_message_when_negated
154
+ message = "expected ##{method_name} not to #{description}"
155
+ reason =
156
+ case @failure_reason
157
+ when :does_not_respond_to_method
158
+ "the object does not respond to ##{method_name}"
159
+ when :method_does_not_have_parameter
160
+ "##{method_name} does not have a #{parameter_name.inspect} parameter"
161
+ end
162
+
163
+ [message, reason].compact.join(', but ')
164
+ end
165
+
166
+ # Asserts that the object validates the specified method parameter.
167
+ #
168
+ # @param actual [Object] The object to match.
169
+ #
170
+ # @return [true, false] true if the object validates the parameter,
171
+ # otherwise false.
172
+ def matches?(actual) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
173
+ @actual = actual
174
+ @failure_reason = nil
175
+
176
+ return false unless supports_parameter_validation?
177
+ return false unless responds_to_method?
178
+ return false unless validates_method?
179
+ return false unless method_has_parameter?
180
+
181
+ if @expected_constraint.nil? && @parameters.nil? && @parameter_value.nil?
182
+ return validates_method_parameter?
183
+ end
184
+
185
+ call_validated_method
186
+
187
+ return false if extra_parameter?
188
+ return false if valid_parameter?
189
+
190
+ matches_expected_error?
191
+ end
192
+
193
+ # Specifies a constraint or type used to validate the parameter.
194
+ #
195
+ # @param constraint [Stannum::Constraints::Base, Class, Module] The
196
+ # constraint or type.
197
+ #
198
+ # @return [Stannum::RSpec::ValidateParameterMatcher] the matcher.
199
+ def using_constraint(constraint, **options)
200
+ @expected_constraint = Stannum::Support::Coercion.type_constraint(
201
+ constraint,
202
+ as: 'constraint',
203
+ **options
204
+ )
205
+
206
+ self
207
+ end
208
+
209
+ # Specifies custom parameters to test.
210
+ #
211
+ # The matcher will pass if and only if the method fails validation with the
212
+ # specified parameters.
213
+ #
214
+ # @param arguments [Array] A list of arguments to test.
215
+ # @param keywords [Hash] A hash of keywords to test.
216
+ #
217
+ # @return [Stannum::RSpec::ValidateParameterMatcher] the matcher.
218
+ def with_parameters(*arguments, **keywords, &block)
219
+ if @parameter_value
220
+ raise 'cannot use both #with_parameters and #with_value'
221
+ end
222
+
223
+ @parameters = [
224
+ arguments,
225
+ keywords,
226
+ block
227
+ ]
228
+
229
+ self
230
+ end
231
+
232
+ # Specifies an invalid value for the parameter.
233
+ #
234
+ # @param parameter_value [Object] The invalid value for the validated
235
+ # parameter.
236
+ #
237
+ # @return [Stannum::RSpec::ValidateParameterMatcher] the matcher.
238
+ def with_value(parameter_value)
239
+ raise 'cannot use both #with_parameters and #with_value' if @parameters
240
+
241
+ @parameter_value = parameter_value
242
+
243
+ self
244
+ end
245
+
246
+ private
247
+
248
+ attr_reader :actual
249
+
250
+ attr_reader :parameter_index
251
+
252
+ attr_reader :parameter_type
253
+
254
+ def build_parameters
255
+ case parameter_type
256
+ when :argument
257
+ @parameter_index = find_parameter_index(method_parameters)
258
+
259
+ [[*Array.new(parameter_index, nil), parameter_value], {}, nil]
260
+ when :keyword
261
+ [[], { parameter_name => parameter_value }, nil]
262
+ when :block
263
+ [[], {}, parameter_value]
264
+ end
265
+ end
266
+
267
+ def call_validated_method
268
+ arguments, keywords, block = @parameters || build_parameters
269
+
270
+ mock_validation_handler do
271
+ actual.send(method_name, *arguments, **keywords, &block)
272
+ rescue InvalidParameterHandledError
273
+ # Do nothing.
274
+ end
275
+ rescue ArgumentError
276
+ # Do nothing.
277
+ end
278
+
279
+ def disallow_fluent_options! # rubocop:disable Metrics/MethodLength
280
+ unless @expected_constraint.nil?
281
+ raise RuntimeError,
282
+ '#does_not_match? with #using_constraint is not supported',
283
+ caller[1..-1]
284
+ end
285
+
286
+ unless @parameters.nil?
287
+ raise RuntimeError,
288
+ '#does_not_match? with #with_parameters is not supported',
289
+ caller[1..-1]
290
+ end
291
+
292
+ return if @parameter_value.nil?
293
+
294
+ raise RuntimeError,
295
+ '#does_not_match? with #with_value is not supported',
296
+ caller[1..-1]
297
+ end
298
+
299
+ def equality_matcher
300
+ RSpec::SleepingKingStudios::Matchers::Core::DeepMatcher
301
+ .new(@expected_errors.to_a)
302
+ rescue NameError
303
+ # :nocov:
304
+ RSpec::Matchers::BuiltIn::Eq.new(@expected_errors.to_a)
305
+ # :nocov:
306
+ end
307
+
308
+ def equality_matcher_failure_message
309
+ equality_matcher
310
+ .tap { |matcher| matcher.matches?(scoped_errors.to_a) }
311
+ .failure_message
312
+ end
313
+
314
+ def extra_parameter?
315
+ extra_arguments_type =
316
+ Stannum::Contracts::Parameters::ArgumentsContract::EXTRA_ARGUMENTS_TYPE
317
+ extra_keywords_type =
318
+ Stannum::Contracts::Parameters::KeywordsContract::EXTRA_KEYWORDS_TYPE
319
+
320
+ return false unless scoped_errors(indexed: true).any? do |error|
321
+ error[:type] == extra_arguments_type ||
322
+ error[:type] == extra_keywords_type
323
+ end
324
+
325
+ @failure_reason = :parameter_not_validated
326
+
327
+ true
328
+ end
329
+
330
+ def find_parameter_index(parameters)
331
+ parameters.index { |_, name| name == parameter_name }
332
+ end
333
+
334
+ def find_parameter_type
335
+ parameters = method_parameters
336
+ type, _ = parameters.find { |_, name| name == parameter_name }
337
+
338
+ case type
339
+ when :req, :opt
340
+ :argument
341
+ when :keyreq, :key
342
+ :keyword
343
+ when :block
344
+ :block
345
+ end
346
+ end
347
+
348
+ def matches_expected_error?
349
+ return true unless expected_constraint
350
+
351
+ @expected_errors = expected_constraint.errors_for(parameter_value)
352
+
353
+ if @expected_errors.all? { |error| scoped_errors.include?(error) }
354
+ return true
355
+ end
356
+
357
+ @failure_reason = :errors_do_not_match
358
+
359
+ false
360
+ end
361
+
362
+ def method_has_parameter?
363
+ @parameter_type = find_parameter_type
364
+
365
+ return true unless parameter_type.nil?
366
+
367
+ @failure_reason = :method_does_not_have_parameter
368
+
369
+ false
370
+ end
371
+
372
+ def method_parameters
373
+ @method_parameters ||=
374
+ self.class.map_parameters(actual: actual, method_name: method_name)
375
+ end
376
+
377
+ def mock_validation_handler
378
+ @validation_handler_called = false
379
+ @validation_errors = nil
380
+
381
+ allow(actual).to receive(:handle_invalid_parameters) do |keywords|
382
+ @validation_handler_called = true
383
+ @validation_errors = keywords[:errors]
384
+
385
+ raise InvalidParameterHandledError
386
+ end
387
+
388
+ yield
389
+
390
+ allow(actual).to receive(:handle_invalid_parameters).and_call_original
391
+ end
392
+
393
+ def responds_to_method?
394
+ return true if actual.respond_to?(method_name)
395
+
396
+ @failure_reason = :does_not_respond_to_method
397
+
398
+ false
399
+ end
400
+
401
+ def scoped_errors(indexed: false) # rubocop:disable Metrics/MethodLength
402
+ return [] if @validation_errors.nil?
403
+
404
+ case parameter_type
405
+ when :argument
406
+ @parameter_index ||= find_parameter_index(method_parameters)
407
+ parameter_key = indexed ? parameter_index : parameter_name
408
+
409
+ @validation_errors[:arguments][parameter_key]
410
+ when :keyword
411
+ @validation_errors[:keywords][parameter_name]
412
+ when :block
413
+ @validation_errors[:block]
414
+ end
415
+ end
416
+
417
+ def supports_parameter_validation?
418
+ return true if actual.is_a?(Stannum::ParameterValidation)
419
+
420
+ @failure_reason = :does_not_support_parameter_validation
421
+
422
+ false
423
+ end
424
+
425
+ def valid_parameter?
426
+ return false unless scoped_errors.empty?
427
+
428
+ @failure_reason = :valid_parameter_value
429
+
430
+ true
431
+ end
432
+
433
+ def valid_value # rubocop:disable Metrics/MethodLength
434
+ return @parameter_value if @parameter_value
435
+
436
+ return nil unless @parameters
437
+
438
+ case parameter_type
439
+ when :argument
440
+ @parameter_index ||= find_parameter_index(method_parameters)
441
+
442
+ parameters[0][parameter_index]
443
+ when :keyword
444
+ parameters[1][parameter_name]
445
+ when :block
446
+ parameters[2]
447
+ end
448
+ end
449
+
450
+ def validates_method?
451
+ return true if validation_contracts.include?(method_name)
452
+
453
+ @failure_reason = :does_not_validate_method
454
+
455
+ false
456
+ end
457
+
458
+ def validates_method_argument?
459
+ contract = validation_contracts.fetch(method_name)
460
+
461
+ contract.send(:arguments_contract).each_constraint.any? do |definition|
462
+ definition.property_name == parameter_name
463
+ end
464
+ end
465
+
466
+ def validates_method_block?
467
+ contract = validation_contracts.fetch(method_name)
468
+
469
+ !contract.send(:block_constraint).nil?
470
+ end
471
+
472
+ def validates_method_keyword?
473
+ contract = validation_contracts.fetch(method_name)
474
+
475
+ contract.send(:keywords_contract).each_constraint.any? do |definition|
476
+ definition.property_name == parameter_name
477
+ end
478
+ end
479
+
480
+ def validates_method_parameter? # rubocop:disable Metrics/MethodLength
481
+ validates_parameter =
482
+ case parameter_type
483
+ when :argument
484
+ validates_method_argument?
485
+ when :keyword
486
+ validates_method_keyword?
487
+ when :block
488
+ validates_method_block?
489
+ end
490
+
491
+ return true if validates_parameter
492
+
493
+ @failure_reason = :parameter_not_validated
494
+
495
+ false
496
+ end
497
+
498
+ def validation_contracts
499
+ if actual.is_a?(Module)
500
+ actual.singleton_class::MethodValidations.contracts
501
+ else
502
+ actual.class::MethodValidations.contracts
503
+ end
504
+ end
505
+ end
506
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum'
4
+
5
+ module Stannum
6
+ # Namespace for RSpec extensions for testing Stannum applications.
7
+ module RSpec; end
8
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ require 'stannum/attribute'
6
+
7
+ module Stannum
8
+ # Abstract class for defining attribute methods for a struct.
9
+ #
10
+ # @see Stannum::Attribute.
11
+ class Schema < Module
12
+ extend Forwardable
13
+ include Enumerable
14
+
15
+ def initialize
16
+ super
17
+
18
+ @attributes = {}
19
+ end
20
+
21
+ # @!method each
22
+ # Iterates through the the attributes by name and attribute object.
23
+ #
24
+ # @yieldparam name [String] The name of the attribute.
25
+ # @yieldparam attribute [Stannum::Attribute] The attribute object.
26
+
27
+ # @!method each_key
28
+ # Iterates through the the attributes by name.
29
+ #
30
+ # @yieldparam name [String] The name of the attribute.
31
+
32
+ # @!method each_value
33
+ # Iterates through the the attributes by attribute object.
34
+ #
35
+ # @yieldparam attribute [Stannum::Attribute] The attribute object.
36
+
37
+ def_delegators :attributes,
38
+ :each,
39
+ :each_key,
40
+ :each_value
41
+
42
+ # Retrieves the named attribute object.
43
+ #
44
+ # @param key [String, Symbol] The name of the requested attribute.
45
+ #
46
+ # @return [Stannum::Attribute] The attribute object.
47
+ #
48
+ # @raise ArgumentError if the key is invalid.
49
+ # @raise KeyError if the attribute is not defined.
50
+ def [](key)
51
+ validate_key(key)
52
+
53
+ attributes.fetch(key.to_s)
54
+ end
55
+
56
+ # rubocop:disable Metrics/MethodLength
57
+
58
+ # @api private
59
+ #
60
+ # Defines an attribute and adds the attribute to the contract.
61
+ #
62
+ # This method should not be called directly. Instead, define attributes via
63
+ # the Struct.attribute class method.
64
+ #
65
+ # @see Stannum::Struct
66
+ def define_attribute(name:, options:, type:)
67
+ attribute = Stannum::Attribute.new(
68
+ name: name,
69
+ options: options,
70
+ type: type
71
+ )
72
+
73
+ if @attributes.key?(attribute.name)
74
+ raise ArgumentError, "attribute #{name.inspect} already exists"
75
+ end
76
+
77
+ define_reader(attribute.name, attribute.reader_name)
78
+ define_writer(attribute.name, attribute.writer_name, attribute.default)
79
+
80
+ @attributes[attribute.name] = attribute
81
+ end
82
+ # rubocop:enable Metrics/MethodLength
83
+
84
+ # Checks if the given attribute is defined.
85
+ #
86
+ # @param key [String, Symbol] the name of the attribute to check.
87
+ #
88
+ # @return [Boolean] true if the attribute is defined; otherwise false.
89
+ def key?(key)
90
+ validate_key(key)
91
+
92
+ attributes.key?(key.to_s)
93
+ end
94
+ alias has_key? key?
95
+
96
+ # @private
97
+ def own_attributes
98
+ @attributes
99
+ end
100
+
101
+ private
102
+
103
+ def attributes
104
+ ancestors
105
+ .reverse_each
106
+ .select { |mod| mod.is_a?(Stannum::Schema) }
107
+ .map(&:own_attributes)
108
+ .reduce(&:merge)
109
+ end
110
+
111
+ def define_reader(attr_name, reader_name)
112
+ define_method(reader_name) { @attributes[attr_name] }
113
+ end
114
+
115
+ def define_writer(attr_name, writer_name, default_value)
116
+ define_method(writer_name) do |value|
117
+ @attributes[attr_name] = value.nil? ? default_value : value
118
+ end
119
+ end
120
+
121
+ def validate_key(key)
122
+ raise ArgumentError, "key can't be blank" if key.nil?
123
+
124
+ unless key.is_a?(String) || key.is_a?(Symbol)
125
+ raise ArgumentError, 'key must be a String or Symbol'
126
+ end
127
+
128
+ raise ArgumentError, "key can't be blank" if key.to_s.empty?
129
+ end
130
+ end
131
+ end