strict 1.4.0 → 2.0.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 (57) 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 +335 -0
  8. data/CHANGELOG.md +110 -1
  9. data/Gemfile +13 -0
  10. data/Gemfile.lock +118 -42
  11. data/README.md +278 -4
  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 -47
  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/configuration.rb +44 -0
  23. data/lib/strict/declaration.rb +109 -0
  24. data/lib/strict/detailed_validator.rb +9 -0
  25. data/lib/strict/error.rb +8 -1
  26. data/lib/strict/initialization_error.rb +11 -2
  27. data/lib/strict/interface.rb +39 -21
  28. data/lib/strict/interfaces/conformance.rb +125 -0
  29. data/lib/strict/interfaces/instance.rb +1 -49
  30. data/lib/strict/method.rb +72 -53
  31. data/lib/strict/method_call_error.rb +12 -2
  32. data/lib/strict/method_return_error.rb +2 -2
  33. data/lib/strict/methods/dsl.rb +16 -8
  34. data/lib/strict/methods/module.rb +27 -8
  35. data/lib/strict/methods/verifiable_method.rb +234 -70
  36. data/lib/strict/object.rb +1 -1
  37. data/lib/strict/parameter.rb +3 -52
  38. data/lib/strict/return.rb +14 -10
  39. data/lib/strict/rspec.rb +159 -0
  40. data/lib/strict/union.rb +180 -0
  41. data/lib/strict/unions/coercer.rb +55 -0
  42. data/lib/strict/validation.rb +40 -0
  43. data/lib/strict/validators/all_of.rb +11 -3
  44. data/lib/strict/validators/array_of.rb +18 -3
  45. data/lib/strict/validators/hash_of.rb +23 -3
  46. data/lib/strict/value.rb +18 -4
  47. data/lib/strict/version.rb +1 -1
  48. data/lib/strict/violation.rb +9 -0
  49. data/lib/strict.rb +42 -0
  50. data/sig/strict/rspec.rbs +10 -0
  51. data/sig/strict.rbs +213 -1
  52. data/strict.gemspec +1 -9
  53. metadata +20 -120
  54. data/lib/strict/accessor/attributes.rb +0 -15
  55. data/lib/strict/accessor/module.rb +0 -45
  56. data/lib/strict/reader/attributes.rb +0 -15
  57. data/lib/strict/reader/module.rb +0 -27
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strict"
4
+
5
+ module StrictBenchmark
6
+ BenchmarkCase = Data.define(:name, :operation)
7
+
8
+ ITERATIONS = Integer(ENV.fetch("ITERATIONS", "100000"), 10)
9
+ WARMUP_ITERATIONS = Integer(ENV.fetch("WARMUP_ITERATIONS", "20000"), 10)
10
+ SAMPLES = Integer(ENV.fetch("SAMPLES", "5"), 10)
11
+ FORMAT = ENV.fetch("FORMAT", "text")
12
+ FORMATS = %w[markdown text].freeze
13
+
14
+ class Value
15
+ include Strict::Value
16
+
17
+ attributes do
18
+ id Integer
19
+ name String
20
+ active Boolean()
21
+ end
22
+ end
23
+
24
+ class Mutable
25
+ include Strict::Object
26
+
27
+ attributes do
28
+ value Integer
29
+ end
30
+ end
31
+
32
+ class VerifiedMethod
33
+ include Strict::Method
34
+
35
+ sig do
36
+ value Integer
37
+ returns Integer
38
+ end
39
+ def call(value:) = value
40
+ end
41
+
42
+ class Interface
43
+ include Strict::Interface
44
+
45
+ expose(:call) do
46
+ value Integer
47
+ returns Integer
48
+ end
49
+ end
50
+
51
+ class Implementation
52
+ def call(value:) = value
53
+ end
54
+
55
+ class Formatter
56
+ def initialize(output_format)
57
+ @output_format = output_format
58
+ end
59
+
60
+ def print_header
61
+ puts markdown? ? markdown_header : text_header
62
+ end
63
+
64
+ def print_result(name:, nanoseconds:, allocations:)
65
+ result = if markdown?
66
+ markdown_result(name:, nanoseconds:, allocations:)
67
+ else
68
+ text_result(name:, nanoseconds:, allocations:)
69
+ end
70
+ puts result
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :output_format
76
+
77
+ def markdown? = output_format == "markdown"
78
+
79
+ def markdown_header
80
+ <<~MARKDOWN.chomp
81
+ ### Strict benchmark
82
+
83
+ `Ruby #{RUBY_VERSION} (#{RUBY_ENGINE})`
84
+
85
+ Iterations: #{ITERATIONS}; warmup: #{WARMUP_ITERATIONS}; samples: #{SAMPLES}
86
+
87
+ | Operation | Median ns/op | Allocations/op |
88
+ | --- | ---: | ---: |
89
+ MARKDOWN
90
+ end
91
+
92
+ def text_header
93
+ <<~TEXT.chomp
94
+ Ruby #{RUBY_VERSION} (#{RUBY_ENGINE})
95
+ Iterations: #{ITERATIONS}; warmup: #{WARMUP_ITERATIONS}; samples: #{SAMPLES}
96
+ Operation median ns/op allocations/op
97
+ TEXT
98
+ end
99
+
100
+ def markdown_result(name:, nanoseconds:, allocations:)
101
+ format("| %<name>s | %<nanoseconds>.1f | %<allocations>.3f |", name:, nanoseconds:, allocations:)
102
+ end
103
+
104
+ def text_result(name:, nanoseconds:, allocations:)
105
+ format("%<name>-24s %<nanoseconds>18.1f %<allocations>18.3f", name:, nanoseconds:, allocations:)
106
+ end
107
+ end
108
+
109
+ class << self
110
+ def run
111
+ validate_settings!
112
+ benchmark_cases = build_cases
113
+ warm_up(benchmark_cases)
114
+ formatter = Formatter.new(FORMAT)
115
+
116
+ formatter.print_header
117
+ benchmark_cases.each { |benchmark_case| print_result(formatter, benchmark_case) }
118
+ end
119
+
120
+ private
121
+
122
+ def print_result(formatter, benchmark_case)
123
+ nanoseconds = median(measure_times(benchmark_case.operation)) * 1_000_000_000 / ITERATIONS
124
+ allocations = measure_allocations(benchmark_case.operation).fdiv(ITERATIONS)
125
+ formatter.print_result(name: benchmark_case.name, nanoseconds:, allocations:)
126
+ end
127
+
128
+ def validate_settings!
129
+ {
130
+ "ITERATIONS" => ITERATIONS,
131
+ "WARMUP_ITERATIONS" => WARMUP_ITERATIONS,
132
+ "SAMPLES" => SAMPLES
133
+ }.each do |name, value|
134
+ raise ArgumentError, "#{name} must be greater than zero, got #{value}" unless value.positive?
135
+ end
136
+ return if FORMATS.include?(FORMAT)
137
+
138
+ raise ArgumentError, "FORMAT must be one of #{FORMATS.join(', ')}, got #{FORMAT.inspect}"
139
+ end
140
+
141
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
142
+ def build_cases
143
+ value = Value.new(id: 1, name: "Strict", active: true)
144
+ equal_value = Value.new(id: 1, name: "Strict", active: true)
145
+ mutable = Mutable.new(value: 1)
146
+ verified_method = VerifiedMethod.new
147
+ implementation = Implementation.new
148
+ interface = Interface.new(implementation)
149
+
150
+ [
151
+ BenchmarkCase.new("value initialization", -> { Value.new(id: 1, name: "Strict", active: true) }),
152
+ BenchmarkCase.new("mutable assignment", -> { mutable.value = 1 }),
153
+ BenchmarkCase.new("verified method call", -> { verified_method.call(value: 1) }),
154
+ BenchmarkCase.new("interface construction", -> { Interface.new(implementation) }),
155
+ BenchmarkCase.new("interface call", -> { interface.call(value: 1) }),
156
+ BenchmarkCase.new("to_h", -> { value.to_h }),
157
+ BenchmarkCase.new("equality", -> { value == equal_value }),
158
+ BenchmarkCase.new("hashing", -> { value.hash })
159
+ ]
160
+ end
161
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
162
+
163
+ def warm_up(benchmark_cases)
164
+ benchmark_cases.each do |benchmark_case|
165
+ repeat(WARMUP_ITERATIONS, benchmark_case.operation)
166
+ end
167
+ end
168
+
169
+ def measure_times(operation)
170
+ Array.new(SAMPLES) do
171
+ GC.start
172
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
+ repeat(ITERATIONS, operation)
174
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
175
+ end
176
+ end
177
+
178
+ def measure_allocations(operation)
179
+ GC.start
180
+ gc_was_disabled = GC.disable
181
+ before = GC.stat(:total_allocated_objects)
182
+ repeat(ITERATIONS, operation)
183
+ GC.stat(:total_allocated_objects) - before
184
+ ensure
185
+ GC.enable unless gc_was_disabled
186
+ end
187
+
188
+ def repeat(iterations, operation)
189
+ result = nil
190
+ iterations.times { result = operation.call }
191
+ result
192
+ end
193
+
194
+ def median(values)
195
+ sorted = values.sort
196
+ middle = sorted.length / 2
197
+ return sorted.fetch(middle) if sorted.length.odd?
198
+
199
+ (sorted.fetch(middle - 1) + sorted.fetch(middle)) / 2
200
+ end
201
+ end
202
+ end
203
+
204
+ StrictBenchmark.run
@@ -4,8 +4,11 @@ module Strict
4
4
  class AssignmentError < Error
5
5
  attr_reader :invalid_attribute, :value
6
6
 
7
- def initialize(assignable_class:, invalid_attribute:, value:)
8
- super(message_from(assignable_class: assignable_class, invalid_attribute: invalid_attribute, value: value))
7
+ def initialize(assignable_class:, invalid_attribute:, value:, violations: Validation::NONE)
8
+ super(
9
+ message_from(assignable_class: assignable_class, invalid_attribute: invalid_attribute, value: value),
10
+ violations: violations
11
+ )
9
12
 
10
13
  @invalid_attribute = invalid_attribute
11
14
  @value = value
@@ -1,63 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Strict
4
- class Attribute
5
- NOT_PROVIDED = ::Object.new.freeze
6
-
4
+ class Attribute < Declaration
7
5
  class << self
8
- def make(name, validator = Validators::Anything.instance, coerce: false, **defaults)
9
- unless valid_defaults?(**defaults)
10
- raise ArgumentError, "Only one of 'default', 'default_value', or 'default_generator' can be provided"
11
- end
12
-
13
- new(
14
- name: name.to_sym,
15
- validator: validator,
16
- default_generator: make_default_generator(**defaults),
17
- coercer: coerce
18
- )
6
+ def instance_variable_for(name)
7
+ "@#{name.to_s.delete_suffix('?').delete_suffix('!')}"
19
8
  end
20
9
 
21
10
  private
22
11
 
23
- def valid_defaults?(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED)
24
- defaults_provided = [default, default_value, default_generator].count do |default_option|
25
- !default_option.equal?(NOT_PROVIDED)
26
- end
27
-
28
- defaults_provided <= 1
29
- end
30
-
31
- def make_default_generator(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED)
32
- if !default.equal?(NOT_PROVIDED)
33
- default.respond_to?(:call) ? default : -> { default }
34
- elsif !default_value.equal?(NOT_PROVIDED)
35
- -> { default_value }
36
- elsif !default_generator.equal?(NOT_PROVIDED)
37
- default_generator
38
- else
39
- NOT_PROVIDED
40
- end
12
+ def coercer_supported?(coercer)
13
+ super || coercer.equal?(true) || coercer.is_a?(Symbol) || coercer.respond_to?(:call)
41
14
  end
42
15
  end
43
16
 
44
- attr_reader :name, :validator, :default_generator, :coercer, :instance_variable
17
+ attr_reader :instance_variable
45
18
 
46
19
  def initialize(name:, validator:, default_generator:, coercer:)
47
- @name = name.to_sym
48
- @validator = validator
49
- @default_generator = default_generator
50
- @coercer = coercer
51
- @optional = !default_generator.equal?(NOT_PROVIDED)
52
- @instance_variable = "@#{name.to_s.chomp('!').chomp('?')}"
53
- end
54
-
55
- def optional?
56
- @optional
57
- end
58
-
59
- def valid?(value)
60
- validator === value
20
+ super
21
+ @instance_variable = self.class.instance_variable_for(self.name)
61
22
  end
62
23
 
63
24
  def coerce(value, for_class:)
@@ -3,12 +3,6 @@
3
3
  module Strict
4
4
  module Attributes
5
5
  module Class
6
- CONSTANT = :STRICT_INTERNAL_ATTRIBUTES_CONFIGURATION__
7
-
8
- def strict_attributes
9
- self::STRICT_INTERNAL_ATTRIBUTES_CONFIGURATION__
10
- end
11
-
12
6
  def coercer
13
7
  Coercer.new(self)
14
8
  end
@@ -3,6 +3,8 @@
3
3
  module Strict
4
4
  module Attributes
5
5
  class Coercer
6
+ NOT_PROVIDED = ::Object.new.freeze
7
+
6
8
  attr_reader :attributes_class
7
9
 
8
10
  def initialize(attributes_class)
@@ -10,15 +12,14 @@ module Strict
10
12
  end
11
13
 
12
14
  def call(value)
13
- return value if value.nil? || !value.respond_to?(:to_h)
15
+ return value if value.nil? || value.instance_of?(attributes_class)
16
+ return value unless value.respond_to?(:to_h)
14
17
 
15
18
  coerce(value.to_h)
16
19
  end
17
20
 
18
21
  private
19
22
 
20
- NOT_PROVIDED = ::Object.new.freeze
21
-
22
23
  def coerce(hash)
23
24
  attributes_class.new(
24
25
  **attributes_class.strict_attributes.each_with_object({}) do |attribute, attributes|
@@ -30,6 +30,7 @@ module Strict
30
30
  attr_reader :attributes
31
31
 
32
32
  def initialize(attributes:)
33
+ validate_instance_variables!(attributes)
33
34
  @attributes = attributes
34
35
  @attributes_index = attributes.to_h { |a| [a.name, a] }
35
36
  end
@@ -41,6 +42,20 @@ module Strict
41
42
  private
42
43
 
43
44
  attr_reader :attributes_index
45
+
46
+ def validate_instance_variables!(attributes)
47
+ attributes_by_instance_variable = {}
48
+ attributes.each do |attribute|
49
+ conflicting_attribute = attributes_by_instance_variable[attribute.instance_variable]
50
+ if conflicting_attribute
51
+ raise ArgumentError,
52
+ "Attribute #{attribute.name.inspect} conflicts with #{conflicting_attribute.name.inspect} " \
53
+ "because both use #{attribute.instance_variable}"
54
+ end
55
+
56
+ attributes_by_instance_variable[attribute.instance_variable] = attribute
57
+ end
58
+ end
44
59
  end
45
60
  end
46
61
  end
@@ -4,9 +4,9 @@ module Strict
4
4
  module Attributes
5
5
  class Dsl < BasicObject
6
6
  class << self
7
- def run(&block)
8
- dsl = new
9
- dsl.instance_eval(&block)
7
+ def run(attributes: [], &)
8
+ dsl = new(attributes: attributes)
9
+ dsl.instance_eval(&)
10
10
  ::Strict::Attributes::Configuration.new(attributes: dsl.__strict_dsl_internal_attributes.values)
11
11
  end
12
12
  end
@@ -16,19 +16,27 @@ module Strict
16
16
 
17
17
  attr_reader :__strict_dsl_internal_attributes
18
18
 
19
- def initialize
20
- @__strict_dsl_internal_attributes = {}
19
+ def initialize(attributes:)
20
+ @__strict_dsl_internal_attributes = attributes.to_h { |attribute| [attribute.name, attribute] }
21
+ @__strict_dsl_internal_inherited_attributes = @__strict_dsl_internal_attributes.dup
21
22
  end
22
23
 
23
- def strict_attribute(*args, **kwargs)
24
- attribute = ::Strict::Attribute.make(*args, **kwargs)
25
- __strict_dsl_internal_attributes[attribute.name] = attribute
24
+ def strict_attribute(*, **)
25
+ attribute = ::Strict::Attribute.make(*, **)
26
+ name = attribute.name
27
+ existing_attribute = __strict_dsl_internal_attributes[name]
28
+ inherited_attribute = @__strict_dsl_internal_inherited_attributes[name]
29
+ if existing_attribute && !existing_attribute.equal?(inherited_attribute)
30
+ ::Kernel.raise ::ArgumentError, "Attribute #{attribute.name.inspect} is already declared"
31
+ end
32
+
33
+ __strict_dsl_internal_attributes[name] = attribute
26
34
  nil
27
35
  end
28
36
 
29
- def method_missing(name, *args, **kwargs)
37
+ def method_missing(name, *, **)
30
38
  if respond_to_missing?(name)
31
- strict_attribute(name, *args, **kwargs)
39
+ strict_attribute(name, *, **)
32
40
  else
33
41
  super
34
42
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Strict
4
+ module Attributes
5
+ class GeneratedMethods < ::Module
6
+ DECLARATION_MARKER = :@__strict_attributes_declared
7
+ RESERVED_METHODS = %i[class eql? hash instance_variable_set public_send raise].freeze
8
+ private_constant :DECLARATION_MARKER, :RESERVED_METHODS
9
+
10
+ class << self
11
+ def install_on(target, writable:)
12
+ installation = ->(receiver, &definition) { install_attributes_on(receiver, writable, &definition) }
13
+ target.define_singleton_method(:attributes) do |&definition|
14
+ installation.call(self, &definition)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ # rubocop:disable Metrics/MethodLength
21
+ def install_attributes_on(target, writable, &definition)
22
+ if target.instance_variable_defined?(DECLARATION_MARKER)
23
+ raise ArgumentError, "Attributes are already declared for #{target}"
24
+ end
25
+
26
+ definition ||= -> {}
27
+ inherited_attributes = target.respond_to?(:strict_attributes) ? target.strict_attributes.to_a : []
28
+ configuration = Strict::Attributes::Dsl.run(attributes: inherited_attributes, &definition)
29
+ declared_attributes = configuration.to_a - inherited_attributes
30
+ generated_methods = new(
31
+ configuration,
32
+ writable: writable,
33
+ target: target,
34
+ declared_attributes: declared_attributes
35
+ )
36
+ target.instance_variable_set(DECLARATION_MARKER, true)
37
+ target.include(Strict::Attributes::Instance, generated_methods)
38
+ target.define_singleton_method(:strict_attributes) { configuration }
39
+ target.extend(Strict::Attributes::Class)
40
+ end
41
+ # rubocop:enable Metrics/MethodLength
42
+ end
43
+
44
+ def initialize(configuration, writable:, target:, declared_attributes:)
45
+ super()
46
+
47
+ validate_collisions!(target, declared_attributes, writable: writable)
48
+ configuration.each do |attribute|
49
+ define_reader(attribute)
50
+ define_writer(attribute) if writable
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def validate_collisions!(target, attributes, writable:)
57
+ attributes.each do |attribute|
58
+ validate_method_available!(target, attribute.name, writable: writable)
59
+ validate_method_available!(target, :"#{attribute.name}=", writable: writable) if writable
60
+ end
61
+ end
62
+
63
+ def validate_method_available!(target, name, writable:)
64
+ return unless method_reserved?(target, name, writable: writable)
65
+
66
+ raise ArgumentError, "Generated attribute method #{name.inspect} already exists for #{target}"
67
+ end
68
+
69
+ def method_reserved?(target, name, writable:)
70
+ method_defined_directly?(target, name) ||
71
+ method_defined_directly?(Strict::Attributes::Instance, name) ||
72
+ (!writable && method_defined_directly?(Strict::Value, name)) ||
73
+ method_defined_directly?(BasicObject, name) ||
74
+ RESERVED_METHODS.include?(name)
75
+ end
76
+
77
+ def method_defined_directly?(owner, name)
78
+ owner.public_method_defined?(name, false) ||
79
+ owner.protected_method_defined?(name, false) ||
80
+ owner.private_method_defined?(name, false)
81
+ end
82
+
83
+ def define_reader(attribute)
84
+ storage_name = attribute.instance_variable.to_s.delete_prefix("@").to_sym
85
+ reader_module = ::Module.new { attr_reader(storage_name) }
86
+ define_method(attribute.name, reader_module.instance_method(storage_name))
87
+ end
88
+
89
+ # rubocop:disable Metrics/MethodLength
90
+ def define_writer(attribute)
91
+ instance_variable = attribute.instance_variable
92
+ define_method(:"#{attribute.name}=") do |value|
93
+ assignable_class = self.class
94
+ value = attribute.coerce(value, for_class: assignable_class)
95
+ configuration = Strict.configuration
96
+ violations = attribute.violations(value, configuration)
97
+
98
+ if violations.empty?
99
+ instance_variable_set(instance_variable, value)
100
+ else
101
+ raise Strict::AssignmentError.new(
102
+ assignable_class: assignable_class,
103
+ invalid_attribute: attribute,
104
+ value: value,
105
+ violations: Strict::Validation.prepend_path(violations, attribute.name)
106
+ )
107
+ end
108
+ end
109
+ end
110
+ # rubocop:enable Metrics/MethodLength
111
+ end
112
+ end
113
+ end
@@ -3,52 +3,69 @@
3
3
  module Strict
4
4
  module Attributes
5
5
  module Instance
6
+ def self.attribute_values(instance)
7
+ instance.class.strict_attributes.to_h do |attribute|
8
+ [attribute.name, instance.public_send(attribute.name)]
9
+ end
10
+ end
11
+
6
12
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
7
13
  def initialize(**attributes)
8
- remaining_attributes = Set.new(attributes.keys)
14
+ initializable_class = self.class
15
+ configuration = Strict.configuration
9
16
  invalid_attributes = nil
10
17
  missing_attributes = nil
18
+ violations = nil
11
19
 
12
- self.class.strict_attributes.each do |attribute|
13
- if remaining_attributes.delete?(attribute.name)
14
- value = attributes.fetch(attribute.name)
20
+ initializable_class.strict_attributes.each do |attribute|
21
+ if attributes.key?(attribute.name)
22
+ value = attributes.delete(attribute.name)
15
23
  elsif attribute.optional?
16
24
  value = attribute.default_generator.call
17
25
  else
18
26
  missing_attributes ||= []
19
27
  missing_attributes << attribute.name
28
+ violations ||= []
29
+ violations << Validation.missing(attribute.validator, path: [attribute.name])
20
30
  next
21
31
  end
22
32
 
23
- value = attribute.coerce(value, for_class: self.class)
24
- if attribute.valid?(value)
33
+ value = attribute.coerce(value, for_class: initializable_class)
34
+ attribute_violations = attribute.violations(value, configuration)
35
+ if attribute_violations.empty?
25
36
  instance_variable_set(attribute.instance_variable, value)
26
37
  else
27
38
  invalid_attributes ||= {}
28
39
  invalid_attributes[attribute] = value
40
+ violations ||= []
41
+ violations.concat(Validation.prepend_path(attribute_violations, attribute.name))
29
42
  end
30
43
  end
31
44
 
32
- return if remaining_attributes.none? && invalid_attributes.nil? && missing_attributes.nil?
45
+ return if attributes.empty? && invalid_attributes.nil? && missing_attributes.nil?
33
46
 
47
+ violations ||= []
48
+ attributes.each do |name, value|
49
+ violations << Validation.unexpected(value, path: [name])
50
+ end
34
51
  raise InitializationError.new(
35
- initializable_class: self.class,
36
- remaining_attributes: remaining_attributes,
52
+ initializable_class: initializable_class,
53
+ remaining_attributes: Set.new(attributes.keys),
37
54
  invalid_attributes: invalid_attributes,
38
- missing_attributes: missing_attributes
55
+ missing_attributes: missing_attributes,
56
+ violations: violations
39
57
  )
40
58
  end
41
59
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
42
60
 
43
61
  def to_h
44
- self.class.strict_attributes.to_h do |attribute|
45
- [attribute.name, public_send(attribute.name)]
46
- end
62
+ Instance.attribute_values(self)
47
63
  end
48
64
 
49
65
  def inspect
50
66
  if self.class.strict_attributes.any?
51
- "#<#{self.class} #{to_h.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')}>"
67
+ attributes = Instance.attribute_values(self)
68
+ "#<#{self.class} #{attributes.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')}>"
52
69
  else
53
70
  "#<#{self.class}>"
54
71
  end
@@ -56,7 +73,7 @@ module Strict
56
73
 
57
74
  def pretty_print(pp)
58
75
  pp.object_group(self) do
59
- to_h.each do |key, value|
76
+ Instance.attribute_values(self).each do |key, value|
60
77
  pp.breakable
61
78
  pp.text("#{key}=")
62
79
  pp.pp(value)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Strict
4
+ class Configuration
5
+ attr_reader :random, :sample_rate
6
+
7
+ def initialize(random: nil, sample_rate: nil)
8
+ self.random = random || Random.new
9
+ self.sample_rate = sample_rate || 1
10
+ end
11
+
12
+ def random=(random)
13
+ case random
14
+ when Random::Formatter
15
+ @random = random
16
+ else
17
+ raise Strict::Error, "Expected a Random::Formatter, got: #{random.inspect}."
18
+ end
19
+ end
20
+
21
+ def sample_rate=(rate)
22
+ case rate
23
+ when 0..1
24
+ @sample_rate = rate
25
+ else
26
+ raise Strict::Error, "Expected a sample rate between 0 and 1 (inclusive), got: #{rate.inspect}. " \
27
+ "A rate of 0 will disable strict validation. " \
28
+ "A rate of 1 will validate 100% of the time. " \
29
+ "A rate of 0.25 will validate roughly 25% of the time."
30
+ end
31
+ end
32
+
33
+ def validate?
34
+ sample_rate >= 1 || (sample_rate > 0 && random.rand < sample_rate) # rubocop:disable Style/NumericPredicate
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ random: random,
40
+ sample_rate: sample_rate
41
+ }
42
+ end
43
+ end
44
+ end