schema-model 0.7.3 → 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 +20 -0
- data/README.md +60 -9
- data/bin/schema-json2csv +19 -33
- data/lib/schema/active_model_parsing_errors.rb +31 -0
- data/lib/schema/active_model_validations.rb +2 -16
- data/lib/schema/array_headers.rb +23 -6
- data/lib/schema/associations/base.rb +4 -1
- 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 -16
- 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 -69
- 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,25 @@
|
|
|
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
|
+
|
|
14
|
+
## [0.7.4](https://github.com/dougyouch/schema/compare/v0.7.3...v0.7.4) (2026-10-04)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **array-headers:** fix nested field names, association columns and column collisions ([f0373e0](https://github.com/dougyouch/schema/commit/f0373e0450e9d474dcde5e02b1db76b9c7bbc7d4))
|
|
20
|
+
* **associations:** support dynamic types in anonymous parent classes ([45d22da](https://github.com/dougyouch/schema/commit/45d22da233ec97703ba3c3bd90a91e577bac5390))
|
|
21
|
+
* **cli:** write csv headers to stdout and lint schema-json2csv ([58ebb13](https://github.com/dougyouch/schema/commit/58ebb138d58cbddca60c2e7214ab980d3c0aae12))
|
|
22
|
+
|
|
3
23
|
## [0.7.3](https://github.com/dougyouch/schema/compare/v0.7.2...v0.7.3) (2026-10-04)
|
|
4
24
|
|
|
5
25
|
|
data/README.md
CHANGED
|
@@ -8,10 +8,16 @@ 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'
|
|
15
|
+
|
|
16
|
+
# needed for Schema::All and Schema::ActiveModelValidations (already present in Rails apps)
|
|
17
|
+
gem 'activemodel'
|
|
18
|
+
|
|
19
|
+
# needed for Schema::CSVParser and bin/schema-json2csv on Ruby 3.4+, where csv is no longer a default gem
|
|
20
|
+
gem 'csv'
|
|
15
21
|
```
|
|
16
22
|
|
|
17
23
|
And then execute:
|
|
@@ -20,6 +26,8 @@ And then execute:
|
|
|
20
26
|
$ bundle install
|
|
21
27
|
```
|
|
22
28
|
|
|
29
|
+
`Schema::Model` on its own has no dependencies beyond `inheritance-helper`.
|
|
30
|
+
|
|
23
31
|
## Quick Start
|
|
24
32
|
|
|
25
33
|
```ruby
|
|
@@ -57,17 +65,21 @@ user.tags # => ["ruby", "rails", "developer"]
|
|
|
57
65
|
|
|
58
66
|
```ruby
|
|
59
67
|
attribute :name, :string # String values
|
|
60
|
-
attribute :count, :integer # Integer values (parses "123" to 123)
|
|
61
|
-
attribute :price, :float # Float values (parses "9.99" to 9.99)
|
|
62
|
-
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
|
|
63
71
|
attribute :notes, :string_or_nil # String, but returns nil if empty
|
|
64
72
|
```
|
|
65
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
|
+
|
|
66
78
|
### Date and Time Types
|
|
67
79
|
|
|
68
80
|
```ruby
|
|
69
|
-
attribute :created_at, :time # ISO 8601
|
|
70
|
-
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
|
|
71
83
|
attribute :us_date, :american_date # MM/DD/YYYY format
|
|
72
84
|
attribute :us_time, :american_time # MM/DD/YYYY HH:MM:SS format
|
|
73
85
|
```
|
|
@@ -82,6 +94,8 @@ attribute :metadata, :hash # Hash/dictionary values
|
|
|
82
94
|
attribute :config, :json # Parse JSON strings
|
|
83
95
|
```
|
|
84
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
|
+
|
|
85
99
|
## Attribute Options
|
|
86
100
|
|
|
87
101
|
### Aliases
|
|
@@ -285,7 +299,7 @@ end
|
|
|
285
299
|
|
|
286
300
|
### Parsing Errors vs Validation Errors
|
|
287
301
|
|
|
288
|
-
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.
|
|
289
303
|
|
|
290
304
|
```ruby
|
|
291
305
|
user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
|
|
@@ -300,6 +314,22 @@ user.valid? # => true (age is nil, which allow_nil permits)
|
|
|
300
314
|
user.errors.full_messages # => []
|
|
301
315
|
```
|
|
302
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
|
+
|
|
303
333
|
### Validating Nested Schemas
|
|
304
334
|
|
|
305
335
|
`validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, is invalid:
|
|
@@ -341,13 +371,13 @@ By default, unknown attributes are captured as parsing errors:
|
|
|
341
371
|
|
|
342
372
|
```ruby
|
|
343
373
|
user = UserSchema.from_hash(name: 'John', unknown_field: 'value')
|
|
344
|
-
user.parsing_errors[:unknown_field] # => ["
|
|
374
|
+
user.parsing_errors[:unknown_field] # => ["is an unknown attribute"]
|
|
345
375
|
|
|
346
376
|
# Disable this behavior
|
|
347
377
|
UserSchema.capture_unknown_attributes = false
|
|
348
378
|
```
|
|
349
379
|
|
|
350
|
-
The setting
|
|
380
|
+
The setting also applies to nested association classes, including ones declared after it is set.
|
|
351
381
|
|
|
352
382
|
## Serialization
|
|
353
383
|
|
|
@@ -372,6 +402,23 @@ user.as_json(select_filter: ->(name, value, opts) { name == :name })
|
|
|
372
402
|
user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
|
|
373
403
|
```
|
|
374
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
|
+
|
|
375
422
|
## Protecting Fields with skip_fields
|
|
376
423
|
|
|
377
424
|
Prevent certain fields from being set by user input:
|
|
@@ -438,6 +485,8 @@ end
|
|
|
438
485
|
UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
|
|
439
486
|
```
|
|
440
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
|
+
|
|
441
490
|
### Schema::CSVParser
|
|
442
491
|
|
|
443
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:
|
|
@@ -521,6 +570,8 @@ schema-json2csv --require ./my_schema.rb --schema MySchema --json data.json --cs
|
|
|
521
570
|
cat data.json | schema-json2csv --require ./my_schema.rb --schema MySchema - --csv output.csv
|
|
522
571
|
```
|
|
523
572
|
|
|
573
|
+
The JSON can be one object or an array of objects. Output goes to stdout when `--csv` is omitted. A header row (from `to_headers`) is written to stdout or to a new file; an existing `--csv` file is appended to without repeating it. Has-many associations need a `size:` so every row has the same columns.
|
|
574
|
+
|
|
524
575
|
## Contributing
|
|
525
576
|
|
|
526
577
|
1. Fork the repository
|
data/bin/schema-json2csv
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'schema-model'
|
|
4
5
|
require 'csv'
|
|
@@ -13,7 +14,7 @@ options = {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
OptionParser.new do |opts|
|
|
16
|
-
opts.banner = "Usage:
|
|
17
|
+
opts.banner = "Usage: #{File.basename(__FILE__)} --require FILE --schema CLASS (--json FILE | -) [--csv FILE]"
|
|
17
18
|
|
|
18
19
|
opts.on('--json JSON_FILE', 'JSON data') do |v|
|
|
19
20
|
options[:json_file] = v
|
|
@@ -23,7 +24,7 @@ OptionParser.new do |opts|
|
|
|
23
24
|
options[:require_file] = v
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
opts.on('--csv CSV_FILE', 'File to output csv data') do |v|
|
|
27
|
+
opts.on('--csv CSV_FILE', 'File to output csv data (appended to if it exists)') do |v|
|
|
27
28
|
options[:csv_file] = v
|
|
28
29
|
end
|
|
29
30
|
|
|
@@ -32,54 +33,39 @@ OptionParser.new do |opts|
|
|
|
32
33
|
end
|
|
33
34
|
end.parse!
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
abort('no file required') unless options[:require_file]
|
|
37
|
+
abort('no schema specified') unless options[:schema_class_name]
|
|
38
|
+
abort("file #{options[:require_file]} not found") unless File.exist?(options[:require_file])
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
raise("file #{options[:require_file]} not found") unless require(options[:require_file])
|
|
40
|
+
require File.expand_path(options[:require_file])
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
abort("schema #{options[:schema_class_name]} not found") unless Object.const_defined?(options[:schema_class_name])
|
|
42
43
|
|
|
43
44
|
schema_class = Object.const_get(options[:schema_class_name])
|
|
44
45
|
schema_class.schema_include Schema::Arrays
|
|
45
46
|
|
|
46
47
|
json_data =
|
|
47
48
|
if options[:json_file]
|
|
48
|
-
|
|
49
|
+
abort("json file #{options[:json_file]} not found") unless File.exist?(options[:json_file])
|
|
49
50
|
File.read(options[:json_file])
|
|
50
51
|
elsif ARGV.last == '-'
|
|
51
|
-
|
|
52
|
+
$stdin.read
|
|
52
53
|
else
|
|
53
|
-
|
|
54
|
+
abort('no json data specified')
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
io.print str
|
|
62
|
-
end
|
|
57
|
+
# headers are written to stdout and to new files; an existing file is appended to as is
|
|
58
|
+
csv_file = options[:csv_file]
|
|
59
|
+
write_headers = csv_file.nil? || !File.exist?(csv_file)
|
|
60
|
+
io = csv_file ? File.open(csv_file, 'ab') : $stdout
|
|
63
61
|
|
|
64
|
-
io
|
|
65
|
-
if file = options[:csv_file]
|
|
66
|
-
if File.exist?(file)
|
|
67
|
-
File.open(options[:csv_file], 'ab')
|
|
68
|
-
else
|
|
69
|
-
headers = CSV.generate do |csv|
|
|
70
|
-
csv << schema_class.to_headers
|
|
71
|
-
end
|
|
72
|
-
f = File.open(options[:csv_file], 'wb')
|
|
73
|
-
f.print headers
|
|
74
|
-
f
|
|
75
|
-
end
|
|
76
|
-
else
|
|
77
|
-
$stdout
|
|
78
|
-
end
|
|
62
|
+
io.print CSV.generate_line(schema_class.to_headers) if write_headers
|
|
79
63
|
|
|
80
64
|
json_data = JSON.parse(json_data)
|
|
81
65
|
json_data = [json_data] unless json_data.is_a?(Array)
|
|
82
66
|
|
|
83
67
|
json_data.each do |data|
|
|
84
|
-
|
|
68
|
+
io.print CSV.generate_line(schema_class.from_hash(data).to_a.flatten)
|
|
85
69
|
end
|
|
70
|
+
|
|
71
|
+
io.close if csv_file
|
|
@@ -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
|
data/lib/schema/array_headers.rb
CHANGED
|
@@ -45,13 +45,11 @@ module Schema
|
|
|
45
45
|
|
|
46
46
|
private
|
|
47
47
|
|
|
48
|
+
# has_many columns are reported as <prefix>X<field>; has_one fields share the parent's prefix
|
|
48
49
|
def get_model_field_names(field_name, field_options, mapped_headers, header_prefix, mapped)
|
|
49
50
|
mapped_model = mapped_headers[field_name] || {}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
header_prefix || field_options[:aliases]&.first,
|
|
53
|
-
mapped
|
|
54
|
-
)
|
|
51
|
+
header_prefix ||= (field_options[:aliases]&.first || field_name).to_s if field_options[:type] == :has_many
|
|
52
|
+
const_get(field_options[:class_name]).get_field_names(mapped_model, header_prefix, mapped)
|
|
55
53
|
end
|
|
56
54
|
|
|
57
55
|
def skip_field?(field_name, mapped_headers, mapped)
|
|
@@ -72,20 +70,36 @@ module Schema
|
|
|
72
70
|
const_get(field_options[:class_name]).map_headers_to_attributes(headers, header_prefix)
|
|
73
71
|
end
|
|
74
72
|
|
|
73
|
+
# a column already claimed by the parent (or an earlier has_one) isn't reused for a nested field
|
|
75
74
|
def map_headers_to_has_one_associations(headers, mapped_headers, header_prefix)
|
|
75
|
+
claimed_indexes = mapped_header_indexes(mapped_headers)
|
|
76
76
|
schema.each do |field_name, field_options|
|
|
77
77
|
next unless field_options[:type] == :has_one
|
|
78
78
|
# aliases are matched through the association's own entry
|
|
79
79
|
next if field_options[:alias_of]
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
available_headers = headers.each_with_index.map { |header, idx| claimed_indexes.include?(idx) ? nil : header }
|
|
82
|
+
mapped_model = get_mapped_model(field_options, available_headers, header_prefix)
|
|
82
83
|
next if mapped_model.empty?
|
|
83
84
|
|
|
84
85
|
mapped_headers[field_name] = mapped_model
|
|
86
|
+
claimed_indexes.concat(mapped_header_indexes(mapped_model))
|
|
85
87
|
end
|
|
86
88
|
mapped_headers
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
def mapped_header_indexes(mapped_headers)
|
|
92
|
+
mapped_headers.each_value.flat_map do |info|
|
|
93
|
+
if info.key?(:index)
|
|
94
|
+
[info[:index]]
|
|
95
|
+
elsif info.key?(:indexes)
|
|
96
|
+
info[:indexes]
|
|
97
|
+
else
|
|
98
|
+
mapped_header_indexes(info)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
89
103
|
def map_headers_to_has_many_associations(headers, mapped_headers)
|
|
90
104
|
schema.each do |field_name, field_options|
|
|
91
105
|
next unless field_options[:type] == :has_many
|
|
@@ -104,6 +118,9 @@ module Schema
|
|
|
104
118
|
|
|
105
119
|
def map_headers_to_fields(headers, mapped_headers, header_prefix)
|
|
106
120
|
schema.each do |field_name, field_options|
|
|
121
|
+
# associations are mapped through their nested fields, not a column of their own
|
|
122
|
+
next if field_options[:association]
|
|
123
|
+
|
|
107
124
|
if header_prefix
|
|
108
125
|
unless (indexes = find_indexes_for_field(headers, field_options, header_prefix)).empty?
|
|
109
126
|
mapped_headers[field_options[:alias_of] || field_name] = { indexes: indexes }
|
|
@@ -12,12 +12,15 @@ module Schema
|
|
|
12
12
|
module ClassMethods
|
|
13
13
|
attr_accessor :schema_name
|
|
14
14
|
|
|
15
|
+
# named classes are looked up by name so reloaded constants are picked up;
|
|
16
|
+
# anonymous classes have no name, so they are kept by reference
|
|
15
17
|
def base_schema_class=(kls)
|
|
18
|
+
@base_schema_class = kls
|
|
16
19
|
@base_schema_class_name = kls.name
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def base_schema_class
|
|
20
|
-
Object.const_get(@base_schema_class_name)
|
|
23
|
+
@base_schema_class_name ? Object.const_get(@base_schema_class_name) : @base_schema_class
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def schema_options
|
|
@@ -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
|