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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53c13ff970f92e81b387ef19cb957afed64e77ec42ff63998a2d73fd2c383849
4
- data.tar.gz: fe073187e725a642bc9e38a43798d99c5388bfad239e709c6d0d8be1c8cf699a
3
+ metadata.gz: c75de56a15a674efd32d16fdedda825e056c4b71e6e3a1d20c63761ae685c5c2
4
+ data.tar.gz: 25537f375a8da4d892bc4c6950e790d8db3b0859f925d302d27b9bef82a773f9
5
5
  SHA512:
6
- metadata.gz: 8186811e01c702fd082948daccc4ec5f0aaf67afefe15c0ca4200557e0b7397178eb0dcdb4ad1c44500d7284b8da31d6f607f4ae8f5652b3bff3fd66512cc0cd
7
- data.tar.gz: 4d8b2fc23e68a4209571ce131b893dfa250ad85cf61f37fae48a47098d1fb4cda6730171c32a3c86dfb406f55cafea10a28ff3b65b8b1e33870801c4cfc39c3d
6
+ metadata.gz: ec765c52a43b319a6e47879239f92f268a6c7c359163c001c2d9411f286fa41f7036a6739adf4e3f6531f7f9becc5c191c5ef51610f1128903d94a5f4ab6653e
7
+ data.tar.gz: 39dc1faf72f89f17dc75909421e1b1cc4c1d3e1b04db03661716f4f772a0ef5051ad8046e284ff34185b0da4dc27fdb44aaf720d7441a795fae18cd89c4f36a8
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.1"
2
+ ".": "0.7.2"
3
3
  }
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,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 # 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
 
@@ -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? # => false (also runs validations)
282
- user.errors.full_messages # => ["Age is invalid", ...]
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
- user = UserSchema.from_hash(name: 'John', email: nil)
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
- ### Schema::CSVParser Module
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
- Parse CSV data directly into models:
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::Model
388
- include Schema::CSVParser
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
- csv_data = CSV.parse("name,email\nJohn,john@example.com", headers: true)
395
- parser = Schema::CSVParser.new(csv_data, UserCSVSchema)
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
- UserSchema.get_mapped_field_names(mapped) # => ["name", "email"]
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
- 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
@@ -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
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.2'
6
6
  end
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 = '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'
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.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: 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
@@ -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: []