schema-model 0.7.4 → 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: f273128aa733d5737e486345a39b1c59f5af1bf3babea0917bc8d6e6a44707c8
4
- data.tar.gz: 393e5e391cde21e5288da99d4321bb619f370d1a937052f427e32f570facd68e
3
+ metadata.gz: f672bab800b79f5e9a8b390268466caaf7a42822b0b0632f023f6539fbae48aa
4
+ data.tar.gz: 5486532778b3a246d66c0c2350586fd77ebf9955ef311a719c5053d656afe0be
5
5
  SHA512:
6
- metadata.gz: df5f93d0615667c6bb455bf920a08f44507f59eec3031cde8fb5e73eb99403738d0d3afe447d53a4a39d1dd67df7e8d3ff2f375810580f26a5804851289c8016
7
- data.tar.gz: 01eb576246b0efa42c3935c0cb1c73038b350062053a767a815c8eaced1b4e1aec3b8c79fca3c32d2810fb26618b8eda397c4eb6b1ecc08e6baf79ff5467f138
6
+ metadata.gz: ca482dfd23e73cea044bf572e819864415ee68768cdb0b98a756e92fbb06c2d800b7cdd6c2afde4d5c77559d226a7e3cf681f4043a17b118c17ed24fee0cdb9c
7
+ data.tar.gz: f555174a32c7adbe3b713bb726038f325168f07820d6309479be0715af91bb5e684d73d51466a0f71cc806ecd7ca98f2006bf8820008c126ee4f25f220ffdcde
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## [0.7.4](https://github.com/dougyouch/schema/compare/v0.7.3...v0.7.4) (2026-10-04)
4
15
 
5
16
 
data/README.md CHANGED
@@ -8,7 +8,7 @@ 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'
@@ -65,17 +65,21 @@ user.tags # => ["ruby", "rails", "developer"]
65
65
 
66
66
  ```ruby
67
67
  attribute :name, :string # String values
68
- attribute :count, :integer # Integer values (parses "123" to 123)
69
- attribute :price, :float # Float values (parses "9.99" to 9.99)
70
- 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
71
71
  attribute :notes, :string_or_nil # String, but returns nil if empty
72
72
  ```
73
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
+
74
78
  ### Date and Time Types
75
79
 
76
80
  ```ruby
77
- attribute :created_at, :time # ISO 8601 format (Time.xmlschema)
78
- 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
79
83
  attribute :us_date, :american_date # MM/DD/YYYY format
80
84
  attribute :us_time, :american_time # MM/DD/YYYY HH:MM:SS format
81
85
  ```
@@ -90,6 +94,8 @@ attribute :metadata, :hash # Hash/dictionary values
90
94
  attribute :config, :json # Parse JSON strings
91
95
  ```
92
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
+
93
99
  ## Attribute Options
94
100
 
95
101
  ### Aliases
@@ -293,7 +299,7 @@ end
293
299
 
294
300
  ### Parsing Errors vs Validation Errors
295
301
 
296
- 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.
297
303
 
298
304
  ```ruby
299
305
  user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
@@ -308,6 +314,22 @@ user.valid? # => true (age is nil, which allow_nil permits)
308
314
  user.errors.full_messages # => []
309
315
  ```
310
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
+
311
333
  ### Validating Nested Schemas
312
334
 
313
335
  `validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, is invalid:
@@ -349,13 +371,13 @@ By default, unknown attributes are captured as parsing errors:
349
371
 
350
372
  ```ruby
351
373
  user = UserSchema.from_hash(name: 'John', unknown_field: 'value')
352
- user.parsing_errors[:unknown_field] # => ["unknown_attribute"]
374
+ user.parsing_errors[:unknown_field] # => ["is an unknown attribute"]
353
375
 
354
376
  # Disable this behavior
355
377
  UserSchema.capture_unknown_attributes = false
356
378
  ```
357
379
 
358
- 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.
359
381
 
360
382
  ## Serialization
361
383
 
@@ -380,6 +402,23 @@ user.as_json(select_filter: ->(name, value, opts) { name == :name })
380
402
  user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
381
403
  ```
382
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
+
383
422
  ## Protecting Fields with skip_fields
384
423
 
385
424
  Prevent certain fields from being set by user input:
@@ -446,6 +485,8 @@ end
446
485
  UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
447
486
  ```
448
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
+
449
490
  ### Schema::CSVParser
450
491
 
451
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:
@@ -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
@@ -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
@@ -6,20 +6,24 @@ module Schema
6
6
  module Parsers
7
7
  # Schema::Parsers::Common are parser methods for basic types
8
8
  module Common
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
9
+ include StringValue
10
+
11
+ # strings are stripped first; a blank string parses to nil without an error
12
+ INTEGER_REGEX = /\A[-+]?\d+(?:\.0+)?\z/
13
+ FLOAT_REGEX = /\A[-+]?\d+(?:\.\d+)?([Ee][-+]?\d+)?\z/
14
+ # matched case-insensitively; any other string is invalid
15
+ BOOLEAN_STRINGS = {
16
+ '1' => true, 't' => true, 'true' => true, 'on' => true, 'y' => true, 'yes' => true,
17
+ '0' => false, 'f' => false, 'false' => false, 'off' => false, 'n' => false, 'no' => false
18
+ }.freeze
12
19
 
13
20
  def parse_integer(field_name, parsing_errors, value)
14
21
  case value
15
22
  when Integer
16
23
  value
17
24
  when String
18
- if INTEGER_REGEX.match(value)
19
- value.to_i
20
- else
21
- parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
22
- nil
25
+ parse_string_value(field_name, parsing_errors, value) do |str|
26
+ str.to_i if INTEGER_REGEX.match?(str)
23
27
  end
24
28
  when Float
25
29
  parse_float_as_integer(field_name, parsing_errors, value)
@@ -77,11 +81,8 @@ module Schema
77
81
  when Integer
78
82
  value.to_f
79
83
  when String
80
- if FLOAT_REGEX.match(value)
81
- Float(value)
82
- else
83
- parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
84
- nil
84
+ parse_string_value(field_name, parsing_errors, value) do |str|
85
+ Float(str) if FLOAT_REGEX.match?(str)
85
86
  end
86
87
  when nil
87
88
  nil
@@ -98,12 +99,7 @@ module Schema
98
99
  when Date
99
100
  value.to_time
100
101
  when String
101
- begin
102
- Time.xmlschema(value)
103
- rescue ArgumentError
104
- parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
105
- nil
106
- end
102
+ parse_string_value(field_name, parsing_errors, value) { |str| Time.xmlschema(str) }
107
103
  when nil
108
104
  nil
109
105
  else
@@ -119,12 +115,7 @@ module Schema
119
115
  when Time
120
116
  value.to_date
121
117
  when String
122
- begin
123
- Date.parse(value)
124
- rescue ArgumentError
125
- parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID)
126
- nil
127
- end
118
+ parse_string_value(field_name, parsing_errors, value) { |str| Date.iso8601(str) }
128
119
  when nil
129
120
  nil
130
121
  else
@@ -140,7 +131,7 @@ module Schema
140
131
  when Integer, Float
141
132
  value != 0
142
133
  when String
143
- BOOLEAN_REGEX.match?(value)
134
+ parse_string_value(field_name, parsing_errors, value) { |str| BOOLEAN_STRINGS[str.downcase] }
144
135
  when nil
145
136
  nil
146
137
  else
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schema
4
+ module Parsers
5
+ # Schema::Parsers::StringValue is the shared string handling for parsers
6
+ module StringValue
7
+ # strips the string, returns nil when it's blank, and otherwise yields it; a nil result
8
+ # or an ArgumentError (e.g. Date::Error) from the block is recorded as invalid
9
+ def parse_string_value(field_name, parsing_errors, value)
10
+ str = value.strip
11
+ return nil if str.empty?
12
+
13
+ result = begin
14
+ yield str
15
+ rescue ArgumentError
16
+ nil
17
+ end
18
+ parsing_errors.add(field_name, ::Schema::ParsingErrors::INVALID) if result.nil?
19
+ result
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schema
4
+ # Schema::ParsingStatus reports whether a model parsed cleanly, and raises at an explicit boundary
5
+ module ParsingStatus
6
+ def parsed?
7
+ parsing_errors.empty?
8
+ end
9
+
10
+ def parsed!
11
+ return if parsed?
12
+
13
+ raise ParsingException.new(
14
+ "schema parsing failed for attributes #{parsing_errors.attribute_names.join(', ')}",
15
+ self,
16
+ parsing_errors
17
+ )
18
+ end
19
+ end
20
+ end
data/lib/schema/utils.rb CHANGED
@@ -5,6 +5,14 @@ module Schema
5
5
  module Utils
6
6
  module_function
7
7
 
8
+ def check_parser!(model, parser, field_name, type)
9
+ return if model.respond_to?(parser)
10
+
11
+ raise ::Schema::UnknownTypeError,
12
+ "unknown type #{type.inspect} for #{model.class.name || model.class}##{field_name}: " \
13
+ "no #{parser} method (schema_include the parser module that defines it)"
14
+ end
15
+
8
16
  def classify_name(name)
9
17
  name.gsub(/[^\da-z_-]/, '').gsub(/(^.|[_|-].)/) { |m| m[-1].upcase }
10
18
  end
@@ -14,6 +22,7 @@ module Schema
14
22
  kls = Class.new(options[:base_class] || Object)
15
23
  kls = base_schema_class.const_set(options[:class_name], kls)
16
24
  include_schema_modules(kls, base_schema_class.schema_config) unless options[:base_class]
25
+ kls.capture_unknown_attributes = base_schema_class.capture_unknown_attributes?
17
26
  kls
18
27
  end
19
28
 
@@ -56,6 +65,24 @@ module Schema
56
65
  kls.send(:include, ::Schema::Associations::DynamicTypes)
57
66
  end
58
67
 
68
+ # copies nested models and collections so the copy can be changed without touching the original
69
+ def deep_dup_value(value)
70
+ case value
71
+ when ::Schema::Model then value.deep_dup
72
+ when ::Array then value.map { |element| deep_dup_value(element) }
73
+ when ::Hash then value.to_h { |key, element| [key, deep_dup_value(element)] }
74
+ when ::String then value.frozen? ? value : value.dup
75
+ else value
76
+ end
77
+ end
78
+
79
+ # [attribute, message] pairs from Schema::Errors or ActiveModel::Errors
80
+ def parsing_error_pairs(errors)
81
+ return errors.map { |error| [error.attribute, error.message] } unless errors.is_a?(::Schema::Errors)
82
+
83
+ errors.errors.flat_map { |attribute, messages| messages.map { |message| [attribute, message] } }
84
+ end
85
+
59
86
  # each call gets its own copy so callers can't mutate the shared default
60
87
  def copy_default(value)
61
88
  return value if value.frozen?
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Schema
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.7.4'
5
+ VERSION = '0.8.0'
6
6
  end
data/lib/schema-model.rb CHANGED
@@ -18,14 +18,21 @@ module Schema
18
18
  class ParsingException < SchemaException; end
19
19
  class ValidationException < SchemaException; end
20
20
 
21
+ # raised when a value is assigned to an attribute whose type has no parse_<type> method
22
+ class UnknownTypeError < StandardError; end
23
+
21
24
  autoload :ActiveModelValidations, 'schema/active_model_validations'
22
25
  autoload :All, 'schema/all'
26
+ autoload :ActiveModelParsingErrors, 'schema/active_model_parsing_errors'
23
27
  autoload :ArrayHeaders, 'schema/array_headers'
24
28
  autoload :Arrays, 'schema/arrays'
29
+ autoload :AttributeValues, 'schema/attribute_values'
25
30
  autoload :CSVParser, 'schema/csv_parser'
31
+ autoload :DeepCopy, 'schema/deep_copy'
26
32
  autoload :Errors, 'schema/errors'
27
33
  autoload :Model, 'schema/model'
28
34
  autoload :ParsingErrors, 'schema/parsing_errors'
35
+ autoload :ParsingStatus, 'schema/parsing_status'
29
36
  autoload :Utils, 'schema/utils'
30
37
 
31
38
  # Schema::Parsers are used to convert values into the correct data type
@@ -35,6 +42,7 @@ module Schema
35
42
  autoload :Common, 'schema/parsers/common'
36
43
  autoload :Hash, 'schema/parsers/hash'
37
44
  autoload :Json, 'schema/parsers/json'
45
+ autoload :StringValue, 'schema/parsers/string_value'
38
46
  end
39
47
 
40
48
  # Schema::Associations mange the associations between schema models