schema-model 0.7.1 → 0.7.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53c13ff970f92e81b387ef19cb957afed64e77ec42ff63998a2d73fd2c383849
4
- data.tar.gz: fe073187e725a642bc9e38a43798d99c5388bfad239e709c6d0d8be1c8cf699a
3
+ metadata.gz: cd3db69d1a56251cda03284c2af23954d69d79e697fa74c47d564eb1ceb9ddb7
4
+ data.tar.gz: 79687f0746ef3ea15c78b9ca99baf650b361e31e7ef2f7612f54da6e6fc2ec2d
5
5
  SHA512:
6
- metadata.gz: 8186811e01c702fd082948daccc4ec5f0aaf67afefe15c0ca4200557e0b7397178eb0dcdb4ad1c44500d7284b8da31d6f607f4ae8f5652b3bff3fd66512cc0cd
7
- data.tar.gz: 4d8b2fc23e68a4209571ce131b893dfa250ad85cf61f37fae48a47098d1fb4cda6730171c32a3c86dfb406f55cafea10a28ff3b65b8b1e33870801c4cfc39c3d
6
+ metadata.gz: 96fa7aa88bbc344fe24dbb245c7e2f3bc90a2dccda8386a41a0171eedb740ca15b3413bf6f73db4ef4f642e21142b83acf6a3de4f136e3b70e87a4e7752bb052
7
+ data.tar.gz: 7b0e3e1960f407ca135516910340b7f05679f1fd12ebed0efe66b4865b8f9d96ea73d64156bc60d45a3298a3be1aaa95cb2c2a34c82af1282438a69b0707ee45
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.1"
2
+ ".": "0.7.3"
3
3
  }
data/.rubocop.yml CHANGED
@@ -48,10 +48,6 @@ Naming/FileName:
48
48
  Exclude:
49
49
  - 'lib/schema-model.rb'
50
50
 
51
- # Gemspec settings
52
- Gemspec/RequiredRubyVersion:
53
- Enabled: false
54
-
55
51
  # DSL methods following ActiveRecord conventions (has_one, has_many)
56
52
  Naming/PredicatePrefix:
57
53
  Exclude:
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 hash keys, matches against schema, invokes setters
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 key determines the subclass.
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
- Methods `parsed!` and `valid!` raise `ParsingException`/`ValidationException` respectively.
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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.3](https://github.com/dougyouch/schema/compare/v0.7.2...v0.7.3) (2026-10-04)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **array-headers:** map aliased associations once under their name ([20258e2](https://github.com/dougyouch/schema/commit/20258e22bb59bbfaac088a71dccd773b7ef9255c))
9
+ * **parsers:** accept signed exponents and treat nil as nil for array, hash and json ([d4ac539](https://github.com/dougyouch/schema/commit/d4ac539ceba92354b47504523c18bbe3ed209e09))
10
+
11
+ ## [0.7.2](https://github.com/dougyouch/schema/compare/v0.7.1...v0.7.2) (2026-10-04)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **associations:** read type field aliases and handle bad hash-keyed entries ([d5bdb05](https://github.com/dougyouch/schema/commit/d5bdb05c4ce90cd40e50f1619829f0e29082cec6))
17
+ * **model:** honor skip_fields for any key style and fix key lookup edge cases ([3ca3450](https://github.com/dougyouch/schema/commit/3ca34500f71e62c9bfcf0c49419bac22bdfc56bd))
18
+ * **parsers:** anchor parser regexes and reject non-finite floats as integers ([0514bd6](https://github.com/dougyouch/schema/commit/0514bd6175031a146bbc3a5d178b9e0fd32b4c7f))
19
+
3
20
  ## [0.7.1](https://github.com/dougyouch/schema/compare/v0.7.0...v0.7.1) (2026-10-04)
4
21
 
5
22
 
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 # Boolean (accepts: 1, t, true, on, y, yes)
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
 
@@ -100,8 +100,11 @@ attribute :name, :string, aliases: [:full_name, :display_name]
100
100
  attribute :status, :string, default: 'pending'
101
101
  attribute :count, :integer, default: 0
102
102
  attribute :tags, :array, default: []
103
+ attribute :starts_on, :date, default: Date.new(2024, 1, 1)
103
104
  ```
104
105
 
106
+ Each read of a default returns a fresh copy (frozen values are shared), so mutating it doesn't change the default for other instances.
107
+
105
108
  ### Checking If Attribute Was Set
106
109
 
107
110
  Every attribute generates a `_was_set?` predicate method:
@@ -171,10 +174,19 @@ order.items.first.sku # => "ABC"
171
174
  has_one :profile, default: true
172
175
  has_many :tags, default: true
173
176
 
177
+ # Aliases for the association key
178
+ has_one :profile, alias: :user_profile
179
+ has_many :items, aliases: [:line_items]
180
+
174
181
  # Reuse existing schema class
175
182
  has_one :shipping_address, base_class: AddressSchema
176
183
  has_one :billing_address, base_class: AddressSchema
177
184
 
185
+ # Name the generated class (defaults to SchemaHasOneProfile, SchemaHasManyItems, ...)
186
+ has_one :profile, class_name: 'ProfileSchema' do
187
+ attribute :bio, :string
188
+ end
189
+
178
190
  # Has many from hash (keyed by field)
179
191
  has_many :items, from: :hash, hash_key_field: :id do
180
192
  attribute :id, :string
@@ -236,7 +248,8 @@ company.employees[1].team_size # => 5
236
248
  ```ruby
237
249
  # Type field within nested data (default)
238
250
  has_many :items, type_field: :kind do
239
- # looks for :kind in each item's data
251
+ # looks for :kind in each item's data, or an alias of the kind attribute
252
+ attribute :kind, :string, alias: :category
240
253
  end
241
254
 
242
255
  # Type determined by parent field
@@ -250,6 +263,8 @@ has_many :items, type_field: :type, type_ignorecase: true do
250
263
  end
251
264
  ```
252
265
 
266
+ 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.
267
+
253
268
  ## Validation and Error Handling
254
269
 
255
270
  ### ActiveModel Validations
@@ -270,16 +285,36 @@ end
270
285
 
271
286
  ### Parsing Errors vs Validation Errors
272
287
 
288
+ Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both).
289
+
273
290
  ```ruby
274
- user = UserSchema.from_hash(name: 'John', age: 'not-a-number')
291
+ user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
275
292
 
276
293
  # Parsing errors (type conversion failures)
277
294
  user.parsing_errors.empty? # => false
278
295
  user.parsed? # => false
296
+ user.age # => nil
279
297
 
280
298
  # Validation errors (business rules)
281
- user.valid? # => false (also runs validations)
282
- user.errors.full_messages # => ["Age is invalid", ...]
299
+ user.valid? # => true (age is nil, which allow_nil permits)
300
+ user.errors.full_messages # => []
301
+ ```
302
+
303
+ ### Validating Nested Schemas
304
+
305
+ `validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, is invalid:
306
+
307
+ ```ruby
308
+ class OrderSchema
309
+ include Schema::All
310
+
311
+ has_one :customer do
312
+ attribute :name, :string
313
+ validates :name, presence: true
314
+ end
315
+
316
+ validates :customer, presence: true, schema: true
317
+ end
283
318
  ```
284
319
 
285
320
  ### Raising Exceptions
@@ -312,12 +347,21 @@ user.parsing_errors[:unknown_field] # => ["unknown_attribute"]
312
347
  UserSchema.capture_unknown_attributes = false
313
348
  ```
314
349
 
350
+ 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.
351
+
315
352
  ## Serialization
316
353
 
317
354
  ### to_hash / as_json
318
355
 
319
356
  ```ruby
320
- user = UserSchema.from_hash(name: 'John', email: nil)
357
+ class ContactSchema
358
+ include Schema::All
359
+
360
+ attribute :name, :string
361
+ attribute :email, :string
362
+ end
363
+
364
+ user = ContactSchema.from_hash(name: 'John', email: nil)
321
365
 
322
366
  user.to_hash # => { name: "John", email: nil }
323
367
  user.as_json # => { name: "John" } (excludes nils)
@@ -348,17 +392,23 @@ user.created_at # => nil (not set)
348
392
 
349
393
  # Nested skip_fields for associations
350
394
  order = OrderSchema.from_hash(data, [:id, { items: [:id] }])
395
+
396
+ # Skip an association entirely
397
+ order = OrderSchema.from_hash(data, [:items])
351
398
  ```
352
399
 
400
+ A skipped field is ignored whether the data uses symbol keys, string keys, or one of the field's aliases.
401
+
353
402
  ## Array and CSV Support
354
403
 
355
404
  ### Schema::Arrays Module
356
405
 
357
- Convert models to/from flat arrays (useful for CSV/spreadsheet data):
406
+ 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
407
 
359
408
  ```ruby
360
409
  class UserSchema
361
410
  include Schema::All
411
+ schema_include Schema::ArrayHeaders
362
412
  schema_include Schema::Arrays
363
413
 
364
414
  attribute :name, :string
@@ -378,21 +428,36 @@ mapped = UserSchema.map_headers_to_attributes(headers)
378
428
  user = UserSchema.from_array(['Jane', 'jane@example.com'], mapped)
379
429
  ```
380
430
 
381
- ### Schema::CSVParser Module
431
+ `to_headers`, `to_empty_array` and `to_a` need a fixed number of columns for each has-many association, so give it a `size`:
432
+
433
+ ```ruby
434
+ has_many :phones, size: 3 do
435
+ attribute :number, :string
436
+ end
437
+
438
+ UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
439
+ ```
440
+
441
+ ### Schema::CSVParser
382
442
 
383
- Parse CSV data directly into models:
443
+ `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
444
 
385
445
  ```ruby
446
+ require 'csv'
447
+
386
448
  class UserCSVSchema
387
- include Schema::Model
388
- include Schema::CSVParser
449
+ include Schema::All
450
+ schema_include Schema::ArrayHeaders
451
+ schema_include Schema::Arrays
389
452
 
390
453
  attribute :name, :string
391
454
  attribute :email, :string
392
455
  end
393
456
 
394
- csv_data = CSV.parse("name,email\nJohn,john@example.com", headers: true)
395
- parser = Schema::CSVParser.new(csv_data, UserCSVSchema)
457
+ csv = CSV.new("name,email\nJohn,john@example.com\n")
458
+ parser = Schema::CSVParser.new(csv, UserCSVSchema)
459
+
460
+ parser.missing_fields(%w[name email phone]) # => ["phone"]
396
461
 
397
462
  parser.each do |user|
398
463
  puts user.name
@@ -416,10 +481,13 @@ headers = ['FullName', 'email', 'unknown_column']
416
481
  mapped = UserSchema.map_headers_to_attributes(headers)
417
482
  # => { name: { index: 0 }, email: { index: 1 } }
418
483
 
419
- UserSchema.get_mapped_field_names(mapped) # => ["name", "email"]
484
+ # Field names are reported by their first alias when they have one
485
+ UserSchema.get_mapped_field_names(mapped) # => ["FullName", "email"]
420
486
  UserSchema.get_unmapped_field_names(mapped) # => []
421
487
  ```
422
488
 
489
+ 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'`.
490
+
423
491
  ## Extending Schemas
424
492
 
425
493
  ### schema_include
@@ -75,6 +75,8 @@ module Schema
75
75
  def map_headers_to_has_one_associations(headers, mapped_headers, header_prefix)
76
76
  schema.each do |field_name, field_options|
77
77
  next unless field_options[:type] == :has_one
78
+ # aliases are matched through the association's own entry
79
+ next if field_options[:alias_of]
78
80
 
79
81
  mapped_model = get_mapped_model(field_options, headers, header_prefix)
80
82
  next if mapped_model.empty?
@@ -87,6 +89,7 @@ module Schema
87
89
  def map_headers_to_has_many_associations(headers, mapped_headers)
88
90
  schema.each do |field_name, field_options|
89
91
  next unless field_options[:type] == :has_many
92
+ next if field_options[:alias_of]
90
93
 
91
94
  get_header_prefixes(field_name, field_options).each do |header_prefix|
92
95
  mapped_model = get_mapped_model(field_options, headers, header_prefix)
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
- elsif info.is_a?(Hash)
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.send(schema.class.schema[@hash_key_field][:setter], key)
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
- elsif @external_type_field
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
- ] + @aliases
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
- @schema_with_string_keys ||=
38
- begin
39
- hsh = {}
40
- schema.each { |field_name, field_options| hsh[field_name.to_s] = field_options }
41
- hsh.freeze
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
- schema = get_schema(data)
124
- update_model_attributes(schema, data, skip_fields)
125
- update_associations(schema, data, skip_fields)
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] = if value.is_a?(Array)
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
- def get_schema(data)
164
- return self.class.schema_with_string_keys unless data.is_a?(Hash)
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
- first_key = data.each_key.first
167
- return self.class.schema_with_string_keys unless first_key.is_a?(Symbol)
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
- self.class.schema
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(schema, data, skip_fields)
171
+ def update_model_attributes(data, skip_fields)
173
172
  data.each do |key, value|
174
- unless schema.key?(key)
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 schema[key][:association]
180
- next if skip_fields.include?(key)
178
+ next if field_options[:association]
179
+ next if skip_fields.intersect?(skip_field_names(field_options))
181
180
 
182
- public_send(schema[key][:setter], value)
181
+ public_send(field_options[:setter], value)
183
182
  end
184
183
  end
185
184
 
186
- def update_associations(schema, data, skip_fields)
185
+ def update_associations(data, skip_fields)
187
186
  data.each do |key, value|
188
- next unless schema.key?(key)
189
- next unless schema[key][:association]
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
- association_skip_fields = skip_fields.detect { |f| f.is_a?(Hash) && f.include?(key) }
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
@@ -17,6 +17,8 @@ module Schema
17
17
 
18
18
  parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE)
19
19
  nil
20
+ when nil
21
+ nil
20
22
  else
21
23
  parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE)
22
24
  nil
@@ -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 = /^[-+]?(?:[1-9]\d*|0)(?:\.0+)?$/
10
- FLOAT_REGEX = /^[-+]?(?:[1-9]\d*|0)(?:\.\d+)?([Ee]-?\d+)?$/
11
- BOOLEAN_REGEX = /^(?:1|t|true|on|y|yes)$/i
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
- parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE) if (value % 1) > 0.0
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
@@ -8,6 +8,8 @@ module Schema
8
8
  case value
9
9
  when ::Hash
10
10
  value
11
+ when nil
12
+ nil
11
13
  else
12
14
  parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE)
13
15
  nil
@@ -15,6 +15,8 @@ module Schema
15
15
  parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
16
16
  nil
17
17
  end
18
+ when nil
19
+ nil
18
20
  else
19
21
  parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE)
20
22
  nil
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]}
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Schema
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.7.1'
5
+ VERSION = '0.7.3'
6
6
  end
data/lib/schema-model.rb CHANGED
@@ -24,7 +24,6 @@ module Schema
24
24
  autoload :Arrays, 'schema/arrays'
25
25
  autoload :CSVParser, 'schema/csv_parser'
26
26
  autoload :Errors, 'schema/errors'
27
- autoload :CSVParser, 'schema/csv_parser'
28
27
  autoload :Model, 'schema/model'
29
28
  autoload :ParsingErrors, 'schema/parsing_errors'
30
29
  autoload :Utils, 'schema/utils'
data/schema-model.gemspec CHANGED
@@ -6,13 +6,16 @@ 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 = 'Data transformation, validation, and type safety for Ruby'
10
- s.description = 'A flexible DSL for defining strongly-typed data models with automatic parsing, ' \
11
- 'nested associations, dynamic types, and ActiveModel validations'
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'
15
17
  s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ s.required_ruby_version = '>= 3.2'
16
19
  s.bindir = 'bin'
17
20
  s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
21
 
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.1
4
+ version: 0.7.3
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: A flexible DSL for defining strongly-typed data models with automatic
27
- parsing, nested associations, dynamic types, and ActiveModel validations
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
@@ -84,7 +87,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
84
87
  requirements:
85
88
  - - ">="
86
89
  - !ruby/object:Gem::Version
87
- version: '0'
90
+ version: '3.2'
88
91
  required_rubygems_version: !ruby/object:Gem::Requirement
89
92
  requirements:
90
93
  - - ">="
@@ -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: Data transformation, validation, and type safety for Ruby
99
+ summary: Typed Ruby models built from hashes, JSON and CSV rows
97
100
  test_files: []