typed_model 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +39 -0
- data/README.md +219 -78
- data/lib/typed_model/attribute_definition.rb +5 -7
- data/lib/typed_model/errors.rb +6 -2
- data/lib/typed_model/map_of.rb +5 -9
- data/lib/typed_model/model_base.rb +13 -6
- data/lib/typed_model/model_validations.rb +5 -6
- data/lib/typed_model/seq_of.rb +2 -4
- data/lib/typed_model/type_def.rb +50 -31
- data/lib/typed_model/types.rb +9 -8
- data/lib/typed_model/validator.rb +5 -13
- data/lib/typed_model/validators.rb +13 -21
- data/lib/typed_model/version.rb +1 -1
- data/typed_model.gemspec +1 -1
- metadata +12 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e073e121645ca98973bfdccf4eec35d7b22280d57ffae982dd82adc5277200d6
|
|
4
|
+
data.tar.gz: 7dec738ef8f379ef9aef9640e1c7ffc40ae8eacbdc019d35fd53a861967abcc4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07a337998fc5a7a9c4e7f3e48682ea3687dce65ee40c65d1c0b839ce10cb4755ca9ff40d0331ba8a21b5dd0cf1b0d980b9261cc44adfa2ba58d204875568293f
|
|
7
|
+
data.tar.gz: 6fd6b7833075e1860f923f1efd3bf4a610348ce6fe127a7eb8250034cfe350fff987e4a7e09b2937ccd844e29548d79ba8d77009377bcb3ef8b917eaf4c0f9a6
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.1.4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- `valid?` now resets errors between calls — previously errors accumulated across invocations
|
|
8
|
+
- `typecast` no longer swallows `false` return values (used `|| value` which treated `false` as nil)
|
|
9
|
+
- `instantiate_type` returns nil for nil input instead of raising NoMethodError
|
|
10
|
+
- `validate_recognized_types` uses `!value.nil?` instead of `value != nil` to avoid ActiveSupport DateTime comparison bug
|
|
11
|
+
|
|
12
|
+
### Breaking Changes
|
|
13
|
+
|
|
14
|
+
- `assert_not_blank` is now a distinct validation from `assert_required` — checks for non-whitespace (`/\S/`) rather than just empty/nil
|
|
15
|
+
- Removed deprecated `Fixnum` from `PRIMITIVE_CLASSES`
|
|
16
|
+
|
|
17
|
+
### Additions
|
|
18
|
+
|
|
19
|
+
- `Errors#to_h` added for accessing the underlying error hash
|
|
20
|
+
- Mapping key attribute assignment now works bidirectionally (set via name or mapping_key)
|
|
21
|
+
|
|
22
|
+
### Code Quality
|
|
23
|
+
|
|
24
|
+
- Modern Ruby syntax: safe navigation, guard clauses, `rescue StandardError`, symbol method names
|
|
25
|
+
- Extracted `build_validators`, `build_seq_of`, `build_map_of` helpers in `TypeDef`
|
|
26
|
+
- Extracted `recognized_scalar_type?` and `merge_value_errors` in `TypeDef`
|
|
27
|
+
- `Errors#each_error` uses `yield` instead of `blk.call`
|
|
28
|
+
- `ModelValidations#each_error` uses anonymous block forwarding (`&`)
|
|
29
|
+
- Added `# frozen_string_literal: true` to `type_def.rb`
|
|
30
|
+
|
|
31
|
+
### Tests
|
|
32
|
+
|
|
33
|
+
- Expanded `assert_not_blank` coverage (nil, empty, whitespace, non-blank, leading/trailing whitespace)
|
|
34
|
+
- `valid?` error reset between calls
|
|
35
|
+
- Boolean typecast with falsey values
|
|
36
|
+
- `instantiate_type` nil handling
|
|
37
|
+
- DateTime `<=>` nil edge case in validate
|
|
38
|
+
- Mapping key bidirectional assignment
|
|
39
|
+
- Nested association messages with mapping key
|
data/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# TypedModel
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
A lightweight attribute declaration, validation, and type-coercion framework for Ruby. Include `TypedModel::ModelBase` in a class to get:
|
|
4
|
+
|
|
5
|
+
- **`attribute`** declarations with type and validation options
|
|
6
|
+
- **`initialize(values)`** — construct from a hash
|
|
7
|
+
- **`attributes=`** — bulk-assign from a hash
|
|
8
|
+
- **`valid?`** / **`errors`** — run validations and inspect failures
|
|
9
|
+
- **`to_data`** — serialize to a plain hash
|
|
10
|
+
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
13
|
Add this line to your application's Gemfile:
|
|
@@ -24,76 +24,217 @@ Or install it yourself as:
|
|
|
24
24
|
|
|
25
25
|
$ gem install typed_model
|
|
26
26
|
|
|
27
|
-
##
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
27
|
+
## Built-in Types
|
|
28
|
+
|
|
29
|
+
`:string`, `:integer`, `:boolean`, `:timestamp`, `:map`, `:seq`
|
|
30
|
+
|
|
31
|
+
## Basic Example
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
class Employee
|
|
35
|
+
include TypedModel::ModelBase
|
|
36
|
+
|
|
37
|
+
attribute :name
|
|
38
|
+
attribute :a_boolean, type: :boolean
|
|
39
|
+
attribute :an_integer, type: :integer
|
|
40
|
+
attribute :a_timestamp, type: :timestamp
|
|
41
|
+
attribute :widget1, type: :map
|
|
42
|
+
attribute :address, type: Address
|
|
43
|
+
attribute :addresses, seq_of: Address
|
|
44
|
+
attribute :colors, type: :seq
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Sequences
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# non-empty/nil sequence
|
|
52
|
+
attribute :a_seq, type: :seq, validations: [:required]
|
|
53
|
+
|
|
54
|
+
# typed sequence of strings
|
|
55
|
+
attribute :a_seq, seq_of: :string
|
|
56
|
+
|
|
57
|
+
# sequence of Address models
|
|
58
|
+
attribute :a_seq, seq_of: Address
|
|
59
|
+
|
|
60
|
+
# sequence of [string, integer] pairs
|
|
61
|
+
attribute :a_seq, seq_of: [:string, :integer]
|
|
62
|
+
|
|
63
|
+
# sequence with nested type and validations
|
|
64
|
+
attribute :a_seq, seq_of: { type: Address, validations: [:some_validation] }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Maps
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# string -> integer
|
|
71
|
+
attribute :a_map, map_of: [:string, :integer]
|
|
72
|
+
|
|
73
|
+
# required map
|
|
74
|
+
attribute :a_map, type: :map, validations: [:required]
|
|
75
|
+
|
|
76
|
+
# map of maps
|
|
77
|
+
attribute :map_of_maps, map_of: [:string, [:string, :integer]]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Validations
|
|
81
|
+
|
|
82
|
+
Primitive types (`:string`, `:integer`, `:timestamp`, `:boolean`, etc.) are automatically validated. Supply built-in or custom validators via the `validations:` option on `attribute`:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
attribute :name, type: :string, validations: [:required]
|
|
86
|
+
attribute :email, type: :string, validations: [:not_blank]
|
|
87
|
+
attribute :started_at, type: :timestamp, validations: [:required]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Built-in Validators
|
|
91
|
+
|
|
92
|
+
| Keyword | Fails when | Error key |
|
|
93
|
+
| ------------- | ----------------------------------------------- | ---------- |
|
|
94
|
+
| `:required` | value is `nil` or empty | `:required` |
|
|
95
|
+
| `:not_blank` | value has no non-whitespace characters | `:required` |
|
|
96
|
+
| `:not_nil` | value is `nil` (empty is OK) | `:required_not_nil` |
|
|
97
|
+
| `:timestamp` | value cannot be parsed as a `Time` | `:invalid` |
|
|
98
|
+
| `:integer` | value cannot be coerced to `Integer` | `:invalid` |
|
|
99
|
+
| `:string` | value cannot be coerced to `String` | `:invalid` |
|
|
100
|
+
|
|
101
|
+
### Custom Validations
|
|
102
|
+
|
|
103
|
+
Via `:keyword` — define an `assert_<name>` method:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
class Employee
|
|
107
|
+
include TypedModel::ModelBase
|
|
108
|
+
|
|
109
|
+
attribute :name, validations: [:required, :my_validation]
|
|
110
|
+
|
|
111
|
+
def assert_my_validation(attr)
|
|
112
|
+
add_error(attr, 'some error')
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Via `Validator` instance:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
validator = TypedModel::Validator.new(:foo) do
|
|
121
|
+
['some error']
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
class Employee
|
|
125
|
+
include TypedModel::ModelBase
|
|
126
|
+
|
|
127
|
+
attribute :name, validations: [validator]
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Before-Validation Callbacks
|
|
132
|
+
|
|
133
|
+
Register methods that run before the main `validate` pass. Callbacks are inherited and run in ancestor order (parent -> child):
|
|
96
134
|
|
|
135
|
+
```ruby
|
|
136
|
+
class Employee
|
|
137
|
+
include TypedModel::ModelBase
|
|
138
|
+
|
|
139
|
+
before_validation :normalize_name
|
|
140
|
+
|
|
141
|
+
attribute :name, type: :string
|
|
142
|
+
|
|
143
|
+
def normalize_name
|
|
144
|
+
self.name = name&.strip
|
|
145
|
+
add_error(:name, :required) if name.to_s.empty?
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Validating Payloads and Reporting Errors
|
|
151
|
+
|
|
152
|
+
Data models are commonly used to validate API request and response payloads. The standard pattern is:
|
|
153
|
+
|
|
154
|
+
1. Instantiate the model from the payload hash
|
|
155
|
+
2. Call `valid?` — returns `true`/`false` and populates `errors`
|
|
156
|
+
3. If invalid, report the problems using `errors.inspect`
|
|
157
|
+
|
|
158
|
+
### The `errors` Object
|
|
159
|
+
|
|
160
|
+
`errors` is an instance of `TypedModel::Errors` — a hash of attribute names to arrays of error symbols/messages:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
model.errors[:name] # => [:required]
|
|
164
|
+
model.errors['address/street'] # => [:required] (nested path)
|
|
165
|
+
model.errors['addresses/1/type'] # => [:required] (array index in path)
|
|
166
|
+
model.errors.empty? # => true/false
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Iterate all errors:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
model.each_error { |key, msg| puts "#{key}: #{msg}" }
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Validating Inbound Requests
|
|
176
|
+
|
|
177
|
+
Validate input before processing. Return or raise with `errors.inspect` to convey what's wrong:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
def process_request(params)
|
|
181
|
+
request = MyRequest.new(params)
|
|
182
|
+
raise "Invalid request: #{request.errors.inspect}" unless request.valid?
|
|
183
|
+
|
|
184
|
+
handle(request)
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
In a Rails controller, render the errors directly as JSON:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
def create
|
|
192
|
+
response = MyResponse.new(response_params)
|
|
193
|
+
|
|
194
|
+
unless response.valid?
|
|
195
|
+
render json: response.errors.to_h, status: :bad_request
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
process(response)
|
|
200
|
+
render json: response, status: :created
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Validating Outbound Responses
|
|
205
|
+
|
|
206
|
+
Validate data received from external APIs to catch unexpected payloads early:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
def build_response(klass, data)
|
|
210
|
+
response = klass.new(data)
|
|
211
|
+
|
|
212
|
+
unless response.valid?
|
|
213
|
+
raise InvalidResponse, "Invalid #{klass}: #{response.errors.inspect}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
response
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Avoid logging raw payload data — it may contain sensitive information. When logging `errors.inspect`, ensure your error messages do not embed raw field values or other sensitive data, and prefer structured or otherwise sanitized error formats where possible.
|
|
221
|
+
|
|
222
|
+
### Soft vs Hard Failure
|
|
223
|
+
|
|
224
|
+
Choose the error-handling strategy that fits the context:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
# Soft — log and return a fallback (caller can continue)
|
|
228
|
+
unless request.valid?
|
|
229
|
+
logger.info("Invalid request: #{request.errors.inspect}")
|
|
230
|
+
return empty_response(errors: request.errors, invalid_request: true)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Hard — raise so the caller knows something is fundamentally wrong
|
|
234
|
+
unless request.valid?
|
|
235
|
+
raise "Invalid request: #{request.errors.inspect}"
|
|
236
|
+
end
|
|
237
|
+
```
|
|
97
238
|
|
|
98
239
|
## Development
|
|
99
240
|
|
|
@@ -103,7 +244,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
|
103
244
|
|
|
104
245
|
## Contributing
|
|
105
246
|
|
|
106
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
247
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/lenny/typed_model.
|
|
107
248
|
|
|
108
249
|
## License
|
|
109
250
|
|
|
@@ -7,7 +7,8 @@ module TypedModel
|
|
|
7
7
|
attr_reader :name, :spec, :model_validations, :mapping_key
|
|
8
8
|
|
|
9
9
|
def initialize(name:, type: nil, seq_of: nil, map_of: nil, validations: [], mapping_key: nil)
|
|
10
|
-
@name
|
|
10
|
+
@name = name
|
|
11
|
+
@mapping_key = mapping_key
|
|
11
12
|
@model_validations = []
|
|
12
13
|
spec_validations = []
|
|
13
14
|
validations.each do |v|
|
|
@@ -32,12 +33,9 @@ module TypedModel
|
|
|
32
33
|
value = attr_value(model)
|
|
33
34
|
spec.validate(value, model.errors, name)
|
|
34
35
|
model_validations.each do |v|
|
|
35
|
-
method_name = "assert_#{v}"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
else
|
|
39
|
-
raise "Unrecognized validation '#{v}'"
|
|
40
|
-
end
|
|
36
|
+
method_name = :"assert_#{v}"
|
|
37
|
+
raise "Unrecognized validation '#{v}'" unless model.respond_to?(method_name)
|
|
38
|
+
model.send(method_name, name)
|
|
41
39
|
end
|
|
42
40
|
end
|
|
43
41
|
|
data/lib/typed_model/errors.rb
CHANGED
|
@@ -16,12 +16,16 @@ module TypedModel
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def each_error
|
|
19
|
+
def each_error
|
|
20
20
|
@errors.each do |(k, msgs)|
|
|
21
|
-
msgs.each { |m|
|
|
21
|
+
msgs.each { |m| yield(k, m) }
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def to_h
|
|
26
|
+
@errors.to_h
|
|
27
|
+
end
|
|
28
|
+
|
|
25
29
|
def [](k)
|
|
26
30
|
@errors[k]
|
|
27
31
|
end
|
data/lib/typed_model/map_of.rb
CHANGED
|
@@ -8,10 +8,8 @@ module TypedModel
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def typecast_value(values)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
h[key_spec.typecast_value(k)] = value_spec.typecast_value(v)
|
|
14
|
-
end
|
|
11
|
+
values&.each_with_object({}) do |(k, v), h|
|
|
12
|
+
h[key_spec.typecast_value(k)] = value_spec.typecast_value(v)
|
|
15
13
|
end
|
|
16
14
|
end
|
|
17
15
|
|
|
@@ -22,11 +20,9 @@ module TypedModel
|
|
|
22
20
|
end
|
|
23
21
|
|
|
24
22
|
def validate(value, errors, key_prefix)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
value_spec.validate(v, errors, "#{key_prefix}/#{k}")
|
|
29
|
-
end
|
|
23
|
+
value&.each_pair do |k, v|
|
|
24
|
+
key_spec.validate(k, errors, "#{key_prefix}/keys/#{k}")
|
|
25
|
+
value_spec.validate(v, errors, "#{key_prefix}/#{k}")
|
|
30
26
|
end
|
|
31
27
|
end
|
|
32
28
|
end
|
|
@@ -32,12 +32,11 @@ module TypedModel
|
|
|
32
32
|
class << self
|
|
33
33
|
def attribute(name, opts = {})
|
|
34
34
|
@declared_attributes ||= {}
|
|
35
|
-
attribute_def = AttributeDefinition.new(opts
|
|
35
|
+
attribute_def = AttributeDefinition.new(**opts, name: name)
|
|
36
36
|
@declared_attributes[name.to_sym] = attribute_def
|
|
37
|
-
@declared_attributes[attribute_def.mapping_key.to_sym] = attribute_def
|
|
38
37
|
attr_reader name
|
|
39
|
-
define_method "#{name}=" do |v|
|
|
40
|
-
instance_variable_set("@#{name}", attribute_def.typecast_value(v))
|
|
38
|
+
define_method :"#{name}=" do |v|
|
|
39
|
+
instance_variable_set(:"@#{name}", attribute_def.typecast_value(v))
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
|
|
@@ -46,6 +45,13 @@ module TypedModel
|
|
|
46
45
|
attributes.merge!(c.instance_variable_get(:@declared_attributes) || {})
|
|
47
46
|
end
|
|
48
47
|
end
|
|
48
|
+
|
|
49
|
+
def declared_attributes_with_alternates
|
|
50
|
+
declared_attributes.each_with_object({}) do |(name, attr_def), h|
|
|
51
|
+
h[name] = attr_def
|
|
52
|
+
h[attr_def.mapping_key.to_sym] = attr_def
|
|
53
|
+
end
|
|
54
|
+
end
|
|
49
55
|
end
|
|
50
56
|
end
|
|
51
57
|
end
|
|
@@ -57,9 +63,10 @@ module TypedModel
|
|
|
57
63
|
def attributes=(values = {})
|
|
58
64
|
return if values.nil?
|
|
59
65
|
|
|
66
|
+
attrs_with_alternates = self.class.declared_attributes_with_alternates
|
|
60
67
|
values.each_pair do |k, v|
|
|
61
|
-
if (attr_def =
|
|
62
|
-
setter = "#{attr_def.name}="
|
|
68
|
+
if (attr_def = attrs_with_alternates[k.to_sym])
|
|
69
|
+
setter = :"#{attr_def.name}="
|
|
63
70
|
send(setter, v) if respond_to?(setter)
|
|
64
71
|
end
|
|
65
72
|
end
|
|
@@ -38,22 +38,21 @@ module TypedModel
|
|
|
38
38
|
|
|
39
39
|
def assert_not_blank(field)
|
|
40
40
|
v = send(field)
|
|
41
|
-
add_error(field, :required) unless v.to_s.match(/\S
|
|
41
|
+
add_error(field, :required) unless v.to_s.match?(/\S/)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def assert_timestamp(field)
|
|
45
45
|
v = send(field)
|
|
46
|
-
|
|
46
|
+
unless v.is_a?(Time)
|
|
47
47
|
s = v.to_s
|
|
48
|
-
if s.match(/\S/)
|
|
49
|
-
send("#{field}=", Time.parse(s))
|
|
50
|
-
end
|
|
48
|
+
send(:"#{field}=", Time.parse(s)) if s.match?(/\S/)
|
|
51
49
|
end
|
|
52
|
-
rescue
|
|
50
|
+
rescue StandardError
|
|
53
51
|
add_error(field, :invalid)
|
|
54
52
|
end
|
|
55
53
|
|
|
56
54
|
def valid?
|
|
55
|
+
@errors = Errors.new
|
|
57
56
|
self.class.before_validation_callbacks.each do |fname|
|
|
58
57
|
send(fname)
|
|
59
58
|
end
|
data/lib/typed_model/seq_of.rb
CHANGED
|
@@ -15,10 +15,8 @@ module TypedModel
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def validate(value, errors, key_prefix)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
spec.validate(v, errors, "#{key_prefix}/#{index}")
|
|
21
|
-
end
|
|
18
|
+
value&.each_with_index do |v, index|
|
|
19
|
+
spec.validate(v, errors, "#{key_prefix}/#{index}")
|
|
22
20
|
end
|
|
23
21
|
end
|
|
24
22
|
end
|
data/lib/typed_model/type_def.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'typed_model/seq_of'
|
|
2
4
|
require 'typed_model/map_of'
|
|
3
5
|
require 'typed_model/types'
|
|
@@ -11,26 +13,37 @@ module TypedModel
|
|
|
11
13
|
|
|
12
14
|
t, seq_of, map_of, validations = spec_map.values_at(:type, :seq_of, :map_of, :validations)
|
|
13
15
|
|
|
14
|
-
validators = (validations
|
|
15
|
-
Validator.build(v)
|
|
16
|
-
end
|
|
17
|
-
|
|
16
|
+
validators = build_validators(validations)
|
|
18
17
|
spec_opts = { type: t, validators: validators }
|
|
19
18
|
|
|
20
19
|
if seq_of
|
|
21
|
-
|
|
22
|
-
new(spec_opts.merge(type: SeqOf.new(seq_spec)))
|
|
20
|
+
build_seq_of(spec_opts, seq_of)
|
|
23
21
|
elsif map_of
|
|
24
|
-
|
|
25
|
-
if val_spec_opts.is_a?(Array)
|
|
26
|
-
val_spec_opts = { type: :map, map_of: val_spec_opts }
|
|
27
|
-
end
|
|
28
|
-
map_of = MapOf.new(build(key_spec_opts), build(val_spec_opts))
|
|
29
|
-
new(spec_opts.merge(type: map_of))
|
|
22
|
+
build_map_of(spec_opts, map_of)
|
|
30
23
|
else
|
|
31
24
|
new(spec_opts)
|
|
32
25
|
end
|
|
33
26
|
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_validators(validations)
|
|
31
|
+
(validations || []).map { |v| Validator.build(v) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_seq_of(spec_opts, seq_of)
|
|
35
|
+
seq_spec = build(seq_of)
|
|
36
|
+
new(spec_opts.merge(type: SeqOf.new(seq_spec)))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_map_of(spec_opts, map_of)
|
|
40
|
+
key_spec_opts, val_spec_opts = map_of
|
|
41
|
+
if val_spec_opts.is_a?(Array)
|
|
42
|
+
val_spec_opts = { type: :map, map_of: val_spec_opts }
|
|
43
|
+
end
|
|
44
|
+
map_of = MapOf.new(build(key_spec_opts), build(val_spec_opts))
|
|
45
|
+
new(spec_opts.merge(type: map_of))
|
|
46
|
+
end
|
|
34
47
|
end
|
|
35
48
|
|
|
36
49
|
attr_accessor :value_type, :validators
|
|
@@ -41,18 +54,16 @@ module TypedModel
|
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
def typecast_value(v)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
raise "unrecognized type '#{value_type.inspect}'"
|
|
53
|
-
end
|
|
57
|
+
return v unless value_type
|
|
58
|
+
|
|
59
|
+
if value_type.respond_to?(:typecast_value)
|
|
60
|
+
value_type.typecast_value(v)
|
|
61
|
+
elsif recognized_scalar_type?
|
|
62
|
+
Types.typecast(value_type, v)
|
|
63
|
+
elsif value_type.is_a?(Class)
|
|
64
|
+
instantiate_type(v)
|
|
54
65
|
else
|
|
55
|
-
|
|
66
|
+
raise "unrecognized type '#{value_type.inspect}'"
|
|
56
67
|
end
|
|
57
68
|
end
|
|
58
69
|
|
|
@@ -65,9 +76,7 @@ module TypedModel
|
|
|
65
76
|
value_type.validate(value, errors, key_prefix)
|
|
66
77
|
end
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
errors.merge!(value.errors, key_prefix)
|
|
70
|
-
end
|
|
79
|
+
merge_value_errors(value, errors, key_prefix)
|
|
71
80
|
end
|
|
72
81
|
|
|
73
82
|
def to_data(value)
|
|
@@ -82,8 +91,12 @@ module TypedModel
|
|
|
82
91
|
|
|
83
92
|
private
|
|
84
93
|
|
|
94
|
+
def recognized_scalar_type?
|
|
95
|
+
value_type.is_a?(Symbol) && Types.recognized?(value_type)
|
|
96
|
+
end
|
|
97
|
+
|
|
85
98
|
def execute_validators(errors, key_prefix, value)
|
|
86
|
-
validators.
|
|
99
|
+
validators.each do |v|
|
|
87
100
|
(v.validate(value) || []).each do |s|
|
|
88
101
|
errors.add(key_prefix, s)
|
|
89
102
|
end
|
|
@@ -91,14 +104,22 @@ module TypedModel
|
|
|
91
104
|
end
|
|
92
105
|
|
|
93
106
|
def validate_recognized_types(errors, key_prefix, value)
|
|
94
|
-
if
|
|
95
|
-
if value
|
|
107
|
+
if recognized_scalar_type?
|
|
108
|
+
if !value.nil? && Types.send(value_type, value).nil?
|
|
96
109
|
errors.add(key_prefix, :invalid)
|
|
97
110
|
end
|
|
98
111
|
end
|
|
99
112
|
end
|
|
100
113
|
|
|
114
|
+
def merge_value_errors(value, errors, key_prefix)
|
|
115
|
+
if value.respond_to?(:valid?) && value.respond_to?(:errors) && !value.valid?
|
|
116
|
+
errors.merge!(value.errors, key_prefix)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
101
120
|
def instantiate_type(v)
|
|
121
|
+
return nil if v.nil?
|
|
122
|
+
|
|
102
123
|
if v.is_a?(value_type)
|
|
103
124
|
v
|
|
104
125
|
else
|
|
@@ -108,5 +129,3 @@ module TypedModel
|
|
|
108
129
|
end
|
|
109
130
|
end
|
|
110
131
|
end
|
|
111
|
-
|
|
112
|
-
|
data/lib/typed_model/types.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
|
|
1
3
|
module TypedModel
|
|
2
4
|
|
|
3
5
|
# Supported Types/Type coercion
|
|
@@ -11,7 +13,7 @@ module TypedModel
|
|
|
11
13
|
#
|
|
12
14
|
#
|
|
13
15
|
class Types
|
|
14
|
-
PRIMITIVE_CLASSES = Set.new([String, TrueClass, FalseClass, Integer, Float
|
|
16
|
+
PRIMITIVE_CLASSES = Set.new([String, TrueClass, FalseClass, Integer, Float])
|
|
15
17
|
|
|
16
18
|
class << self
|
|
17
19
|
def recognized?(t)
|
|
@@ -19,7 +21,8 @@ module TypedModel
|
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def typecast(sym, value)
|
|
22
|
-
send(sym, value)
|
|
24
|
+
type_casted = send(sym, value)
|
|
25
|
+
type_casted.nil? ? value : type_casted
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def timestamp(v)
|
|
@@ -28,24 +31,22 @@ module TypedModel
|
|
|
28
31
|
else
|
|
29
32
|
Time.parse(v)
|
|
30
33
|
end
|
|
31
|
-
rescue
|
|
34
|
+
rescue StandardError
|
|
32
35
|
nil
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
def boolean(v)
|
|
36
39
|
case v
|
|
37
|
-
when
|
|
40
|
+
when 'true', true
|
|
38
41
|
true
|
|
39
|
-
when
|
|
42
|
+
when 'false', false
|
|
40
43
|
false
|
|
41
|
-
else
|
|
42
|
-
nil
|
|
43
44
|
end
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def integer(v)
|
|
47
48
|
Integer(v)
|
|
48
|
-
rescue
|
|
49
|
+
rescue StandardError
|
|
49
50
|
nil
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -4,24 +4,16 @@ module TypedModel
|
|
|
4
4
|
class Validator
|
|
5
5
|
class << self
|
|
6
6
|
def build(arg)
|
|
7
|
-
if arg.respond_to?(:validate) && arg.respond_to?(:name)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if Validators.recognized?(arg)
|
|
12
|
-
new_builtin_validator(arg)
|
|
13
|
-
else
|
|
14
|
-
raise "Unrecognized validation '#{arg}'"
|
|
15
|
-
end
|
|
16
|
-
else
|
|
17
|
-
raise "failed to create validator from '#{arg}'"
|
|
18
|
-
end
|
|
7
|
+
return arg if arg.respond_to?(:validate) && arg.respond_to?(:name)
|
|
8
|
+
raise "failed to create validator from '#{arg}'" unless arg.is_a?(Symbol)
|
|
9
|
+
raise "Unrecognized validation '#{arg}'" unless Validators.recognized?(arg)
|
|
10
|
+
new_builtin_validator(arg)
|
|
19
11
|
end
|
|
20
12
|
|
|
21
13
|
def from_primitive(t)
|
|
22
14
|
new(t) do |v|
|
|
23
15
|
errors = []
|
|
24
|
-
if v
|
|
16
|
+
if !v.nil? && (msg = send(:"assert_#{t}", v))
|
|
25
17
|
errors << msg
|
|
26
18
|
end
|
|
27
19
|
errors
|
|
@@ -5,47 +5,39 @@ module TypedModel
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def recognized?(sym)
|
|
8
|
-
respond_to?("assert_#{sym}")
|
|
8
|
+
respond_to?(:"assert_#{sym}")
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def validate(sym, value)
|
|
12
|
-
method_name = "assert_#{sym}"
|
|
13
|
-
unless respond_to?(method_name)
|
|
14
|
-
raise "Unrecognized validation '#{sym}'"
|
|
15
|
-
end
|
|
12
|
+
method_name = :"assert_#{sym}"
|
|
13
|
+
raise "Unrecognized validation '#{sym}'" unless respond_to?(method_name)
|
|
16
14
|
send(method_name, value)
|
|
17
15
|
end
|
|
18
16
|
|
|
19
17
|
def assert_timestamp(v)
|
|
20
|
-
if Types.timestamp(v).nil?
|
|
21
|
-
:invalid
|
|
22
|
-
end
|
|
18
|
+
:invalid if Types.timestamp(v).nil?
|
|
23
19
|
end
|
|
24
20
|
|
|
25
21
|
def assert_required(v)
|
|
26
|
-
if v.nil? || (v.respond_to?(:empty?) && v.empty?)
|
|
27
|
-
:required
|
|
28
|
-
end
|
|
22
|
+
:required if v.nil? || (v.respond_to?(:empty?) && v.empty?)
|
|
29
23
|
end
|
|
30
24
|
|
|
31
|
-
|
|
25
|
+
# Checks that value contains at least one non-whitespace character.
|
|
26
|
+
# Semantically different from assert_required which only checks empty/nil.
|
|
27
|
+
def assert_not_blank(v)
|
|
28
|
+
:required unless /\S/.match?(v.to_s)
|
|
29
|
+
end
|
|
32
30
|
|
|
33
31
|
def assert_not_nil(v)
|
|
34
|
-
if v.nil?
|
|
35
|
-
:required_not_nil
|
|
36
|
-
end
|
|
32
|
+
:required_not_nil if v.nil?
|
|
37
33
|
end
|
|
38
34
|
|
|
39
35
|
def assert_string(v)
|
|
40
|
-
if Types.string(v).nil?
|
|
41
|
-
:invalid
|
|
42
|
-
end
|
|
36
|
+
:invalid if Types.string(v).nil?
|
|
43
37
|
end
|
|
44
38
|
|
|
45
39
|
def assert_integer(v)
|
|
46
|
-
if Types.integer(v).nil?
|
|
47
|
-
:invalid
|
|
48
|
-
end
|
|
40
|
+
:invalid if Types.integer(v).nil?
|
|
49
41
|
end
|
|
50
42
|
end
|
|
51
43
|
end
|
data/lib/typed_model/version.rb
CHANGED
data/typed_model.gemspec
CHANGED
|
@@ -36,7 +36,7 @@ Gem::Specification.new do |spec|
|
|
|
36
36
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
37
37
|
spec.require_paths = ["lib"]
|
|
38
38
|
|
|
39
|
-
spec.add_development_dependency "bundler"
|
|
39
|
+
spec.add_development_dependency "bundler"
|
|
40
40
|
spec.add_development_dependency "rake", "~> 10.0"
|
|
41
41
|
spec.add_development_dependency "rspec", "~> 3.0"
|
|
42
42
|
end
|
metadata
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: typed_model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lenny Marks
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-04-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- - "
|
|
17
|
+
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '0'
|
|
20
20
|
type: :development
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
|
-
- - "
|
|
24
|
+
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '0'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: rake
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -62,7 +62,9 @@ extra_rdoc_files: []
|
|
|
62
62
|
files:
|
|
63
63
|
- ".gitignore"
|
|
64
64
|
- ".rspec"
|
|
65
|
+
- ".ruby-version"
|
|
65
66
|
- ".travis.yml"
|
|
67
|
+
- CHANGELOG.md
|
|
66
68
|
- Gemfile
|
|
67
69
|
- LICENSE
|
|
68
70
|
- LICENSE.txt
|
|
@@ -91,7 +93,7 @@ metadata:
|
|
|
91
93
|
homepage_uri: https://github.com/lenny/typed_model
|
|
92
94
|
source_code_uri: https://github.com/lenny/typed_model
|
|
93
95
|
changelog_uri: https://github.com/lenny/typed_model/blob/master/CHANGELOG.md
|
|
94
|
-
post_install_message:
|
|
96
|
+
post_install_message:
|
|
95
97
|
rdoc_options: []
|
|
96
98
|
require_paths:
|
|
97
99
|
- lib
|
|
@@ -106,9 +108,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
106
108
|
- !ruby/object:Gem::Version
|
|
107
109
|
version: '0'
|
|
108
110
|
requirements: []
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
signing_key:
|
|
111
|
+
rubygems_version: 3.3.26
|
|
112
|
+
signing_key:
|
|
112
113
|
specification_version: 4
|
|
113
114
|
summary: A Ruby library for defining data schemas via classes with typed fields and
|
|
114
115
|
built in hydration and serialization
|