strict 1.5.0 → 2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/resume +4 -0
  3. data/.agents/setup +105 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +14 -4
  6. data/.tool-versions +1 -1
  7. data/API.md +337 -0
  8. data/CHANGELOG.md +117 -1
  9. data/Gemfile +13 -0
  10. data/Gemfile.lock +117 -41
  11. data/README.md +238 -6
  12. data/Rakefile +13 -7
  13. data/benchmark/baseline.rb +204 -0
  14. data/lib/strict/assignment_error.rb +5 -2
  15. data/lib/strict/attribute.rb +8 -49
  16. data/lib/strict/attributes/class.rb +0 -6
  17. data/lib/strict/attributes/coercer.rb +4 -3
  18. data/lib/strict/attributes/configuration.rb +15 -0
  19. data/lib/strict/attributes/dsl.rb +18 -10
  20. data/lib/strict/attributes/generated_methods.rb +113 -0
  21. data/lib/strict/attributes/instance.rb +32 -15
  22. data/lib/strict/declaration.rb +109 -0
  23. data/lib/strict/detailed_validator.rb +9 -0
  24. data/lib/strict/error.rb +8 -1
  25. data/lib/strict/initialization_error.rb +11 -2
  26. data/lib/strict/interface.rb +39 -21
  27. data/lib/strict/interfaces/conformance.rb +125 -0
  28. data/lib/strict/interfaces/instance.rb +1 -49
  29. data/lib/strict/method.rb +72 -53
  30. data/lib/strict/method_call_error.rb +12 -2
  31. data/lib/strict/method_return_error.rb +2 -2
  32. data/lib/strict/methods/dsl.rb +16 -8
  33. data/lib/strict/methods/module.rb +27 -8
  34. data/lib/strict/methods/verifiable_method.rb +234 -70
  35. data/lib/strict/object.rb +1 -1
  36. data/lib/strict/parameter.rb +3 -54
  37. data/lib/strict/return.rb +14 -12
  38. data/lib/strict/rspec.rb +159 -0
  39. data/lib/strict/union.rb +214 -0
  40. data/lib/strict/unions/coercer.rb +55 -0
  41. data/lib/strict/validation.rb +40 -0
  42. data/lib/strict/validators/all_of.rb +11 -3
  43. data/lib/strict/validators/array_of.rb +18 -3
  44. data/lib/strict/validators/hash_of.rb +23 -3
  45. data/lib/strict/value.rb +18 -4
  46. data/lib/strict/version.rb +1 -1
  47. data/lib/strict/violation.rb +9 -0
  48. data/lib/strict.rb +10 -9
  49. data/sig/strict/rspec.rbs +10 -0
  50. data/sig/strict.rbs +213 -1
  51. data/strict.gemspec +1 -9
  52. metadata +19 -120
  53. data/lib/strict/accessor/attributes.rb +0 -15
  54. data/lib/strict/accessor/module.rb +0 -45
  55. data/lib/strict/reader/attributes.rb +0 -15
  56. data/lib/strict/reader/module.rb +0 -27
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strict"
4
+ require "rspec/core"
5
+ require "rspec/expectations"
6
+ require "rspec/mocks"
7
+ require "rspec/support/fuzzy_matcher"
8
+
9
+ module Strict
10
+ module RSpec
11
+ module Support
12
+ NO_VIOLATIONS = [].freeze
13
+ INSTANCE_VARIABLE_GET = ::Object.instance_method(:instance_variable_get)
14
+ private_constant :NO_VIOLATIONS
15
+ private_constant :INSTANCE_VARIABLE_GET
16
+
17
+ class << self
18
+ def violations(validator, value)
19
+ return NO_VIOLATIONS if test_value?(validator, value)
20
+ return validator.violations(value) if Strict::DetailedValidator === validator
21
+ return NO_VIOLATIONS if validator === value
22
+
23
+ [Strict::Violation.new(path: [], code: :invalid, value: value, validator: validator)]
24
+ end
25
+
26
+ def test_value?(validator, value)
27
+ matcher?(value) || instance_double_valid_for?(validator, value)
28
+ end
29
+
30
+ def no_violations
31
+ NO_VIOLATIONS
32
+ end
33
+
34
+ def stubs_for(strict_class, stubs)
35
+ return stubs unless strict_class.respond_to?(:verify_implementation!)
36
+
37
+ strict_class.strict_instance_methods.to_h { |name, _method| [name, nil] }.merge(stubs)
38
+ end
39
+
40
+ def violation_details(violations)
41
+ descriptions = violations.map { |violation| " - #{violation_description(violation)}" }
42
+ "violations:\n#{descriptions.join("\n")}"
43
+ end
44
+
45
+ private
46
+
47
+ def matcher?(value)
48
+ ::RSpec::Support.is_a_matcher?(value)
49
+ end
50
+
51
+ def instance_double_valid_for?(validator, value)
52
+ return false unless ::RSpec::Mocks::InstanceVerifyingDouble === value
53
+ return false unless ::Module === validator
54
+
55
+ doubled_module = INSTANCE_VARIABLE_GET.bind_call(value, :@doubled_module).target
56
+ doubled_module && !!(doubled_module <= validator)
57
+ end
58
+
59
+ def violation_description(violation)
60
+ location = violation.path.empty? ? "root" : violation.path.inspect
61
+
62
+ case violation.code
63
+ when :invalid
64
+ "at #{location}: expected #{format(violation.validator)}, got #{format(violation.value)}"
65
+ when :missing
66
+ "at #{location}: expected #{format(violation.validator)}, but the value was missing"
67
+ when :unexpected
68
+ "at #{location}: got unexpected #{format(violation.value)}"
69
+ end
70
+ end
71
+
72
+ def format(value)
73
+ ::RSpec::Support::ObjectFormatter.format(value)
74
+ end
75
+ end
76
+ end
77
+
78
+ module AcceptsMatchersAndDoubles
79
+ def violations(value, configuration = Strict.configuration)
80
+ return Support.no_violations if Support.test_value?(validator, value)
81
+
82
+ super
83
+ end
84
+ end
85
+
86
+ module AcceptsNestedMatchersAndDoubles
87
+ def violations(validator, value)
88
+ return Support.no_violations if Support.test_value?(validator, value)
89
+
90
+ super
91
+ end
92
+ end
93
+
94
+ module ComposableValue
95
+ def ===(other)
96
+ return false unless other.instance_of?(self.class)
97
+
98
+ self.class.strict_attributes.all? do |attribute|
99
+ expected = public_send(attribute.name)
100
+ actual = other.public_send(attribute.name)
101
+ ::RSpec::Support::FuzzyMatcher.values_match?(expected, actual)
102
+ end
103
+ end
104
+ end
105
+
106
+ module ExampleMethods
107
+ def strict_double(strict_class, stubs = {})
108
+ instance_double(strict_class, Support.stubs_for(strict_class, stubs))
109
+ end
110
+ end
111
+
112
+ Strict::Declaration.prepend(AcceptsMatchersAndDoubles)
113
+ Strict::Return.prepend(AcceptsMatchersAndDoubles)
114
+ Strict::Validation.singleton_class.prepend(AcceptsNestedMatchersAndDoubles)
115
+ Strict::Value.prepend(ComposableValue)
116
+
117
+ ::RSpec.configure do |config|
118
+ config.include ExampleMethods
119
+ end
120
+
121
+ ::RSpec::Matchers.define :conform_to do |interface|
122
+ match do |implementation|
123
+ @conformance_error = nil
124
+ interface.verify_implementation!(implementation)
125
+ true
126
+ rescue Strict::ImplementationDoesNotConformError => e
127
+ @conformance_error = e
128
+ false
129
+ end
130
+
131
+ failure_message do |implementation|
132
+ "expected #{implementation.inspect} to conform to #{interface.inspect}\n\n#{@conformance_error.message}"
133
+ end
134
+
135
+ failure_message_when_negated do |implementation|
136
+ "expected #{implementation.inspect} not to conform to #{interface.inspect}"
137
+ end
138
+ end
139
+
140
+ ::RSpec::Matchers.define :validate do |value|
141
+ match do |validator|
142
+ @violations = Support.violations(validator, value)
143
+ @violations.empty?
144
+ end
145
+
146
+ failure_message do |validator|
147
+ message = "expected #{validator.inspect} to validate #{value.inspect}"
148
+ "#{message}\n\n#{Support.violation_details(@violations)}"
149
+ end
150
+
151
+ failure_message_when_negated do |validator|
152
+ "expected #{validator.inspect} not to validate #{value.inspect}"
153
+ end
154
+ end
155
+
156
+ private_constant :Support, :AcceptsMatchersAndDoubles, :AcceptsNestedMatchersAndDoubles, :ComposableValue,
157
+ :ExampleMethods
158
+ end
159
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Strict
4
+ module Union
5
+ VARIANT_NAME = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/
6
+ private_constant :VARIANT_NAME
7
+ Variant = Data.define(:tag, :variant_class)
8
+ private_constant :Variant
9
+
10
+ def self.included(union_class)
11
+ union_class.extend(ClassMethods)
12
+ union_class.include(Instance)
13
+ union_class.instance_variable_set(:@strict_union_discriminator, nil)
14
+ union_class.instance_variable_set(:@strict_union_attributes, nil)
15
+ union_class.instance_variable_set(:@strict_union_variant_names, {})
16
+ union_class.instance_variable_set(:@strict_union_variants, {})
17
+ end
18
+
19
+ module ClassMethods # rubocop:disable Metrics/ModuleLength
20
+ def discriminator(name)
21
+ raise ArgumentError, "discriminator already declared for #{self}" if @strict_union_discriminator
22
+
23
+ discriminator = name.to_sym
24
+ strict_union_validate_attributes!(@strict_union_attributes, discriminator)
25
+ @strict_union_discriminator = discriminator
26
+ nil
27
+ end
28
+
29
+ def attributes(&definition)
30
+ raise ArgumentError, "attributes already declared for #{self}" if @strict_union_attributes
31
+ raise ArgumentError, "attributes must be declared before variants for #{self}" if @strict_union_variants.any?
32
+
33
+ definition ||= -> {}
34
+ attributes = Attributes::Dsl.run(&definition)
35
+ strict_union_validate_attributes!(attributes, @strict_union_discriminator)
36
+ @strict_union_attributes = attributes
37
+ nil
38
+ end
39
+
40
+ def variant(name, tag: name.to_sym, &definition)
41
+ discriminator = strict_union_discriminator!
42
+ name = name.to_sym
43
+ tag_key = strict_union_tag_key(tag)
44
+ constant_name = strict_union_validate_variant!(name, tag, tag_key)
45
+ variant_class = strict_union_build_variant(discriminator, name, tag, definition)
46
+ strict_union_validate_interrogation_method!(name, variant_class)
47
+ strict_union_register_variant(name, tag, tag_key, constant_name, variant_class)
48
+ end
49
+
50
+ def coercer
51
+ discriminator = @strict_union_discriminator
52
+ raise ArgumentError, "declare a discriminator before using the coercer for #{self}" unless discriminator
53
+
54
+ Unions::Coercer.new(self, discriminator:, variants: @strict_union_variants)
55
+ end
56
+
57
+ def ===(value)
58
+ @strict_union_variant_names&.value?(value.class) || false
59
+ end
60
+
61
+ private
62
+
63
+ def strict_union_validate_variant!(name, tag, tag_key)
64
+ constant_name = strict_union_constant_name(name)
65
+ raise ArgumentError, "variant #{name.inspect} already declared for #{self}" if
66
+ @strict_union_variant_names.key?(name)
67
+ raise ArgumentError, "tag #{tag.inspect} already declared for #{self}" if @strict_union_variants.key?(tag_key)
68
+ raise ArgumentError, "constant #{constant_name} is already defined for #{self}" if
69
+ const_defined?(constant_name, false)
70
+ raise ArgumentError, "variant convenience method #{name.inspect} already exists for #{self}" if
71
+ strict_union_method_defined?(singleton_class, name)
72
+
73
+ constant_name
74
+ end
75
+
76
+ def strict_union_validate_interrogation_method!(name, variant_class)
77
+ method_name = strict_union_interrogation_method(name)
78
+ owners = [self, *@strict_union_variant_names.values, variant_class]
79
+ conflicting_owner = owners.find { |owner| strict_union_method_defined?(owner, method_name) }
80
+ raise ArgumentError, "interrogation method #{method_name.inspect} already exists for #{conflicting_owner}" if
81
+ conflicting_owner
82
+
83
+ strict_union_validate_existing_interrogation_methods!(variant_class)
84
+ end
85
+
86
+ def strict_union_validate_existing_interrogation_methods!(variant_class)
87
+ @strict_union_variant_names.each_key do |variant_name|
88
+ existing_method = strict_union_interrogation_method(variant_name)
89
+ next if variant_class.instance_method(existing_method).owner.equal?(self)
90
+
91
+ raise ArgumentError, "interrogation method #{existing_method.inspect} already exists for #{variant_class}"
92
+ end
93
+ end
94
+
95
+ def strict_union_register_variant(name, tag, tag_key, constant_name, variant_class)
96
+ const_set(constant_name, variant_class)
97
+ @strict_union_variant_names[name] = variant_class
98
+ @strict_union_variants[tag_key] = Variant.new(tag:, variant_class:)
99
+ define_singleton_method(name) { |**attributes| variant_class.new(**attributes) }
100
+ define_method(strict_union_interrogation_method(name)) { variant_class === self }
101
+ variant_class
102
+ end
103
+
104
+ def strict_union_interrogation_method(name)
105
+ :"#{name}?"
106
+ end
107
+
108
+ def strict_union_method_defined?(owner, name)
109
+ owner.public_method_defined?(name) ||
110
+ owner.protected_method_defined?(name) ||
111
+ owner.private_method_defined?(name)
112
+ end
113
+
114
+ def strict_union_discriminator!
115
+ @strict_union_discriminator ||
116
+ raise(ArgumentError, "declare a discriminator before variants for #{self}")
117
+ end
118
+
119
+ def strict_union_build_variant(discriminator, name, tag, definition)
120
+ variant_class = Class.new(self)
121
+ attribute_definition = strict_union_evaluate_variant(variant_class, name, definition)
122
+ variant_class.include(Strict::Value)
123
+ strict_union_define_variant_attributes(variant_class, discriminator, tag, attribute_definition)
124
+ variant_class.define_singleton_method(:===, ::Module.instance_method(:===))
125
+ variant_class
126
+ end
127
+
128
+ def strict_union_evaluate_variant(variant_class, tag, definition)
129
+ return unless definition
130
+
131
+ attribute_definition = nil
132
+ variant_class.define_singleton_method(:attributes) do |&block|
133
+ raise ArgumentError, "attributes already declared for variant #{tag.inspect}" if attribute_definition
134
+
135
+ attribute_definition = block || -> {}
136
+ nil
137
+ end
138
+ variant_class.class_exec(&definition)
139
+ variant_class.singleton_class.remove_method(:attributes)
140
+ attribute_definition
141
+ end
142
+
143
+ # rubocop:disable Metrics/MethodLength
144
+ def strict_union_define_variant_attributes(variant_class, discriminator, tag, attribute_definition)
145
+ discriminator_coercer = strict_union_discriminator_coercer(discriminator, tag)
146
+ shared_attributes = @strict_union_attributes
147
+ variant_class.attributes do
148
+ strict_attribute discriminator, tag, default_value: tag, coerce: discriminator_coercer
149
+ discriminator_attribute = __strict_dsl_internal_attributes.fetch(discriminator)
150
+ shared_attributes&.each do |attribute|
151
+ __strict_dsl_internal_attributes[attribute.name] = attribute
152
+ end
153
+ instance_exec(&attribute_definition) if attribute_definition
154
+ unless __strict_dsl_internal_attributes.fetch(discriminator).equal?(discriminator_attribute)
155
+ ::Kernel.raise ::ArgumentError, "variants cannot redeclare discriminator #{discriminator.inspect}"
156
+ end
157
+ end
158
+ end
159
+ # rubocop:enable Metrics/MethodLength
160
+
161
+ def strict_union_validate_attributes!(attributes, discriminator)
162
+ return unless discriminator && attributes
163
+
164
+ storage = Attribute.instance_variable_for(discriminator)
165
+ conflicting_attribute = attributes.find { |attribute| attribute.instance_variable.eql?(storage) }
166
+ return unless conflicting_attribute
167
+
168
+ if conflicting_attribute.name.eql?(discriminator)
169
+ raise ArgumentError, "union attributes cannot redeclare discriminator #{discriminator.inspect}"
170
+ end
171
+
172
+ raise ArgumentError,
173
+ "union attributes cannot conflict with discriminator #{discriminator.inspect}: " \
174
+ "#{conflicting_attribute.name.inspect} uses #{storage}"
175
+ end
176
+
177
+ def strict_union_discriminator_coercer(discriminator, tag)
178
+ tag_key = strict_union_tag_key(tag)
179
+ lambda do |value|
180
+ unless strict_union_tag_key(value).eql?(tag_key)
181
+ raise ArgumentError, "discriminator #{discriminator.inspect} must equal #{tag.inspect}"
182
+ end
183
+
184
+ tag
185
+ end
186
+ end
187
+
188
+ def strict_union_tag_key(tag)
189
+ unless tag.is_a?(::String) || tag.is_a?(::Symbol)
190
+ raise ArgumentError, "variant tag must be a String or Symbol, got #{tag.inspect}"
191
+ end
192
+
193
+ tag.is_a?(::String) ? tag.to_sym : tag
194
+ end
195
+
196
+ def strict_union_constant_name(tag)
197
+ name = tag.to_s
198
+ unless VARIANT_NAME.match?(name)
199
+ raise ArgumentError, "variant name must be lower snake_case, got #{name.inspect}"
200
+ end
201
+
202
+ name.split("_").map { |part| part.sub(/\A./, &:upcase) }.join
203
+ end
204
+ end # rubocop:enable Metrics/ModuleLength
205
+
206
+ module Instance
207
+ def initialize(...)
208
+ raise TypeError, "cannot instantiate union #{self.class} directly; instantiate one of its variants"
209
+ end
210
+ end
211
+
212
+ private_constant :ClassMethods, :Instance
213
+ end
214
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Strict
4
+ module Unions
5
+ class Coercer
6
+ NOT_PROVIDED = ::Object.new.freeze
7
+ private_constant :NOT_PROVIDED
8
+
9
+ def initialize(union_class, discriminator:, variants:)
10
+ @union_class = union_class
11
+ @discriminator = discriminator
12
+ @variants = variants
13
+ end
14
+
15
+ def call(value)
16
+ return value if value.nil? || union_class === value
17
+ return value unless value.respond_to?(:to_h)
18
+
19
+ coerce(value.to_h)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :union_class, :discriminator, :variants
25
+
26
+ def coerce(hash)
27
+ tag = tag_from(hash)
28
+ variant = variant_for(normalized_tag(tag), original_tag: tag)
29
+ variant.variant_class.coercer.call(hash.merge(discriminator => variant.tag))
30
+ end
31
+
32
+ def tag_from(hash)
33
+ tag = hash.fetch(discriminator) { hash.fetch(discriminator.to_s, NOT_PROVIDED) }
34
+ if tag.equal?(NOT_PROVIDED)
35
+ raise ArgumentError, "missing discriminator #{discriminator.inspect} for #{union_class}"
36
+ end
37
+
38
+ tag
39
+ end
40
+
41
+ def variant_for(tag, original_tag:)
42
+ variants.fetch(tag)
43
+ rescue KeyError
44
+ expected = variants.values.map { |variant| variant.tag.inspect }.join(", ")
45
+ raise ArgumentError,
46
+ "unknown discriminator #{discriminator.inspect} for #{union_class}: #{original_tag.inspect}; " \
47
+ "expected one of #{expected}"
48
+ end
49
+
50
+ def normalized_tag(tag)
51
+ tag.is_a?(String) ? tag.to_sym : tag
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Strict
4
+ module Validation
5
+ NONE = [].freeze
6
+ ROOT_PATH = [].freeze
7
+
8
+ module_function
9
+
10
+ def violations(validator, value)
11
+ return validator.violations(value) if DetailedValidator === validator
12
+ return NONE if validator === value
13
+
14
+ invalid(validator, value)
15
+ end
16
+
17
+ def invalid(validator, value)
18
+ [Violation.new(path: ROOT_PATH, code: :invalid, value: value, validator: validator)]
19
+ end
20
+
21
+ def missing(validator, path:)
22
+ Violation.new(path: path, code: :missing, value: nil, validator: validator)
23
+ end
24
+
25
+ def unexpected(value, path:)
26
+ Violation.new(path: path, code: :unexpected, value: value, validator: nil)
27
+ end
28
+
29
+ def prepend_path(violations, *segments)
30
+ violations.map do |violation|
31
+ Violation.new(
32
+ path: segments + violation.path,
33
+ code: violation.code,
34
+ value: violation.value,
35
+ validator: violation.validator
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,16 +3,24 @@
3
3
  module Strict
4
4
  module Validators
5
5
  class AllOf
6
+ include DetailedValidator
7
+
6
8
  attr_reader :subvalidators
7
9
 
8
10
  def initialize(*subvalidators)
9
11
  @subvalidators = subvalidators
10
12
  end
11
13
 
12
- def ===(value)
13
- subvalidators.all? do |subvalidator|
14
- subvalidator === value
14
+ def violations(value)
15
+ violations = nil
16
+ subvalidators.each do |subvalidator|
17
+ subvalidator_violations = Validation.violations(subvalidator, value)
18
+ next if subvalidator_violations.empty?
19
+
20
+ violations ||= []
21
+ violations.concat(subvalidator_violations)
15
22
  end
23
+ violations || Validation::NONE
16
24
  end
17
25
 
18
26
  def inspect
@@ -3,16 +3,31 @@
3
3
  module Strict
4
4
  module Validators
5
5
  class ArrayOf
6
+ include DetailedValidator
7
+
6
8
  attr_reader :element_validator
7
9
 
8
10
  def initialize(element_validator)
9
11
  @element_validator = element_validator
10
12
  end
11
13
 
12
- def ===(value)
13
- Array === value && value.all? do |v|
14
- element_validator === v
14
+ def coercer
15
+ element_coercer = element_validator.coercer if element_validator.respond_to?(:coercer)
16
+ Coercers::Array.new(element_coercer)
17
+ end
18
+
19
+ def violations(value)
20
+ return Validation.invalid(self, value) unless Array === value
21
+
22
+ violations = nil
23
+ value.each_with_index do |element, index|
24
+ element_violations = Validation.violations(element_validator, element)
25
+ next if element_violations.empty?
26
+
27
+ violations ||= []
28
+ violations.concat(Validation.prepend_path(element_violations, index))
15
29
  end
30
+ violations || Validation::NONE
16
31
  end
17
32
 
18
33
  def inspect
@@ -3,6 +3,8 @@
3
3
  module Strict
4
4
  module Validators
5
5
  class HashOf
6
+ include DetailedValidator
7
+
6
8
  attr_reader :key_validator, :value_validator
7
9
 
8
10
  def initialize(key_validator, value_validator)
@@ -10,11 +12,29 @@ module Strict
10
12
  @value_validator = value_validator
11
13
  end
12
14
 
13
- def ===(value)
14
- Hash === value && value.all? do |k, v|
15
- key_validator === k && value_validator === v
15
+ def coercer
16
+ key_coercer = key_validator.coercer if key_validator.respond_to?(:coercer)
17
+ value_coercer = value_validator.coercer if value_validator.respond_to?(:coercer)
18
+ Coercers::Hash.new(key_coercer, value_coercer)
19
+ end
20
+
21
+ # rubocop:disable Metrics/MethodLength
22
+ def violations(value)
23
+ return Validation.invalid(self, value) unless Hash === value
24
+
25
+ violations = nil
26
+ value.each do |key, entry_value|
27
+ key_violations = Validation.violations(key_validator, key)
28
+ value_violations = Validation.violations(value_validator, entry_value)
29
+ next if key_violations.empty? && value_violations.empty?
30
+
31
+ violations ||= []
32
+ violations.concat(Validation.prepend_path(key_violations, key))
33
+ violations.concat(Validation.prepend_path(value_violations, key))
16
34
  end
35
+ violations || Validation::NONE
17
36
  end
37
+ # rubocop:enable Metrics/MethodLength
18
38
 
19
39
  def inspect
20
40
  "HashOf(#{key_validator.inspect} => #{value_validator.inspect})"
data/lib/strict/value.rb CHANGED
@@ -3,20 +3,34 @@
3
3
  module Strict
4
4
  module Value
5
5
  def self.included(mod)
6
- mod.extend(Reader::Attributes)
6
+ Attributes::GeneratedMethods.install_on(mod, writable: false)
7
7
  end
8
8
 
9
9
  def with(**attributes)
10
- self.class.new(**to_h.merge(attributes))
10
+ self.class.new(**Attributes::Instance.attribute_values(self), **attributes)
11
+ end
12
+
13
+ def deconstruct_keys(keys)
14
+ attributes = Attributes::Instance.attribute_values(self)
15
+ keys ? attributes.slice(*keys) : attributes
11
16
  end
12
17
 
13
18
  def eql?(other)
14
- self.class.equal?(other.class) && to_h.eql?(other.to_h)
19
+ return false unless self.class.equal?(other.class)
20
+
21
+ self.class.strict_attributes.all? do |attribute|
22
+ public_send(attribute.name).eql?(other.public_send(attribute.name))
23
+ end
15
24
  end
16
25
  alias == eql?
17
26
 
18
27
  def hash
19
- [self.class, to_h].hash
28
+ value_class = self.class
29
+ components = [value_class]
30
+ value_class.strict_attributes.each do |attribute|
31
+ components << public_send(attribute.name)
32
+ end
33
+ components.hash
20
34
  end
21
35
  end
22
36
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Strict
4
- VERSION = "1.5.0"
4
+ VERSION = "2.1.0"
5
5
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Strict
4
+ Violation = Data.define(:path, :code, :value, :validator) do
5
+ def initialize(path:, code:, value:, validator:)
6
+ super(path: path.dup.freeze, code: code, value: value, validator: validator)
7
+ end
8
+ end
9
+ end
data/lib/strict.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "zeitwerk"
4
4
  loader = Zeitwerk::Loader.for_gem
5
+ loader.ignore("#{__dir__}/strict/rspec.rb")
5
6
  loader.setup
6
7
 
7
8
  module Strict
@@ -9,7 +10,7 @@ module Strict
9
10
 
10
11
  class << self
11
12
  def configuration
12
- thread_configuration || global_configuration
13
+ execution_context_override || global_configuration
13
14
  end
14
15
 
15
16
  def configure
@@ -19,28 +20,28 @@ module Strict
19
20
  end
20
21
 
21
22
  def with_overrides(**overrides)
22
- original_thread_configuration = thread_configuration
23
+ original_override = execution_context_override
23
24
 
24
25
  begin
25
- self.thread_configuration = Strict::Configuration.new(**configuration.to_h.merge(overrides))
26
+ self.execution_context_override = Strict::Configuration.new(**configuration.to_h, **overrides)
26
27
  yield
27
28
  ensure
28
- self.thread_configuration = original_thread_configuration
29
+ self.execution_context_override = original_override
29
30
  end
30
31
  end
31
32
 
32
33
  private
33
34
 
34
35
  def overridden?
35
- !!thread_configuration
36
+ !!execution_context_override
36
37
  end
37
38
 
38
- def thread_configuration
39
- Thread.current[:configuration]
39
+ def execution_context_override
40
+ Thread.current[:__strict_configuration_override]
40
41
  end
41
42
 
42
- def thread_configuration=(configuration)
43
- Thread.current[:configuration] = configuration
43
+ def execution_context_override=(configuration)
44
+ Thread.current[:__strict_configuration_override] = configuration
44
45
  end
45
46
 
46
47
  def global_configuration