structish 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/structable-rspec.yml +26 -0
- data/.gitignore +10 -0
- data/Gemfile +15 -0
- data/README.md +257 -0
- data/Rakefile +2 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/structish.rb +27 -0
- data/lib/structish/array.rb +18 -0
- data/lib/structish/hash.rb +51 -0
- data/lib/structish/validation.rb +16 -0
- data/lib/structish/validation_error.rb +8 -0
- data/lib/structish/validations.rb +230 -0
- data/lib/structish/version.rb +3 -0
- data/lib/structish_object_extensions.rb +21 -0
- data/structish.gemspec +30 -0
- metadata +103 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b8edee95c617e99bb439af1073b4b79697d301c487106320c2b075b2246a5bab
|
4
|
+
data.tar.gz: eb452debefdf9b4d6348bb11e6b5c812bdffbe97272d33651be399a4b7d12e4b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f3ebf97ad136e4f175a5a4fbfb0bb06a8a31d3b63e01df5db44edbee439f8f43eacf4805fd262cb05cc20b51d884d5b6864b4e8b794f3d9c9a3c017b2c21c21d
|
7
|
+
data.tar.gz: 633f3b2c776edc67e49bc8084683e4eaf5e628c1e434631f1e2950636d941b1849c3987dd3e2a5854ff492a98b970c5e932a2b9e3e53447748515e1931a06221
|
@@ -0,0 +1,26 @@
|
|
1
|
+
env:
|
2
|
+
RUBY_VERSION: '2.6.6'
|
3
|
+
|
4
|
+
name: Tests
|
5
|
+
on: [push, pull_request]
|
6
|
+
jobs:
|
7
|
+
rspec-test:
|
8
|
+
name: Rspec
|
9
|
+
runs-on: ubuntu-18.04
|
10
|
+
steps:
|
11
|
+
- uses: actions/checkout@v2
|
12
|
+
- uses: actions/setup-ruby@v1
|
13
|
+
with:
|
14
|
+
ruby-version: ${{ env.RUBY_VERSION }}
|
15
|
+
- name: Install dependencies
|
16
|
+
run: |
|
17
|
+
gem install bundler
|
18
|
+
bundler install
|
19
|
+
- name: Run tests
|
20
|
+
run: bundler exec rspec
|
21
|
+
- name: Upload coverage results
|
22
|
+
uses: actions/upload-artifact@master
|
23
|
+
if: always()
|
24
|
+
with:
|
25
|
+
name: coverage-report
|
26
|
+
path: coverage
|
data/.gitignore
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
source "https://rubygems.org"
|
2
|
+
|
3
|
+
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
|
4
|
+
|
5
|
+
gemspec
|
6
|
+
|
7
|
+
group :test do
|
8
|
+
gem "rspec"
|
9
|
+
gem "simplecov", :require => false
|
10
|
+
end
|
11
|
+
|
12
|
+
group :test, :development do
|
13
|
+
gem "pry"
|
14
|
+
gem "pry-byebug"
|
15
|
+
end
|
data/README.md
ADDED
@@ -0,0 +1,257 @@
|
|
1
|
+
# Structish
|
2
|
+
|
3
|
+
Structish objects are objects which maintain all the properties and methods of their parent classes, but add customisable validations, defaults, and mutations.
|
4
|
+
|
5
|
+
The concept came about when trying to create a data structure which could provide flexible validation for entries in a `Hash`; serve as a base class for inheritance so that further functions could be defined; and keep all the functionality of standard `Hash` objects.
|
6
|
+
|
7
|
+
Existing gems did not quite satisfy the requirements. [Dry::Struct](https://github.com/dry-rb/dry-struct) objects were found to be too rigid for the use cases, in that they do not allow additional keys to be defined other than the explicitly defined attributes. Furthermore, the biggest drawback is that `Dry::Struct` objects *are not hashes*. We wanted to keep all the functionality of hashes so that they would be both intuitive to use and highly flexible.
|
8
|
+
|
9
|
+
Other existing solutions generally fall into the `schema` pattern. Some type of schema is defined, and a method is added to the `Hash` object which allows one to validate against the schema. We still wanted a class-based inheritance system similar to `Dry::Struct`, in order to give more meaning to the objects we were creating. This in turn meant that continuous hash creation and validation would become tiresome and create less readable code.
|
10
|
+
|
11
|
+
So to summarise, the problem we were trying to solve needed a data structure that:
|
12
|
+
- Should be *rigid* enough to allow us to validate the data stored within the structure
|
13
|
+
- Should be *flexible* enough to allow us to define our own validations and methods, as well as allow for more than just the validated data to exist within the object
|
14
|
+
- Should be *functional* enough to still be used as a Hash (or other underlying base object)
|
15
|
+
|
16
|
+
The solution is a data structure which includes the functionality of `Hashes` (or `Arrays`), flexibility of `Schema` validations, and rigidity of `Structs` - the `Structish` object. When a class inherits from `Structish::Hash` or `Structish::Array`, it gains the ability to `validate` entries in the object. Properties which can be validated include the class type, value, and presence. Custom validations can also be added by creating a class which inherits from the `Structish::Validation` class and overriding the `validate` method.
|
17
|
+
|
18
|
+
Besides validating the constructor object, `Structish` objects also dynamically create accessor methods for the validated keys. So if we have `validate :foo` on the class, and an instance `obj` of that class, then we will have `obj.foo == constructor_hash[:foo]`
|
19
|
+
|
20
|
+
## Installation
|
21
|
+
|
22
|
+
Add this line to your application's Gemfile:
|
23
|
+
|
24
|
+
```ruby
|
25
|
+
gem 'structish'
|
26
|
+
```
|
27
|
+
|
28
|
+
And then execute:
|
29
|
+
|
30
|
+
$ bundle
|
31
|
+
|
32
|
+
Or install it yourself as:
|
33
|
+
|
34
|
+
$ gem install structish
|
35
|
+
|
36
|
+
## Usage
|
37
|
+
|
38
|
+
### Creating Structish hashes and arrays
|
39
|
+
|
40
|
+
#### Class validation
|
41
|
+
|
42
|
+
There are two existing `Structish` classes: `Structish::Hash` and `Structish::Array`. Both follow similar usage. The simplest example is a hash which validates the classes of the keys:
|
43
|
+
```ruby
|
44
|
+
class MyStructishHash < Structish::Hash
|
45
|
+
validate :foo, Float
|
46
|
+
end
|
47
|
+
|
48
|
+
# Validations
|
49
|
+
MyStructishHash.new({}) -> "Structish::ValidationError: Required value foo not present"
|
50
|
+
MyStructishHash.new({foo: "bar"}) -> "Structish::ValidationError: Class mismatch for foo -> String. Should be a Float"
|
51
|
+
MyStructishHash.new({foo: 1.0}) -> {:foo=>1.0}
|
52
|
+
MyStructishHash.new({foo: 1.0, bar: 2.0}) -> {:foo=>1.0, :bar=>2.0}
|
53
|
+
|
54
|
+
# Dynamic method creation
|
55
|
+
MyStructishHash.new({foo: 1.0, bar: 2.0}).foo -> 1.0
|
56
|
+
MyStructishHash.new({foo: 1.0, bar: 2.0})[:foo] -> 1.0
|
57
|
+
MyStructishHash.new({foo: 1.0, bar: 2.0}).bar -> "NoMethodError: undefined method `bar' for {:foo=>1.0, :bar=>2.0}:MyStructishHash"
|
58
|
+
MyStructishHash.new({foo: 1.0, bar: 2.0})[:bar] -> 1.0
|
59
|
+
|
60
|
+
# Inherited functionality
|
61
|
+
MyStructishHash.new({foo: 1.0}).merge(bar: 2.0) -> {:foo=>1.0, :bar=>2.0}
|
62
|
+
MyStructishHash.new({foo: 1.0}).merge(bar: 2.0).class -> MyStructishHash
|
63
|
+
```
|
64
|
+
|
65
|
+
From the above example we can see that the validation is checking two properties - presence and class. Since the `:foo` key is validated, it is by default required to create the object. If the validation conditions are not met, we get clear errors telling us what is failing the validation. This is how we introduce rigidity and struct-like behaviour into the object.
|
66
|
+
|
67
|
+
We can also see that `Structish` obects are not restrictive, since any keys can be added to the constructor hash, but only the specified keys are validated. This is where the flexibility comes in.
|
68
|
+
|
69
|
+
The example also shows how the dynamic accessor methods are assigned - although in the last two lines we are defining `{foo: 1.0, bar: 2.0}` as the constructor, we only get `.foo` as an accessor method. This is by design - the idea is that the validated entries will in general be expected - any other entries are extra details, and not necessary to fully describe the object.
|
70
|
+
|
71
|
+
Finally we can see how `Structish` objects inherit the functionality from the parent class. Standard Ruby hash functions can be used freely with the new Structish objects, and (where applicable) we will get a new object of the same type as the object on which we are performing the operation. This is where the deep functionality comes in.
|
72
|
+
|
73
|
+
Note that the required class can be an array, and will pass if the value is an instance of one of the classes in the array.
|
74
|
+
|
75
|
+
When the required class is a `Array`, we can define a further requirement using the `of:` keyword to validate each element of the value array.
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
class MyStructishHash < Structish::Hash
|
79
|
+
validate :foo, ::Array, of: Float
|
80
|
+
end
|
81
|
+
|
82
|
+
MyStructishHash.new({foo: 1.0}) -> "Structish::ValidationError: Class mismatch for foo. All values should be of type Float"
|
83
|
+
MyStructishHash.new({foo: [1.0, "bar"]}) -> "Structish::ValidationError: Class mismatch for foo. All values should be of type Float"
|
84
|
+
MyStructishHash.new({foo: [1.0, 2.0]}) -> {:foo=>[1.0, 2.0]}
|
85
|
+
```
|
86
|
+
|
87
|
+
#### Accessor aliasing
|
88
|
+
|
89
|
+
Instead of having the accessor methods be automatically named, we can define an alias for the method.
|
90
|
+
```ruby
|
91
|
+
class MyStructishHash < Structish::Hash
|
92
|
+
validate :foo, Float, alias_to: :aliased_method
|
93
|
+
end
|
94
|
+
|
95
|
+
MyStructishHash.new({foo: 1.0}).aliased_method -> 1.0
|
96
|
+
```
|
97
|
+
|
98
|
+
#### Optional attributes
|
99
|
+
|
100
|
+
`Structish` object attributes can be flagged as optional. The usage should be fairly intuitive:
|
101
|
+
```ruby
|
102
|
+
class MyStructishHash < Structish::Hash
|
103
|
+
validate :foo, Float, optional: true
|
104
|
+
end
|
105
|
+
|
106
|
+
MyStructishHash.new({}) -> {:foo=>nil}
|
107
|
+
MyStructishHash.new({}).foo -> nil
|
108
|
+
```
|
109
|
+
|
110
|
+
As shown above, when an attribute is missing from the constructor hash, the key gets added to the `Structish` object, and the accessor method is defined as usual.
|
111
|
+
|
112
|
+
#### Default values
|
113
|
+
|
114
|
+
When an attribute is flagged as optional, a default value can be assigned to the key. Assigning a default to a non-optional (required) key does nothing - instantiating the object without a required key will still raise an error, regardless of whether a default is defined.
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
class MyStructishHash < Structish::Hash
|
118
|
+
validate :foo, Float, optional: true, default: 1.0
|
119
|
+
end
|
120
|
+
|
121
|
+
MyStructishHash.new({}) -> {:foo=>1.0}
|
122
|
+
MyStructishHash.new({}).foo -> 1.0
|
123
|
+
```
|
124
|
+
|
125
|
+
```ruby
|
126
|
+
class MyStructishHash < Structish::Hash
|
127
|
+
validate :foo, Float, default: 1.0
|
128
|
+
end
|
129
|
+
|
130
|
+
MyStructishHash.new({}) -> "Structish::ValidationError: Required value foo not present"
|
131
|
+
```
|
132
|
+
|
133
|
+
A useful feature of the default option is that you can map the value from one key to the default value:
|
134
|
+
|
135
|
+
```ruby
|
136
|
+
class MyStructishHash < Structish::Hash
|
137
|
+
validate :foo, Float
|
138
|
+
validate :bar, Float, optional: true, default: assign(:foo)
|
139
|
+
end
|
140
|
+
|
141
|
+
MyStructishHash.new({foo: 1.0}) -> {:foo=>1.0, :bar=>1.0}
|
142
|
+
MyStructishHash.new({foo: 1.0}).foo -> 1.0
|
143
|
+
MyStructishHash.new({foo: 1.0}).bar -> 1.0
|
144
|
+
```
|
145
|
+
|
146
|
+
#### Type casting
|
147
|
+
|
148
|
+
`Structish` validations support forced type-casting. This occurs *before* data type validation, which means that we can potentially pass in an object which is not of the required class, but force type casting so that it passes the validation:
|
149
|
+
|
150
|
+
```ruby
|
151
|
+
class StructishHashWithoutCasting < Structish::Hash
|
152
|
+
validate :foo, Float
|
153
|
+
end
|
154
|
+
|
155
|
+
StructishHashWithoutCasting.new({foo: "1"}) -> "Structish::ValidationError: Class mismatch for foo -> String. Should be a Float"
|
156
|
+
|
157
|
+
class StructishHashWithCasting < Structish::Hash
|
158
|
+
validate :foo, Float, cast: true
|
159
|
+
end
|
160
|
+
|
161
|
+
StructishHashWithCasting.new({foo: "1"}) -> {:foo=>1.0}
|
162
|
+
StructishHashWithCasting.new({foo: "1"}).foo -> 1.0
|
163
|
+
StructishHashWithCasting.new({foo: {}}) -> "NoMethodError: undefined method `to_f' for {}:Hash"
|
164
|
+
```
|
165
|
+
|
166
|
+
For common Ruby types (specifically `String, Float, Integer, Symbol, Array, Hash`) this uses the relevant `to_x` function, namely `to_s, to_f, to_i, to_sym, to_a, to_h` respectively. For any custom classes, this will call `Klass.new(value)`.
|
167
|
+
|
168
|
+
#### Specific values
|
169
|
+
|
170
|
+
`Structish` objects are not limited to validating classes and presence - they can also validate specific values, using the `one_of:` key:
|
171
|
+
|
172
|
+
```ruby
|
173
|
+
class MyStructishHash < Structish::Hash
|
174
|
+
validate :foo, Float, one_of: [0.0, 1.0, 2.0]
|
175
|
+
end
|
176
|
+
|
177
|
+
MyStructishHash.new({foo: 1.0}) -> {:foo=>1.0}
|
178
|
+
MyStructishHash.new({foo: 5.0}) -> "Structish::ValidationError: Value not one of 0.0, 1.0, 2.0"
|
179
|
+
```
|
180
|
+
|
181
|
+
#### Global validations
|
182
|
+
|
183
|
+
`Structable` objects allow for validations at the global level, i.e. validations that apply to every value in the object. Global validations are defined almost identically to individual validations, with the exception that they do not specify the key:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
class MyStructishHash < Structish::Hash
|
187
|
+
validate_all Float
|
188
|
+
end
|
189
|
+
|
190
|
+
MyStructishHash.new({foo: 1.0, bar: 2.0}) -> {:foo=>1.0, :bar=>2.0}
|
191
|
+
MyStructishHash.new({foo: 1.0, bar: "2.0"}) -> "Structish::ValidationError: Class mismatch for bar -> String. Should be a Float"
|
192
|
+
```
|
193
|
+
|
194
|
+
The `validate_all` function can perform all the same validations as the individual validations, and can also be mixed and matched with individual validations.
|
195
|
+
|
196
|
+
#### Accessor block mutations
|
197
|
+
|
198
|
+
A nifty feature of `Structish` object is, within the validation, we can define a block which mutates the output of the dynamic accessor method:
|
199
|
+
|
200
|
+
```ruby
|
201
|
+
class MyStructishHash < Structish::Hash
|
202
|
+
validate :foo, Float, do |num|
|
203
|
+
num * 2
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
MyStructishHash.new(validated_key: 5.0)[:validated_key] -> 5.0
|
208
|
+
MyStructishHash.new(validated_key: 5.0).validated_key -> 10.0
|
209
|
+
```
|
210
|
+
|
211
|
+
It is important to realize that the mutation *only applies to the dynamically created accessor method*. We still want to allow access to the original data - the idea here is that the accessor method can perform any operations on the original value, while the hash version stores the original data.
|
212
|
+
|
213
|
+
#### Function delegations
|
214
|
+
|
215
|
+
Along with dynamically created accessor methods, we can create delegations, so that calling a function on the `Structish` object returns the result of calling it on the specified attribute.
|
216
|
+
|
217
|
+
```ruby
|
218
|
+
class MyStructishHash < Structish::Hash
|
219
|
+
validate :foo, String
|
220
|
+
|
221
|
+
delegate :downcase, :foo
|
222
|
+
delegate :upcase, :foo
|
223
|
+
end
|
224
|
+
|
225
|
+
MyStructishHash.new(validated_key: "HeLlo").downcase -> "hello"
|
226
|
+
MyStructishHash.new(validated_key: "HeLlo").upcase -> "HELLO"
|
227
|
+
```
|
228
|
+
|
229
|
+
### Custom validations
|
230
|
+
|
231
|
+
We can define custom validations, which may contain any logic that returns a truthy or falsey value. The validation class must inherit from `Structish::Validation` and must override the `validate` method. The accessible attribute to the class are `value` and `conditions` - `value` is the value detected for that key, and `conditions` are the conditions defined in the validation on the class.
|
232
|
+
|
233
|
+
```ruby
|
234
|
+
class PositiveNumber < Structish::Validation
|
235
|
+
def validate
|
236
|
+
value > 0
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
class MyStructishHash < Structish::Hash
|
241
|
+
validate :foo, Float, validation: PositiveNumber
|
242
|
+
end
|
243
|
+
|
244
|
+
MyStructishHash.new({foo: 0.0}) -> "Structish::ValidationError: Custom validation PositiveNumberStructishValidation not met"
|
245
|
+
MyStructishHash.new({foo: 1.0}) -> {:foo=>1.0}
|
246
|
+
```
|
247
|
+
|
248
|
+
### Custom Structish data types
|
249
|
+
|
250
|
+
There are four custom 'classes' available for validation:
|
251
|
+
- `Structish::Number` passes for `Integer` or `Float`
|
252
|
+
- `Structish::Boolean` passes for `TrueClass` or `FalseClass`
|
253
|
+
- `Structish::Primitive` passes for `TrueClass`, `FalseClass`, `String`, `Float`, or `Integer`
|
254
|
+
- `Structish::Any` passes for any class
|
255
|
+
|
256
|
+
Although these are used like classes, they are in fact constants in the `Structish` which are given values of an array of the relevant classes (or `nil` in the case of `Structish::Any`). This is for simplicity, since the standard validations can accept arrays of classes.
|
257
|
+
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "structish"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/lib/structish.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
|
2
|
+
require 'active_support/core_ext/hash'
|
3
|
+
|
4
|
+
require "structish/version"
|
5
|
+
require "structish_object_extensions"
|
6
|
+
require "structish/validation_error"
|
7
|
+
require "structish/validation"
|
8
|
+
require "structish/validations"
|
9
|
+
require "structish/hash"
|
10
|
+
require "structish/array"
|
11
|
+
|
12
|
+
module Structish
|
13
|
+
Any = nil
|
14
|
+
Boolean = [TrueClass, FalseClass].freeze
|
15
|
+
Number = [Integer, Float].freeze
|
16
|
+
Primitive = [String, Float, Integer, TrueClass, FalseClass, Symbol].freeze
|
17
|
+
|
18
|
+
CAST_METHODS = {
|
19
|
+
"String" => :to_s,
|
20
|
+
"Float" => :to_f,
|
21
|
+
"Integer" => :to_i,
|
22
|
+
"Hash" => :to_h,
|
23
|
+
"Symbol" => :to_sym,
|
24
|
+
"Array" => :to_a
|
25
|
+
}.freeze
|
26
|
+
|
27
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Structish
|
2
|
+
class Array < ::Array
|
3
|
+
|
4
|
+
include Structish::Validations
|
5
|
+
|
6
|
+
def initialize(constructor)
|
7
|
+
raise(ArgumentError, "Only array-like objects can be used as constructors for Structish::Array") unless constructor.class <= ::Array
|
8
|
+
validate_structish(constructor)
|
9
|
+
super(constructor)
|
10
|
+
end
|
11
|
+
|
12
|
+
def <<(entry)
|
13
|
+
super(entry)
|
14
|
+
validate_structish(self)
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Structish
|
2
|
+
class Hash < ::Hash
|
3
|
+
|
4
|
+
include Structish::Validations
|
5
|
+
|
6
|
+
def initialize(raw_constructor = {})
|
7
|
+
raise(ArgumentError, "Only hash-like objects can be used as constructors for Structish::Hash") unless raw_constructor.respond_to?(:to_hash)
|
8
|
+
|
9
|
+
constructor = self.class.symbolize? ? raw_constructor.symbolize_keys : raw_constructor
|
10
|
+
hash = constructor.to_h
|
11
|
+
validate_structish(hash)
|
12
|
+
super()
|
13
|
+
update(hash)
|
14
|
+
self.default = hash.default if hash.default
|
15
|
+
self.default_proc = hash.default_proc if hash.default_proc
|
16
|
+
end
|
17
|
+
|
18
|
+
def merge(other)
|
19
|
+
self.class.new(to_h.merge(other))
|
20
|
+
end
|
21
|
+
|
22
|
+
def merge!(other)
|
23
|
+
super(other)
|
24
|
+
validate_structish(self)
|
25
|
+
end
|
26
|
+
|
27
|
+
def except(*except_keys)
|
28
|
+
self.class.new(to_h.except(*except_keys))
|
29
|
+
end
|
30
|
+
|
31
|
+
def except!(*except_keys)
|
32
|
+
super(*except_keys)
|
33
|
+
validate_structish(self)
|
34
|
+
end
|
35
|
+
|
36
|
+
def compact
|
37
|
+
self.class.new(to_h.compact)
|
38
|
+
end
|
39
|
+
|
40
|
+
protected
|
41
|
+
|
42
|
+
def self.symbolize(sym)
|
43
|
+
@symbolize = sym
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.symbolize?
|
47
|
+
@symbolize
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Structish
|
2
|
+
class Validation
|
3
|
+
|
4
|
+
attr_reader :value, :conditions, :constructor
|
5
|
+
|
6
|
+
def initialize(value, conditions, constructor)
|
7
|
+
@value = value
|
8
|
+
@conditions = conditions
|
9
|
+
@constructor = constructor
|
10
|
+
end
|
11
|
+
|
12
|
+
def validate
|
13
|
+
raise(NotImplementedError, "Validation conditions function must be defined")
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,230 @@
|
|
1
|
+
module Structish
|
2
|
+
module Validations
|
3
|
+
|
4
|
+
def self.included base
|
5
|
+
base.send :include, InstanceMethods
|
6
|
+
base.extend ClassMethods
|
7
|
+
end
|
8
|
+
|
9
|
+
module InstanceMethods
|
10
|
+
|
11
|
+
def validate_structish(constructor)
|
12
|
+
validate_key_restriction(constructor)
|
13
|
+
apply_defaults(constructor)
|
14
|
+
cast_values(constructor)
|
15
|
+
validate_constructor(constructor)
|
16
|
+
define_accessor_methods(constructor)
|
17
|
+
define_delegated_methods
|
18
|
+
end
|
19
|
+
|
20
|
+
def define_delegated_methods
|
21
|
+
self.class.delegations.each do |function, object|
|
22
|
+
define_singleton_method(function.to_s) { self.send(object.to_sym)&.send(function.to_sym) }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate_key_restriction(constructor)
|
27
|
+
if self.class.restrict?
|
28
|
+
allowed_keys = validations.map { |attribute| attribute[:key] }
|
29
|
+
valid = (constructor.keys - allowed_keys).empty?
|
30
|
+
raise(Structish::ValidationError.new("Keys are restricted to #{allowed_keys.join(", ")}", self.class)) unless valid
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def apply_defaults(constructor)
|
35
|
+
validations.select { |v| v[:optional] }.each do |attribute|
|
36
|
+
key = attribute[:key]
|
37
|
+
default_value = if attribute[:default].is_a?(::Array) && attribute[:default].first == :other_attribute
|
38
|
+
constructor[attribute[:default][1]]
|
39
|
+
else
|
40
|
+
attribute[:default]
|
41
|
+
end
|
42
|
+
constructor[key] = default_value if constructor[key].nil?
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def cast_values(constructor)
|
47
|
+
(validations + global_attributes_for(constructor)).each do |attribute|
|
48
|
+
key = attribute[:key]
|
49
|
+
if attribute[:cast] && constructor[key]
|
50
|
+
if attribute[:klass] == ::Array && attribute[:of]
|
51
|
+
constructor[key] = constructor[key].map { |v| cast_single(v, attribute[:of]) }
|
52
|
+
else
|
53
|
+
constructor[key] = cast_single(constructor[key], attribute[:klass])
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def cast_single(value, klass)
|
60
|
+
if value.is_a?(klass)
|
61
|
+
value
|
62
|
+
else
|
63
|
+
if cast_method = Structish::CAST_METHODS[klass.to_s]
|
64
|
+
value.send(cast_method)
|
65
|
+
else
|
66
|
+
klass.new(value)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def define_accessor_methods(constructor)
|
72
|
+
validations.each do |attribute|
|
73
|
+
if accessor = attribute[:accessor]
|
74
|
+
value = attribute[:proc] ? attribute[:proc].call(constructor[attribute[:key]]) : constructor[attribute[:key]]
|
75
|
+
instance_variable_set "@#{accessor}", value
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def validate_constructor(constructor)
|
81
|
+
(validations + global_attributes_for(constructor)).each do |attribute|
|
82
|
+
value = constructor[attribute[:key]]
|
83
|
+
if attribute[:optional] && value.nil?
|
84
|
+
true
|
85
|
+
else
|
86
|
+
validate_presence(attribute, value)
|
87
|
+
validate_class(attribute, value)
|
88
|
+
validate_one_of(attribute, value)
|
89
|
+
validate_custom(attribute, value, constructor)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def global_attributes_for(constructor)
|
95
|
+
global_attributes_hash[constructor] = begin
|
96
|
+
constructor_keys = constructor.keys
|
97
|
+
global_validations.each_with_object([]) do |validation, arr|
|
98
|
+
constructor_keys.each { |key| arr << validation.merge(key: key) }
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def validate_presence(attribute, value)
|
104
|
+
raise(Structish::ValidationError.new("Required value #{attribute[:key]} not present", self.class)) unless !value.nil?
|
105
|
+
end
|
106
|
+
|
107
|
+
def validate_class(attribute, value)
|
108
|
+
if attribute[:klass].nil?
|
109
|
+
return
|
110
|
+
elsif attribute[:of]
|
111
|
+
valid = if attribute[:klass] <= ::Array
|
112
|
+
value.class <= ::Array && value.all? { |v| v.class <= attribute[:of] }
|
113
|
+
elsif attribute[:klass] <= ::Hash
|
114
|
+
value.class <= ::Hash && value.values.all? { |v| v.class <= attribute[:of] }
|
115
|
+
end
|
116
|
+
raise(Structish::ValidationError.new("Class mismatch for #{attribute[:key]}. All values should be of type #{attribute[:of].to_s}", self.class)) unless valid
|
117
|
+
else
|
118
|
+
valid_klasses = [attribute[:klass]].flatten.compact
|
119
|
+
valid = valid_klasses.any? { |klass| value.class <= klass }
|
120
|
+
raise(Structish::ValidationError.new("Class mismatch for #{attribute[:key]} -> #{value.class}. Should be a #{valid_klasses.join(", ")}", self.class)) unless valid
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def validate_one_of(attribute, value)
|
125
|
+
valid = attribute[:one_of] ? attribute[:one_of].include?(value) : true
|
126
|
+
raise(Structish::ValidationError.new("Value not one of #{attribute[:one_of].join(", ")}", self.class)) unless valid
|
127
|
+
end
|
128
|
+
|
129
|
+
def validate_custom(attribute, value, constructor)
|
130
|
+
valid = attribute[:validation] ? attribute[:validation].new(value, attribute, constructor).validate : true
|
131
|
+
raise(Structish::ValidationError.new("Custom validation #{attribute[:validation].to_s} not met", self.class)) unless valid
|
132
|
+
end
|
133
|
+
|
134
|
+
def []=(key, value)
|
135
|
+
super(key, value)
|
136
|
+
validate_structish(self)
|
137
|
+
end
|
138
|
+
|
139
|
+
def global_attributes_hash
|
140
|
+
@global_attributes_hash ||= {}
|
141
|
+
end
|
142
|
+
|
143
|
+
def validations
|
144
|
+
@validations ||= self.class.attributes + parent_attributes(self.class)
|
145
|
+
end
|
146
|
+
|
147
|
+
def global_validations
|
148
|
+
@global_validations ||= self.class.global_validations + parent_global_validations(self.class)
|
149
|
+
end
|
150
|
+
|
151
|
+
def parent_attributes(klass)
|
152
|
+
if klass.superclass.respond_to?(:structish?) && klass.superclass.structish?
|
153
|
+
klass.superclass.attributes + parent_attributes(klass.superclass)
|
154
|
+
end || []
|
155
|
+
end
|
156
|
+
|
157
|
+
def parent_global_validations(klass)
|
158
|
+
if klass.superclass.respond_to?(:structish?) && klass.superclass.structish?
|
159
|
+
klass.superclass.global_validations + parent_global_validations(klass.superclass)
|
160
|
+
end || []
|
161
|
+
end
|
162
|
+
|
163
|
+
end
|
164
|
+
|
165
|
+
module ClassMethods
|
166
|
+
|
167
|
+
def structish?
|
168
|
+
true
|
169
|
+
end
|
170
|
+
|
171
|
+
def delegate(function, object)
|
172
|
+
delegations << [function, object]
|
173
|
+
end
|
174
|
+
|
175
|
+
def assign(key)
|
176
|
+
[:other_attribute, key]
|
177
|
+
end
|
178
|
+
|
179
|
+
def restrict_attributes
|
180
|
+
@restrict_attributes = true
|
181
|
+
end
|
182
|
+
|
183
|
+
def validate(key, klass = nil, kwargs = {}, &block)
|
184
|
+
accessor_name = kwargs[:alias_to] ? kwargs[:alias_to] : key
|
185
|
+
accessor = accessor_name.to_s if (accessor_name.is_a?(String) || accessor_name.is_a?(Symbol))
|
186
|
+
attr_reader(accessor) if accessor
|
187
|
+
attribute_array = kwargs[:optional] ? optional_attributes : required_attributes
|
188
|
+
attribute_array << attribute_hash(key, klass, kwargs.merge(accessor: accessor), block)
|
189
|
+
end
|
190
|
+
|
191
|
+
def validate_all(klass = nil, kwargs = {}, &block)
|
192
|
+
global_validations << attribute_hash(nil, klass, kwargs, block)
|
193
|
+
end
|
194
|
+
|
195
|
+
def attribute_hash(key, klass = nil, kwargs = {}, block)
|
196
|
+
{
|
197
|
+
key: key,
|
198
|
+
klass: klass,
|
199
|
+
proc: block,
|
200
|
+
}.merge(kwargs.except(:key, :klass, :proc))
|
201
|
+
end
|
202
|
+
|
203
|
+
def global_validations
|
204
|
+
@global_validations ||= []
|
205
|
+
end
|
206
|
+
|
207
|
+
def required_attributes
|
208
|
+
@required_attributes ||= []
|
209
|
+
end
|
210
|
+
|
211
|
+
def optional_attributes
|
212
|
+
@optional_attributes ||= []
|
213
|
+
end
|
214
|
+
|
215
|
+
def attributes
|
216
|
+
required_attributes + optional_attributes
|
217
|
+
end
|
218
|
+
|
219
|
+
def delegations
|
220
|
+
@delegations ||= []
|
221
|
+
end
|
222
|
+
|
223
|
+
def restrict?
|
224
|
+
@restrict_attributes
|
225
|
+
end
|
226
|
+
|
227
|
+
end
|
228
|
+
|
229
|
+
end
|
230
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class ::Hash
|
2
|
+
def to_structish(structish_klass)
|
3
|
+
raise(ArgumentError, "Class is not a child of Structish::Hash") unless structish_klass < Structish::Hash
|
4
|
+
structish_klass.new(self)
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class ::Array
|
9
|
+
def values
|
10
|
+
self.to_a
|
11
|
+
end
|
12
|
+
|
13
|
+
def keys
|
14
|
+
[*0..self.size-1]
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_structish(structish_klass)
|
18
|
+
raise(ArgumentError, "Class is not a child of Structish::Array") unless structish_klass < Structish::Array
|
19
|
+
structish_klass.new(self)
|
20
|
+
end
|
21
|
+
end
|
data/structish.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "structish/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "structish"
|
8
|
+
spec.version = Structish::VERSION
|
9
|
+
spec.authors = ["Dylan Blakemore"]
|
10
|
+
spec.email = ["dylan.blakemore@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Adding struct-like properties to Ruby Arrays and Hashes}
|
13
|
+
spec.description = %q{Adds validations, function creation, function delegation,
|
14
|
+
and key restrictions to arrays and hashes so that they may
|
15
|
+
function similarly to Structs}
|
16
|
+
|
17
|
+
raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.' unless spec.respond_to?(:metadata)
|
18
|
+
|
19
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
20
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
21
|
+
end
|
22
|
+
spec.bindir = "exe"
|
23
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
24
|
+
spec.require_paths = ["lib"]
|
25
|
+
|
26
|
+
spec.add_runtime_dependency "activesupport", "~> 5.0"
|
27
|
+
|
28
|
+
spec.add_development_dependency "bundler"
|
29
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
30
|
+
end
|
metadata
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: structish
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Dylan Blakemore
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-01-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
description: |-
|
56
|
+
Adds validations, function creation, function delegation,
|
57
|
+
and key restrictions to arrays and hashes so that they may
|
58
|
+
function similarly to Structs
|
59
|
+
email:
|
60
|
+
- dylan.blakemore@gmail.com
|
61
|
+
executables: []
|
62
|
+
extensions: []
|
63
|
+
extra_rdoc_files: []
|
64
|
+
files:
|
65
|
+
- ".github/workflows/structable-rspec.yml"
|
66
|
+
- ".gitignore"
|
67
|
+
- Gemfile
|
68
|
+
- README.md
|
69
|
+
- Rakefile
|
70
|
+
- bin/console
|
71
|
+
- bin/setup
|
72
|
+
- lib/structish.rb
|
73
|
+
- lib/structish/array.rb
|
74
|
+
- lib/structish/hash.rb
|
75
|
+
- lib/structish/validation.rb
|
76
|
+
- lib/structish/validation_error.rb
|
77
|
+
- lib/structish/validations.rb
|
78
|
+
- lib/structish/version.rb
|
79
|
+
- lib/structish_object_extensions.rb
|
80
|
+
- structish.gemspec
|
81
|
+
homepage:
|
82
|
+
licenses: []
|
83
|
+
metadata: {}
|
84
|
+
post_install_message:
|
85
|
+
rdoc_options: []
|
86
|
+
require_paths:
|
87
|
+
- lib
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '0'
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
requirements: []
|
99
|
+
rubygems_version: 3.0.3
|
100
|
+
signing_key:
|
101
|
+
specification_version: 4
|
102
|
+
summary: Adding struct-like properties to Ruby Arrays and Hashes
|
103
|
+
test_files: []
|