schema-model 0.7.1 → 0.7.2
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/.release-please-manifest.json +1 -1
- data/ARCHITECTURE.md +32 -6
- data/CHANGELOG.md +9 -0
- data/CLAUDE.md +6 -2
- data/README.md +84 -14
- data/lib/schema/arrays.rb +1 -1
- data/lib/schema/associations/schema_creator.rb +7 -7
- data/lib/schema/model.rb +43 -34
- data/lib/schema/parsers/common.rb +14 -5
- data/lib/schema/utils.rb +11 -4
- data/lib/schema/version.rb +1 -1
- data/schema-model.gemspec +5 -3
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c75de56a15a674efd32d16fdedda825e056c4b71e6e3a1d20c63761ae685c5c2
|
|
4
|
+
data.tar.gz: 25537f375a8da4d892bc4c6950e790d8db3b0859f925d302d27b9bef82a773f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec765c52a43b319a6e47879239f92f268a6c7c359163c001c2d9411f286fa41f7036a6739adf4e3f6531f7f9becc5c191c5ef51610f1128903d94a5f4ab6653e
|
|
7
|
+
data.tar.gz: 39dc1faf72f89f17dc75909421e1b1cc4c1d3e1b04db03661716f4f772a0ef5051ad8046e284ff34185b0da4dc27fdb44aaf720d7441a795fae18cd89c4f36a8
|
data/ARCHITECTURE.md
CHANGED
|
@@ -17,16 +17,26 @@ Hash Data → from_hash() → Parser Methods → Schema Instance
|
|
|
17
17
|
```
|
|
18
18
|
Schema::All (convenience bundle)
|
|
19
19
|
├── Schema::Model (core attribute system)
|
|
20
|
+
│ └── Schema::Parsers::Common
|
|
20
21
|
├── Schema::Associations::HasOne
|
|
21
22
|
├── Schema::Associations::HasMany
|
|
22
|
-
├── Schema::Parsers::Common
|
|
23
23
|
├── Schema::Parsers::American
|
|
24
24
|
├── Schema::Parsers::Array
|
|
25
25
|
├── Schema::Parsers::Hash
|
|
26
26
|
├── Schema::Parsers::Json
|
|
27
27
|
└── Schema::ActiveModelValidations
|
|
28
|
+
|
|
29
|
+
Opt-in (via schema_include):
|
|
30
|
+
├── Schema::ArrayHeaders (map CSV headers to attributes)
|
|
31
|
+
└── Schema::Arrays (models to/from flat arrays)
|
|
32
|
+
|
|
33
|
+
Standalone:
|
|
34
|
+
├── Schema::CSVParser (class; reads CSV rows into models)
|
|
35
|
+
└── SchemaValidator (ActiveModel validator for `schema: true`)
|
|
28
36
|
```
|
|
29
37
|
|
|
38
|
+
`schema_include` both includes a module and records it in `schema_config[:schema_includes]`, so it is also included in every nested association class, including ones defined later.
|
|
39
|
+
|
|
30
40
|
## Core Components
|
|
31
41
|
|
|
32
42
|
### Schema::Model (`lib/schema/model.rb`)
|
|
@@ -40,7 +50,9 @@ The foundation module providing:
|
|
|
40
50
|
|
|
41
51
|
- **`from_hash(data, skip_fields)`**: Class method that creates instance and calls `update_attributes`
|
|
42
52
|
|
|
43
|
-
- **`update_attributes(data, skip_fields)`**: Iterates
|
|
53
|
+
- **`update_attributes(data, skip_fields)`**: Iterates the data's key/value pairs, matches them against the schema, and invokes setters. Plain attributes are set before associations, so an association's `external_type_field` can read a sibling attribute. Each key is looked up on its own: symbol keys in `schema`, anything else in `schema_with_string_keys` (rebuilt whenever `schema` changes). `skip_fields` entries match a field by name or alias, as a symbol or string; a `{ association: [...] }` entry passes a nested list down.
|
|
54
|
+
|
|
55
|
+
- **Aliases**: each alias adds a second schema entry with `alias_of:` pointing at the real attribute, plus aliased getter/setter methods. Serialization and array conversion skip entries with `alias_of`.
|
|
44
56
|
|
|
45
57
|
- **`as_json` / `to_hash`**: Serialization back to hash format
|
|
46
58
|
|
|
@@ -66,10 +78,14 @@ has_one(:profile) { attribute :bio, :string }
|
|
|
66
78
|
has_many(:posts) { attribute :title, :string }
|
|
67
79
|
```
|
|
68
80
|
|
|
81
|
+
Each association gets a generated class (`SchemaHasOne<Name>` / `SchemaHasMany<Name>` by default, or `class_name:`), defined as a constant inside the parent class. `base_class:` subclasses an existing schema instead of creating a fresh model class.
|
|
82
|
+
|
|
69
83
|
Both use **SchemaCreator** (`schema_creator.rb`) to:
|
|
70
84
|
1. Determine which class to instantiate (static or dynamic)
|
|
71
85
|
2. Call `from_hash` on the nested class
|
|
72
|
-
3. Propagate parsing errors to parent
|
|
86
|
+
3. Propagate parsing errors to parent: `invalid` when the nested model has parsing errors, `incompatible` when the value isn't a hash (or, for has-many, an array), and `unknown` when no dynamic type matches
|
|
87
|
+
|
|
88
|
+
`has_many ..., from: :hash` accepts a hash keyed by id instead of an array; each key is written to `hash_key_field` (default `:id`) on the nested model.
|
|
73
89
|
|
|
74
90
|
**DynamicTypes** enables polymorphic associations:
|
|
75
91
|
|
|
@@ -81,7 +97,9 @@ has_many(:items, type_field: :kind) do
|
|
|
81
97
|
end
|
|
82
98
|
```
|
|
83
99
|
|
|
84
|
-
The `type_field` option tells SchemaCreator which data
|
|
100
|
+
The `type_field` option tells SchemaCreator which key in the nested data determines the subclass (the type field attribute's aliases are checked too); `external_type_field` reads the type from an attribute on the parent instead. `type_ignorecase: true` compares type names case-insensitively. Each `add_type` creates a subclass of the association class, defined on the parent class as `<Name>AssociationType<Type>`.
|
|
101
|
+
|
|
102
|
+
`Associations::Base` stores the parent class by name and resolves it with `Object.const_get`, so a parent class must be assigned to a constant before any association that uses dynamic types is declared.
|
|
85
103
|
|
|
86
104
|
### Schema::Utils (`lib/schema/utils.rb`)
|
|
87
105
|
|
|
@@ -89,7 +107,7 @@ Utility methods for:
|
|
|
89
107
|
- `classify_name`: String → ClassName conversion
|
|
90
108
|
- `create_schema_class`: Dynamically creates nested schema classes
|
|
91
109
|
- `add_association_class`: Wires up association with proper modules
|
|
92
|
-
- `add_attribute_default_methods` / `add_association_default_methods`: Default value handling
|
|
110
|
+
- `add_attribute_default_methods` / `add_association_default_methods`: Default value handling (`copy_default` hands each read a deep copy of the default)
|
|
93
111
|
|
|
94
112
|
### Error Handling
|
|
95
113
|
|
|
@@ -103,7 +121,15 @@ Parsing errors are distinct from validation errors:
|
|
|
103
121
|
- **Parsing errors**: Type conversion failures (string "abc" → integer)
|
|
104
122
|
- **Validation errors**: Business rule failures (via `validates` DSL)
|
|
105
123
|
|
|
106
|
-
|
|
124
|
+
`parsed!` raises `ParsingException` when there are parsing errors and `valid_model!` raises `ValidationException` when validations fail; `valid!` calls both. `valid?` runs validations only and does not look at parsing errors.
|
|
125
|
+
|
|
126
|
+
Parsing errors use these codes from `Schema::ParsingErrors`: `invalid`, `incompatible`, `unknown`, `unknown_attribute`, `unhandled_type`.
|
|
127
|
+
|
|
128
|
+
### CSV and Arrays
|
|
129
|
+
|
|
130
|
+
- **`Schema::ArrayHeaders`** (`lib/schema/array_headers.rb`): `map_headers_to_attributes(headers)` returns a nested hash of `{ field: { index: n } }` entries. Has-many fields are matched as `<prefix><n><key>` (prefix is the association name or an alias, `n` counts from 1) and map to `{ indexes: [...] }`.
|
|
131
|
+
- **`Schema::Arrays`** (`lib/schema/arrays.rb`): `to_headers`, `to_empty_array`, `to_a` and `from_array(array, mapped_headers)`. Has-many associations need a `size:` option for the fixed-width methods.
|
|
132
|
+
- **`Schema::CSVParser`** (`lib/schema/csv_parser.rb`): wraps a `CSV` object, maps its header row once, and yields a model per row (`each`, `shift`, `missing_fields`).
|
|
107
133
|
|
|
108
134
|
### Inheritance Helper Integration
|
|
109
135
|
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.2](https://github.com/dougyouch/schema/compare/v0.7.1...v0.7.2) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **associations:** read type field aliases and handle bad hash-keyed entries ([d5bdb05](https://github.com/dougyouch/schema/commit/d5bdb05c4ce90cd40e50f1619829f0e29082cec6))
|
|
9
|
+
* **model:** honor skip_fields for any key style and fix key lookup edge cases ([3ca3450](https://github.com/dougyouch/schema/commit/3ca34500f71e62c9bfcf0c49419bac22bdfc56bd))
|
|
10
|
+
* **parsers:** anchor parser regexes and reject non-finite floats as integers ([0514bd6](https://github.com/dougyouch/schema/commit/0514bd6175031a146bbc3a5d178b9e0fd32b4c7f))
|
|
11
|
+
|
|
3
12
|
## [0.7.1](https://github.com/dougyouch/schema/compare/v0.7.0...v0.7.1) (2026-10-04)
|
|
4
13
|
|
|
5
14
|
|
data/CLAUDE.md
CHANGED
|
@@ -35,7 +35,7 @@ This is a Ruby gem (`schema-model`) for data transformation, validation, and typ
|
|
|
35
35
|
- **Schema::All** (`lib/schema/all.rb`) - Convenience module bundling Model + Associations + Parsers + ActiveModel validations. This is the typical include.
|
|
36
36
|
|
|
37
37
|
- **Schema::Parsers** - Type parsers in `lib/schema/parsers/`:
|
|
38
|
-
- `Common` - integer, string, float, time, date, boolean
|
|
38
|
+
- `Common` - integer, string, string_or_nil, float, time, date, boolean (included by `Schema::Model`)
|
|
39
39
|
- `American` - american_date, american_time (MM/DD/YYYY format)
|
|
40
40
|
- `Array` - array with optional separator and data_type
|
|
41
41
|
- `Hash` - hash/dictionary values
|
|
@@ -47,11 +47,15 @@ This is a Ruby gem (`schema-model`) for data transformation, validation, and typ
|
|
|
47
47
|
|
|
48
48
|
- **Schema::ArrayHeaders** (`lib/schema/array_headers.rb`) - Map CSV headers to schema attributes.
|
|
49
49
|
|
|
50
|
+
- **Schema::CSVParser** (`lib/schema/csv_parser.rb`) - Class that reads `CSV` rows into models (needs ArrayHeaders + Arrays on the model).
|
|
51
|
+
|
|
52
|
+
- **SchemaValidator** (`lib/schema_validator.rb`) - ActiveModel validator behind `validates :assoc, schema: true`.
|
|
53
|
+
|
|
50
54
|
### Key Patterns
|
|
51
55
|
|
|
52
56
|
**Attribute Definition**: Each `attribute` call generates getter, setter, and `<name>_was_set?` predicate. Setter invokes type-specific parser.
|
|
53
57
|
|
|
54
|
-
**Parsing Errors**: Stored in `parsing_errors`. Parsers add errors for invalid values rather than raising exceptions. With ActiveModelValidations, use `parsed?`/`parsed
|
|
58
|
+
**Parsing Errors**: Stored in `parsing_errors`. Parsers add errors for invalid values rather than raising exceptions. With ActiveModelValidations, use `parsed?`/`parsed!`; `valid?` does not check parsing errors.
|
|
55
59
|
|
|
56
60
|
**Schema Inheritance**: Uses `inheritance-helper` gem. Schema definitions accumulate via `add_value_to_class_method(:schema, ...)`.
|
|
57
61
|
|
data/README.md
CHANGED
|
@@ -59,7 +59,7 @@ user.tags # => ["ruby", "rails", "developer"]
|
|
|
59
59
|
attribute :name, :string # String values
|
|
60
60
|
attribute :count, :integer # Integer values (parses "123" to 123)
|
|
61
61
|
attribute :price, :float # Float values (parses "9.99" to 9.99)
|
|
62
|
-
attribute :active, :boolean #
|
|
62
|
+
attribute :active, :boolean # true for 1, t, true, on, y, yes (any case); other strings are false; numbers are true unless 0
|
|
63
63
|
attribute :notes, :string_or_nil # String, but returns nil if empty
|
|
64
64
|
```
|
|
65
65
|
|
|
@@ -82,6 +82,8 @@ attribute :metadata, :hash # Hash/dictionary values
|
|
|
82
82
|
attribute :config, :json # Parse JSON strings
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Unlike the basic types, `:array`, `:hash` and `:json` record an `incompatible` parsing error when given `nil`.
|
|
86
|
+
|
|
85
87
|
## Attribute Options
|
|
86
88
|
|
|
87
89
|
### Aliases
|
|
@@ -100,8 +102,11 @@ attribute :name, :string, aliases: [:full_name, :display_name]
|
|
|
100
102
|
attribute :status, :string, default: 'pending'
|
|
101
103
|
attribute :count, :integer, default: 0
|
|
102
104
|
attribute :tags, :array, default: []
|
|
105
|
+
attribute :starts_on, :date, default: Date.new(2024, 1, 1)
|
|
103
106
|
```
|
|
104
107
|
|
|
108
|
+
Each read of a default returns a fresh copy (frozen values are shared), so mutating it doesn't change the default for other instances.
|
|
109
|
+
|
|
105
110
|
### Checking If Attribute Was Set
|
|
106
111
|
|
|
107
112
|
Every attribute generates a `_was_set?` predicate method:
|
|
@@ -171,10 +176,19 @@ order.items.first.sku # => "ABC"
|
|
|
171
176
|
has_one :profile, default: true
|
|
172
177
|
has_many :tags, default: true
|
|
173
178
|
|
|
179
|
+
# Aliases for the association key
|
|
180
|
+
has_one :profile, alias: :user_profile
|
|
181
|
+
has_many :items, aliases: [:line_items]
|
|
182
|
+
|
|
174
183
|
# Reuse existing schema class
|
|
175
184
|
has_one :shipping_address, base_class: AddressSchema
|
|
176
185
|
has_one :billing_address, base_class: AddressSchema
|
|
177
186
|
|
|
187
|
+
# Name the generated class (defaults to SchemaHasOneProfile, SchemaHasManyItems, ...)
|
|
188
|
+
has_one :profile, class_name: 'ProfileSchema' do
|
|
189
|
+
attribute :bio, :string
|
|
190
|
+
end
|
|
191
|
+
|
|
178
192
|
# Has many from hash (keyed by field)
|
|
179
193
|
has_many :items, from: :hash, hash_key_field: :id do
|
|
180
194
|
attribute :id, :string
|
|
@@ -236,7 +250,8 @@ company.employees[1].team_size # => 5
|
|
|
236
250
|
```ruby
|
|
237
251
|
# Type field within nested data (default)
|
|
238
252
|
has_many :items, type_field: :kind do
|
|
239
|
-
# looks for :kind in each item's data
|
|
253
|
+
# looks for :kind in each item's data, or an alias of the kind attribute
|
|
254
|
+
attribute :kind, :string, alias: :category
|
|
240
255
|
end
|
|
241
256
|
|
|
242
257
|
# Type determined by parent field
|
|
@@ -250,6 +265,8 @@ has_many :items, type_field: :type, type_ignorecase: true do
|
|
|
250
265
|
end
|
|
251
266
|
```
|
|
252
267
|
|
|
268
|
+
When the type matches no `add_type` and there is no `default_type`, the association is `nil` and the parent records an `unknown` parsing error.
|
|
269
|
+
|
|
253
270
|
## Validation and Error Handling
|
|
254
271
|
|
|
255
272
|
### ActiveModel Validations
|
|
@@ -270,16 +287,36 @@ end
|
|
|
270
287
|
|
|
271
288
|
### Parsing Errors vs Validation Errors
|
|
272
289
|
|
|
290
|
+
Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both).
|
|
291
|
+
|
|
273
292
|
```ruby
|
|
274
|
-
user = UserSchema.from_hash(name: 'John', age: 'not-a-number')
|
|
293
|
+
user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
|
|
275
294
|
|
|
276
295
|
# Parsing errors (type conversion failures)
|
|
277
296
|
user.parsing_errors.empty? # => false
|
|
278
297
|
user.parsed? # => false
|
|
298
|
+
user.age # => nil
|
|
279
299
|
|
|
280
300
|
# Validation errors (business rules)
|
|
281
|
-
user.valid? # =>
|
|
282
|
-
user.errors.full_messages # => [
|
|
301
|
+
user.valid? # => true (age is nil, which allow_nil permits)
|
|
302
|
+
user.errors.full_messages # => []
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Validating Nested Schemas
|
|
306
|
+
|
|
307
|
+
`validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, is invalid:
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
class OrderSchema
|
|
311
|
+
include Schema::All
|
|
312
|
+
|
|
313
|
+
has_one :customer do
|
|
314
|
+
attribute :name, :string
|
|
315
|
+
validates :name, presence: true
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
validates :customer, presence: true, schema: true
|
|
319
|
+
end
|
|
283
320
|
```
|
|
284
321
|
|
|
285
322
|
### Raising Exceptions
|
|
@@ -312,12 +349,21 @@ user.parsing_errors[:unknown_field] # => ["unknown_attribute"]
|
|
|
312
349
|
UserSchema.capture_unknown_attributes = false
|
|
313
350
|
```
|
|
314
351
|
|
|
352
|
+
The setting is per class. Nested association classes keep their own setting, so an unknown key inside a nested hash still makes the parent record an `invalid` error for that association.
|
|
353
|
+
|
|
315
354
|
## Serialization
|
|
316
355
|
|
|
317
356
|
### to_hash / as_json
|
|
318
357
|
|
|
319
358
|
```ruby
|
|
320
|
-
|
|
359
|
+
class ContactSchema
|
|
360
|
+
include Schema::All
|
|
361
|
+
|
|
362
|
+
attribute :name, :string
|
|
363
|
+
attribute :email, :string
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
user = ContactSchema.from_hash(name: 'John', email: nil)
|
|
321
367
|
|
|
322
368
|
user.to_hash # => { name: "John", email: nil }
|
|
323
369
|
user.as_json # => { name: "John" } (excludes nils)
|
|
@@ -348,17 +394,23 @@ user.created_at # => nil (not set)
|
|
|
348
394
|
|
|
349
395
|
# Nested skip_fields for associations
|
|
350
396
|
order = OrderSchema.from_hash(data, [:id, { items: [:id] }])
|
|
397
|
+
|
|
398
|
+
# Skip an association entirely
|
|
399
|
+
order = OrderSchema.from_hash(data, [:items])
|
|
351
400
|
```
|
|
352
401
|
|
|
402
|
+
A skipped field is ignored whether the data uses symbol keys, string keys, or one of the field's aliases.
|
|
403
|
+
|
|
353
404
|
## Array and CSV Support
|
|
354
405
|
|
|
355
406
|
### Schema::Arrays Module
|
|
356
407
|
|
|
357
|
-
Convert models to/from flat arrays (useful for CSV/spreadsheet data):
|
|
408
|
+
Convert models to/from flat arrays (useful for CSV/spreadsheet data). `from_array` takes the header mapping built by `Schema::ArrayHeaders`, so include both:
|
|
358
409
|
|
|
359
410
|
```ruby
|
|
360
411
|
class UserSchema
|
|
361
412
|
include Schema::All
|
|
413
|
+
schema_include Schema::ArrayHeaders
|
|
362
414
|
schema_include Schema::Arrays
|
|
363
415
|
|
|
364
416
|
attribute :name, :string
|
|
@@ -378,21 +430,36 @@ mapped = UserSchema.map_headers_to_attributes(headers)
|
|
|
378
430
|
user = UserSchema.from_array(['Jane', 'jane@example.com'], mapped)
|
|
379
431
|
```
|
|
380
432
|
|
|
381
|
-
|
|
433
|
+
`to_headers`, `to_empty_array` and `to_a` need a fixed number of columns for each has-many association, so give it a `size`:
|
|
382
434
|
|
|
383
|
-
|
|
435
|
+
```ruby
|
|
436
|
+
has_many :phones, size: 3 do
|
|
437
|
+
attribute :number, :string
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Schema::CSVParser
|
|
444
|
+
|
|
445
|
+
`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:
|
|
384
446
|
|
|
385
447
|
```ruby
|
|
448
|
+
require 'csv'
|
|
449
|
+
|
|
386
450
|
class UserCSVSchema
|
|
387
|
-
include Schema::
|
|
388
|
-
|
|
451
|
+
include Schema::All
|
|
452
|
+
schema_include Schema::ArrayHeaders
|
|
453
|
+
schema_include Schema::Arrays
|
|
389
454
|
|
|
390
455
|
attribute :name, :string
|
|
391
456
|
attribute :email, :string
|
|
392
457
|
end
|
|
393
458
|
|
|
394
|
-
|
|
395
|
-
parser = Schema::CSVParser.new(
|
|
459
|
+
csv = CSV.new("name,email\nJohn,john@example.com\n")
|
|
460
|
+
parser = Schema::CSVParser.new(csv, UserCSVSchema)
|
|
461
|
+
|
|
462
|
+
parser.missing_fields(%w[name email phone]) # => ["phone"]
|
|
396
463
|
|
|
397
464
|
parser.each do |user|
|
|
398
465
|
puts user.name
|
|
@@ -416,10 +483,13 @@ headers = ['FullName', 'email', 'unknown_column']
|
|
|
416
483
|
mapped = UserSchema.map_headers_to_attributes(headers)
|
|
417
484
|
# => { name: { index: 0 }, email: { index: 1 } }
|
|
418
485
|
|
|
419
|
-
|
|
486
|
+
# Field names are reported by their first alias when they have one
|
|
487
|
+
UserSchema.get_mapped_field_names(mapped) # => ["FullName", "email"]
|
|
420
488
|
UserSchema.get_unmapped_field_names(mapped) # => []
|
|
421
489
|
```
|
|
422
490
|
|
|
491
|
+
Has-many columns are matched by the association name (or its aliases) followed by a 1-based index and the field key, e.g. `Phones1Number`, `Phones2Number` for `has_many :phones, alias: 'Phones'` with `attribute :number, :string, alias: 'Number'`.
|
|
492
|
+
|
|
423
493
|
## Extending Schemas
|
|
424
494
|
|
|
425
495
|
### schema_include
|
data/lib/schema/arrays.rb
CHANGED
|
@@ -137,7 +137,7 @@ module Schema
|
|
|
137
137
|
mapped_model.each_value do |info|
|
|
138
138
|
if info[:indexes]
|
|
139
139
|
size = info[:indexes].size if info[:indexes].size > size
|
|
140
|
-
|
|
140
|
+
else
|
|
141
141
|
new_size = largest_number_of_indexes_from_map(info)
|
|
142
142
|
size = new_size if new_size > size
|
|
143
143
|
end
|
|
@@ -10,7 +10,6 @@ module Schema
|
|
|
10
10
|
options = base_schema.class.schema[name]
|
|
11
11
|
@schema_name = name
|
|
12
12
|
@schema_class = base_schema.class.const_get(options[:class_name])
|
|
13
|
-
@aliases = options.fetch(:aliases, [])
|
|
14
13
|
@ignorecase = options[:type_ignorecase]
|
|
15
14
|
@is_list = options[:from] != :hash
|
|
16
15
|
@hash_key_field = options[:hash_key_field]
|
|
@@ -38,7 +37,7 @@ module Schema
|
|
|
38
37
|
elsif !is_list? && list.is_a?(Hash)
|
|
39
38
|
list.map do |(key, data)|
|
|
40
39
|
schema = create_schema(base_schema, data, "#{@schema_name}:#{key}", skip_fields)
|
|
41
|
-
schema
|
|
40
|
+
schema&.public_send(schema.class.schema[@hash_key_field][:setter], key)
|
|
42
41
|
schema
|
|
43
42
|
end
|
|
44
43
|
elsif !list.nil?
|
|
@@ -86,16 +85,17 @@ module Schema
|
|
|
86
85
|
return type if type
|
|
87
86
|
end
|
|
88
87
|
nil
|
|
89
|
-
|
|
88
|
+
else
|
|
90
89
|
base_schema.public_send(@external_type_field)
|
|
91
90
|
end
|
|
92
91
|
end
|
|
93
92
|
|
|
93
|
+
# the type field and its attribute aliases, as symbols and strings
|
|
94
94
|
def type_fields
|
|
95
|
-
@type_fields ||=
|
|
96
|
-
@type_field,
|
|
97
|
-
@type_field.to_s
|
|
98
|
-
|
|
95
|
+
@type_fields ||= begin
|
|
96
|
+
aliases = @schema_class.schema.dig(@type_field.to_sym, :aliases) || []
|
|
97
|
+
[@type_field, *aliases].flat_map { |name| [name.to_sym, name.to_s] }.uniq
|
|
98
|
+
end
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def add_parsing_error(base_schema, error_name, error_msg)
|
data/lib/schema/model.rb
CHANGED
|
@@ -25,21 +25,17 @@ module Schema
|
|
|
25
25
|
|
|
26
26
|
# no-doc
|
|
27
27
|
module ClassMethods
|
|
28
|
-
def self.include(base)
|
|
29
|
-
base.capture_unknown_attributes = true
|
|
30
|
-
end
|
|
31
|
-
|
|
32
28
|
def schema
|
|
33
29
|
{}.freeze
|
|
34
30
|
end
|
|
35
31
|
|
|
32
|
+
# rebuilt whenever schema is redefined, e.g. by an attribute added after the first lookup
|
|
36
33
|
def schema_with_string_keys
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
34
|
+
current_schema = schema
|
|
35
|
+
return @schema_with_string_keys if @schema_with_string_keys_source.equal?(current_schema)
|
|
36
|
+
|
|
37
|
+
@schema_with_string_keys_source = current_schema
|
|
38
|
+
@schema_with_string_keys = current_schema.transform_keys(&:to_s).freeze
|
|
43
39
|
end
|
|
44
40
|
|
|
45
41
|
def schema_config
|
|
@@ -120,9 +116,10 @@ module Schema
|
|
|
120
116
|
end
|
|
121
117
|
|
|
122
118
|
def update_attributes(data = nil, skip_fields = [])
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
119
|
+
return self if data.nil?
|
|
120
|
+
|
|
121
|
+
update_model_attributes(data, skip_fields)
|
|
122
|
+
update_associations(data, skip_fields)
|
|
126
123
|
self
|
|
127
124
|
end
|
|
128
125
|
|
|
@@ -135,11 +132,7 @@ module Schema
|
|
|
135
132
|
next if opts[:select_filter] && !opts[:select_filter].call(field_name, value, field_options)
|
|
136
133
|
next if opts[:reject_filter]&.call(field_name, value, field_options)
|
|
137
134
|
|
|
138
|
-
memo[field_name] =
|
|
139
|
-
value.map { |e| e.as_json(opts) }
|
|
140
|
-
else
|
|
141
|
-
value.respond_to?(:as_json) ? value.as_json(opts) : value
|
|
142
|
-
end
|
|
135
|
+
memo[field_name] = value.is_a?(Array) ? value.map { |e| value_as_json(e, opts) } : value_as_json(value, opts)
|
|
143
136
|
end
|
|
144
137
|
end
|
|
145
138
|
|
|
@@ -160,38 +153,54 @@ module Schema
|
|
|
160
153
|
|
|
161
154
|
private
|
|
162
155
|
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
# values without as_json (no ActiveSupport JSON extension) are returned as is
|
|
157
|
+
def value_as_json(value, opts)
|
|
158
|
+
value.respond_to?(:as_json) ? value.as_json(opts) : value
|
|
159
|
+
end
|
|
165
160
|
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
# symbol keys use the schema directly, anything else is matched by its string form
|
|
162
|
+
def field_options_for_key(key)
|
|
163
|
+
key.is_a?(Symbol) ? self.class.schema[key] : self.class.schema_with_string_keys[key.to_s]
|
|
164
|
+
end
|
|
168
165
|
|
|
169
|
-
|
|
166
|
+
# names a field can be listed under in skip_fields: its name or the alias used in the data
|
|
167
|
+
def skip_field_names(field_options)
|
|
168
|
+
[field_options[:name], field_options[:key]].flat_map { |name| [name.to_sym, name.to_s] }
|
|
170
169
|
end
|
|
171
170
|
|
|
172
|
-
def update_model_attributes(
|
|
171
|
+
def update_model_attributes(data, skip_fields)
|
|
173
172
|
data.each do |key, value|
|
|
174
|
-
unless
|
|
173
|
+
unless (field_options = field_options_for_key(key))
|
|
175
174
|
parsing_errors.add(key, ::Schema::ParsingErrors::UNKNOWN_ATTRIBUTE) if self.class.capture_unknown_attributes?
|
|
176
175
|
next
|
|
177
176
|
end
|
|
178
177
|
|
|
179
|
-
next if
|
|
180
|
-
next if skip_fields.
|
|
178
|
+
next if field_options[:association]
|
|
179
|
+
next if skip_fields.intersect?(skip_field_names(field_options))
|
|
181
180
|
|
|
182
|
-
public_send(
|
|
181
|
+
public_send(field_options[:setter], value)
|
|
183
182
|
end
|
|
184
183
|
end
|
|
185
184
|
|
|
186
|
-
def update_associations(
|
|
185
|
+
def update_associations(data, skip_fields)
|
|
187
186
|
data.each do |key, value|
|
|
188
|
-
next unless
|
|
189
|
-
next unless
|
|
187
|
+
next unless (field_options = field_options_for_key(key))
|
|
188
|
+
next unless field_options[:association]
|
|
189
|
+
|
|
190
|
+
names = skip_field_names(field_options)
|
|
191
|
+
next if skip_fields.intersect?(names)
|
|
192
|
+
|
|
193
|
+
public_send(field_options[:setter], value, association_skip_fields(skip_fields, names))
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def association_skip_fields(skip_fields, names)
|
|
198
|
+
skip_fields.each do |skip_field|
|
|
199
|
+
next unless skip_field.is_a?(Hash)
|
|
190
200
|
|
|
191
|
-
|
|
192
|
-
association_skip_fields = association_skip_fields ? association_skip_fields[key] : []
|
|
193
|
-
public_send(schema[key][:setter], value, association_skip_fields)
|
|
201
|
+
names.each { |name| return skip_field[name] if skip_field.key?(name) }
|
|
194
202
|
end
|
|
203
|
+
[]
|
|
195
204
|
end
|
|
196
205
|
end
|
|
197
206
|
end
|
|
@@ -6,9 +6,9 @@ module Schema
|
|
|
6
6
|
module Parsers
|
|
7
7
|
# Schema::Parsers::Common are parser methods for basic types
|
|
8
8
|
module Common
|
|
9
|
-
INTEGER_REGEX =
|
|
10
|
-
FLOAT_REGEX =
|
|
11
|
-
BOOLEAN_REGEX =
|
|
9
|
+
INTEGER_REGEX = /\A[-+]?(?:[1-9]\d*|0)(?:\.0+)?\z/
|
|
10
|
+
FLOAT_REGEX = /\A[-+]?(?:[1-9]\d*|0)(?:\.\d+)?([Ee]-?\d+)?\z/
|
|
11
|
+
BOOLEAN_REGEX = /\A(?:1|t|true|on|y|yes)\z/i
|
|
12
12
|
|
|
13
13
|
def parse_integer(field_name, parsing_errors, value)
|
|
14
14
|
case value
|
|
@@ -22,8 +22,7 @@ module Schema
|
|
|
22
22
|
nil
|
|
23
23
|
end
|
|
24
24
|
when Float
|
|
25
|
-
|
|
26
|
-
value.to_i
|
|
25
|
+
parse_float_as_integer(field_name, parsing_errors, value)
|
|
27
26
|
when nil
|
|
28
27
|
nil
|
|
29
28
|
else
|
|
@@ -32,6 +31,16 @@ module Schema
|
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
34
|
+
def parse_float_as_integer(field_name, parsing_errors, value)
|
|
35
|
+
unless value.finite?
|
|
36
|
+
parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE)
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE) if (value % 1) > 0.0
|
|
41
|
+
value.to_i
|
|
42
|
+
end
|
|
43
|
+
|
|
35
44
|
def parse_string(field_name, parsing_errors, value)
|
|
36
45
|
case value
|
|
37
46
|
when String
|
data/lib/schema/utils.rb
CHANGED
|
@@ -56,13 +56,20 @@ module Schema
|
|
|
56
56
|
kls.send(:include, ::Schema::Associations::DynamicTypes)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# each call gets its own copy so callers can't mutate the shared default
|
|
60
|
+
def copy_default(value)
|
|
61
|
+
return value if value.frozen?
|
|
62
|
+
|
|
63
|
+
Marshal.load(Marshal.dump(value))
|
|
64
|
+
rescue TypeError
|
|
65
|
+
value.dup
|
|
66
|
+
end
|
|
67
|
+
|
|
59
68
|
def add_attribute_default_methods(kls, options)
|
|
69
|
+
default = options[:default]
|
|
70
|
+
kls.send(:define_method, options[:default_method]) { ::Schema::Utils.copy_default(default) }
|
|
60
71
|
kls.class_eval(
|
|
61
72
|
<<-STR, __FILE__, __LINE__ + 1
|
|
62
|
-
def #{options[:default_method]}
|
|
63
|
-
#{options[:default].inspect}
|
|
64
|
-
end
|
|
65
|
-
|
|
66
73
|
def #{options[:getter]}
|
|
67
74
|
if #{options[:instance_variable]}.nil?
|
|
68
75
|
#{options[:default_method]}
|
data/lib/schema/version.rb
CHANGED
data/schema-model.gemspec
CHANGED
|
@@ -6,9 +6,11 @@ Gem::Specification.new do |s|
|
|
|
6
6
|
s.name = 'schema-model'
|
|
7
7
|
s.version = Schema::VERSION
|
|
8
8
|
s.licenses = ['MIT']
|
|
9
|
-
s.summary = '
|
|
10
|
-
s.description = '
|
|
11
|
-
'
|
|
9
|
+
s.summary = 'Typed Ruby models built from hashes, JSON and CSV rows'
|
|
10
|
+
s.description = 'Declare typed attributes and Schema turns incoming hashes, JSON and CSV rows into Ruby objects. ' \
|
|
11
|
+
'Values are coerced to integers, floats, booleans, dates, times, arrays and hashes, and bad input ' \
|
|
12
|
+
'is recorded as parsing errors instead of raised. Supports nested has_one/has_many associations, ' \
|
|
13
|
+
'polymorphic types chosen by a type field, CSV header mapping, and ActiveModel validations.'
|
|
12
14
|
s.authors = ['Doug Youch']
|
|
13
15
|
s.email = 'dougyouch@gmail.com'
|
|
14
16
|
s.homepage = 'https://github.com/dougyouch/schema'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: schema-model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
@@ -23,8 +23,11 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
|
-
description:
|
|
27
|
-
|
|
26
|
+
description: Declare typed attributes and Schema turns incoming hashes, JSON and CSV
|
|
27
|
+
rows into Ruby objects. Values are coerced to integers, floats, booleans, dates,
|
|
28
|
+
times, arrays and hashes, and bad input is recorded as parsing errors instead of
|
|
29
|
+
raised. Supports nested has_one/has_many associations, polymorphic types chosen
|
|
30
|
+
by a type field, CSV header mapping, and ActiveModel validations.
|
|
28
31
|
email: dougyouch@gmail.com
|
|
29
32
|
executables:
|
|
30
33
|
- schema-json2csv
|
|
@@ -93,5 +96,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
93
96
|
requirements: []
|
|
94
97
|
rubygems_version: 4.0.20
|
|
95
98
|
specification_version: 4
|
|
96
|
-
summary:
|
|
99
|
+
summary: Typed Ruby models built from hashes, JSON and CSV rows
|
|
97
100
|
test_files: []
|