schema-model 0.7.4 → 0.9.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/CHANGELOG.md +23 -0
- data/README.md +185 -10
- data/lib/schema/active_model_parsing_errors.rb +31 -0
- data/lib/schema/active_model_validations.rb +13 -16
- data/lib/schema/attribute_values.rb +41 -0
- data/lib/schema/deep_copy.rb +33 -0
- data/lib/schema/errors.rb +4 -0
- data/lib/schema/model.rb +19 -0
- data/lib/schema/parsers/american.rb +4 -12
- data/lib/schema/parsers/array.rb +1 -0
- data/lib/schema/parsers/common.rb +17 -26
- data/lib/schema/parsers/string_value.rb +23 -0
- data/lib/schema/parsing_status.rb +20 -0
- data/lib/schema/utils.rb +27 -0
- data/lib/schema/version.rb +1 -1
- data/lib/schema-model.rb +8 -0
- data/lib/schema_validator.rb +9 -8
- metadata +9 -17
- data/.github/dependabot.yml +0 -18
- data/.github/workflows/ci.yml +0 -93
- data/.github/workflows/release.yml +0 -93
- data/.gitignore +0 -51
- data/.release-please-manifest.json +0 -3
- data/.rubocop.yml +0 -68
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/ARCHITECTURE.md +0 -161
- data/CLAUDE.md +0 -87
- data/Gemfile +0 -16
- data/Gemfile.lock +0 -134
- data/release-please-config.json +0 -12
- data/schema-model.gemspec +0 -24
- data/script/console +0 -7
- data/script/coverage_badge.rb +0 -49
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bd3614644a664aa647cc01d0ae8032a4f08d3523f0377b9e92f2c34597228815
|
|
4
|
+
data.tar.gz: 2f6c67150e5473230f8660cc21d7a7f26a2351dbc18f05b5754578c33cf521a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 28674549ae35fc72fa413a59aa87a20d72ed752ee56688d657bb61911a8d032308b3fc44908bd4c2702f7b86c261519801bf86c7751cd3b40c9364fdfce32bb4
|
|
7
|
+
data.tar.gz: 5da6fcb9b0a79b21f926340d16486e4a5f8774725c1ca9cf3a58e3bbf996cee2856df0f84ad74acd4345dac1b999c1dcf09e41cfacf5f158819f13e78e432084
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0](https://github.com/dougyouch/schema/compare/v0.8.0...v0.9.0) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **model:** add set_attribute_values, parsed_and_valid? and full_error_messages ([0c8b922](https://github.com/dougyouch/schema/commit/0c8b922cb4417720b70d069a8c51fb80b3b1c644))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **validator:** fail schema validation on nested parsing errors ([b17704a](https://github.com/dougyouch/schema/commit/b17704af2629d3a4a81ee69f748e216a8c5d7488))
|
|
14
|
+
|
|
15
|
+
## [0.8.0](https://github.com/dougyouch/schema/compare/v0.7.4...v0.8.0) (2026-10-04)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### ⚠ BREAKING CHANGES
|
|
19
|
+
|
|
20
|
+
* **model:** unrecognized boolean strings, and dates that aren't ISO 8601, are now invalid instead of false or a guessed date; blank strings parse to nil instead of being invalid (integer, float, date, time) or false (boolean); with ActiveModel, parsing_errors messages are readable text such as "is invalid" instead of codes such as "invalid"; capture_unknown_attributes= now changes nested association classes; and models compare by value with ==.
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
* **model:** stricter parsing, readable parsing errors and model helpers ([9267d77](https://github.com/dougyouch/schema/commit/9267d770f79b260bae2b3890f31f583e1684bdbe))
|
|
25
|
+
|
|
3
26
|
## [0.7.4](https://github.com/dougyouch/schema/compare/v0.7.3...v0.7.4) (2026-10-04)
|
|
4
27
|
|
|
5
28
|
|
data/README.md
CHANGED
|
@@ -8,7 +8,7 @@ A powerful Ruby gem for data transformation, validation, and type safety. Schema
|
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
|
-
Add this line to your application's Gemfile:
|
|
11
|
+
Requires Ruby 3.2 or newer. Add this line to your application's Gemfile:
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
14
|
gem 'schema-model'
|
|
@@ -59,23 +59,149 @@ user.active # => true (parsed from "yes")
|
|
|
59
59
|
user.tags # => ["ruby", "rails", "developer"]
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Use Cases
|
|
63
|
+
|
|
64
|
+
### Typed JSON columns on ActiveRecord models
|
|
65
|
+
|
|
66
|
+
Wrap a JSON/JSONB column in a schema so the record works with typed values, rejects bad data, and writes back clean JSON:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class ConfigSchema
|
|
70
|
+
include Schema::All
|
|
71
|
+
|
|
72
|
+
attribute :api_key, :string
|
|
73
|
+
attribute :timeout, :integer, default: 30
|
|
74
|
+
attribute :enabled, :boolean
|
|
75
|
+
|
|
76
|
+
has_many :endpoints do
|
|
77
|
+
attribute :url, :string
|
|
78
|
+
validates :url, presence: true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
validates :api_key, presence: true
|
|
82
|
+
validates :endpoints, schema: true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class Integration < ActiveRecord::Base
|
|
86
|
+
validate :validate_config, if: :config_updated?
|
|
87
|
+
before_save :update_data_from_config, if: :config_updated?
|
|
88
|
+
|
|
89
|
+
def config=(config_data)
|
|
90
|
+
@config = ConfigSchema.from_hash(config_data)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def config
|
|
94
|
+
@config ||= ConfigSchema.from_hash(data)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# reading config counts as an update, since the caller may change it in place
|
|
98
|
+
def config_updated?
|
|
99
|
+
defined?(@config)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def update_data_from_config
|
|
105
|
+
self.data = config.as_json
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# parsing errors (e.g. timeout: "abc") don't make valid? false, so check both
|
|
109
|
+
def validate_config
|
|
110
|
+
return if config.parsed_and_valid?
|
|
111
|
+
|
|
112
|
+
config.full_error_messages.each { |message| errors.add(:config, message) }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
integration.config = { 'api_key' => 'abc', 'timeout' => '60', 'endpoints' => [{ 'url' => 'https://example.com' }] }
|
|
117
|
+
integration.config.timeout # => 60
|
|
118
|
+
integration.save # writes data as { api_key: "abc", timeout: 60, endpoints: [{ url: "https://example.com" }] }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Use `from_hash`, not `new`, to build a schema from data. `from_hash(nil)` returns an empty model, so a record whose column is still `NULL` works too.
|
|
122
|
+
|
|
123
|
+
If a single "Config is invalid" error is enough, `validates :config, schema: true, if: :config_updated?` replaces `validate_config`; it fails on parsing errors as well as validation errors.
|
|
124
|
+
|
|
125
|
+
### Validating API input in controllers
|
|
126
|
+
|
|
127
|
+
Describe the JSON an endpoint accepts as a schema. Values arrive converted to the right types, unknown keys are reported, and the schema acts as the allowlist, so strong parameters aren't needed:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class CreateUserSchema
|
|
131
|
+
include Schema::All
|
|
132
|
+
|
|
133
|
+
attribute :name, :string
|
|
134
|
+
attribute :email, :string
|
|
135
|
+
attribute :age, :integer
|
|
136
|
+
|
|
137
|
+
validates :name, :email, presence: true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class UsersController < ApplicationController
|
|
141
|
+
def create
|
|
142
|
+
input = CreateUserSchema.from_hash(JSON.parse(request.raw_post))
|
|
143
|
+
unless input.parsed_and_valid?
|
|
144
|
+
return render json: { errors: input.full_error_messages }, status: :unprocessable_entity
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
user = User.create!(input.as_json)
|
|
148
|
+
render json: user, status: :created
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`parsed_and_valid?` runs the validations even when there are parsing errors, so `full_error_messages` reports everything wrong with the input at once, e.g. `["Age is invalid", "Admin is an unknown attribute", "Email can't be blank"]`.
|
|
154
|
+
|
|
155
|
+
The body is parsed from `request.raw_post` rather than taken from `params`, because Rails adds keys to `params` (`controller`, `action`, and the wrapped parameters key) that the schema would report as unknown attributes.
|
|
156
|
+
|
|
157
|
+
### PATCH endpoints
|
|
158
|
+
|
|
159
|
+
`set_attribute_values` returns only the fields that were present in the input, including ones sent as `null`. That separates "not sent" from "sent as null", which is what a PATCH route needs:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
class UpdateUserSchema
|
|
163
|
+
include Schema::All
|
|
164
|
+
|
|
165
|
+
attribute :name, :string
|
|
166
|
+
attribute :email, :string
|
|
167
|
+
attribute :age, :integer
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def update
|
|
171
|
+
input = UpdateUserSchema.from_hash(JSON.parse(request.raw_post))
|
|
172
|
+
unless input.parsed_and_valid?
|
|
173
|
+
return render json: { errors: input.full_error_messages }, status: :unprocessable_entity
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# body {"name": "Joe", "age": null} => { name: "Joe", age: nil }: updates name, clears age, leaves email alone
|
|
177
|
+
user.update!(input.set_attribute_values)
|
|
178
|
+
render json: user
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Associations in `set_attribute_values` are schema models; call `as_json` on them if the record expects hashes. For a single attribute, `input.name_was_set?` answers the same question.
|
|
183
|
+
|
|
62
184
|
## Data Types
|
|
63
185
|
|
|
64
186
|
### Basic Types
|
|
65
187
|
|
|
66
188
|
```ruby
|
|
67
189
|
attribute :name, :string # String values
|
|
68
|
-
attribute :count, :integer # Integer values (parses "123" to 123)
|
|
69
|
-
attribute :price, :float # Float values (parses "9.99" to 9.99)
|
|
70
|
-
attribute :active, :boolean #
|
|
190
|
+
attribute :count, :integer # Integer values (parses "123", " 123 " and "0123" to 123)
|
|
191
|
+
attribute :price, :float # Float values (parses "9.99" to 9.99, also "1e+5")
|
|
192
|
+
attribute :active, :boolean # see below
|
|
71
193
|
attribute :notes, :string_or_nil # String, but returns nil if empty
|
|
72
194
|
```
|
|
73
195
|
|
|
196
|
+
Booleans accept `1, t, true, on, y, yes` as `true` and `0, f, false, off, n, no` as `false` (any case). Any other string is an `invalid` parsing error, and numbers are `true` unless they're 0.
|
|
197
|
+
|
|
198
|
+
For `:integer`, `:float`, `:boolean`, `:date`, `:time`, `:american_date` and `:american_time`, strings are stripped first and a blank string parses to `nil` without an error, so empty CSV cells don't count as bad input.
|
|
199
|
+
|
|
74
200
|
### Date and Time Types
|
|
75
201
|
|
|
76
202
|
```ruby
|
|
77
|
-
attribute :created_at, :time # ISO 8601
|
|
78
|
-
attribute :birth_date, :date # Date.
|
|
203
|
+
attribute :created_at, :time # ISO 8601 date and time (Time.xmlschema)
|
|
204
|
+
attribute :birth_date, :date # ISO 8601 (Date.iso8601), e.g. "2024-01-31"; free text like "May 1" is invalid
|
|
79
205
|
attribute :us_date, :american_date # MM/DD/YYYY format
|
|
80
206
|
attribute :us_time, :american_time # MM/DD/YYYY HH:MM:SS format
|
|
81
207
|
```
|
|
@@ -90,6 +216,8 @@ attribute :metadata, :hash # Hash/dictionary values
|
|
|
90
216
|
attribute :config, :json # Parse JSON strings
|
|
91
217
|
```
|
|
92
218
|
|
|
219
|
+
Each type `:foo` is parsed by a `parse_foo` method, so you can add types by defining that method on the model or in a module you `schema_include`. Setting a value on an attribute whose type has no parser raises `Schema::UnknownTypeError` naming the attribute and the missing method.
|
|
220
|
+
|
|
93
221
|
## Attribute Options
|
|
94
222
|
|
|
95
223
|
### Aliases
|
|
@@ -293,7 +421,7 @@ end
|
|
|
293
421
|
|
|
294
422
|
### Parsing Errors vs Validation Errors
|
|
295
423
|
|
|
296
|
-
Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both).
|
|
424
|
+
Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both). `parsed?` and `parsed!` are available on every `Schema::Model`, not only with ActiveModel.
|
|
297
425
|
|
|
298
426
|
```ruby
|
|
299
427
|
user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
|
|
@@ -308,9 +436,36 @@ user.valid? # => true (age is nil, which allow_nil permits)
|
|
|
308
436
|
user.errors.full_messages # => []
|
|
309
437
|
```
|
|
310
438
|
|
|
439
|
+
With `Schema::All` (or `Schema::ActiveModelValidations`), `parsing_errors` is an `ActiveModel::Errors` with readable messages:
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
user.parsing_errors.full_messages # => ["Age is invalid"]
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
| Code | Message | I18n key |
|
|
446
|
+
|---|---|---|
|
|
447
|
+
| `invalid` | is invalid | `schema.parsing_errors.invalid` |
|
|
448
|
+
| `incompatible` | is an incompatible type | `schema.parsing_errors.incompatible` |
|
|
449
|
+
| `unknown` | has an unknown type | `schema.parsing_errors.unknown` |
|
|
450
|
+
| `unknown_attribute` | is an unknown attribute | `schema.parsing_errors.unknown_attribute` |
|
|
451
|
+
| `unhandled_type` | is an unhandled type | `schema.parsing_errors.unhandled_type` |
|
|
452
|
+
|
|
453
|
+
A plain `Schema::Model` uses `Schema::Errors`, which stores the codes themselves (`user.parsing_errors[:age] # => ["invalid"]`).
|
|
454
|
+
|
|
455
|
+
### Checking Everything at Once
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
user = UserSchema.from_hash(name: 'John', age: 'not-a-number', email: nil)
|
|
459
|
+
|
|
460
|
+
user.parsed_and_valid? # => false; runs validations even though parsing failed
|
|
461
|
+
user.full_error_messages # => ["Age is invalid", "Email can't be blank"] (parsing messages, then validation messages)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
`full_error_messages` reports the last validation run, so call `parsed_and_valid?` (or `valid?`) first.
|
|
465
|
+
|
|
311
466
|
### Validating Nested Schemas
|
|
312
467
|
|
|
313
|
-
`validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list,
|
|
468
|
+
`validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, has parsing errors or fails its validations:
|
|
314
469
|
|
|
315
470
|
```ruby
|
|
316
471
|
class OrderSchema
|
|
@@ -349,13 +504,13 @@ By default, unknown attributes are captured as parsing errors:
|
|
|
349
504
|
|
|
350
505
|
```ruby
|
|
351
506
|
user = UserSchema.from_hash(name: 'John', unknown_field: 'value')
|
|
352
|
-
user.parsing_errors[:unknown_field] # => ["
|
|
507
|
+
user.parsing_errors[:unknown_field] # => ["is an unknown attribute"]
|
|
353
508
|
|
|
354
509
|
# Disable this behavior
|
|
355
510
|
UserSchema.capture_unknown_attributes = false
|
|
356
511
|
```
|
|
357
512
|
|
|
358
|
-
The setting
|
|
513
|
+
The setting also applies to nested association classes, including ones declared after it is set.
|
|
359
514
|
|
|
360
515
|
## Serialization
|
|
361
516
|
|
|
@@ -380,6 +535,24 @@ user.as_json(select_filter: ->(name, value, opts) { name == :name })
|
|
|
380
535
|
user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
|
|
381
536
|
```
|
|
382
537
|
|
|
538
|
+
### Comparing, Inspecting and Copying
|
|
539
|
+
|
|
540
|
+
```ruby
|
|
541
|
+
a = ContactSchema.from_hash(name: 'John')
|
|
542
|
+
b = ContactSchema.from_hash('name' => 'John')
|
|
543
|
+
|
|
544
|
+
a == b # => true (same class and attribute values, nested models included)
|
|
545
|
+
a.attribute_values # => { name: "John", email: nil }
|
|
546
|
+
a.set_attribute_values # => { name: "John" } (only fields present in the input)
|
|
547
|
+
a.inspect # => #<ContactSchema name: "John"> (only attributes that were set)
|
|
548
|
+
|
|
549
|
+
copy = a.deep_dup # copies nested models, arrays, hashes, strings and parsing errors
|
|
550
|
+
copy.name << '!'
|
|
551
|
+
a.name # => "John"
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
`dup` is Ruby's shallow copy, so nested models are shared with the original.
|
|
555
|
+
|
|
383
556
|
## Protecting Fields with skip_fields
|
|
384
557
|
|
|
385
558
|
Prevent certain fields from being set by user input:
|
|
@@ -446,6 +619,8 @@ end
|
|
|
446
619
|
UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
|
|
447
620
|
```
|
|
448
621
|
|
|
622
|
+
`to_a` writes at most `size` entries: a model with more entries than that has the extra ones left out of the array, with no error, so pick a `size` large enough for your data.
|
|
623
|
+
|
|
449
624
|
### Schema::CSVParser
|
|
450
625
|
|
|
451
626
|
`Schema::CSVParser` is a class that reads rows from a `CSV` object into models. The model needs `Schema::ArrayHeaders` and `Schema::Arrays`. Pass a plain `CSV` (not one created with `headers: true`); the first row is used as the headers unless you pass them in:
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
|
|
5
|
+
module Schema
|
|
6
|
+
# Schema::ActiveModelParsingErrors turns parsing error codes into readable messages,
|
|
7
|
+
# e.g. "Age is invalid" instead of "Age invalid". Override them with I18n keys under schema.parsing_errors.
|
|
8
|
+
class ActiveModelParsingErrors < ActiveModel::Errors
|
|
9
|
+
MESSAGES = {
|
|
10
|
+
::Schema::ParsingErrors::INVALID => 'is invalid',
|
|
11
|
+
::Schema::ParsingErrors::INCOMPATIBLE => 'is an incompatible type',
|
|
12
|
+
::Schema::ParsingErrors::UNKNOWN => 'has an unknown type',
|
|
13
|
+
::Schema::ParsingErrors::UNKNOWN_ATTRIBUTE => 'is an unknown attribute',
|
|
14
|
+
::Schema::ParsingErrors::UNHANDLED_TYPE => 'is an unhandled type'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# parsing error keys (e.g. "items:0" or an unknown key) often aren't attributes on the model,
|
|
18
|
+
# so codes are added as message strings rather than ActiveModel error types
|
|
19
|
+
def add(attribute, type = :invalid, **)
|
|
20
|
+
super(attribute, message_for(type), **)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def message_for(type)
|
|
26
|
+
return type unless MESSAGES.key?(type)
|
|
27
|
+
|
|
28
|
+
I18n.t("schema.parsing_errors.#{type}", default: MESSAGES[type])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'active_model'
|
|
4
4
|
|
|
5
5
|
module Schema
|
|
6
|
-
# Schema::
|
|
6
|
+
# Schema::ActiveModelValidations adds ActiveModel validations, and readable ActiveModel::Errors for parsing_errors
|
|
7
7
|
module ActiveModelValidations
|
|
8
8
|
def self.included(base)
|
|
9
9
|
base.schema_include ::ActiveModel::Validations
|
|
@@ -25,24 +25,21 @@ module Schema
|
|
|
25
25
|
valid_model!
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# runs validations even when there are parsing errors, so full_error_messages has both
|
|
29
|
+
def parsed_and_valid?
|
|
30
|
+
valid = valid?
|
|
31
|
+
valid && parsed?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# parsing error messages followed by the messages from the last validation run
|
|
35
|
+
def full_error_messages
|
|
36
|
+
parsing_errors.full_messages + errors.full_messages
|
|
37
|
+
end
|
|
38
|
+
|
|
28
39
|
# no-doc
|
|
29
40
|
module OverrideParsingErrors
|
|
30
41
|
def parsing_errors
|
|
31
|
-
@parsing_errors ||=
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def parsed?
|
|
35
|
-
parsing_errors.empty?
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def parsed!
|
|
39
|
-
return if parsed?
|
|
40
|
-
|
|
41
|
-
raise ParsingException.new(
|
|
42
|
-
"schema parsing failed for attributes #{parsing_errors.errors.map(&:attribute).join(', ')}",
|
|
43
|
-
self,
|
|
44
|
-
parsing_errors
|
|
45
|
-
)
|
|
42
|
+
@parsing_errors ||= ActiveModelParsingErrors.new(self)
|
|
46
43
|
end
|
|
47
44
|
end
|
|
48
45
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schema
|
|
4
|
+
# Schema::AttributeValues compares and inspects models by their attribute values
|
|
5
|
+
module AttributeValues
|
|
6
|
+
# every attribute (aliases excluded) and its current value, defaults included
|
|
7
|
+
def attribute_values
|
|
8
|
+
self.class.schema.each_with_object({}) do |(field_name, field_options), values|
|
|
9
|
+
next if field_options[:alias_of]
|
|
10
|
+
|
|
11
|
+
values[field_name] = public_send(field_options[:getter])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# only the attributes and associations present in the input (including ones set to nil),
|
|
16
|
+
# e.g. the fields a PATCH request sent
|
|
17
|
+
def set_attribute_values
|
|
18
|
+
set_field_options.to_h { |field_options| [field_options[:name], public_send(field_options[:getter])] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ==(other)
|
|
22
|
+
other.instance_of?(self.class) && other.attribute_values == attribute_values
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# lists the attributes that were set, e.g. #<UserSchema name: "Joe", age: 30>
|
|
26
|
+
def inspect
|
|
27
|
+
details = set_field_options.map do |field_options|
|
|
28
|
+
"#{field_options[:name]}: #{public_send(field_options[:getter]).inspect}"
|
|
29
|
+
end
|
|
30
|
+
"#<#{self.class.name || self.class}#{" #{details.join(', ')}" unless details.empty?}>"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def set_field_options
|
|
36
|
+
self.class.schema.each_value.select do |field_options|
|
|
37
|
+
!field_options[:alias_of] && instance_variable_defined?(field_options[:instance_variable])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schema
|
|
4
|
+
# Schema::DeepCopy copies a model along with its nested models, collections and parsing errors
|
|
5
|
+
module DeepCopy
|
|
6
|
+
def deep_dup
|
|
7
|
+
copy = dup
|
|
8
|
+
copy.copy_attribute_values_from(self)
|
|
9
|
+
copy.copy_parsing_errors_from(self)
|
|
10
|
+
copy
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def copy_attribute_values_from(source)
|
|
16
|
+
self.class.schema.each_value do |field_options|
|
|
17
|
+
next if field_options[:alias_of]
|
|
18
|
+
|
|
19
|
+
ivar = field_options[:instance_variable]
|
|
20
|
+
next unless source.instance_variable_defined?(ivar)
|
|
21
|
+
|
|
22
|
+
instance_variable_set(ivar, ::Schema::Utils.deep_dup_value(source.instance_variable_get(ivar)))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def copy_parsing_errors_from(source)
|
|
27
|
+
@parsing_errors = nil
|
|
28
|
+
::Schema::Utils.parsing_error_pairs(source.parsing_errors).each do |attribute, message|
|
|
29
|
+
parsing_errors.add(attribute, message)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/schema/errors.rb
CHANGED
data/lib/schema/model.rb
CHANGED
|
@@ -8,6 +8,9 @@ module Schema
|
|
|
8
8
|
def self.included(base)
|
|
9
9
|
base.extend InheritanceHelper::Methods
|
|
10
10
|
base.send(:include, Schema::Parsers::Common)
|
|
11
|
+
base.send(:include, Schema::ParsingStatus)
|
|
12
|
+
base.send(:include, Schema::AttributeValues)
|
|
13
|
+
base.send(:include, Schema::DeepCopy)
|
|
11
14
|
base.extend ClassMethods
|
|
12
15
|
end
|
|
13
16
|
|
|
@@ -45,10 +48,17 @@ module Schema
|
|
|
45
48
|
}.freeze
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
# applies to nested association classes too, like schema_include
|
|
48
52
|
def capture_unknown_attributes=(v)
|
|
49
53
|
config = schema_config.dup
|
|
50
54
|
config[:capture_unknown_attributes] = v
|
|
51
55
|
redefine_class_method(:schema_config, config.freeze)
|
|
56
|
+
|
|
57
|
+
schema.each_value do |field_options|
|
|
58
|
+
next unless field_options[:association]
|
|
59
|
+
|
|
60
|
+
const_get(field_options[:class_name]).capture_unknown_attributes = v
|
|
61
|
+
end
|
|
52
62
|
end
|
|
53
63
|
|
|
54
64
|
def capture_unknown_attributes?
|
|
@@ -87,6 +97,7 @@ module Schema
|
|
|
87
97
|
end
|
|
88
98
|
|
|
89
99
|
def add_attribute_methods(name, options)
|
|
100
|
+
parser_check = parser_check_code(name, options)
|
|
90
101
|
class_eval(
|
|
91
102
|
<<-STR, __FILE__, __LINE__ + 1
|
|
92
103
|
def #{options[:getter]}
|
|
@@ -94,6 +105,7 @@ module Schema
|
|
|
94
105
|
end
|
|
95
106
|
|
|
96
107
|
def #{options[:setter]}(v)
|
|
108
|
+
#{parser_check}
|
|
97
109
|
#{options[:instance_variable]} = #{options[:parser]}(#{name.inspect}, parsing_errors, v)
|
|
98
110
|
end
|
|
99
111
|
|
|
@@ -104,6 +116,13 @@ module Schema
|
|
|
104
116
|
)
|
|
105
117
|
end
|
|
106
118
|
|
|
119
|
+
# a parser that isn't defined yet may come from a module included later, so it's checked when a value is set
|
|
120
|
+
def parser_check_code(name, options)
|
|
121
|
+
return if method_defined?(options[:parser])
|
|
122
|
+
|
|
123
|
+
"::Schema::Utils.check_parser!(self, #{options[:parser].inspect}, #{name.inspect}, #{options[:type].inspect})"
|
|
124
|
+
end
|
|
125
|
+
|
|
107
126
|
def add_aliases(name, options)
|
|
108
127
|
return unless options[:aliases]
|
|
109
128
|
|
|
@@ -6,6 +6,8 @@ module Schema
|
|
|
6
6
|
module Parsers
|
|
7
7
|
# Schema::Parsers::American parses dates and times in American format
|
|
8
8
|
module American
|
|
9
|
+
include StringValue
|
|
10
|
+
|
|
9
11
|
DATE_FORMAT = '%m/%d/%Y'
|
|
10
12
|
TIME_FORMAT = '%m/%d/%Y %H:%M:%S'
|
|
11
13
|
|
|
@@ -16,12 +18,7 @@ module Schema
|
|
|
16
18
|
when Time
|
|
17
19
|
value.to_date
|
|
18
20
|
when String
|
|
19
|
-
|
|
20
|
-
Date.strptime(value, DATE_FORMAT)
|
|
21
|
-
rescue ArgumentError
|
|
22
|
-
parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
|
|
23
|
-
nil
|
|
24
|
-
end
|
|
21
|
+
parse_string_value(field_name, parsing_errors, value) { |str| Date.strptime(str, DATE_FORMAT) }
|
|
25
22
|
when nil
|
|
26
23
|
nil
|
|
27
24
|
else
|
|
@@ -37,12 +34,7 @@ module Schema
|
|
|
37
34
|
when Date
|
|
38
35
|
value.to_time
|
|
39
36
|
when String
|
|
40
|
-
|
|
41
|
-
Time.strptime(value, TIME_FORMAT)
|
|
42
|
-
rescue ArgumentError
|
|
43
|
-
parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
|
|
44
|
-
nil
|
|
45
|
-
end
|
|
37
|
+
parse_string_value(field_name, parsing_errors, value) { |str| Time.strptime(str, TIME_FORMAT) }
|
|
46
38
|
when nil
|
|
47
39
|
nil
|
|
48
40
|
else
|
data/lib/schema/parsers/array.rb
CHANGED
|
@@ -35,6 +35,7 @@ module Schema
|
|
|
35
35
|
return data unless (data_type = schema_options[:data_type])
|
|
36
36
|
|
|
37
37
|
parser_method = "parse_#{data_type}"
|
|
38
|
+
::Schema::Utils.check_parser!(model, parser_method, field_name, data_type)
|
|
38
39
|
data.each_with_index.map do |datum, idx|
|
|
39
40
|
model.send(parser_method, "#{field_name}:#{idx}", parsing_errors, datum)
|
|
40
41
|
end
|
|
@@ -6,20 +6,24 @@ module Schema
|
|
|
6
6
|
module Parsers
|
|
7
7
|
# Schema::Parsers::Common are parser methods for basic types
|
|
8
8
|
module Common
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
include StringValue
|
|
10
|
+
|
|
11
|
+
# strings are stripped first; a blank string parses to nil without an error
|
|
12
|
+
INTEGER_REGEX = /\A[-+]?\d+(?:\.0+)?\z/
|
|
13
|
+
FLOAT_REGEX = /\A[-+]?\d+(?:\.\d+)?([Ee][-+]?\d+)?\z/
|
|
14
|
+
# matched case-insensitively; any other string is invalid
|
|
15
|
+
BOOLEAN_STRINGS = {
|
|
16
|
+
'1' => true, 't' => true, 'true' => true, 'on' => true, 'y' => true, 'yes' => true,
|
|
17
|
+
'0' => false, 'f' => false, 'false' => false, 'off' => false, 'n' => false, 'no' => false
|
|
18
|
+
}.freeze
|
|
12
19
|
|
|
13
20
|
def parse_integer(field_name, parsing_errors, value)
|
|
14
21
|
case value
|
|
15
22
|
when Integer
|
|
16
23
|
value
|
|
17
24
|
when String
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
else
|
|
21
|
-
parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
|
|
22
|
-
nil
|
|
25
|
+
parse_string_value(field_name, parsing_errors, value) do |str|
|
|
26
|
+
str.to_i if INTEGER_REGEX.match?(str)
|
|
23
27
|
end
|
|
24
28
|
when Float
|
|
25
29
|
parse_float_as_integer(field_name, parsing_errors, value)
|
|
@@ -77,11 +81,8 @@ module Schema
|
|
|
77
81
|
when Integer
|
|
78
82
|
value.to_f
|
|
79
83
|
when String
|
|
80
|
-
|
|
81
|
-
Float(
|
|
82
|
-
else
|
|
83
|
-
parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
|
|
84
|
-
nil
|
|
84
|
+
parse_string_value(field_name, parsing_errors, value) do |str|
|
|
85
|
+
Float(str) if FLOAT_REGEX.match?(str)
|
|
85
86
|
end
|
|
86
87
|
when nil
|
|
87
88
|
nil
|
|
@@ -98,12 +99,7 @@ module Schema
|
|
|
98
99
|
when Date
|
|
99
100
|
value.to_time
|
|
100
101
|
when String
|
|
101
|
-
|
|
102
|
-
Time.xmlschema(value)
|
|
103
|
-
rescue ArgumentError
|
|
104
|
-
parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
|
|
105
|
-
nil
|
|
106
|
-
end
|
|
102
|
+
parse_string_value(field_name, parsing_errors, value) { |str| Time.xmlschema(str) }
|
|
107
103
|
when nil
|
|
108
104
|
nil
|
|
109
105
|
else
|
|
@@ -119,12 +115,7 @@ module Schema
|
|
|
119
115
|
when Time
|
|
120
116
|
value.to_date
|
|
121
117
|
when String
|
|
122
|
-
|
|
123
|
-
Date.parse(value)
|
|
124
|
-
rescue ArgumentError
|
|
125
|
-
parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
|
|
126
|
-
nil
|
|
127
|
-
end
|
|
118
|
+
parse_string_value(field_name, parsing_errors, value) { |str| Date.iso8601(str) }
|
|
128
119
|
when nil
|
|
129
120
|
nil
|
|
130
121
|
else
|
|
@@ -140,7 +131,7 @@ module Schema
|
|
|
140
131
|
when Integer, Float
|
|
141
132
|
value != 0
|
|
142
133
|
when String
|
|
143
|
-
|
|
134
|
+
parse_string_value(field_name, parsing_errors, value) { |str| BOOLEAN_STRINGS[str.downcase] }
|
|
144
135
|
when nil
|
|
145
136
|
nil
|
|
146
137
|
else
|