strict 0.0.0 → 1.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +14 -2
- data/Gemfile.lock +19 -2
- data/README.md +165 -6
- data/lib/strict/accessor/attributes.rb +15 -0
- data/lib/strict/accessor/module.rb +45 -0
- data/lib/strict/assignment_error.rb +25 -0
- data/lib/strict/attribute.rb +71 -0
- data/lib/strict/attributes/class.rb +17 -0
- data/lib/strict/attributes/coercer.rb +32 -0
- data/lib/strict/attributes/configuration.rb +46 -0
- data/lib/strict/attributes/dsl.rb +43 -0
- data/lib/strict/attributes/instance.rb +68 -0
- data/lib/strict/coercers/array.rb +22 -0
- data/lib/strict/coercers/hash.rb +34 -0
- data/lib/strict/dsl/coercible.rb +14 -0
- data/lib/strict/dsl/validatable.rb +28 -0
- data/lib/strict/implementation_does_not_conform_error.rb +88 -0
- data/lib/strict/initialization_error.rb +57 -0
- data/lib/strict/interface.rb +21 -0
- data/lib/strict/interfaces/instance.rb +54 -0
- data/lib/strict/method.rb +72 -0
- data/lib/strict/method_call_error.rb +72 -0
- data/lib/strict/method_definition_error.rb +46 -0
- data/lib/strict/method_return_error.rb +25 -0
- data/lib/strict/methods/configuration.rb +14 -0
- data/lib/strict/methods/dsl.rb +56 -0
- data/lib/strict/methods/module.rb +26 -0
- data/lib/strict/methods/verifiable_method.rb +158 -0
- data/lib/strict/object.rb +9 -0
- data/lib/strict/parameter.rb +63 -0
- data/lib/strict/reader/attributes.rb +15 -0
- data/lib/strict/reader/module.rb +27 -0
- data/lib/strict/return.rb +28 -0
- data/lib/strict/validators/all_of.rb +24 -0
- data/lib/strict/validators/any_of.rb +24 -0
- data/lib/strict/validators/anything.rb +20 -0
- data/lib/strict/validators/array_of.rb +24 -0
- data/lib/strict/validators/boolean.rb +20 -0
- data/lib/strict/validators/hash_of.rb +25 -0
- data/lib/strict/validators/range_of.rb +24 -0
- data/lib/strict/value.rb +22 -0
- data/lib/strict/version.rb +1 -1
- data/lib/strict.rb +1 -0
- data/strict.gemspec +41 -0
- metadata +97 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2bf58678470f8cc840c19af0f64ce47ea55abe7124779d1b37ebe10707708d41
|
4
|
+
data.tar.gz: 02fb60b1183ab1d5eb90c8438a2271512db588438e75f1f89958e67325788360
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e333d0836e79b87dfe89fbdc88c915506609a815b1bfce94b4432f40cdf87696c94da52294d3410d11faa48de6c2197194f8be1216cc50ccce2fc9871ab543fc
|
7
|
+
data.tar.gz: 14c429b220b1a1c890f164daf50dbda22b86d85659abd1c4a8980abd90785d6c52efc7840af85a75740f2dd928c45861d1244061eb703f5e1461b4cabbe8c2f9
|
data/.rubocop.yml
CHANGED
@@ -1,9 +1,21 @@
|
|
1
|
+
require:
|
2
|
+
- rubocop-minitest
|
3
|
+
- rubocop-rake
|
4
|
+
|
1
5
|
AllCops:
|
2
6
|
NewCops: enable
|
3
7
|
TargetRubyVersion: 3.1.2
|
4
8
|
|
5
|
-
Style/
|
6
|
-
|
9
|
+
Style/CaseEquality:
|
10
|
+
Enabled: false
|
7
11
|
|
8
12
|
Style/Documentation:
|
9
13
|
Enabled: false
|
14
|
+
|
15
|
+
Style/StringLiterals:
|
16
|
+
EnforcedStyle: double_quotes
|
17
|
+
|
18
|
+
Metrics/BlockLength:
|
19
|
+
Exclude:
|
20
|
+
- "test/**/*"
|
21
|
+
- "*.gemspec"
|
data/Gemfile.lock
CHANGED
@@ -1,22 +1,31 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
strict (
|
4
|
+
strict (1.1.0)
|
5
5
|
zeitwerk (~> 2.6)
|
6
6
|
|
7
7
|
GEM
|
8
8
|
remote: https://rubygems.org/
|
9
9
|
specs:
|
10
10
|
ast (2.4.2)
|
11
|
+
debug (1.6.1)
|
12
|
+
irb (>= 1.3.6)
|
13
|
+
reline (>= 0.3.1)
|
11
14
|
gem-release (2.2.2)
|
15
|
+
io-console (0.5.11)
|
16
|
+
irb (1.4.1)
|
17
|
+
reline (>= 0.3.0)
|
12
18
|
json (2.6.2)
|
13
19
|
minitest (5.16.2)
|
20
|
+
minitest-spec-context (0.0.4)
|
14
21
|
parallel (1.22.1)
|
15
22
|
parser (3.1.2.1)
|
16
23
|
ast (~> 2.4.1)
|
17
24
|
rainbow (3.1.1)
|
18
25
|
rake (13.0.6)
|
19
26
|
regexp_parser (2.6.0)
|
27
|
+
reline (0.3.1)
|
28
|
+
io-console (~> 0.5)
|
20
29
|
rexml (3.2.5)
|
21
30
|
rubocop (1.36.0)
|
22
31
|
json (~> 2.3)
|
@@ -30,19 +39,27 @@ GEM
|
|
30
39
|
unicode-display_width (>= 1.4.0, < 3.0)
|
31
40
|
rubocop-ast (1.21.0)
|
32
41
|
parser (>= 3.1.1.0)
|
42
|
+
rubocop-minitest (0.22.2)
|
43
|
+
rubocop (>= 0.90, < 2.0)
|
44
|
+
rubocop-rake (0.6.0)
|
45
|
+
rubocop (~> 1.0)
|
33
46
|
ruby-progressbar (1.11.0)
|
34
47
|
unicode-display_width (2.3.0)
|
35
|
-
zeitwerk (2.6.
|
48
|
+
zeitwerk (2.6.1)
|
36
49
|
|
37
50
|
PLATFORMS
|
38
51
|
arm64-darwin-21
|
39
52
|
x86_64-linux
|
40
53
|
|
41
54
|
DEPENDENCIES
|
55
|
+
debug (>= 1.0.0)
|
42
56
|
gem-release (~> 2.2)
|
43
57
|
minitest (~> 5.0)
|
58
|
+
minitest-spec-context (~> 0.0.4)
|
44
59
|
rake (~> 13.0)
|
45
60
|
rubocop (~> 1.21)
|
61
|
+
rubocop-minitest (~> 0.22)
|
62
|
+
rubocop-rake (~> 0.6)
|
46
63
|
strict!
|
47
64
|
|
48
65
|
BUNDLED WITH
|
data/README.md
CHANGED
@@ -1,22 +1,177 @@
|
|
1
1
|
# Strict
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
TODO: Delete this and the text above, and describe your gem
|
3
|
+
Strict provides a means to strictly validate instantiation of values, instantiation and attribute assignment of objects, and method calls at runtime.
|
6
4
|
|
7
5
|
## Installation
|
8
6
|
|
9
7
|
Install the gem and add to the application's Gemfile by executing:
|
10
8
|
|
11
|
-
|
9
|
+
```sh
|
10
|
+
$ bundle add strict
|
11
|
+
```
|
12
12
|
|
13
13
|
If bundler is not being used to manage dependencies, install the gem by executing:
|
14
14
|
|
15
|
-
|
15
|
+
```sh
|
16
|
+
$ gem install strict
|
17
|
+
```
|
16
18
|
|
17
19
|
## Usage
|
18
20
|
|
19
|
-
|
21
|
+
### `Strict::Value`
|
22
|
+
|
23
|
+
```rb
|
24
|
+
class Money
|
25
|
+
include Strict::Value
|
26
|
+
|
27
|
+
attributes do
|
28
|
+
amount_in_cents Integer
|
29
|
+
currency AnyOf("USD", "CAD"), default: "USD"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
Money.new(amount_in_cents: 100_00)
|
34
|
+
# => #<Money amount_in_cents=100_00 currency="USD">
|
35
|
+
|
36
|
+
Money.new(amount_in_cents: 100_00, currency: "CAD")
|
37
|
+
# => #<Money amount_in_cents=100_00 currency="CAD">
|
38
|
+
|
39
|
+
Money.new(amount_in_cents: 100.00)
|
40
|
+
# => Strict::InitializationError
|
41
|
+
|
42
|
+
Money.new(amount_in_cents: 100_00).with(amount_in_cents: 200_00)
|
43
|
+
# => #<Money amount_in_cents=200_00 currency="USD">
|
44
|
+
|
45
|
+
Money.new(amount_in_cents: 100_00).amount_in_cents = 50_00
|
46
|
+
# => NoMethodError
|
47
|
+
|
48
|
+
Money.new(amount_in_cents: 100_00) == Money.new(amount_in_cents: 100_00)
|
49
|
+
# => true
|
50
|
+
```
|
51
|
+
|
52
|
+
### `Strict::Object`
|
53
|
+
|
54
|
+
```rb
|
55
|
+
class Stateful
|
56
|
+
include Strict::Object
|
57
|
+
|
58
|
+
attributes do
|
59
|
+
some_state String
|
60
|
+
dependency Anything(), default: nil
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
Stateful.new(some_state: "123")
|
65
|
+
# => #<Stateful some_state="123" dependency=nil>
|
66
|
+
|
67
|
+
Stateful.new(some_state: "123").with(some_state: "456")
|
68
|
+
# => NoMethodError
|
69
|
+
|
70
|
+
Stateful.new(some_state: "123").some_state = "456"
|
71
|
+
# => "456"
|
72
|
+
# => #<Stateful some_state="456" dependency=nil>
|
73
|
+
|
74
|
+
Stateful.new(some_state: "123").some_state = 456
|
75
|
+
# => Strict::AssignmentError
|
76
|
+
|
77
|
+
Stateful.new(some_state: "123") == Stateful.new(some_state: "123")
|
78
|
+
# => false
|
79
|
+
```
|
80
|
+
|
81
|
+
### `Strict::Method`
|
82
|
+
|
83
|
+
```rb
|
84
|
+
class UpdateEmail
|
85
|
+
extend Strict::Method
|
86
|
+
|
87
|
+
sig do
|
88
|
+
user_id String, coerce: ->(value) { value.to_s }
|
89
|
+
email String
|
90
|
+
returns AnyOf(true, nil)
|
91
|
+
end
|
92
|
+
def call(user_id:, email:)
|
93
|
+
# contrived logic
|
94
|
+
user_id == email
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
UpdateEmail.new.call(user_id: 123, email: "123")
|
99
|
+
# => true
|
100
|
+
|
101
|
+
UpdateEmail.new.call(user_id: "123", email: "123")
|
102
|
+
# => true
|
103
|
+
|
104
|
+
UpdateEmail.new.call(user_id: "123", email: 123)
|
105
|
+
# => Strict::MethodCallError
|
106
|
+
|
107
|
+
UpdateEmail.new.call(user_id: "123", email: "456")
|
108
|
+
# => Strict::MethodReturnError
|
109
|
+
```
|
110
|
+
|
111
|
+
### `Strict::Interface`
|
112
|
+
|
113
|
+
```rb
|
114
|
+
class Storage
|
115
|
+
extend Strict::Interface
|
116
|
+
|
117
|
+
expose(:write) do
|
118
|
+
key String
|
119
|
+
contents String
|
120
|
+
returns Boolean()
|
121
|
+
end
|
122
|
+
|
123
|
+
expose(:read) do
|
124
|
+
key String
|
125
|
+
returns AnyOf(String, nil)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
module Storages
|
130
|
+
class Memory
|
131
|
+
def initialize
|
132
|
+
@storage = {}
|
133
|
+
end
|
134
|
+
|
135
|
+
def write(key:, contents:)
|
136
|
+
storage[key] = contents
|
137
|
+
true
|
138
|
+
end
|
139
|
+
|
140
|
+
def read(key:)
|
141
|
+
storage[key]
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
|
146
|
+
attr_reader :storage
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
storage = Storage.new(Storages::Memory.new)
|
151
|
+
# => #<Storage implementation=#<Storages::Memory>>
|
152
|
+
|
153
|
+
storage.write(key: "some/path/to/file.rb", contents: "Hello")
|
154
|
+
# => true
|
155
|
+
|
156
|
+
storage.write(key: "some/path/to/file.rb", contents: {})
|
157
|
+
# => Strict::MethodCallError
|
158
|
+
|
159
|
+
storage.read(key: "some/path/to/file.rb")
|
160
|
+
# => "Hello"
|
161
|
+
|
162
|
+
storage.read(key: "some/path/to/other.rb")
|
163
|
+
# => nil
|
164
|
+
|
165
|
+
module Storages
|
166
|
+
class Wat
|
167
|
+
def write(key:)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
storage = Storage.new(Storages::Wat.new)
|
173
|
+
# => Strict::ImplementationDoesNotConformError
|
174
|
+
```
|
20
175
|
|
21
176
|
## Development
|
22
177
|
|
@@ -35,3 +190,7 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
35
190
|
## Code of Conduct
|
36
191
|
|
37
192
|
Everyone interacting in the Strict project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/kylekthompson/strict/blob/main/CODE_OF_CONDUCT.md).
|
193
|
+
|
194
|
+
## Credit
|
195
|
+
|
196
|
+
I can't thank [Tom Dalling](https://github.com/tomdalling) enough for his excellent [ValueSemantics](https://github.com/tomdalling/value_semantics) gem. Strict is heavily inspired and influenced by Tom's work and has some borrowed concepts and code.
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
module Accessor
|
5
|
+
module Attributes
|
6
|
+
def attributes(&block)
|
7
|
+
block ||= -> {}
|
8
|
+
configuration = Strict::Attributes::Dsl.run(&block)
|
9
|
+
include Module.new(configuration)
|
10
|
+
include Strict::Attributes::Instance
|
11
|
+
extend Strict::Attributes::Class
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
module Accessor
|
5
|
+
class Module < ::Module
|
6
|
+
attr_reader :configuration
|
7
|
+
|
8
|
+
# rubocop:disable Metrics/MethodLength
|
9
|
+
def initialize(configuration)
|
10
|
+
super()
|
11
|
+
|
12
|
+
@configuration = configuration
|
13
|
+
const_set(Strict::Attributes::Class::CONSTANT, configuration)
|
14
|
+
configuration.attributes.each do |attribute|
|
15
|
+
module_eval(
|
16
|
+
"def #{attribute.name} = #{attribute.instance_variable}", # def name = @instance_variable
|
17
|
+
__FILE__,
|
18
|
+
__LINE__ - 2
|
19
|
+
)
|
20
|
+
|
21
|
+
module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
|
22
|
+
def #{attribute.name}=(value) # def name=(value)
|
23
|
+
attribute = self.class.strict_attributes.named!(:#{attribute.name}) # attribute = self.class.strict_attributes.named!(:name)
|
24
|
+
value = attribute.coerce(value, for_class: self.class) # value = attribute.coerce(value, for_class: self.class)
|
25
|
+
if attribute.valid?(value) # if attribute.valid?(value)
|
26
|
+
#{attribute.instance_variable} = value # @instance_variable = value
|
27
|
+
else # else
|
28
|
+
raise Strict::AssignmentError.new( # raise Strict::AssignmentError.new(
|
29
|
+
assignable_class: self.class, # assignable_class: self.class,
|
30
|
+
invalid_attribute: attribute, # invalid_attribute: attribute,
|
31
|
+
value: # value:
|
32
|
+
) # )
|
33
|
+
end # end
|
34
|
+
end # end
|
35
|
+
RUBY
|
36
|
+
end
|
37
|
+
end
|
38
|
+
# rubocop:enable Metrics/MethodLength
|
39
|
+
|
40
|
+
def inspect
|
41
|
+
"#<#{self.class} (#{configuration.attributes.map(&:name).join(', ')})>"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
class AssignmentError < Error
|
5
|
+
attr_reader :invalid_attribute, :value
|
6
|
+
|
7
|
+
def initialize(assignable_class:, invalid_attribute:, value:)
|
8
|
+
super(message_from(assignable_class:, invalid_attribute:, value:))
|
9
|
+
|
10
|
+
@invalid_attribute = invalid_attribute
|
11
|
+
@value = value
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def message_from(assignable_class:, invalid_attribute:, value:)
|
17
|
+
details = invalid_attribute_message_from(invalid_attribute, value)
|
18
|
+
"Assignment to #{invalid_attribute.name} of #{assignable_class} failed because:\n#{details}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def invalid_attribute_message_from(invalid_attribute, value)
|
22
|
+
" - got #{value.inspect}, expected #{invalid_attribute.validator.inspect}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
class Attribute
|
5
|
+
NOT_PROVIDED = ::Object.new.freeze
|
6
|
+
|
7
|
+
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(name: name.to_sym, validator:, default_generator: make_default_generator(**defaults), coercer: coerce)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def valid_defaults?(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED)
|
19
|
+
defaults_provided = [default, default_value, default_generator].count do |default_option|
|
20
|
+
!default_option.equal?(NOT_PROVIDED)
|
21
|
+
end
|
22
|
+
|
23
|
+
defaults_provided <= 1
|
24
|
+
end
|
25
|
+
|
26
|
+
def make_default_generator(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED)
|
27
|
+
if !default.equal?(NOT_PROVIDED)
|
28
|
+
default.respond_to?(:call) ? default : -> { default }
|
29
|
+
elsif !default_value.equal?(NOT_PROVIDED)
|
30
|
+
-> { default_value }
|
31
|
+
elsif !default_generator.equal?(NOT_PROVIDED)
|
32
|
+
default_generator
|
33
|
+
else
|
34
|
+
NOT_PROVIDED
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
attr_reader :name, :validator, :default_generator, :coercer, :instance_variable
|
40
|
+
|
41
|
+
def initialize(name:, validator:, default_generator:, coercer:)
|
42
|
+
@name = name.to_sym
|
43
|
+
@validator = validator
|
44
|
+
@default_generator = default_generator
|
45
|
+
@coercer = coercer
|
46
|
+
@optional = !default_generator.equal?(NOT_PROVIDED)
|
47
|
+
@instance_variable = "@#{name.to_s.chomp('!').chomp('?')}"
|
48
|
+
end
|
49
|
+
|
50
|
+
def optional?
|
51
|
+
@optional
|
52
|
+
end
|
53
|
+
|
54
|
+
def valid?(value)
|
55
|
+
validator === value
|
56
|
+
end
|
57
|
+
|
58
|
+
def coerce(value, for_class:)
|
59
|
+
return value unless coercer
|
60
|
+
|
61
|
+
case coercer
|
62
|
+
when Symbol
|
63
|
+
for_class.public_send(coercer, value)
|
64
|
+
when true
|
65
|
+
for_class.public_send("coerce_#{name}", value)
|
66
|
+
else
|
67
|
+
coercer.call(value)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
module Attributes
|
5
|
+
module Class
|
6
|
+
CONSTANT = :STRICT_INTERNAL_ATTRIBUTES_CONFIGURATION__
|
7
|
+
|
8
|
+
def strict_attributes
|
9
|
+
self::STRICT_INTERNAL_ATTRIBUTES_CONFIGURATION__
|
10
|
+
end
|
11
|
+
|
12
|
+
def coercer
|
13
|
+
Coercer.new(self)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
module Attributes
|
5
|
+
class Coercer
|
6
|
+
attr_reader :attributes_class
|
7
|
+
|
8
|
+
def initialize(attributes_class)
|
9
|
+
@attributes_class = attributes_class
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(value)
|
13
|
+
return value if value.nil? || !value.respond_to?(:to_h)
|
14
|
+
|
15
|
+
coerce(value.to_h)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
NOT_PROVIDED = ::Object.new.freeze
|
21
|
+
|
22
|
+
def coerce(hash)
|
23
|
+
attributes_class.new(
|
24
|
+
**attributes_class.strict_attributes.each_with_object({}) do |attribute, attributes|
|
25
|
+
value = hash.fetch(attribute.name) { hash.fetch(attribute.name.to_s, NOT_PROVIDED) }
|
26
|
+
attributes[attribute.name] = value unless value.equal?(NOT_PROVIDED)
|
27
|
+
end
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module Strict
|
6
|
+
module Attributes
|
7
|
+
class Configuration
|
8
|
+
include Enumerable
|
9
|
+
extend Forwardable
|
10
|
+
|
11
|
+
class UnknownAttributeError < Error
|
12
|
+
attr_reader :attribute_name
|
13
|
+
|
14
|
+
def initialize(attribute_name:)
|
15
|
+
super(message_from(attribute_name:))
|
16
|
+
|
17
|
+
@attribute_name = attribute_name
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def message_from(attribute_name:)
|
23
|
+
"Strict tried to find an attribute named #{attribute_name} but was unable. " \
|
24
|
+
"It's likely this in an internal bug, feel free to open an issue at #{Strict::ISSUE_TRACKER} for help."
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def_delegator :attributes, :each
|
29
|
+
|
30
|
+
attr_reader :attributes
|
31
|
+
|
32
|
+
def initialize(attributes:)
|
33
|
+
@attributes = attributes
|
34
|
+
@attributes_index = attributes.to_h { |a| [a.name, a] }
|
35
|
+
end
|
36
|
+
|
37
|
+
def named!(name)
|
38
|
+
attributes_index.fetch(name) { raise UnknownAttributeError.new(attribute_name: name) }
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
attr_reader :attributes_index
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
module Attributes
|
5
|
+
class Dsl < BasicObject
|
6
|
+
class << self
|
7
|
+
def run(&)
|
8
|
+
dsl = new
|
9
|
+
dsl.instance_eval(&)
|
10
|
+
::Strict::Attributes::Configuration.new(attributes: dsl.__strict_dsl_internal_attributes.values)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
include ::Strict::Dsl::Coercible
|
15
|
+
include ::Strict::Dsl::Validatable
|
16
|
+
|
17
|
+
attr_reader :__strict_dsl_internal_attributes
|
18
|
+
|
19
|
+
def initialize
|
20
|
+
@__strict_dsl_internal_attributes = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
def strict_attribute(*args, **kwargs)
|
24
|
+
attribute = ::Strict::Attribute.make(*args, **kwargs)
|
25
|
+
__strict_dsl_internal_attributes[attribute.name] = attribute
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
|
29
|
+
def method_missing(name, *args, **kwargs)
|
30
|
+
if respond_to_missing?(name)
|
31
|
+
strict_attribute(name, *args, **kwargs)
|
32
|
+
else
|
33
|
+
super
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def respond_to_missing?(method_name, _include_private = nil)
|
38
|
+
first_letter = method_name.to_s.each_char.first
|
39
|
+
first_letter.eql?(first_letter.downcase)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
module Attributes
|
5
|
+
module Instance
|
6
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
7
|
+
def initialize(**attributes)
|
8
|
+
remaining_attributes = Set.new(attributes.keys)
|
9
|
+
invalid_attributes = nil
|
10
|
+
missing_attributes = nil
|
11
|
+
|
12
|
+
self.class.strict_attributes.each do |attribute|
|
13
|
+
if remaining_attributes.delete?(attribute.name)
|
14
|
+
value = attributes.fetch(attribute.name)
|
15
|
+
elsif attribute.optional?
|
16
|
+
value = attribute.default_generator.call
|
17
|
+
else
|
18
|
+
missing_attributes ||= []
|
19
|
+
missing_attributes << attribute.name
|
20
|
+
next
|
21
|
+
end
|
22
|
+
|
23
|
+
value = attribute.coerce(value, for_class: self.class)
|
24
|
+
if attribute.valid?(value)
|
25
|
+
instance_variable_set(attribute.instance_variable, value)
|
26
|
+
else
|
27
|
+
invalid_attributes ||= {}
|
28
|
+
invalid_attributes[attribute] = value
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
return if remaining_attributes.none? && invalid_attributes.nil? && missing_attributes.nil?
|
33
|
+
|
34
|
+
raise InitializationError.new(
|
35
|
+
initializable_class: self.class,
|
36
|
+
remaining_attributes:,
|
37
|
+
invalid_attributes:,
|
38
|
+
missing_attributes:
|
39
|
+
)
|
40
|
+
end
|
41
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
42
|
+
|
43
|
+
def to_h
|
44
|
+
self.class.strict_attributes.to_h do |attribute|
|
45
|
+
[attribute.name, public_send(attribute.name)]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def inspect
|
50
|
+
if self.class.strict_attributes.any?
|
51
|
+
"#<#{self.class} #{to_h.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')}>"
|
52
|
+
else
|
53
|
+
"#<#{self.class}>"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def pretty_print(pp)
|
58
|
+
pp.object_group(self) do
|
59
|
+
to_h.each do |key, value|
|
60
|
+
pp.breakable
|
61
|
+
pp.text("#{key}=")
|
62
|
+
pp.pp(value)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Strict
|
4
|
+
module Coercers
|
5
|
+
class Array
|
6
|
+
attr_reader :element_coercer
|
7
|
+
|
8
|
+
def initialize(element_coercer = nil)
|
9
|
+
@element_coercer = element_coercer
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(value)
|
13
|
+
return value if value.nil? || !value.respond_to?(:to_a)
|
14
|
+
|
15
|
+
array = value.to_a
|
16
|
+
return array unless element_coercer
|
17
|
+
|
18
|
+
array.map { |element| element_coercer.call(element) }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|