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.
- checksums.yaml +4 -4
- data/.agents/resume +4 -0
- data/.agents/setup +105 -0
- data/.rspec +2 -0
- data/.rubocop.yml +14 -4
- data/.tool-versions +1 -1
- data/API.md +335 -0
- data/CHANGELOG.md +110 -1
- data/Gemfile +13 -0
- data/Gemfile.lock +118 -42
- data/README.md +278 -4
- data/Rakefile +13 -7
- data/benchmark/baseline.rb +204 -0
- data/lib/strict/assignment_error.rb +5 -2
- data/lib/strict/attribute.rb +8 -47
- data/lib/strict/attributes/class.rb +0 -6
- data/lib/strict/attributes/coercer.rb +4 -3
- data/lib/strict/attributes/configuration.rb +15 -0
- data/lib/strict/attributes/dsl.rb +18 -10
- data/lib/strict/attributes/generated_methods.rb +113 -0
- data/lib/strict/attributes/instance.rb +32 -15
- data/lib/strict/configuration.rb +44 -0
- data/lib/strict/declaration.rb +109 -0
- data/lib/strict/detailed_validator.rb +9 -0
- data/lib/strict/error.rb +8 -1
- data/lib/strict/initialization_error.rb +11 -2
- data/lib/strict/interface.rb +39 -21
- data/lib/strict/interfaces/conformance.rb +125 -0
- data/lib/strict/interfaces/instance.rb +1 -49
- data/lib/strict/method.rb +72 -53
- data/lib/strict/method_call_error.rb +12 -2
- data/lib/strict/method_return_error.rb +2 -2
- data/lib/strict/methods/dsl.rb +16 -8
- data/lib/strict/methods/module.rb +27 -8
- data/lib/strict/methods/verifiable_method.rb +234 -70
- data/lib/strict/object.rb +1 -1
- data/lib/strict/parameter.rb +3 -52
- data/lib/strict/return.rb +14 -10
- data/lib/strict/rspec.rb +159 -0
- data/lib/strict/union.rb +180 -0
- data/lib/strict/unions/coercer.rb +55 -0
- data/lib/strict/validation.rb +40 -0
- data/lib/strict/validators/all_of.rb +11 -3
- data/lib/strict/validators/array_of.rb +18 -3
- data/lib/strict/validators/hash_of.rb +23 -3
- data/lib/strict/value.rb +18 -4
- data/lib/strict/version.rb +1 -1
- data/lib/strict/violation.rb +9 -0
- data/lib/strict.rb +42 -0
- data/sig/strict/rspec.rbs +10 -0
- data/sig/strict.rbs +213 -1
- data/strict.gemspec +1 -9
- metadata +20 -120
- data/lib/strict/accessor/attributes.rb +0 -15
- data/lib/strict/accessor/module.rb +0 -45
- data/lib/strict/reader/attributes.rb +0 -15
- 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(
|
|
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
|
data/lib/strict/attribute.rb
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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
|
|
24
|
-
|
|
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 :
|
|
17
|
+
attr_reader :instance_variable
|
|
45
18
|
|
|
46
19
|
def initialize(name:, validator:, default_generator:, coercer:)
|
|
47
|
-
|
|
48
|
-
@
|
|
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,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? ||
|
|
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(&
|
|
8
|
-
dsl = new
|
|
9
|
-
dsl.instance_eval(&
|
|
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(
|
|
24
|
-
attribute = ::Strict::Attribute.make(
|
|
25
|
-
|
|
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,
|
|
37
|
+
def method_missing(name, *, **)
|
|
30
38
|
if respond_to_missing?(name)
|
|
31
|
-
strict_attribute(name,
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
if
|
|
14
|
-
value = attributes.
|
|
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:
|
|
24
|
-
|
|
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
|
|
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:
|
|
36
|
-
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|