datacaster 0.9.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +59 -0
- data/LICENSE.txt +21 -0
- data/README.md +981 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/datacaster.gemspec +30 -0
- data/lib/datacaster.rb +50 -0
- data/lib/datacaster/absent.rb +19 -0
- data/lib/datacaster/and_node.rb +22 -0
- data/lib/datacaster/and_with_error_aggregation_node.rb +31 -0
- data/lib/datacaster/array_schema.rb +35 -0
- data/lib/datacaster/base.rb +77 -0
- data/lib/datacaster/caster.rb +30 -0
- data/lib/datacaster/checker.rb +26 -0
- data/lib/datacaster/comparator.rb +24 -0
- data/lib/datacaster/hash_mapper.rb +70 -0
- data/lib/datacaster/hash_schema.rb +56 -0
- data/lib/datacaster/or_node.rb +22 -0
- data/lib/datacaster/predefined.rb +179 -0
- data/lib/datacaster/result.rb +61 -0
- data/lib/datacaster/runner_context.rb +21 -0
- data/lib/datacaster/terminator.rb +77 -0
- data/lib/datacaster/then_node.rb +35 -0
- data/lib/datacaster/transformer.rb +21 -0
- data/lib/datacaster/trier.rb +27 -0
- data/lib/datacaster/validator.rb +41 -0
- data/lib/datacaster/version.rb +3 -0
- metadata +138 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: '0945e72cd8cba6c89e6eed3b202e8241eb25ecc12e4a1c44bfeca2be78206306'
|
4
|
+
data.tar.gz: 9c02c5a237e1d2853a804fa1e31fd2fc13b4e46c5fc207b16461fb5ce9f30570
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f0a9b9b2aca6f58001550631cd31c381f38b95d95ea652e2a3d1184698fe82cc24bdf8a79074a606a4b3d5437d0cf1f31e8fee997765b83cac6cb4d4ce6670c5
|
7
|
+
data.tar.gz: eb99b873a43d7c73eeebade0d9a25111c47d3dcd32686f718c2ba1aaa738aca69aece76c096f0c48e21c4f55cd1d754683d02d0c8618c270a4515d039f29c5f4
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
datacaster (0.9.0)
|
5
|
+
dry-monads (>= 1.3, < 1.4)
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
activemodel (6.0.3.2)
|
11
|
+
activesupport (= 6.0.3.2)
|
12
|
+
activesupport (6.0.3.2)
|
13
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
14
|
+
i18n (>= 0.7, < 2)
|
15
|
+
minitest (~> 5.1)
|
16
|
+
tzinfo (~> 1.1)
|
17
|
+
zeitwerk (~> 2.2, >= 2.2.2)
|
18
|
+
concurrent-ruby (1.1.6)
|
19
|
+
diff-lcs (1.3)
|
20
|
+
dry-core (0.4.9)
|
21
|
+
concurrent-ruby (~> 1.0)
|
22
|
+
dry-equalizer (0.3.0)
|
23
|
+
dry-monads (1.3.5)
|
24
|
+
concurrent-ruby (~> 1.0)
|
25
|
+
dry-core (~> 0.4, >= 0.4.4)
|
26
|
+
dry-equalizer
|
27
|
+
i18n (1.8.3)
|
28
|
+
concurrent-ruby (~> 1.0)
|
29
|
+
minitest (5.14.1)
|
30
|
+
rake (12.3.3)
|
31
|
+
rspec (3.9.0)
|
32
|
+
rspec-core (~> 3.9.0)
|
33
|
+
rspec-expectations (~> 3.9.0)
|
34
|
+
rspec-mocks (~> 3.9.0)
|
35
|
+
rspec-core (3.9.2)
|
36
|
+
rspec-support (~> 3.9.3)
|
37
|
+
rspec-expectations (3.9.2)
|
38
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
39
|
+
rspec-support (~> 3.9.0)
|
40
|
+
rspec-mocks (3.9.1)
|
41
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
42
|
+
rspec-support (~> 3.9.0)
|
43
|
+
rspec-support (3.9.3)
|
44
|
+
thread_safe (0.3.6)
|
45
|
+
tzinfo (1.2.7)
|
46
|
+
thread_safe (~> 0.1)
|
47
|
+
zeitwerk (2.3.0)
|
48
|
+
|
49
|
+
PLATFORMS
|
50
|
+
ruby
|
51
|
+
|
52
|
+
DEPENDENCIES
|
53
|
+
activemodel (>= 5.2)
|
54
|
+
datacaster!
|
55
|
+
rake (>= 12.0)
|
56
|
+
rspec (~> 3.0)
|
57
|
+
|
58
|
+
BUNDLED WITH
|
59
|
+
2.1.4
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 Eugene Zolotarev
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,981 @@
|
|
1
|
+
# Datacaster
|
2
|
+
|
3
|
+
This gem provides run-time type checking and mapping of composite data structures (i.e. hashes/arrays of hashes/arrays of ... of literals).
|
4
|
+
|
5
|
+
Its main use is in the validation and preliminary transformation of API params requests.
|
6
|
+
|
7
|
+
## Installing
|
8
|
+
|
9
|
+
Add to your Gemfile:
|
10
|
+
|
11
|
+
```
|
12
|
+
gem 'datacaster'
|
13
|
+
```
|
14
|
+
|
15
|
+
## Why not ...
|
16
|
+
|
17
|
+
**Why not Rails strong params**?
|
18
|
+
|
19
|
+
Strong params don't provide easy composition of validations and are restricted in error (failure) reporting.
|
20
|
+
|
21
|
+
**Why not ActiveModel validations**?
|
22
|
+
|
23
|
+
ActiveModel requires a substantial amount of boilerplate (e.g. separate class for each of nested objects/hashes) and is limited in composition.
|
24
|
+
|
25
|
+
**Why not [Dry Types](https://dry-rb.org/gems/dry-types)?**
|
26
|
+
|
27
|
+
Poor validation error reporting, a substantial amount of boilerplate, arguably complex/inconsistent DSL.
|
28
|
+
|
29
|
+
## Basics
|
30
|
+
|
31
|
+
### Conveyor belt
|
32
|
+
|
33
|
+
Datacaster could be thought of as a conveyor belt, where each step of the conveyor either performs some validation of a value or some transformation of it.
|
34
|
+
|
35
|
+
For example, the following code validates that value is a string:
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
require 'datacaster'
|
39
|
+
|
40
|
+
validator = Datacaster.schema { string }
|
41
|
+
|
42
|
+
validator.("test") # Datacaster::ValidResult("test")
|
43
|
+
validator.("test").valid? # true
|
44
|
+
validator.("test").value # "test"
|
45
|
+
validator.("test").errors # nil
|
46
|
+
|
47
|
+
validator.(1) # Datacaster::ErrorResult(["must be string"])
|
48
|
+
validator.(1).valid? # false
|
49
|
+
validator.(1).value # nil
|
50
|
+
validator.(1).errors # ["must be string"]
|
51
|
+
```
|
52
|
+
|
53
|
+
Datacaster instances are created with a call to `Datacaster.schema { ... }` or `Datacaster.partial_schema { ... }` (described later in this file).
|
54
|
+
|
55
|
+
Datacaster validators' results could be converted to [dry result monad](https://dry-rb.org/gems/dry-monads/1.0/result/):
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
require 'datacaster'
|
59
|
+
|
60
|
+
validator = Datacaster.schema { string }
|
61
|
+
|
62
|
+
validator.("test").to_dry_result # Success("test")
|
63
|
+
validator.(1).to_dry_result # Failure(["must be string"])
|
64
|
+
```
|
65
|
+
|
66
|
+
`string` method call inside of the block in the examples above returns (with the help of some basic meta-programming magic) 'chainable' datacaster instance. To 'chain' datacaster instances 'logical AND' (`&`) operator is used:
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
require 'datacaster'
|
70
|
+
|
71
|
+
validator = Datacaster.schema { string & check { |x| x.length > 5 } }
|
72
|
+
|
73
|
+
validator.("test1") # Datacaster::ValidResult("test12")
|
74
|
+
validator.(1) # Datacaster::ErrorResult(["must be string"])
|
75
|
+
validator.("test") # Datacaster::ErrorResult(["is invalid"])
|
76
|
+
```
|
77
|
+
|
78
|
+
In the code above we ensure that validated value is:
|
79
|
+
|
80
|
+
a) a string,
|
81
|
+
b) has length > 5.
|
82
|
+
|
83
|
+
If first condition is not met, second one is not evaluated at all (i.e. evaluation is always "short-circuit", just as one might expect).
|
84
|
+
|
85
|
+
Later in this file `string` and other such validations are referred to as "basic types", and `check { ... }` and other custom validations are referred to as "custom types".
|
86
|
+
|
87
|
+
It is worth noting that in `a & b` validation composition as above, if `a` in some way transforms the value and passes, then `b` receives the transformed value (though `string` validation in particular guarantees to not change the initial value).
|
88
|
+
|
89
|
+
### Result value
|
90
|
+
|
91
|
+
All datacaster validations, when called, return an instance of `Datacaster::Result` value, i.e. `Datacaster::ValidResult` or `Datacaster::ErrorResult`.
|
92
|
+
|
93
|
+
You can call `#valid?`, `#value`, `#errors` methods directly, or, if preferred, call `#to_dry_result` method to convert `Datacaster::Result` to the corresponding `Dry::Monads::Result` (with all the included "batteries" of the latter, e.g. pattern matching, 'binding', etc.).
|
94
|
+
|
95
|
+
`#value` and `#errors` would return `#nil` if the result is, correspondingly, `ErrorResult` and `ValidResult`. No methods would raise an error.
|
96
|
+
|
97
|
+
Errors are returned as array or hash (or hash of arrays, or array of hashes, etc., for complex data structures). Each element of the returned array shows a separate error (as a string), and each key of the returned hash corresponds to the key of the validated hash. More or less errors are similar to what you expect from `ActiveModel::Errors#to_hash`.
|
98
|
+
|
99
|
+
### Hash schema
|
100
|
+
|
101
|
+
Validating hashes is the main case scenario for datacaster. Several specific conventions are used here, which are listed below in this file.
|
102
|
+
|
103
|
+
Let's assume we want to validate that a hash (which represents data about a person):
|
104
|
+
|
105
|
+
a) is, in fact, a Hash;
|
106
|
+
a) has exactly 2 keys, `name` and `salary`,
|
107
|
+
b) key 'name' is a string,
|
108
|
+
c) key 'salary' is an integer:
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
person_validator =
|
112
|
+
Datacaster.schema do
|
113
|
+
hash_schema(
|
114
|
+
name: string,
|
115
|
+
salary: integer
|
116
|
+
)
|
117
|
+
end
|
118
|
+
|
119
|
+
person_validator.(name: "Jack Simon", salary: 50_000)
|
120
|
+
# => Datacaster::ValidResult({:name=>"Jack Simon", :salary=>50000})
|
121
|
+
|
122
|
+
person_validator.(name: "Jack Simon")
|
123
|
+
# => Datacaster::ErrorResult({:salary=>["must be integer"]})
|
124
|
+
|
125
|
+
person_validator.("test")
|
126
|
+
# => Datacaster::ErrorResult(["must be hash"])
|
127
|
+
|
128
|
+
person_validator.(name: "John Smith", salary: "1000")
|
129
|
+
# => Datacaster::ErrorResult({:salary=>["must be integer"]})
|
130
|
+
|
131
|
+
person_validator.(name: :john, salary: "1000")
|
132
|
+
# => Datacaster::ErrorResult({:name=>["must be string"], :salary=>["must be integer"]})
|
133
|
+
|
134
|
+
person_validator.(name: "John Smith", salary: 100_000, title: "developer")
|
135
|
+
# => Datacaster::ErrorResult({:title=>["must be absent"]})
|
136
|
+
```
|
137
|
+
|
138
|
+
`Datacaster.schema` definitions don't permit, as you likely noticed from the example above, extra fields in the hash. In fact, `Datacaster.schema` automatically adds special built-in validator, called `Datacaster::Terminator`, at the end of your validation chain, which function is to ensure that all hash keys had been validated.
|
139
|
+
|
140
|
+
If you want to permit your hashes to contain extra fields, use `Datacaster.partial_schema` (it's the only difference between `.schema` and `.partial_schema`):
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
person_with_extra_keys_validator =
|
144
|
+
Datacaster.partial_schema do
|
145
|
+
hash_schema(
|
146
|
+
name: string,
|
147
|
+
salary: integer
|
148
|
+
)
|
149
|
+
end
|
150
|
+
|
151
|
+
person_with_extra_keys_validator.(name: "John Smith", salary: 100_000, title: "developer")
|
152
|
+
# => Datacaster::ValidResult({:name=>"John Smith", :salary=>100000, :title=>"developer"})
|
153
|
+
```
|
154
|
+
|
155
|
+
Datacaster 'hash schema' makes strict difference between absent and nil values, allows to use shortcuts for defining nested schemas (with no limitation on the level of nesting), and has convinient 'AND with error aggregation' (`*`, same symbol as in numbers multiplication) for joining validation errors of multiple failures. See below in the corresponding sections.
|
156
|
+
|
157
|
+
### Logical operators
|
158
|
+
|
159
|
+
There are 3 regular 'logical operators':
|
160
|
+
|
161
|
+
* AND (`&`)
|
162
|
+
* OR (`|`)
|
163
|
+
* IF... THEN... ELSE
|
164
|
+
|
165
|
+
And one special: AND with error aggregation (`*`).
|
166
|
+
|
167
|
+
The former 3 is described immediately below, and the latter is described in the section on hash schemas further in this file.
|
168
|
+
|
169
|
+
#### *AND operator*:
|
170
|
+
|
171
|
+
```ruby
|
172
|
+
even_number = Datacaster.schema { integer & check { |x| x.even? } }
|
173
|
+
|
174
|
+
even_number.(2)
|
175
|
+
# => Datacaster::ValidResult(2)
|
176
|
+
|
177
|
+
even_number.(3)
|
178
|
+
# => Datacaster::ErrorResult(["is invalid"])
|
179
|
+
even_number.("test")
|
180
|
+
# => #<Datacaster::ErrorResult(["must be integer"])>
|
181
|
+
```
|
182
|
+
|
183
|
+
If left-hand validation of AND operator passes, *its result* (not the original value) is passed to the right-hand validation. See below in this file section on transformations where this might be relevant.
|
184
|
+
|
185
|
+
#### *OR operator*:
|
186
|
+
|
187
|
+
```ruby
|
188
|
+
# 'compare' custom type returns ValidResult if and only if validated value == compare's argument
|
189
|
+
person_or_entity = Datacaster.schema { compare(:person) | compare(:entity) }
|
190
|
+
|
191
|
+
person_or_entity.(:person) # => Datacaster::ValidResult(:person)
|
192
|
+
person_or_entity.(:entity) # => Datacaster::ValidResult(:entity)
|
193
|
+
|
194
|
+
person_or_entity.(:ngo) # => Datacaster::ErrorResult(["must be equal to :entity"])
|
195
|
+
```
|
196
|
+
|
197
|
+
Notice that OR operator, if left-hand validation fails, passes the original value to the right-hand validation. As you see in the example above resultant error messages are not always convenient (i.e. to show something like "value must be :person or :entity" is preferable to showing somewhat misleading "must be equal to :entity"). See the next section on "IF... THEN... ELSE" for closer to the real world example.
|
198
|
+
|
199
|
+
#### *IF... THEN... ELSE operator*:
|
200
|
+
|
201
|
+
Let's suppose we want to validate that incoming hash is either 'person' or 'entity', where
|
202
|
+
|
203
|
+
- 'person' is a hash with 3 keys (kind: `:person`, name: string, salary: integer),
|
204
|
+
- 'entity' is a hash with 4 keys (kind: `:entity`, title: string, form: string, revenue: integer).
|
205
|
+
|
206
|
+
```ruby
|
207
|
+
person_or_entity =
|
208
|
+
Datacaster.schema do
|
209
|
+
# separate 'kind' validator, ensures that 'kind' is either :person or :entity
|
210
|
+
kind_is_valid = hash_schema(
|
211
|
+
kind: check { |x| %i[person entity].include?(x) }
|
212
|
+
)
|
213
|
+
|
214
|
+
# separate person validator (excluding validation of 'kind' field)
|
215
|
+
person = hash_schema(name: string, salary: integer)
|
216
|
+
|
217
|
+
# separate entity validator (excluding validation of 'kind' field)
|
218
|
+
entity = hash_schema(title: string, form: string, revenue: integer)
|
219
|
+
|
220
|
+
kind_is_valid & hash_schema(kind: compare(:person)).then(person).else(entity)
|
221
|
+
end
|
222
|
+
|
223
|
+
person_or_entity.(
|
224
|
+
kind: :person,
|
225
|
+
name: "John Smith",
|
226
|
+
salary: 100_000
|
227
|
+
)
|
228
|
+
# => Datacaster::ValidResult({:kind=>:person, :name=>"John Smith", :salary=>100000})
|
229
|
+
|
230
|
+
person_or_entity.(
|
231
|
+
kind: :entity,
|
232
|
+
title: "Hooves and Hornes",
|
233
|
+
form: "LLC",
|
234
|
+
revenue: 5_000_000
|
235
|
+
)
|
236
|
+
# => Datacaster::ValidResult({:kind=>:entity, :title=>"Hooves and Hornes", :form=>"LLC", :revenue=>5000000})
|
237
|
+
|
238
|
+
person_or_entity.(
|
239
|
+
title: "?"
|
240
|
+
)
|
241
|
+
# => Datacaster::ErrorResult({:kind=>["is invalid"]})
|
242
|
+
```
|
243
|
+
|
244
|
+
See below documentation on 'check' custom type to know how to provide custom error message instead of 'is invalid'.
|
245
|
+
|
246
|
+
Schema, defined above, behaves in all aspects (shown in the example and in other practical applications which might come to your mind) just as you might expect it to, after reading previous examples and the code above.
|
247
|
+
|
248
|
+
In our opinion the above example shows most laconic way to express underlying 'business-logic' (including elaborate error reporting on all kinds of failures) among all available competitor approaches/gems.
|
249
|
+
|
250
|
+
Formally, with `a.then(b).else(c)`:
|
251
|
+
|
252
|
+
* if `a` returns `ValidResult`, then `b` is called *with the result of `a`* (not the original value) and whatever `b` returns is returned;
|
253
|
+
* otherwise, `c` is called with the original value, and whatever `c` returns is returned.
|
254
|
+
|
255
|
+
`else`-part is required and could not be omitted.
|
256
|
+
|
257
|
+
Note: this construct is *not* an equivalent of `a & b | c`.
|
258
|
+
|
259
|
+
With `a.then(b).else(c)` if `a` and `b` fails, then `b`'s error is returned. With `a & b | c`, instead, `c`'s result would be returned.
|
260
|
+
|
261
|
+
## Built-in types
|
262
|
+
|
263
|
+
Full description of all built-in types follows.
|
264
|
+
|
265
|
+
### Basic types
|
266
|
+
|
267
|
+
#### `string`
|
268
|
+
|
269
|
+
Returns ValidResult if and only if provided value is a string. Doesn't transform the value.
|
270
|
+
|
271
|
+
#### `integer`
|
272
|
+
|
273
|
+
Returns ValidResult if and only if provided value is an integer. Doesn't transform the value.
|
274
|
+
|
275
|
+
#### `float`
|
276
|
+
|
277
|
+
Returns ValidResult if and only if provided value is a float (checked with Ruby's `#is_a?(Float)`, i.e. integers are not considered valid floats). Doesn't transform the value.
|
278
|
+
|
279
|
+
#### `decimal([digits = 8])`
|
280
|
+
|
281
|
+
Returns ValidResult if and only if provided value is either a float, integer or string representing float/integer.
|
282
|
+
|
283
|
+
Transforms the value to `BigDecimal` instance.
|
284
|
+
|
285
|
+
#### `array`
|
286
|
+
|
287
|
+
Returns ValidResult if and only if provided value is an `Array`. Doesn't transform the value.
|
288
|
+
|
289
|
+
#### `hash_value`
|
290
|
+
|
291
|
+
Returns ValidResult if and only if provided value is a `Hash`. Doesn't transform the value.
|
292
|
+
|
293
|
+
Note: this type is called `hash_value` instead of `hash`, because `hash` is reserved method name in Ruby.
|
294
|
+
|
295
|
+
### Convenience types
|
296
|
+
|
297
|
+
#### `non_empty_string`
|
298
|
+
|
299
|
+
Returns ValidResult if and only if provided value is a string and is not empty. Doesn't transform the value.
|
300
|
+
|
301
|
+
#### `hash_with_symbolized_keys`
|
302
|
+
|
303
|
+
Returns ValidResult if and only if provided value is an instance of `Hash`. Transforms the value to `#hash_with_symbolized_keys` (requires `ActiveSupport`).
|
304
|
+
|
305
|
+
#### `integer32`
|
306
|
+
|
307
|
+
Returns ValidResult if and only if provided value is an integer and it's absolute value is <= 2_147_483_647. Doesn't transform the value.
|
308
|
+
|
309
|
+
### Special types
|
310
|
+
|
311
|
+
#### `absent`
|
312
|
+
|
313
|
+
Returns ValidResult if and only if provided value is `Datacaster.absent` (this is singleton instance). Relevant only for hash schemas (see below). Doesn't transform the value.
|
314
|
+
|
315
|
+
#### `any`
|
316
|
+
|
317
|
+
Returns ValidResult if and only if provided value is not `Datacaster.absent` (this is singleton instance). Relevant only for hash schemas (see below). Doesn't transform the value.
|
318
|
+
|
319
|
+
#### `transform_to_value(value)`
|
320
|
+
|
321
|
+
Always returns ValidResult. The value is transformed to provided argument. Is used to provide default values, e.g.:
|
322
|
+
|
323
|
+
```ruby
|
324
|
+
max_concurrent_connections = Datacaster.schema { compare(nil).then(transform_to_value(5)).else(integer) }
|
325
|
+
|
326
|
+
max_concurrent_connections.(9) # => Datacaster::ValidResult(9)
|
327
|
+
max_concurrent_connections.("9") # => Datacaster::ErrorResult(["must be integer"])
|
328
|
+
max_concurrent_connections.(nil) #=> #<Datacaster::ValidResult(5)>
|
329
|
+
```
|
330
|
+
|
331
|
+
#### `remove`
|
332
|
+
|
333
|
+
Equivalent to `transform_to_value(Datacaster.absent)`. Always returns ValidResult. The value is transformed to `Datacaster.absent` (see section below on hash schemas, where this is useful).
|
334
|
+
|
335
|
+
#### `pass`
|
336
|
+
|
337
|
+
Equivalent to `transform_to_value { |x| x }`. Always returns ValidResult. Doesn't transform the value. Useful to "mark" the value as validated (see section below on hash schemas, where this could be applied).
|
338
|
+
|
339
|
+
#### `responds_to(method)`
|
340
|
+
|
341
|
+
Returns ValidResult if and only if value `#responds_to?(method)`. Doesn't transform the value.
|
342
|
+
|
343
|
+
#### `must_be(klass)`
|
344
|
+
|
345
|
+
Returns ValidResult if and only if value `#is_a?(klass)`. Doesn't transform the value.
|
346
|
+
|
347
|
+
#### `optional(base)`
|
348
|
+
|
349
|
+
Returns ValidResult if and only if value is either `Datacaster.absent` (singleton instance) or passes `base` validation. See below documentation on hash schemas for details on `Datacaster.absent`.
|
350
|
+
|
351
|
+
```ruby
|
352
|
+
item_with_optional_price =
|
353
|
+
Datacaster.schema do
|
354
|
+
hash_schema(
|
355
|
+
name: string,
|
356
|
+
price: optional(float)
|
357
|
+
)
|
358
|
+
end
|
359
|
+
|
360
|
+
item_with_optional_price.(name: "Book", price: 1.23)
|
361
|
+
# => Datacaster::ValidResult({:name=>"Book", :price=>1.23})
|
362
|
+
item_with_optional_price.(name: "Book")
|
363
|
+
# => Datacaster::ValidResult({:name=>"Book"})
|
364
|
+
|
365
|
+
item_with_optional_price.(name: "Book", price: "wrong")
|
366
|
+
# => Datacaster::ErrorResult({:price=>["must be float"]})
|
367
|
+
```
|
368
|
+
|
369
|
+
#### `pick(key)`
|
370
|
+
|
371
|
+
Returns ValidResult if and only if value `#is_a?(Enumerable)`.
|
372
|
+
|
373
|
+
Transforms the value to/returns:
|
374
|
+
|
375
|
+
* `value[key]` if key is set in the value
|
376
|
+
* `nil` if `value[key]` is set and is nil
|
377
|
+
* `Datacaster.absent` if key is not set
|
378
|
+
|
379
|
+
```ruby
|
380
|
+
pick_name = Datacaster.schema { pick(:name) }
|
381
|
+
|
382
|
+
pick_name.(name: "George") # => Datacaster::ValidResult("George")
|
383
|
+
pick_name.(last_name: "Johnson") # => Datacaster::ValidResult(#<Datacaster.absent>)
|
384
|
+
|
385
|
+
pick_name.("test") # => Datacaster::ErrorResult(["must be Enumerable"])
|
386
|
+
```
|
387
|
+
|
388
|
+
Alternative form could be used: `pick(*keys)`.
|
389
|
+
|
390
|
+
In this case, an array of results is returned, each element in which corresponds to the element in `keys` array (i.e. is an argument of the `pick`) and evaluated in accordance with the above rules.
|
391
|
+
|
392
|
+
```ruby
|
393
|
+
pick_name_and_age = Datacaster.schema { pick(:name, :age) }
|
394
|
+
|
395
|
+
pick_name_and_age.(name: "George", age: 20) # => Datacaster::ValidResult(["George", 20])
|
396
|
+
pick_name_and_age.(last_name: "Johnson", age: 20) # => Datacaster::ValidResult([#<Datacaster.absent>, 20])
|
397
|
+
|
398
|
+
pick_name_and_age.("test") # => Datacaster::ErrorResult(["must be Enumerable"])
|
399
|
+
```
|
400
|
+
|
401
|
+
### "Web-form" types
|
402
|
+
|
403
|
+
These types are convenient to parse and validate POST forms and decode JSON requests.
|
404
|
+
|
405
|
+
#### `to_integer`
|
406
|
+
|
407
|
+
Returns ValidResult if and only if value is an integer, float or string representing integer/float. Transforms value to integer.
|
408
|
+
|
409
|
+
#### `to_float`
|
410
|
+
|
411
|
+
Returns ValidResult if and only if value is an integer, float or string representing integer/float. Transforms value to float.
|
412
|
+
|
413
|
+
#### `to_boolean`
|
414
|
+
|
415
|
+
Returns ValidResult if and only if value is `true`, `1`, `'true'` or `false`, `0`, `'false'`. Transforms value to `true` or `false` (using apparent convention).
|
416
|
+
|
417
|
+
#### `iso8601`
|
418
|
+
|
419
|
+
Returns ValidResult if and only if value is a string in [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) date-time format.
|
420
|
+
|
421
|
+
```ruby
|
422
|
+
dob = Datacaster.schema { iso8601 }
|
423
|
+
|
424
|
+
dob.("2011-02-03")
|
425
|
+
# => Datacaster::ValidResult(#<DateTime: 2011-02-03T00:00:00+00:00 ...>)
|
426
|
+
```
|
427
|
+
|
428
|
+
Transforms value to `DateTime` instance.
|
429
|
+
|
430
|
+
#### `optional_param(base)`
|
431
|
+
|
432
|
+
Returns ValidResult if and only if value is absent, empty string or passes `base` validation.
|
433
|
+
|
434
|
+
If the value is empty string (`""`), transforms it to `Datacaster.absent` instance. It makes sense to use this type only in conjunction with hash schema validations (see below), where `Datacaster.absent` keys are removed from the resultant hash.
|
435
|
+
|
436
|
+
Otherwise, doesn't transform the value.
|
437
|
+
|
438
|
+
### Custom and fundamental types
|
439
|
+
|
440
|
+
These custom types (or 'meta' types) are used to create 'hand-crafted' validators.
|
441
|
+
|
442
|
+
When `name` argument is available, that argument determines what would display with `#inspect` of that validator (and nothing else).
|
443
|
+
|
444
|
+
When `error` argument is available, that argument determines what error text (should be string, but actual error will automatically be displayed as array of strings, see examples in the previous sections of this file) will be used if validation fails.
|
445
|
+
|
446
|
+
#### `cast(name = 'Anonymous') { |value| ... }`
|
447
|
+
|
448
|
+
The most basic — "fully manual" — validator.
|
449
|
+
|
450
|
+
Calls block with the value. Returns whatever block returns.
|
451
|
+
|
452
|
+
Provided block must return either `Datacaster::Result` or `Dry::Result::Monad` (the latter will automatically be converted to the former), otherwise `cast` will raise runtime `TypeError`.
|
453
|
+
|
454
|
+
```ruby
|
455
|
+
# Actually, better use 'check' here instead (see below)
|
456
|
+
user_id_exists =
|
457
|
+
Datacaster.schema do
|
458
|
+
cast('UserIdExists') do |user_id|
|
459
|
+
if User.exists?(user_id)
|
460
|
+
Success(user_id) # or Datacaster::ValidResult(user_id)
|
461
|
+
else
|
462
|
+
# Note: actual returned error will always be an array, despite what
|
463
|
+
# you manually set as return value of caster. E.g., ["user is not found"]
|
464
|
+
# in this example.
|
465
|
+
Failure("user is not found") # or Datacaster::ErrorResult("user is not found")
|
466
|
+
end
|
467
|
+
end
|
468
|
+
end
|
469
|
+
```
|
470
|
+
|
471
|
+
Notice, that for this example (as is written in the comment) `check` type is better option (see below). It's actually so hard to come up with an example where explicit `cast` is the best option that we didn't manage to do that. Refrain from using `cast` unless absolutely no other type could be used.
|
472
|
+
|
473
|
+
`cast` will transform value, if such is the logic of provided block.
|
474
|
+
|
475
|
+
#### `check(name = 'Anonymous', error = 'is invalid') { |value| ... }`
|
476
|
+
|
477
|
+
Returns ValidResult if and only if provided block returns truthy value (i.e. anything except `false` and `nil`).
|
478
|
+
|
479
|
+
```ruby
|
480
|
+
user_id_exists =
|
481
|
+
Datacaster.schema do
|
482
|
+
check('UserIdExists', 'user is not found') do |user_id|
|
483
|
+
User.exists?(user_id)
|
484
|
+
end
|
485
|
+
end
|
486
|
+
```
|
487
|
+
|
488
|
+
Doesn't transform the value.
|
489
|
+
|
490
|
+
#### `try(name = 'Anonymous', error = 'is invalid', catched_exception:) { |value| ... }`
|
491
|
+
|
492
|
+
Returns ValidResult if and only if block finishes without exceptions. If block raises an exception:
|
493
|
+
|
494
|
+
* if exception class equals to `catched_exception`, then ErrorResult is returned;
|
495
|
+
* otherwise, exception is re-raised.
|
496
|
+
|
497
|
+
Note: instead of specific exception class an array of classes could be provided.
|
498
|
+
|
499
|
+
```ruby
|
500
|
+
def dangerous_method!
|
501
|
+
raise RuntimeError
|
502
|
+
end
|
503
|
+
|
504
|
+
dangerous_validator =
|
505
|
+
Datacaster.schema do
|
506
|
+
try(catched_exception: RuntimeError) { |value| dangerous_method! }
|
507
|
+
end
|
508
|
+
```
|
509
|
+
|
510
|
+
As you see from the example, that's another 'meta type', which direct use is hard to justify. Consider using `check` instead (returning boolean value from the block instead of raising error).
|
511
|
+
|
512
|
+
Doesn't transform the value.
|
513
|
+
|
514
|
+
#### `validate(active_model_validations, name = 'Anonymous')`
|
515
|
+
|
516
|
+
Requires ActiveModel.
|
517
|
+
|
518
|
+
Add `require 'datacaster/validator'` to your source code before using this.
|
519
|
+
|
520
|
+
Returns ValidResult if and only if provided ActiveModel validations passes. Otherwise, returns ActiveModel errors wrapped as ErrorResult.
|
521
|
+
|
522
|
+
```ruby
|
523
|
+
require 'datacaster/validator'
|
524
|
+
|
525
|
+
nickname =
|
526
|
+
Datacaster.schema do
|
527
|
+
validate(format: {
|
528
|
+
with: /\A[a-zA-Z]+\z/,
|
529
|
+
message: "only allows letters"
|
530
|
+
})
|
531
|
+
end
|
532
|
+
|
533
|
+
nickname.("longshot") # Datacaster::ValidResult("longshot")
|
534
|
+
nickname.("user32") # Datacaster::ErrorResult(["only allows letters"])
|
535
|
+
```
|
536
|
+
|
537
|
+
Doesn't transform the value.
|
538
|
+
|
539
|
+
#### `compare(reference_value, name = 'Anonymous', error = nil)`
|
540
|
+
|
541
|
+
This type is the way to ensure some value in your schema is some predefined "constant".
|
542
|
+
|
543
|
+
Returns ValidResult if and only if `reference_value` equals value.
|
544
|
+
|
545
|
+
```ruby
|
546
|
+
agreed_with_tos =
|
547
|
+
Datacaster.partial_schema do
|
548
|
+
hash_schema(
|
549
|
+
agreed: compare(true)
|
550
|
+
)
|
551
|
+
end
|
552
|
+
```
|
553
|
+
|
554
|
+
#### `transform(name = 'Anonymous') { |value| ... }`
|
555
|
+
|
556
|
+
Always returns ValidResult. Transforms the value: returns whatever block returned, automatically wrapping it into `ValidResult`.
|
557
|
+
|
558
|
+
```ruby
|
559
|
+
city =
|
560
|
+
Datacaster.schema do
|
561
|
+
hash_schema(
|
562
|
+
name: string,
|
563
|
+
# convert miles to km
|
564
|
+
distance: to_float & transform { |v| v * 1.60934 }
|
565
|
+
)
|
566
|
+
end
|
567
|
+
|
568
|
+
city.(name: "Denver", distance: "2.5") # => Datacaster::ValidResult({:name=>"Denver", :distance=>4.02335})
|
569
|
+
```
|
570
|
+
|
571
|
+
#### `transform_if_present(name = 'Anonymous') { |value| ... }`
|
572
|
+
|
573
|
+
Always returns ValidResult. If the value is `Datacaster.absent` (singleton instance, see below section on hash schemas), then `Datacaster.absent` is returned (block isn't called). Otherwise, works like `transform`.
|
574
|
+
|
575
|
+
### Array schemas
|
576
|
+
|
577
|
+
To define compound data type, array of 'something', use `array_schema(something)` (or, synonymically, `array_of(something)`). There is no way to define array wherein each element is of different type.
|
578
|
+
|
579
|
+
```ruby
|
580
|
+
salaries = Datacaster.schema { array_of(integer) }
|
581
|
+
|
582
|
+
salaries.([1000, 2000, 3000]) # Datacaster::ValidResult([1000, 2000, 3000])
|
583
|
+
|
584
|
+
salaries.(["one thousand"]) # Datacaster::ErrorResult({0=>["must be integer"]})
|
585
|
+
salaries.(:not_an_array) # Datacaster::ErrorResult(["must be array"])
|
586
|
+
salaries.([]) # Datacaster::ErrorResult(["must not be empty"])
|
587
|
+
```
|
588
|
+
|
589
|
+
To allow empty array use the following construct: `compare([]) | array_of(...)`.
|
590
|
+
|
591
|
+
If you want to define array of hashes, shortcut definition could be used: instead of `array_of(hash_schema({...}))` use `array_of({...})`:
|
592
|
+
|
593
|
+
```ruby
|
594
|
+
people =
|
595
|
+
Datacaster.schema do
|
596
|
+
array_of(
|
597
|
+
# hash_schema(
|
598
|
+
{
|
599
|
+
name: string,
|
600
|
+
salary: float
|
601
|
+
}
|
602
|
+
# )
|
603
|
+
)
|
604
|
+
end
|
605
|
+
|
606
|
+
person1 = {name: "John Smith", salary: 250_000.0}
|
607
|
+
person2 = {name: "George Johnson", salary: 50_000.0}
|
608
|
+
people.([person1, person2]) # => Datacaster::ValidResult([{...}, {...}])
|
609
|
+
|
610
|
+
people.([{salary: 250_000.0}, {salary: "50000"}])
|
611
|
+
# => Datacaster::ErrorResult({
|
612
|
+
# 0 => {:name => ["must be string"]},
|
613
|
+
# 1 => {:name => ["must be string"], :salary => ["must be float"]}
|
614
|
+
# })
|
615
|
+
```
|
616
|
+
|
617
|
+
Notice, that extra keys of inner hashes could be validated only if each element is otherwise valid. In other words, if some of the elements have other validation errors, then "extra key must be absent" validation error won't appear on any element.
|
618
|
+
|
619
|
+
Formally, `array_of(x)` will return ValidResult if and only if:
|
620
|
+
|
621
|
+
a) provided value implements basic array methods (`#map`, `#zip`),
|
622
|
+
b) provided value is not `#empty?`,
|
623
|
+
c) each element of the provided value passes validation of `x`.
|
624
|
+
|
625
|
+
If a) fails, `ErrorResult(["must be array"])` is returned.
|
626
|
+
If b) fails, `ErrorResult(["must not be empty"])` is returned.
|
627
|
+
If c) fails, `ErrorResult({0 => ..., 1 => ...})` is returned. Wrapped hash contains keys which correspond to initial array's indices, and values correspond to failure returned from `x` validator, called for the corresponding element.
|
628
|
+
|
629
|
+
Array schema transforms array if inner type (`x`) transforms element (in this case `array_schema` works more or less like `map` function). Otherwise, it doesn't transform.
|
630
|
+
|
631
|
+
### Hash schemas
|
632
|
+
|
633
|
+
Hash schemas are "bread and butter" of Datacaster.
|
634
|
+
|
635
|
+
To define compound data type, hash of 'something', use `hash_schema({key: type, ...})`:
|
636
|
+
|
637
|
+
```ruby
|
638
|
+
person =
|
639
|
+
Datacaster.schema do
|
640
|
+
hash_schema(
|
641
|
+
name: string,
|
642
|
+
salary: integer
|
643
|
+
)
|
644
|
+
end
|
645
|
+
|
646
|
+
person.(name: "John Smith", salary: 100_000)
|
647
|
+
# => Datacaster::ValidResult({:name=>"John Smith", :salary=>100000})
|
648
|
+
|
649
|
+
person.(name: "John Smith", salary: "100_000")
|
650
|
+
# => Datacaster::ErrorResult({:salary=>["must be integer"]})
|
651
|
+
```
|
652
|
+
|
653
|
+
Formally, hash schema returns ValidResult if and only if:
|
654
|
+
|
655
|
+
a) provided value `is_a?(Hash)`,
|
656
|
+
b) all values, fetched by keys mentioned in `hash_schema(...)` definition, pass corresponding validations,
|
657
|
+
c) after all checks (including logical operators), there are no unchecked keys in the hash.
|
658
|
+
|
659
|
+
If a) fails, `ErrorResult(["must be hash"])` is returned.
|
660
|
+
if b) fails, `ErrorResult(key1 => [errors...], key2 => [errors...])` is returned. Each key of wrapped "error hash" corresponds to the key of validated hash, and each value of "error hash" contains array of errors, returned by the corresponding validator.
|
661
|
+
If b) fulfilled, then and only then validated hash is checked for extra keys. If they are found, `ErrorResult(extra_key_1 => ["must be absent"], ...)` is returned.
|
662
|
+
|
663
|
+
Technically, last part is implemented with special singleton validator, called `#<Datacaster::Terminator>`, which is automatically added to the validation chain (with the use of `&` operator) by `Datacaster.schema` method. Don't be scared if you see it in the output of `#inspect` method of your validators (e.g. in `irb`).
|
664
|
+
|
665
|
+
#### Absent is not nil
|
666
|
+
|
667
|
+
In practical tasks it's important to distinguish between absent (i.e. not set or deleted) and `nil` values of a hash.
|
668
|
+
|
669
|
+
To check some value for `nil`, use ordinary `compare(nil)` validator, mentioned above.
|
670
|
+
|
671
|
+
To check some value for absence, use `absent` validator:
|
672
|
+
|
673
|
+
```ruby
|
674
|
+
restricted_params =
|
675
|
+
Datacaster.schema do
|
676
|
+
hash_schema(
|
677
|
+
username: string,
|
678
|
+
is_admin: absent
|
679
|
+
)
|
680
|
+
end
|
681
|
+
|
682
|
+
restricted_params.(username: "test")
|
683
|
+
# => Datacaster::ValidResult({:username=>"test"})
|
684
|
+
|
685
|
+
restricted_params.(username: "test", is_admin: true)
|
686
|
+
# => Datacaster::ErrorResult({:is_admin=>["must be absent"]})
|
687
|
+
restricted_params.(username: "test", is_admin: nil)
|
688
|
+
# => Datacaster::ErrorResult({:is_admin=>["must be absent"]})
|
689
|
+
```
|
690
|
+
|
691
|
+
More practical case is to include `absent` validator in logical expressions, e.g. `something: absent | string`. If `something` is set to `nil`, this validation will fail, which could be the desired (and hardly achieved by any other validation framework) behavior.
|
692
|
+
|
693
|
+
Also, see documentation for `optional(base)` and `optional_param(base)` above. If some value becomes `Datacaster.absent` in its chain of validations-transformations, it is removed from the resultant hash (on the same stage where the lack of extra/unchecked keys in the hash is validated):
|
694
|
+
|
695
|
+
```ruby
|
696
|
+
person =
|
697
|
+
Datacaster.schema do
|
698
|
+
hash_schema(
|
699
|
+
name: string,
|
700
|
+
dob: optional(iso8601)
|
701
|
+
)
|
702
|
+
end
|
703
|
+
|
704
|
+
person.(name: "John Smith", dob: "1990-05-23")
|
705
|
+
# => Datacaster::ValidResult({:name=>"John Smith", :dob=>#<DateTime: 1990-05-23T00:00:00+00:00 ...>})
|
706
|
+
person.(name: "John Smith")
|
707
|
+
# => Datacaster::ValidResult({:name=>"John Smith"})
|
708
|
+
|
709
|
+
person.(name: "John Smith", dob: "invalid date")
|
710
|
+
# => Datacaster::ErrorResult({:dob=>["must be iso8601 string"]})
|
711
|
+
```
|
712
|
+
|
713
|
+
Another use-case for `Datacaster.absent` is to directly set some key to that value. In that case, it will be removed from the resultant hash. The most convenient way to do that is to use `remove` type (described above in this file):
|
714
|
+
|
715
|
+
```ruby
|
716
|
+
anonimized_person =
|
717
|
+
Datacaster.schema do
|
718
|
+
hash_schema(
|
719
|
+
name: remove,
|
720
|
+
dob: pass
|
721
|
+
)
|
722
|
+
end
|
723
|
+
|
724
|
+
anonimized_person.(name: "John Johnson", dob: "1990-05-23")
|
725
|
+
# => Datacaster::ValidResult({:dob=>"1990-05-23"})
|
726
|
+
```
|
727
|
+
|
728
|
+
Note: we need to `pass` `dob` field to "mark" it as validated, otherwise `Datacaster.schema` will return ErrorResult, notifying that unchecked extra field was in the initial hash.
|
729
|
+
|
730
|
+
#### Schema vs Partial schema
|
731
|
+
|
732
|
+
As written in the beginning of this section on `hash_schema`, at the last stage of validation it is ensured that hash contains no extra keys.
|
733
|
+
|
734
|
+
Sometimes it is necessary to omit that requirement and allow for hash to contain any keys (in addition to the ones defined in `hash_schema`). One practical use-case for that is when datacaster definitions are spread among several files.
|
735
|
+
|
736
|
+
Let's say we have:
|
737
|
+
|
738
|
+
* 'people' (hashes with `name: string`, `description: string` and `kind: 'person'` fields),
|
739
|
+
* 'entities' (hash with `title: string`, `description: string` and `kind: 'entity'` fields).
|
740
|
+
|
741
|
+
In other words, we have some polymorphic resource, which type is defined by `kind` field, and which has common fields for all its "sub-kinds" (in this example: `description`), and also fields specific to each "kind" (in database we often model this as [STI](https://api.rubyonrails.org/v6.0.3.2/classes/ActiveRecord/Base.html#class-ActiveRecord::Base-label-Single+table+inheritance)).
|
742
|
+
|
743
|
+
Here's how we would model this type with Datacaster (filenames are given for the sake of explanation, use whatever convention your project dictates; also, use whatever codestyle is preferred, below is shown the one which we prefer):
|
744
|
+
|
745
|
+
```ruby
|
746
|
+
# commmon_fields_validator.rb
|
747
|
+
CommonFieldsValidator =
|
748
|
+
Datacaster.partial_schema do
|
749
|
+
# validate common fields
|
750
|
+
hash_schema(
|
751
|
+
description: string
|
752
|
+
)
|
753
|
+
end
|
754
|
+
|
755
|
+
# person_validator.rb
|
756
|
+
PersonValidator =
|
757
|
+
Datacaster.partial_schema do
|
758
|
+
# validate fields specific to person
|
759
|
+
hash_schema(
|
760
|
+
name: string,
|
761
|
+
kind: compare('person')
|
762
|
+
)
|
763
|
+
end
|
764
|
+
|
765
|
+
# entity_validator.rb
|
766
|
+
EntityValidator =
|
767
|
+
Datacaster.partial_schema do
|
768
|
+
# validate fields specific to entity
|
769
|
+
hash_schema(
|
770
|
+
title: string,
|
771
|
+
kind: compare('entity')
|
772
|
+
)
|
773
|
+
end
|
774
|
+
|
775
|
+
# record_validator.rb
|
776
|
+
RecordValidator =
|
777
|
+
Datacaster.schema do
|
778
|
+
# separate validator for 'kind' field - to produce convenient error message
|
779
|
+
kind = check("Kind", "must be either 'person' or 'enity'") do |v|
|
780
|
+
%w(person entity).include?(v)
|
781
|
+
end
|
782
|
+
|
783
|
+
# check that 'kind' field is correct and then select validator
|
784
|
+
# in accordance with it
|
785
|
+
hash_schema(kind: kind) & CommonFieldsValidator &
|
786
|
+
hash_schema(kind: compare('person')).
|
787
|
+
then(PersonValidator).
|
788
|
+
else(EntityValidator)
|
789
|
+
end
|
790
|
+
```
|
791
|
+
|
792
|
+
See "IF... THEN... ELSE" section above in this file for full description of how `a.then(b).else(c)` validator works.
|
793
|
+
|
794
|
+
Examples of how this validator would work:
|
795
|
+
|
796
|
+
```ruby
|
797
|
+
# some_file.rb
|
798
|
+
|
799
|
+
RecordValidator.(
|
800
|
+
kind: 'person',
|
801
|
+
name: 'George Johnson',
|
802
|
+
description: 'CEO'
|
803
|
+
)
|
804
|
+
# => Datacaster::ValidResult({:kind=>"person", :name=>"George Johnson", :description=>"CEO"})
|
805
|
+
|
806
|
+
RecordValidator.(kind: 'unknown')
|
807
|
+
# => Datacaster::ErrorResult({:kind=>["must be either 'person' or 'enity'"]})
|
808
|
+
RecordValidator.(
|
809
|
+
kind: 'person',
|
810
|
+
name: 'George Johnson',
|
811
|
+
description: 'CEO',
|
812
|
+
extra: :key
|
813
|
+
)
|
814
|
+
# => Datacaster::ErrorResult({:extra=>["must be absent"]})
|
815
|
+
```
|
816
|
+
|
817
|
+
Note that only the usage of `Datacaster.partial_schema` instead of `Datacaster.schema` allowed us to compose several `hash_schema`s from different files (from different calls to Datacaster API).
|
818
|
+
|
819
|
+
Had we used `schema` everywhere, `CommonFieldsValidator` would return failure for records which are supposed to be valid, because they would contain "extra" (i.e. not defined in `CommonFieldsValidator` itself) keys (e.g. `name` for person).
|
820
|
+
|
821
|
+
As a rule of thumb, use `partial_schema` in any "intermediary" validators (extracted for the sake of clarity of code and reusability) and use `schema` in any "end" validators (ones which receive full record as input and use intermediary validators behind the scenes).
|
822
|
+
|
823
|
+
#### AND with error aggregation (`*`)
|
824
|
+
|
825
|
+
Often it is useful to run validator which are "further down the conveyor" (i.e. placed at the right-hand side of AND operator `&`) even if current (i.e. left-hand side) validator has failed.
|
826
|
+
|
827
|
+
Let's say we have extracted some "common validations" and have some concrete validators, which utilize these reusable common validations (more or less repeating the motif of the previous example, shortening non-essential for this section parts for clarity):
|
828
|
+
|
829
|
+
```ruby
|
830
|
+
CommonValidator =
|
831
|
+
Datacaster.partial_schema do
|
832
|
+
hash_schema(
|
833
|
+
description: string
|
834
|
+
)
|
835
|
+
end
|
836
|
+
|
837
|
+
PersonValidator =
|
838
|
+
Datacaster.schema do
|
839
|
+
hash_schema(
|
840
|
+
name: string
|
841
|
+
)
|
842
|
+
end
|
843
|
+
|
844
|
+
RecordValidator =
|
845
|
+
Datacaster.schema do
|
846
|
+
CommonValidator & PersonValidator
|
847
|
+
end
|
848
|
+
```
|
849
|
+
|
850
|
+
This code will work as expected (i.e. `RecordValidator`, the "end" validator, will check that provided hash value both has `name` and `description` string fields), except for one specific case:
|
851
|
+
|
852
|
+
```ruby
|
853
|
+
RecordValidator.(kind: 'person', name: 1)
|
854
|
+
# => Datacaster::ErrorResult({:description=>["must be string"]})
|
855
|
+
```
|
856
|
+
|
857
|
+
It correctly returns `ErrorResult`, but it doesn't mention that in addition to `description` being wrongfully absent, `name` field is of wrong type (integer instead of string). That could be inconvenient where Datacaster is used, for example, as a params validator for an API service: end user of the API would need to repeatedly send requests, essentially "brute forcing" his way in through all the errors (fixing them one by one), instead of having the list of all errors in one iteration.
|
858
|
+
|
859
|
+
Specifically to resolve this, "AND with error aggregation" (`*`) operator should be used in place of regular AND (`&`):
|
860
|
+
|
861
|
+
```ruby
|
862
|
+
RecordValidator =
|
863
|
+
Datacaster.schema do
|
864
|
+
CommonValidator * PersonValidator
|
865
|
+
end
|
866
|
+
|
867
|
+
RecordValidator.(kind: 'person', name: 1)
|
868
|
+
# => Datacaster::ErrorResult({:description=>["must be string"], :name=>["must be string"]})
|
869
|
+
```
|
870
|
+
|
871
|
+
Note: "star" (`*`) has been chosen arbitrarily among available Ruby operators. It shouldn't be read as multiplication (and, in fact, in Ruby it is used not only as multiplication sign).
|
872
|
+
|
873
|
+
Described in this example is the only case where `*` and `&` differ: in all other aspects they are full equivalents.
|
874
|
+
|
875
|
+
Formally, "AND with error aggregation" (`*`):
|
876
|
+
|
877
|
+
a) if left-hand side fails, calls right-hand side anyway and then returns aggregated (merged) `ErrorResult`s,
|
878
|
+
b) in all other cases behaves as regular "AND" (`&`).
|
879
|
+
|
880
|
+
### Shortcut nested definitions
|
881
|
+
|
882
|
+
Datacaster aimed at ease of use where multi-level embedded structures need to be validated, boilerplate reduced to inevitable minimum.
|
883
|
+
|
884
|
+
The words `hash_schema` and `array_schema`/`array_of` could be, therefore, omitted from the definition of nested structures (replaced with `{...}` and `[...]` correspondingly):
|
885
|
+
|
886
|
+
```ruby
|
887
|
+
# full definition
|
888
|
+
person =
|
889
|
+
Datacaster.schema do
|
890
|
+
hash_schema(
|
891
|
+
name: string,
|
892
|
+
date_of_birth: hash_schema(
|
893
|
+
day: integer,
|
894
|
+
month: integer,
|
895
|
+
year: integer
|
896
|
+
),
|
897
|
+
friends: array_of(
|
898
|
+
hash_schema(
|
899
|
+
id: integer,
|
900
|
+
login: string
|
901
|
+
)
|
902
|
+
)
|
903
|
+
)
|
904
|
+
end
|
905
|
+
|
906
|
+
# shortcut definition
|
907
|
+
person =
|
908
|
+
Datacaster.schema do
|
909
|
+
hash_schema(
|
910
|
+
name: string,
|
911
|
+
date_of_birth: {
|
912
|
+
day: integer,
|
913
|
+
month: integer,
|
914
|
+
year: integer
|
915
|
+
},
|
916
|
+
friends: [
|
917
|
+
{
|
918
|
+
id: integer,
|
919
|
+
login: string
|
920
|
+
}
|
921
|
+
]
|
922
|
+
)
|
923
|
+
end
|
924
|
+
```
|
925
|
+
|
926
|
+
Note: in "root" scope (immediately inside of `schema { ... }` block) words `hash_schema` and `array_of` are still required. We consider that allowing to omit them as well would hurt readability of code.
|
927
|
+
|
928
|
+
### Mapping hashes: `transform_to_hash`
|
929
|
+
|
930
|
+
One common task in processing compound data structures is to map one set of hash keys to another set. That's where `transform_to_hash` type comes to play (see also `pluck` and `remove` description above in this file).
|
931
|
+
|
932
|
+
```ruby
|
933
|
+
city_with_distance =
|
934
|
+
Datacaster.schema do
|
935
|
+
transform_to_hash(
|
936
|
+
distance_in_km: pick(:distance_in_meters) & transform { |x| x / 1000 },
|
937
|
+
distance_in_miles: pick(:distance_in_meters) & transform { |x| x / 1000 * 1.609 },
|
938
|
+
distance_in_meters: remove
|
939
|
+
)
|
940
|
+
end
|
941
|
+
|
942
|
+
city_with_distance.(distance_in_meters: 1200.0)
|
943
|
+
# => Datacaster::ValidResult({:distance_in_km=>1.2, :distance_in_miles=>1.9307999999999998})
|
944
|
+
```
|
945
|
+
|
946
|
+
Of course, order of keys in the definition hash doesn't change anything.
|
947
|
+
|
948
|
+
Formally, `transform_to_hash`:
|
949
|
+
|
950
|
+
a) transforms (any) value to hash;
|
951
|
+
b) this hash will contain keys listed in `transform_to_hash` definition;
|
952
|
+
c) value of these keys will be: initial value (*not the corresponding key of it, the value altogether*) transformed with the corresponding validator/type;
|
953
|
+
d) if any of the values from c) happen to be `Datacaster.absent`, this value *with its key* is removed from the resultant hash;
|
954
|
+
e) if the initial value happens to also be a hash, all its keys, except those which had been transformed, are merged to the resultant hash.
|
955
|
+
|
956
|
+
`transform_to_hash` will return ValidResult if and only if all transformations return ValidResults.
|
957
|
+
|
958
|
+
`transform_to_hash` will always transform the initial value.
|
959
|
+
|
960
|
+
Here is what is happening when `city_with_distance` (from the example above) is called:
|
961
|
+
|
962
|
+
* Initial hash `{distance_in_meters: 1200}` is passed to `transform_to_hash`
|
963
|
+
* `transform_to_hash` reads through its definition and creates resultant hash with the keys `distance_in_km`, `distance_in_miles`, `distance_in_meters`
|
964
|
+
* The key `distance_in_km` of the resultant hash in the transformation of the initial hash: firstly, hash is transformed to the value of its key with `pluck`, then that value is divided by 1000
|
965
|
+
* Similarly, `distance_in_miles` value is built
|
966
|
+
* `distance_in_meters` value is created by transforming initial value to `Datacaster.absent` (that is how `remove` works)
|
967
|
+
|
968
|
+
Note: because of point e) above we need to explicitly delete `distance_in_meters` key, because otherwise `transform_to_hash` will copy it to the resultant hash without validation. And all non-validated keys at the end of `Datacaster.schema` block (as explained above in section on partial schemas) result in error.
|
969
|
+
|
970
|
+
## Contributing
|
971
|
+
|
972
|
+
Fork, create issues and make PRs as usual.
|
973
|
+
|
974
|
+
## Ideas/TODO
|
975
|
+
|
976
|
+
* Support pattern matching on Datacaster::Result
|
977
|
+
* Duplicate all standard ActiveModel validations as built-in datacaster counterparts
|
978
|
+
|
979
|
+
## License
|
980
|
+
|
981
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|