schema-model 0.7.4 → 0.8.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 +11 -0
- data/README.md +50 -9
- data/lib/schema/active_model_parsing_errors.rb +31 -0
- data/lib/schema/active_model_validations.rb +2 -16
- data/lib/schema/attribute_values.rb +35 -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
- 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: f672bab800b79f5e9a8b390268466caaf7a42822b0b0632f023f6539fbae48aa
|
|
4
|
+
data.tar.gz: 5486532778b3a246d66c0c2350586fd77ebf9955ef311a719c5053d656afe0be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca482dfd23e73cea044bf572e819864415ee68768cdb0b98a756e92fbb06c2d800b7cdd6c2afde4d5c77559d226a7e3cf681f4043a17b118c17ed24fee0cdb9c
|
|
7
|
+
data.tar.gz: f555174a32c7adbe3b713bb726038f325168f07820d6309479be0715af91bb5e684d73d51466a0f71cc806ecd7ca98f2006bf8820008c126ee4f25f220ffdcde
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/dougyouch/schema/compare/v0.7.4...v0.8.0) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* **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 ==.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **model:** stricter parsing, readable parsing errors and model helpers ([9267d77](https://github.com/dougyouch/schema/commit/9267d770f79b260bae2b3890f31f583e1684bdbe))
|
|
13
|
+
|
|
3
14
|
## [0.7.4](https://github.com/dougyouch/schema/compare/v0.7.3...v0.7.4) (2026-10-04)
|
|
4
15
|
|
|
5
16
|
|
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'
|
|
@@ -65,17 +65,21 @@ user.tags # => ["ruby", "rails", "developer"]
|
|
|
65
65
|
|
|
66
66
|
```ruby
|
|
67
67
|
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 #
|
|
68
|
+
attribute :count, :integer # Integer values (parses "123", " 123 " and "0123" to 123)
|
|
69
|
+
attribute :price, :float # Float values (parses "9.99" to 9.99, also "1e+5")
|
|
70
|
+
attribute :active, :boolean # see below
|
|
71
71
|
attribute :notes, :string_or_nil # String, but returns nil if empty
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
74
78
|
### Date and Time Types
|
|
75
79
|
|
|
76
80
|
```ruby
|
|
77
|
-
attribute :created_at, :time # ISO 8601
|
|
78
|
-
attribute :birth_date, :date # Date.
|
|
81
|
+
attribute :created_at, :time # ISO 8601 date and time (Time.xmlschema)
|
|
82
|
+
attribute :birth_date, :date # ISO 8601 (Date.iso8601), e.g. "2024-01-31"; free text like "May 1" is invalid
|
|
79
83
|
attribute :us_date, :american_date # MM/DD/YYYY format
|
|
80
84
|
attribute :us_time, :american_time # MM/DD/YYYY HH:MM:SS format
|
|
81
85
|
```
|
|
@@ -90,6 +94,8 @@ attribute :metadata, :hash # Hash/dictionary values
|
|
|
90
94
|
attribute :config, :json # Parse JSON strings
|
|
91
95
|
```
|
|
92
96
|
|
|
97
|
+
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.
|
|
98
|
+
|
|
93
99
|
## Attribute Options
|
|
94
100
|
|
|
95
101
|
### Aliases
|
|
@@ -293,7 +299,7 @@ end
|
|
|
293
299
|
|
|
294
300
|
### Parsing Errors vs Validation Errors
|
|
295
301
|
|
|
296
|
-
Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both).
|
|
302
|
+
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
303
|
|
|
298
304
|
```ruby
|
|
299
305
|
user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
|
|
@@ -308,6 +314,22 @@ user.valid? # => true (age is nil, which allow_nil permits)
|
|
|
308
314
|
user.errors.full_messages # => []
|
|
309
315
|
```
|
|
310
316
|
|
|
317
|
+
With `Schema::All` (or `Schema::ActiveModelValidations`), `parsing_errors` is an `ActiveModel::Errors` with readable messages:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
user.parsing_errors.full_messages # => ["Age is invalid"]
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
| Code | Message | I18n key |
|
|
324
|
+
|---|---|---|
|
|
325
|
+
| `invalid` | is invalid | `schema.parsing_errors.invalid` |
|
|
326
|
+
| `incompatible` | is an incompatible type | `schema.parsing_errors.incompatible` |
|
|
327
|
+
| `unknown` | has an unknown type | `schema.parsing_errors.unknown` |
|
|
328
|
+
| `unknown_attribute` | is an unknown attribute | `schema.parsing_errors.unknown_attribute` |
|
|
329
|
+
| `unhandled_type` | is an unhandled type | `schema.parsing_errors.unhandled_type` |
|
|
330
|
+
|
|
331
|
+
A plain `Schema::Model` uses `Schema::Errors`, which stores the codes themselves (`user.parsing_errors[:age] # => ["invalid"]`).
|
|
332
|
+
|
|
311
333
|
### Validating Nested Schemas
|
|
312
334
|
|
|
313
335
|
`validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, is invalid:
|
|
@@ -349,13 +371,13 @@ By default, unknown attributes are captured as parsing errors:
|
|
|
349
371
|
|
|
350
372
|
```ruby
|
|
351
373
|
user = UserSchema.from_hash(name: 'John', unknown_field: 'value')
|
|
352
|
-
user.parsing_errors[:unknown_field] # => ["
|
|
374
|
+
user.parsing_errors[:unknown_field] # => ["is an unknown attribute"]
|
|
353
375
|
|
|
354
376
|
# Disable this behavior
|
|
355
377
|
UserSchema.capture_unknown_attributes = false
|
|
356
378
|
```
|
|
357
379
|
|
|
358
|
-
The setting
|
|
380
|
+
The setting also applies to nested association classes, including ones declared after it is set.
|
|
359
381
|
|
|
360
382
|
## Serialization
|
|
361
383
|
|
|
@@ -380,6 +402,23 @@ user.as_json(select_filter: ->(name, value, opts) { name == :name })
|
|
|
380
402
|
user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
|
|
381
403
|
```
|
|
382
404
|
|
|
405
|
+
### Comparing, Inspecting and Copying
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
a = ContactSchema.from_hash(name: 'John')
|
|
409
|
+
b = ContactSchema.from_hash('name' => 'John')
|
|
410
|
+
|
|
411
|
+
a == b # => true (same class and attribute values, nested models included)
|
|
412
|
+
a.attribute_values # => { name: "John", email: nil }
|
|
413
|
+
a.inspect # => #<ContactSchema name: "John"> (only attributes that were set)
|
|
414
|
+
|
|
415
|
+
copy = a.deep_dup # copies nested models, arrays, hashes, strings and parsing errors
|
|
416
|
+
copy.name << '!'
|
|
417
|
+
a.name # => "John"
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
`dup` is Ruby's shallow copy, so nested models are shared with the original.
|
|
421
|
+
|
|
383
422
|
## Protecting Fields with skip_fields
|
|
384
423
|
|
|
385
424
|
Prevent certain fields from being set by user input:
|
|
@@ -446,6 +485,8 @@ end
|
|
|
446
485
|
UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
|
|
447
486
|
```
|
|
448
487
|
|
|
488
|
+
`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.
|
|
489
|
+
|
|
449
490
|
### Schema::CSVParser
|
|
450
491
|
|
|
451
492
|
`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
|
|
@@ -28,21 +28,7 @@ module Schema
|
|
|
28
28
|
# no-doc
|
|
29
29
|
module OverrideParsingErrors
|
|
30
30
|
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
|
-
)
|
|
31
|
+
@parsing_errors ||= ActiveModelParsingErrors.new(self)
|
|
46
32
|
end
|
|
47
33
|
end
|
|
48
34
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
def ==(other)
|
|
16
|
+
other.instance_of?(self.class) && other.attribute_values == attribute_values
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# lists the attributes that were set, e.g. #<UserSchema name: "Joe", age: 30>
|
|
20
|
+
def inspect
|
|
21
|
+
details = set_field_options.map do |field_options|
|
|
22
|
+
"#{field_options[:name]}: #{public_send(field_options[:getter]).inspect}"
|
|
23
|
+
end
|
|
24
|
+
"#<#{self.class.name || self.class}#{" #{details.join(', ')}" unless details.empty?}>"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def set_field_options
|
|
30
|
+
self.class.schema.each_value.select do |field_options|
|
|
31
|
+
!field_options[:alias_of] && instance_variable_defined?(field_options[:instance_variable])
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
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
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schema
|
|
4
|
+
module Parsers
|
|
5
|
+
# Schema::Parsers::StringValue is the shared string handling for parsers
|
|
6
|
+
module StringValue
|
|
7
|
+
# strips the string, returns nil when it's blank, and otherwise yields it; a nil result
|
|
8
|
+
# or an ArgumentError (e.g. Date::Error) from the block is recorded as invalid
|
|
9
|
+
def parse_string_value(field_name, parsing_errors, value)
|
|
10
|
+
str = value.strip
|
|
11
|
+
return nil if str.empty?
|
|
12
|
+
|
|
13
|
+
result = begin
|
|
14
|
+
yield str
|
|
15
|
+
rescue ArgumentError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID) if result.nil?
|
|
19
|
+
result
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schema
|
|
4
|
+
# Schema::ParsingStatus reports whether a model parsed cleanly, and raises at an explicit boundary
|
|
5
|
+
module ParsingStatus
|
|
6
|
+
def parsed?
|
|
7
|
+
parsing_errors.empty?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def parsed!
|
|
11
|
+
return if parsed?
|
|
12
|
+
|
|
13
|
+
raise ParsingException.new(
|
|
14
|
+
"schema parsing failed for attributes #{parsing_errors.attribute_names.join(', ')}",
|
|
15
|
+
self,
|
|
16
|
+
parsing_errors
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/schema/utils.rb
CHANGED
|
@@ -5,6 +5,14 @@ module Schema
|
|
|
5
5
|
module Utils
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
|
+
def check_parser!(model, parser, field_name, type)
|
|
9
|
+
return if model.respond_to?(parser)
|
|
10
|
+
|
|
11
|
+
raise ::Schema::UnknownTypeError,
|
|
12
|
+
"unknown type #{type.inspect} for #{model.class.name || model.class}##{field_name}: " \
|
|
13
|
+
"no #{parser} method (schema_include the parser module that defines it)"
|
|
14
|
+
end
|
|
15
|
+
|
|
8
16
|
def classify_name(name)
|
|
9
17
|
name.gsub(/[^\da-z_-]/, '').gsub(/(^.|[_|-].)/) { |m| m[-1].upcase }
|
|
10
18
|
end
|
|
@@ -14,6 +22,7 @@ module Schema
|
|
|
14
22
|
kls = Class.new(options[:base_class] || Object)
|
|
15
23
|
kls = base_schema_class.const_set(options[:class_name], kls)
|
|
16
24
|
include_schema_modules(kls, base_schema_class.schema_config) unless options[:base_class]
|
|
25
|
+
kls.capture_unknown_attributes = base_schema_class.capture_unknown_attributes?
|
|
17
26
|
kls
|
|
18
27
|
end
|
|
19
28
|
|
|
@@ -56,6 +65,24 @@ module Schema
|
|
|
56
65
|
kls.send(:include, ::Schema::Associations::DynamicTypes)
|
|
57
66
|
end
|
|
58
67
|
|
|
68
|
+
# copies nested models and collections so the copy can be changed without touching the original
|
|
69
|
+
def deep_dup_value(value)
|
|
70
|
+
case value
|
|
71
|
+
when ::Schema::Model then value.deep_dup
|
|
72
|
+
when ::Array then value.map { |element| deep_dup_value(element) }
|
|
73
|
+
when ::Hash then value.to_h { |key, element| [key, deep_dup_value(element)] }
|
|
74
|
+
when ::String then value.frozen? ? value : value.dup
|
|
75
|
+
else value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# [attribute, message] pairs from Schema::Errors or ActiveModel::Errors
|
|
80
|
+
def parsing_error_pairs(errors)
|
|
81
|
+
return errors.map { |error| [error.attribute, error.message] } unless errors.is_a?(::Schema::Errors)
|
|
82
|
+
|
|
83
|
+
errors.errors.flat_map { |attribute, messages| messages.map { |message| [attribute, message] } }
|
|
84
|
+
end
|
|
85
|
+
|
|
59
86
|
# each call gets its own copy so callers can't mutate the shared default
|
|
60
87
|
def copy_default(value)
|
|
61
88
|
return value if value.frozen?
|
data/lib/schema/version.rb
CHANGED
data/lib/schema-model.rb
CHANGED
|
@@ -18,14 +18,21 @@ module Schema
|
|
|
18
18
|
class ParsingException < SchemaException; end
|
|
19
19
|
class ValidationException < SchemaException; end
|
|
20
20
|
|
|
21
|
+
# raised when a value is assigned to an attribute whose type has no parse_<type> method
|
|
22
|
+
class UnknownTypeError < StandardError; end
|
|
23
|
+
|
|
21
24
|
autoload :ActiveModelValidations, 'schema/active_model_validations'
|
|
22
25
|
autoload :All, 'schema/all'
|
|
26
|
+
autoload :ActiveModelParsingErrors, 'schema/active_model_parsing_errors'
|
|
23
27
|
autoload :ArrayHeaders, 'schema/array_headers'
|
|
24
28
|
autoload :Arrays, 'schema/arrays'
|
|
29
|
+
autoload :AttributeValues, 'schema/attribute_values'
|
|
25
30
|
autoload :CSVParser, 'schema/csv_parser'
|
|
31
|
+
autoload :DeepCopy, 'schema/deep_copy'
|
|
26
32
|
autoload :Errors, 'schema/errors'
|
|
27
33
|
autoload :Model, 'schema/model'
|
|
28
34
|
autoload :ParsingErrors, 'schema/parsing_errors'
|
|
35
|
+
autoload :ParsingStatus, 'schema/parsing_status'
|
|
29
36
|
autoload :Utils, 'schema/utils'
|
|
30
37
|
|
|
31
38
|
# Schema::Parsers are used to convert values into the correct data type
|
|
@@ -35,6 +42,7 @@ module Schema
|
|
|
35
42
|
autoload :Common, 'schema/parsers/common'
|
|
36
43
|
autoload :Hash, 'schema/parsers/hash'
|
|
37
44
|
autoload :Json, 'schema/parsers/json'
|
|
45
|
+
autoload :StringValue, 'schema/parsers/string_value'
|
|
38
46
|
end
|
|
39
47
|
|
|
40
48
|
# Schema::Associations mange the associations between schema models
|