schema-model 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd3db69d1a56251cda03284c2af23954d69d79e697fa74c47d564eb1ceb9ddb7
4
- data.tar.gz: 79687f0746ef3ea15c78b9ca99baf650b361e31e7ef2f7612f54da6e6fc2ec2d
3
+ metadata.gz: f672bab800b79f5e9a8b390268466caaf7a42822b0b0632f023f6539fbae48aa
4
+ data.tar.gz: 5486532778b3a246d66c0c2350586fd77ebf9955ef311a719c5053d656afe0be
5
5
  SHA512:
6
- metadata.gz: 96fa7aa88bbc344fe24dbb245c7e2f3bc90a2dccda8386a41a0171eedb740ca15b3413bf6f73db4ef4f642e21142b83acf6a3de4f136e3b70e87a4e7752bb052
7
- data.tar.gz: 7b0e3e1960f407ca135516910340b7f05679f1fd12ebed0efe66b4865b8f9d96ea73d64156bc60d45a3298a3be1aaa95cb2c2a34c82af1282438a69b0707ee45
6
+ metadata.gz: ca482dfd23e73cea044bf572e819864415ee68768cdb0b98a756e92fbb06c2d800b7cdd6c2afde4d5c77559d226a7e3cf681f4043a17b118c17ed24fee0cdb9c
7
+ data.tar.gz: f555174a32c7adbe3b713bb726038f325168f07820d6309479be0715af91bb5e684d73d51466a0f71cc806ecd7ca98f2006bf8820008c126ee4f25f220ffdcde
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/dougyouch/schema/compare/v0.7.4...v0.8.0) (2026-10-04)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **model:** unrecognized boolean strings, and dates that aren't ISO 8601, are now invalid instead of false or a guessed date; blank strings parse to nil instead of being invalid (integer, float, date, time) or false (boolean); with ActiveModel, parsing_errors messages are readable text such as "is invalid" instead of codes such as "invalid"; capture_unknown_attributes= now changes nested association classes; and models compare by value with ==.
9
+
10
+ ### Features
11
+
12
+ * **model:** stricter parsing, readable parsing errors and model helpers ([9267d77](https://github.com/dougyouch/schema/commit/9267d770f79b260bae2b3890f31f583e1684bdbe))
13
+
14
+ ## [0.7.4](https://github.com/dougyouch/schema/compare/v0.7.3...v0.7.4) (2026-10-04)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **array-headers:** fix nested field names, association columns and column collisions ([f0373e0](https://github.com/dougyouch/schema/commit/f0373e0450e9d474dcde5e02b1db76b9c7bbc7d4))
20
+ * **associations:** support dynamic types in anonymous parent classes ([45d22da](https://github.com/dougyouch/schema/commit/45d22da233ec97703ba3c3bd90a91e577bac5390))
21
+ * **cli:** write csv headers to stdout and lint schema-json2csv ([58ebb13](https://github.com/dougyouch/schema/commit/58ebb138d58cbddca60c2e7214ab980d3c0aae12))
22
+
3
23
  ## [0.7.3](https://github.com/dougyouch/schema/compare/v0.7.2...v0.7.3) (2026-10-04)
4
24
 
5
25
 
data/README.md CHANGED
@@ -8,10 +8,16 @@ A powerful Ruby gem for data transformation, validation, and type safety. Schema
8
8
 
9
9
  ## Installation
10
10
 
11
- Add this line to your application's Gemfile:
11
+ Requires Ruby 3.2 or newer. Add this line to your application's Gemfile:
12
12
 
13
13
  ```ruby
14
14
  gem 'schema-model'
15
+
16
+ # needed for Schema::All and Schema::ActiveModelValidations (already present in Rails apps)
17
+ gem 'activemodel'
18
+
19
+ # needed for Schema::CSVParser and bin/schema-json2csv on Ruby 3.4+, where csv is no longer a default gem
20
+ gem 'csv'
15
21
  ```
16
22
 
17
23
  And then execute:
@@ -20,6 +26,8 @@ And then execute:
20
26
  $ bundle install
21
27
  ```
22
28
 
29
+ `Schema::Model` on its own has no dependencies beyond `inheritance-helper`.
30
+
23
31
  ## Quick Start
24
32
 
25
33
  ```ruby
@@ -57,17 +65,21 @@ user.tags # => ["ruby", "rails", "developer"]
57
65
 
58
66
  ```ruby
59
67
  attribute :name, :string # String values
60
- attribute :count, :integer # Integer values (parses "123" to 123)
61
- attribute :price, :float # Float values (parses "9.99" to 9.99)
62
- attribute :active, :boolean # true for 1, t, true, on, y, yes (any case); other strings are false; numbers are true unless 0
68
+ attribute :count, :integer # Integer values (parses "123", " 123 " and "0123" to 123)
69
+ attribute :price, :float # Float values (parses "9.99" to 9.99, also "1e+5")
70
+ attribute :active, :boolean # see below
63
71
  attribute :notes, :string_or_nil # String, but returns nil if empty
64
72
  ```
65
73
 
74
+ Booleans accept `1, t, true, on, y, yes` as `true` and `0, f, false, off, n, no` as `false` (any case). Any other string is an `invalid` parsing error, and numbers are `true` unless they're 0.
75
+
76
+ For `:integer`, `:float`, `:boolean`, `:date`, `:time`, `:american_date` and `:american_time`, strings are stripped first and a blank string parses to `nil` without an error, so empty CSV cells don't count as bad input.
77
+
66
78
  ### Date and Time Types
67
79
 
68
80
  ```ruby
69
- attribute :created_at, :time # ISO 8601 format (Time.xmlschema)
70
- attribute :birth_date, :date # Date.parse format
81
+ attribute :created_at, :time # ISO 8601 date and time (Time.xmlschema)
82
+ attribute :birth_date, :date # ISO 8601 (Date.iso8601), e.g. "2024-01-31"; free text like "May 1" is invalid
71
83
  attribute :us_date, :american_date # MM/DD/YYYY format
72
84
  attribute :us_time, :american_time # MM/DD/YYYY HH:MM:SS format
73
85
  ```
@@ -82,6 +94,8 @@ attribute :metadata, :hash # Hash/dictionary values
82
94
  attribute :config, :json # Parse JSON strings
83
95
  ```
84
96
 
97
+ Each type `:foo` is parsed by a `parse_foo` method, so you can add types by defining that method on the model or in a module you `schema_include`. Setting a value on an attribute whose type has no parser raises `Schema::UnknownTypeError` naming the attribute and the missing method.
98
+
85
99
  ## Attribute Options
86
100
 
87
101
  ### Aliases
@@ -285,7 +299,7 @@ end
285
299
 
286
300
  ### Parsing Errors vs Validation Errors
287
301
 
288
- Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both).
302
+ Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both). `parsed?` and `parsed!` are available on every `Schema::Model`, not only with ActiveModel.
289
303
 
290
304
  ```ruby
291
305
  user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
@@ -300,6 +314,22 @@ user.valid? # => true (age is nil, which allow_nil permits)
300
314
  user.errors.full_messages # => []
301
315
  ```
302
316
 
317
+ With `Schema::All` (or `Schema::ActiveModelValidations`), `parsing_errors` is an `ActiveModel::Errors` with readable messages:
318
+
319
+ ```ruby
320
+ user.parsing_errors.full_messages # => ["Age is invalid"]
321
+ ```
322
+
323
+ | Code | Message | I18n key |
324
+ |---|---|---|
325
+ | `invalid` | is invalid | `schema.parsing_errors.invalid` |
326
+ | `incompatible` | is an incompatible type | `schema.parsing_errors.incompatible` |
327
+ | `unknown` | has an unknown type | `schema.parsing_errors.unknown` |
328
+ | `unknown_attribute` | is an unknown attribute | `schema.parsing_errors.unknown_attribute` |
329
+ | `unhandled_type` | is an unhandled type | `schema.parsing_errors.unhandled_type` |
330
+
331
+ A plain `Schema::Model` uses `Schema::Errors`, which stores the codes themselves (`user.parsing_errors[:age] # => ["invalid"]`).
332
+
303
333
  ### Validating Nested Schemas
304
334
 
305
335
  `validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, is invalid:
@@ -341,13 +371,13 @@ By default, unknown attributes are captured as parsing errors:
341
371
 
342
372
  ```ruby
343
373
  user = UserSchema.from_hash(name: 'John', unknown_field: 'value')
344
- user.parsing_errors[:unknown_field] # => ["unknown_attribute"]
374
+ user.parsing_errors[:unknown_field] # => ["is an unknown attribute"]
345
375
 
346
376
  # Disable this behavior
347
377
  UserSchema.capture_unknown_attributes = false
348
378
  ```
349
379
 
350
- The setting 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.
380
+ The setting also applies to nested association classes, including ones declared after it is set.
351
381
 
352
382
  ## Serialization
353
383
 
@@ -372,6 +402,23 @@ user.as_json(select_filter: ->(name, value, opts) { name == :name })
372
402
  user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
373
403
  ```
374
404
 
405
+ ### Comparing, Inspecting and Copying
406
+
407
+ ```ruby
408
+ a = ContactSchema.from_hash(name: 'John')
409
+ b = ContactSchema.from_hash('name' => 'John')
410
+
411
+ a == b # => true (same class and attribute values, nested models included)
412
+ a.attribute_values # => { name: "John", email: nil }
413
+ a.inspect # => #<ContactSchema name: "John"> (only attributes that were set)
414
+
415
+ copy = a.deep_dup # copies nested models, arrays, hashes, strings and parsing errors
416
+ copy.name << '!'
417
+ a.name # => "John"
418
+ ```
419
+
420
+ `dup` is Ruby's shallow copy, so nested models are shared with the original.
421
+
375
422
  ## Protecting Fields with skip_fields
376
423
 
377
424
  Prevent certain fields from being set by user input:
@@ -438,6 +485,8 @@ end
438
485
  UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
439
486
  ```
440
487
 
488
+ `to_a` writes at most `size` entries: a model with more entries than that has the extra ones left out of the array, with no error, so pick a `size` large enough for your data.
489
+
441
490
  ### Schema::CSVParser
442
491
 
443
492
  `Schema::CSVParser` is a class that reads rows from a `CSV` object into models. The model needs `Schema::ArrayHeaders` and `Schema::Arrays`. Pass a plain `CSV` (not one created with `headers: true`); the first row is used as the headers unless you pass them in:
@@ -521,6 +570,8 @@ schema-json2csv --require ./my_schema.rb --schema MySchema --json data.json --cs
521
570
  cat data.json | schema-json2csv --require ./my_schema.rb --schema MySchema - --csv output.csv
522
571
  ```
523
572
 
573
+ The JSON can be one object or an array of objects. Output goes to stdout when `--csv` is omitted. A header row (from `to_headers`) is written to stdout or to a new file; an existing `--csv` file is appended to without repeating it. Has-many associations need a `size:` so every row has the same columns.
574
+
524
575
  ## Contributing
525
576
 
526
577
  1. Fork the repository
data/bin/schema-json2csv CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'schema-model'
4
5
  require 'csv'
@@ -13,7 +14,7 @@ options = {
13
14
  }
14
15
 
15
16
  OptionParser.new do |opts|
16
- opts.banner = "Usage: " + File.basename(__FILE__)
17
+ opts.banner = "Usage: #{File.basename(__FILE__)} --require FILE --schema CLASS (--json FILE | -) [--csv FILE]"
17
18
 
18
19
  opts.on('--json JSON_FILE', 'JSON data') do |v|
19
20
  options[:json_file] = v
@@ -23,7 +24,7 @@ OptionParser.new do |opts|
23
24
  options[:require_file] = v
24
25
  end
25
26
 
26
- opts.on('--csv CSV_FILE', 'File to output csv data') do |v|
27
+ opts.on('--csv CSV_FILE', 'File to output csv data (appended to if it exists)') do |v|
27
28
  options[:csv_file] = v
28
29
  end
29
30
 
@@ -32,54 +33,39 @@ OptionParser.new do |opts|
32
33
  end
33
34
  end.parse!
34
35
 
35
- raise('no filed required') unless options[:require_file]
36
- raise('no schema specified') unless options[:schema_class_name]
36
+ abort('no file required') unless options[:require_file]
37
+ abort('no schema specified') unless options[:schema_class_name]
38
+ abort("file #{options[:require_file]} not found") unless File.exist?(options[:require_file])
37
39
 
38
- $LOAD_PATH << '.'
39
- raise("file #{options[:require_file]} not found") unless require(options[:require_file])
40
+ require File.expand_path(options[:require_file])
40
41
 
41
- raise("schema #{options[:schema_class_name]} not found") unless Object.const_defined?(options[:schema_class_name])
42
+ abort("schema #{options[:schema_class_name]} not found") unless Object.const_defined?(options[:schema_class_name])
42
43
 
43
44
  schema_class = Object.const_get(options[:schema_class_name])
44
45
  schema_class.schema_include Schema::Arrays
45
46
 
46
47
  json_data =
47
48
  if options[:json_file]
48
- raise("json file #{options[:json_file]} not found") unless File.exist?(options[:json_file])
49
+ abort("json file #{options[:json_file]} not found") unless File.exist?(options[:json_file])
49
50
  File.read(options[:json_file])
50
51
  elsif ARGV.last == '-'
51
- ARGF.read
52
+ $stdin.read
52
53
  else
53
- raise('no json data specified')
54
+ abort('no json data specified')
54
55
  end
55
56
 
56
- def output_csv(schema, io)
57
- str = CSV.generate do |csv|
58
- csv << schema.to_a.flatten
59
- end
60
-
61
- io.print str
62
- end
57
+ # headers are written to stdout and to new files; an existing file is appended to as is
58
+ csv_file = options[:csv_file]
59
+ write_headers = csv_file.nil? || !File.exist?(csv_file)
60
+ io = csv_file ? File.open(csv_file, 'ab') : $stdout
63
61
 
64
- io =
65
- if file = options[:csv_file]
66
- if File.exist?(file)
67
- File.open(options[:csv_file], 'ab')
68
- else
69
- headers = CSV.generate do |csv|
70
- csv << schema_class.to_headers
71
- end
72
- f = File.open(options[:csv_file], 'wb')
73
- f.print headers
74
- f
75
- end
76
- else
77
- $stdout
78
- end
62
+ io.print CSV.generate_line(schema_class.to_headers) if write_headers
79
63
 
80
64
  json_data = JSON.parse(json_data)
81
65
  json_data = [json_data] unless json_data.is_a?(Array)
82
66
 
83
67
  json_data.each do |data|
84
- output_csv(schema_class.from_hash(data), io)
68
+ io.print CSV.generate_line(schema_class.from_hash(data).to_a.flatten)
85
69
  end
70
+
71
+ io.close if csv_file
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ module Schema
6
+ # Schema::ActiveModelParsingErrors turns parsing error codes into readable messages,
7
+ # e.g. "Age is invalid" instead of "Age invalid". Override them with I18n keys under schema.parsing_errors.
8
+ class ActiveModelParsingErrors < ActiveModel::Errors
9
+ MESSAGES = {
10
+ ::Schema::ParsingErrors::INVALID => 'is invalid',
11
+ ::Schema::ParsingErrors::INCOMPATIBLE => 'is an incompatible type',
12
+ ::Schema::ParsingErrors::UNKNOWN => 'has an unknown type',
13
+ ::Schema::ParsingErrors::UNKNOWN_ATTRIBUTE => 'is an unknown attribute',
14
+ ::Schema::ParsingErrors::UNHANDLED_TYPE => 'is an unhandled type'
15
+ }.freeze
16
+
17
+ # parsing error keys (e.g. "items:0" or an unknown key) often aren't attributes on the model,
18
+ # so codes are added as message strings rather than ActiveModel error types
19
+ def add(attribute, type = :invalid, **)
20
+ super(attribute, message_for(type), **)
21
+ end
22
+
23
+ private
24
+
25
+ def message_for(type)
26
+ return type unless MESSAGES.key?(type)
27
+
28
+ I18n.t("schema.parsing_errors.#{type}", default: MESSAGES[type])
29
+ end
30
+ end
31
+ end
@@ -3,7 +3,7 @@
3
3
  require 'active_model'
4
4
 
5
5
  module Schema
6
- # Schema::Model adds schema building methods to a class, uses ActiveModel::Errors for parsing_errors
6
+ # Schema::ActiveModelValidations adds ActiveModel validations, and readable ActiveModel::Errors for parsing_errors
7
7
  module ActiveModelValidations
8
8
  def self.included(base)
9
9
  base.schema_include ::ActiveModel::Validations
@@ -28,21 +28,7 @@ module Schema
28
28
  # no-doc
29
29
  module OverrideParsingErrors
30
30
  def parsing_errors
31
- @parsing_errors ||= ActiveModel::Errors.new(self)
32
- end
33
-
34
- def parsed?
35
- parsing_errors.empty?
36
- end
37
-
38
- def parsed!
39
- return if parsed?
40
-
41
- raise ParsingException.new(
42
- "schema parsing failed for attributes #{parsing_errors.errors.map(&:attribute).join(', ')}",
43
- self,
44
- parsing_errors
45
- )
31
+ @parsing_errors ||= ActiveModelParsingErrors.new(self)
46
32
  end
47
33
  end
48
34
  end
@@ -45,13 +45,11 @@ module Schema
45
45
 
46
46
  private
47
47
 
48
+ # has_many columns are reported as <prefix>X<field>; has_one fields share the parent's prefix
48
49
  def get_model_field_names(field_name, field_options, mapped_headers, header_prefix, mapped)
49
50
  mapped_model = mapped_headers[field_name] || {}
50
- const_get(field_options[:class_name]).get_field_names(
51
- mapped_model,
52
- header_prefix || field_options[:aliases]&.first,
53
- mapped
54
- )
51
+ header_prefix ||= (field_options[:aliases]&.first || field_name).to_s if field_options[:type] == :has_many
52
+ const_get(field_options[:class_name]).get_field_names(mapped_model, header_prefix, mapped)
55
53
  end
56
54
 
57
55
  def skip_field?(field_name, mapped_headers, mapped)
@@ -72,20 +70,36 @@ module Schema
72
70
  const_get(field_options[:class_name]).map_headers_to_attributes(headers, header_prefix)
73
71
  end
74
72
 
73
+ # a column already claimed by the parent (or an earlier has_one) isn't reused for a nested field
75
74
  def map_headers_to_has_one_associations(headers, mapped_headers, header_prefix)
75
+ claimed_indexes = mapped_header_indexes(mapped_headers)
76
76
  schema.each do |field_name, field_options|
77
77
  next unless field_options[:type] == :has_one
78
78
  # aliases are matched through the association's own entry
79
79
  next if field_options[:alias_of]
80
80
 
81
- mapped_model = get_mapped_model(field_options, headers, header_prefix)
81
+ available_headers = headers.each_with_index.map { |header, idx| claimed_indexes.include?(idx) ? nil : header }
82
+ mapped_model = get_mapped_model(field_options, available_headers, header_prefix)
82
83
  next if mapped_model.empty?
83
84
 
84
85
  mapped_headers[field_name] = mapped_model
86
+ claimed_indexes.concat(mapped_header_indexes(mapped_model))
85
87
  end
86
88
  mapped_headers
87
89
  end
88
90
 
91
+ def mapped_header_indexes(mapped_headers)
92
+ mapped_headers.each_value.flat_map do |info|
93
+ if info.key?(:index)
94
+ [info[:index]]
95
+ elsif info.key?(:indexes)
96
+ info[:indexes]
97
+ else
98
+ mapped_header_indexes(info)
99
+ end
100
+ end
101
+ end
102
+
89
103
  def map_headers_to_has_many_associations(headers, mapped_headers)
90
104
  schema.each do |field_name, field_options|
91
105
  next unless field_options[:type] == :has_many
@@ -104,6 +118,9 @@ module Schema
104
118
 
105
119
  def map_headers_to_fields(headers, mapped_headers, header_prefix)
106
120
  schema.each do |field_name, field_options|
121
+ # associations are mapped through their nested fields, not a column of their own
122
+ next if field_options[:association]
123
+
107
124
  if header_prefix
108
125
  unless (indexes = find_indexes_for_field(headers, field_options, header_prefix)).empty?
109
126
  mapped_headers[field_options[:alias_of] || field_name] = { indexes: indexes }
@@ -12,12 +12,15 @@ module Schema
12
12
  module ClassMethods
13
13
  attr_accessor :schema_name
14
14
 
15
+ # named classes are looked up by name so reloaded constants are picked up;
16
+ # anonymous classes have no name, so they are kept by reference
15
17
  def base_schema_class=(kls)
18
+ @base_schema_class = kls
16
19
  @base_schema_class_name = kls.name
17
20
  end
18
21
 
19
22
  def base_schema_class
20
- Object.const_get(@base_schema_class_name)
23
+ @base_schema_class_name ? Object.const_get(@base_schema_class_name) : @base_schema_class
21
24
  end
22
25
 
23
26
  def schema_options
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schema
4
+ # Schema::AttributeValues compares and inspects models by their attribute values
5
+ module AttributeValues
6
+ # every attribute (aliases excluded) and its current value, defaults included
7
+ def attribute_values
8
+ self.class.schema.each_with_object({}) do |(field_name, field_options), values|
9
+ next if field_options[:alias_of]
10
+
11
+ values[field_name] = public_send(field_options[:getter])
12
+ end
13
+ end
14
+
15
+ def ==(other)
16
+ other.instance_of?(self.class) && other.attribute_values == attribute_values
17
+ end
18
+
19
+ # lists the attributes that were set, e.g. #<UserSchema name: "Joe", age: 30>
20
+ def inspect
21
+ details = set_field_options.map do |field_options|
22
+ "#{field_options[:name]}: #{public_send(field_options[:getter]).inspect}"
23
+ end
24
+ "#<#{self.class.name || self.class}#{" #{details.join(', ')}" unless details.empty?}>"
25
+ end
26
+
27
+ private
28
+
29
+ def set_field_options
30
+ self.class.schema.each_value.select do |field_options|
31
+ !field_options[:alias_of] && instance_variable_defined?(field_options[:instance_variable])
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schema
4
+ # Schema::DeepCopy copies a model along with its nested models, collections and parsing errors
5
+ module DeepCopy
6
+ def deep_dup
7
+ copy = dup
8
+ copy.copy_attribute_values_from(self)
9
+ copy.copy_parsing_errors_from(self)
10
+ copy
11
+ end
12
+
13
+ protected
14
+
15
+ def copy_attribute_values_from(source)
16
+ self.class.schema.each_value do |field_options|
17
+ next if field_options[:alias_of]
18
+
19
+ ivar = field_options[:instance_variable]
20
+ next unless source.instance_variable_defined?(ivar)
21
+
22
+ instance_variable_set(ivar, ::Schema::Utils.deep_dup_value(source.instance_variable_get(ivar)))
23
+ end
24
+ end
25
+
26
+ def copy_parsing_errors_from(source)
27
+ @parsing_errors = nil
28
+ ::Schema::Utils.parsing_error_pairs(source.parsing_errors).each do |attribute, message|
29
+ parsing_errors.add(attribute, message)
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/schema/errors.rb CHANGED
@@ -24,5 +24,9 @@ module Schema
24
24
  def empty?
25
25
  @errors.empty?
26
26
  end
27
+
28
+ def attribute_names
29
+ @errors.keys
30
+ end
27
31
  end
28
32
  end
data/lib/schema/model.rb CHANGED
@@ -8,6 +8,9 @@ module Schema
8
8
  def self.included(base)
9
9
  base.extend InheritanceHelper::Methods
10
10
  base.send(:include, Schema::Parsers::Common)
11
+ base.send(:include, Schema::ParsingStatus)
12
+ base.send(:include, Schema::AttributeValues)
13
+ base.send(:include, Schema::DeepCopy)
11
14
  base.extend ClassMethods
12
15
  end
13
16
 
@@ -45,10 +48,17 @@ module Schema
45
48
  }.freeze
46
49
  end
47
50
 
51
+ # applies to nested association classes too, like schema_include
48
52
  def capture_unknown_attributes=(v)
49
53
  config = schema_config.dup
50
54
  config[:capture_unknown_attributes] = v
51
55
  redefine_class_method(:schema_config, config.freeze)
56
+
57
+ schema.each_value do |field_options|
58
+ next unless field_options[:association]
59
+
60
+ const_get(field_options[:class_name]).capture_unknown_attributes = v
61
+ end
52
62
  end
53
63
 
54
64
  def capture_unknown_attributes?
@@ -87,6 +97,7 @@ module Schema
87
97
  end
88
98
 
89
99
  def add_attribute_methods(name, options)
100
+ parser_check = parser_check_code(name, options)
90
101
  class_eval(
91
102
  <<-STR, __FILE__, __LINE__ + 1
92
103
  def #{options[:getter]}
@@ -94,6 +105,7 @@ module Schema
94
105
  end
95
106
 
96
107
  def #{options[:setter]}(v)
108
+ #{parser_check}
97
109
  #{options[:instance_variable]} = #{options[:parser]}(#{name.inspect}, parsing_errors, v)
98
110
  end
99
111
 
@@ -104,6 +116,13 @@ module Schema
104
116
  )
105
117
  end
106
118
 
119
+ # a parser that isn't defined yet may come from a module included later, so it's checked when a value is set
120
+ def parser_check_code(name, options)
121
+ return if method_defined?(options[:parser])
122
+
123
+ "::Schema::Utils.check_parser!(self, #{options[:parser].inspect}, #{name.inspect}, #{options[:type].inspect})"
124
+ end
125
+
107
126
  def add_aliases(name, options)
108
127
  return unless options[:aliases]
109
128
 
@@ -6,6 +6,8 @@ module Schema
6
6
  module Parsers
7
7
  # Schema::Parsers::American parses dates and times in American format
8
8
  module American
9
+ include StringValue
10
+
9
11
  DATE_FORMAT = '%m/%d/%Y'
10
12
  TIME_FORMAT = '%m/%d/%Y %H:%M:%S'
11
13
 
@@ -16,12 +18,7 @@ module Schema
16
18
  when Time
17
19
  value.to_date
18
20
  when String
19
- begin
20
- Date.strptime(value, DATE_FORMAT)
21
- rescue ArgumentError
22
- parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
23
- nil
24
- end
21
+ parse_string_value(field_name, parsing_errors, value) { |str| Date.strptime(str, DATE_FORMAT) }
25
22
  when nil
26
23
  nil
27
24
  else
@@ -37,12 +34,7 @@ module Schema
37
34
  when Date
38
35
  value.to_time
39
36
  when String
40
- begin
41
- Time.strptime(value, TIME_FORMAT)
42
- rescue ArgumentError
43
- parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
44
- nil
45
- end
37
+ parse_string_value(field_name, parsing_errors, value) { |str| Time.strptime(str, TIME_FORMAT) }
46
38
  when nil
47
39
  nil
48
40
  else
@@ -35,6 +35,7 @@ module Schema
35
35
  return data unless (data_type = schema_options[:data_type])
36
36
 
37
37
  parser_method = "parse_#{data_type}"
38
+ ::Schema::Utils.check_parser!(model, parser_method, field_name, data_type)
38
39
  data.each_with_index.map do |datum, idx|
39
40
  model.send(parser_method, "#{field_name}:#{idx}", parsing_errors, datum)
40
41
  end