schema-model 0.7.4 → 0.9.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: bd3614644a664aa647cc01d0ae8032a4f08d3523f0377b9e92f2c34597228815
4
+ data.tar.gz: 2f6c67150e5473230f8660cc21d7a7f26a2351dbc18f05b5754578c33cf521a3
5
5
  SHA512:
6
- metadata.gz: df5f93d0615667c6bb455bf920a08f44507f59eec3031cde8fb5e73eb99403738d0d3afe447d53a4a39d1dd67df7e8d3ff2f375810580f26a5804851289c8016
7
- data.tar.gz: 01eb576246b0efa42c3935c0cb1c73038b350062053a767a815c8eaced1b4e1aec3b8c79fca3c32d2810fb26618b8eda397c4eb6b1ecc08e6baf79ff5467f138
6
+ metadata.gz: 28674549ae35fc72fa413a59aa87a20d72ed752ee56688d657bb61911a8d032308b3fc44908bd4c2702f7b86c261519801bf86c7751cd3b40c9364fdfce32bb4
7
+ data.tar.gz: 5da6fcb9b0a79b21f926340d16486e4a5f8774725c1ca9cf3a58e3bbf996cee2856df0f84ad74acd4345dac1b999c1dcf09e41cfacf5f158819f13e78e432084
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0](https://github.com/dougyouch/schema/compare/v0.8.0...v0.9.0) (2026-10-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **model:** add set_attribute_values, parsed_and_valid? and full_error_messages ([0c8b922](https://github.com/dougyouch/schema/commit/0c8b922cb4417720b70d069a8c51fb80b3b1c644))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **validator:** fail schema validation on nested parsing errors ([b17704a](https://github.com/dougyouch/schema/commit/b17704af2629d3a4a81ee69f748e216a8c5d7488))
14
+
15
+ ## [0.8.0](https://github.com/dougyouch/schema/compare/v0.7.4...v0.8.0) (2026-10-04)
16
+
17
+
18
+ ### ⚠ BREAKING CHANGES
19
+
20
+ * **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 ==.
21
+
22
+ ### Features
23
+
24
+ * **model:** stricter parsing, readable parsing errors and model helpers ([9267d77](https://github.com/dougyouch/schema/commit/9267d770f79b260bae2b3890f31f583e1684bdbe))
25
+
3
26
  ## [0.7.4](https://github.com/dougyouch/schema/compare/v0.7.3...v0.7.4) (2026-10-04)
4
27
 
5
28
 
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'
@@ -59,23 +59,149 @@ user.active # => true (parsed from "yes")
59
59
  user.tags # => ["ruby", "rails", "developer"]
60
60
  ```
61
61
 
62
+ ## Use Cases
63
+
64
+ ### Typed JSON columns on ActiveRecord models
65
+
66
+ Wrap a JSON/JSONB column in a schema so the record works with typed values, rejects bad data, and writes back clean JSON:
67
+
68
+ ```ruby
69
+ class ConfigSchema
70
+ include Schema::All
71
+
72
+ attribute :api_key, :string
73
+ attribute :timeout, :integer, default: 30
74
+ attribute :enabled, :boolean
75
+
76
+ has_many :endpoints do
77
+ attribute :url, :string
78
+ validates :url, presence: true
79
+ end
80
+
81
+ validates :api_key, presence: true
82
+ validates :endpoints, schema: true
83
+ end
84
+
85
+ class Integration < ActiveRecord::Base
86
+ validate :validate_config, if: :config_updated?
87
+ before_save :update_data_from_config, if: :config_updated?
88
+
89
+ def config=(config_data)
90
+ @config = ConfigSchema.from_hash(config_data)
91
+ end
92
+
93
+ def config
94
+ @config ||= ConfigSchema.from_hash(data)
95
+ end
96
+
97
+ # reading config counts as an update, since the caller may change it in place
98
+ def config_updated?
99
+ defined?(@config)
100
+ end
101
+
102
+ private
103
+
104
+ def update_data_from_config
105
+ self.data = config.as_json
106
+ end
107
+
108
+ # parsing errors (e.g. timeout: "abc") don't make valid? false, so check both
109
+ def validate_config
110
+ return if config.parsed_and_valid?
111
+
112
+ config.full_error_messages.each { |message| errors.add(:config, message) }
113
+ end
114
+ end
115
+
116
+ integration.config = { 'api_key' => 'abc', 'timeout' => '60', 'endpoints' => [{ 'url' => 'https://example.com' }] }
117
+ integration.config.timeout # => 60
118
+ integration.save # writes data as { api_key: "abc", timeout: 60, endpoints: [{ url: "https://example.com" }] }
119
+ ```
120
+
121
+ Use `from_hash`, not `new`, to build a schema from data. `from_hash(nil)` returns an empty model, so a record whose column is still `NULL` works too.
122
+
123
+ If a single "Config is invalid" error is enough, `validates :config, schema: true, if: :config_updated?` replaces `validate_config`; it fails on parsing errors as well as validation errors.
124
+
125
+ ### Validating API input in controllers
126
+
127
+ Describe the JSON an endpoint accepts as a schema. Values arrive converted to the right types, unknown keys are reported, and the schema acts as the allowlist, so strong parameters aren't needed:
128
+
129
+ ```ruby
130
+ class CreateUserSchema
131
+ include Schema::All
132
+
133
+ attribute :name, :string
134
+ attribute :email, :string
135
+ attribute :age, :integer
136
+
137
+ validates :name, :email, presence: true
138
+ end
139
+
140
+ class UsersController < ApplicationController
141
+ def create
142
+ input = CreateUserSchema.from_hash(JSON.parse(request.raw_post))
143
+ unless input.parsed_and_valid?
144
+ return render json: { errors: input.full_error_messages }, status: :unprocessable_entity
145
+ end
146
+
147
+ user = User.create!(input.as_json)
148
+ render json: user, status: :created
149
+ end
150
+ end
151
+ ```
152
+
153
+ `parsed_and_valid?` runs the validations even when there are parsing errors, so `full_error_messages` reports everything wrong with the input at once, e.g. `["Age is invalid", "Admin is an unknown attribute", "Email can't be blank"]`.
154
+
155
+ The body is parsed from `request.raw_post` rather than taken from `params`, because Rails adds keys to `params` (`controller`, `action`, and the wrapped parameters key) that the schema would report as unknown attributes.
156
+
157
+ ### PATCH endpoints
158
+
159
+ `set_attribute_values` returns only the fields that were present in the input, including ones sent as `null`. That separates "not sent" from "sent as null", which is what a PATCH route needs:
160
+
161
+ ```ruby
162
+ class UpdateUserSchema
163
+ include Schema::All
164
+
165
+ attribute :name, :string
166
+ attribute :email, :string
167
+ attribute :age, :integer
168
+ end
169
+
170
+ def update
171
+ input = UpdateUserSchema.from_hash(JSON.parse(request.raw_post))
172
+ unless input.parsed_and_valid?
173
+ return render json: { errors: input.full_error_messages }, status: :unprocessable_entity
174
+ end
175
+
176
+ # body {"name": "Joe", "age": null} => { name: "Joe", age: nil }: updates name, clears age, leaves email alone
177
+ user.update!(input.set_attribute_values)
178
+ render json: user
179
+ end
180
+ ```
181
+
182
+ Associations in `set_attribute_values` are schema models; call `as_json` on them if the record expects hashes. For a single attribute, `input.name_was_set?` answers the same question.
183
+
62
184
  ## Data Types
63
185
 
64
186
  ### Basic Types
65
187
 
66
188
  ```ruby
67
189
  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
190
+ attribute :count, :integer # Integer values (parses "123", " 123 " and "0123" to 123)
191
+ attribute :price, :float # Float values (parses "9.99" to 9.99, also "1e+5")
192
+ attribute :active, :boolean # see below
71
193
  attribute :notes, :string_or_nil # String, but returns nil if empty
72
194
  ```
73
195
 
196
+ 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.
197
+
198
+ 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.
199
+
74
200
  ### Date and Time Types
75
201
 
76
202
  ```ruby
77
- attribute :created_at, :time # ISO 8601 format (Time.xmlschema)
78
- attribute :birth_date, :date # Date.parse format
203
+ attribute :created_at, :time # ISO 8601 date and time (Time.xmlschema)
204
+ attribute :birth_date, :date # ISO 8601 (Date.iso8601), e.g. "2024-01-31"; free text like "May 1" is invalid
79
205
  attribute :us_date, :american_date # MM/DD/YYYY format
80
206
  attribute :us_time, :american_time # MM/DD/YYYY HH:MM:SS format
81
207
  ```
@@ -90,6 +216,8 @@ attribute :metadata, :hash # Hash/dictionary values
90
216
  attribute :config, :json # Parse JSON strings
91
217
  ```
92
218
 
219
+ 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.
220
+
93
221
  ## Attribute Options
94
222
 
95
223
  ### Aliases
@@ -293,7 +421,7 @@ end
293
421
 
294
422
  ### Parsing Errors vs Validation Errors
295
423
 
296
- Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both).
424
+ 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
425
 
298
426
  ```ruby
299
427
  user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
@@ -308,9 +436,36 @@ user.valid? # => true (age is nil, which allow_nil permits)
308
436
  user.errors.full_messages # => []
309
437
  ```
310
438
 
439
+ With `Schema::All` (or `Schema::ActiveModelValidations`), `parsing_errors` is an `ActiveModel::Errors` with readable messages:
440
+
441
+ ```ruby
442
+ user.parsing_errors.full_messages # => ["Age is invalid"]
443
+ ```
444
+
445
+ | Code | Message | I18n key |
446
+ |---|---|---|
447
+ | `invalid` | is invalid | `schema.parsing_errors.invalid` |
448
+ | `incompatible` | is an incompatible type | `schema.parsing_errors.incompatible` |
449
+ | `unknown` | has an unknown type | `schema.parsing_errors.unknown` |
450
+ | `unknown_attribute` | is an unknown attribute | `schema.parsing_errors.unknown_attribute` |
451
+ | `unhandled_type` | is an unhandled type | `schema.parsing_errors.unhandled_type` |
452
+
453
+ A plain `Schema::Model` uses `Schema::Errors`, which stores the codes themselves (`user.parsing_errors[:age] # => ["invalid"]`).
454
+
455
+ ### Checking Everything at Once
456
+
457
+ ```ruby
458
+ user = UserSchema.from_hash(name: 'John', age: 'not-a-number', email: nil)
459
+
460
+ user.parsed_and_valid? # => false; runs validations even though parsing failed
461
+ user.full_error_messages # => ["Age is invalid", "Email can't be blank"] (parsing messages, then validation messages)
462
+ ```
463
+
464
+ `full_error_messages` reports the last validation run, so call `parsed_and_valid?` (or `valid?`) first.
465
+
311
466
  ### Validating Nested Schemas
312
467
 
313
- `validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, is invalid:
468
+ `validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, has parsing errors or fails its validations:
314
469
 
315
470
  ```ruby
316
471
  class OrderSchema
@@ -349,13 +504,13 @@ By default, unknown attributes are captured as parsing errors:
349
504
 
350
505
  ```ruby
351
506
  user = UserSchema.from_hash(name: 'John', unknown_field: 'value')
352
- user.parsing_errors[:unknown_field] # => ["unknown_attribute"]
507
+ user.parsing_errors[:unknown_field] # => ["is an unknown attribute"]
353
508
 
354
509
  # Disable this behavior
355
510
  UserSchema.capture_unknown_attributes = false
356
511
  ```
357
512
 
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.
513
+ The setting also applies to nested association classes, including ones declared after it is set.
359
514
 
360
515
  ## Serialization
361
516
 
@@ -380,6 +535,24 @@ user.as_json(select_filter: ->(name, value, opts) { name == :name })
380
535
  user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
381
536
  ```
382
537
 
538
+ ### Comparing, Inspecting and Copying
539
+
540
+ ```ruby
541
+ a = ContactSchema.from_hash(name: 'John')
542
+ b = ContactSchema.from_hash('name' => 'John')
543
+
544
+ a == b # => true (same class and attribute values, nested models included)
545
+ a.attribute_values # => { name: "John", email: nil }
546
+ a.set_attribute_values # => { name: "John" } (only fields present in the input)
547
+ a.inspect # => #<ContactSchema name: "John"> (only attributes that were set)
548
+
549
+ copy = a.deep_dup # copies nested models, arrays, hashes, strings and parsing errors
550
+ copy.name << '!'
551
+ a.name # => "John"
552
+ ```
553
+
554
+ `dup` is Ruby's shallow copy, so nested models are shared with the original.
555
+
383
556
  ## Protecting Fields with skip_fields
384
557
 
385
558
  Prevent certain fields from being set by user input:
@@ -446,6 +619,8 @@ end
446
619
  UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
447
620
  ```
448
621
 
622
+ `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.
623
+
449
624
  ### Schema::CSVParser
450
625
 
451
626
  `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
@@ -25,24 +25,21 @@ module Schema
25
25
  valid_model!
26
26
  end
27
27
 
28
+ # runs validations even when there are parsing errors, so full_error_messages has both
29
+ def parsed_and_valid?
30
+ valid = valid?
31
+ valid && parsed?
32
+ end
33
+
34
+ # parsing error messages followed by the messages from the last validation run
35
+ def full_error_messages
36
+ parsing_errors.full_messages + errors.full_messages
37
+ end
38
+
28
39
  # no-doc
29
40
  module OverrideParsingErrors
30
41
  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
- )
42
+ @parsing_errors ||= ActiveModelParsingErrors.new(self)
46
43
  end
47
44
  end
48
45
  end
@@ -0,0 +1,41 @@
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
+ # only the attributes and associations present in the input (including ones set to nil),
16
+ # e.g. the fields a PATCH request sent
17
+ def set_attribute_values
18
+ set_field_options.to_h { |field_options| [field_options[:name], public_send(field_options[:getter])] }
19
+ end
20
+
21
+ def ==(other)
22
+ other.instance_of?(self.class) && other.attribute_values == attribute_values
23
+ end
24
+
25
+ # lists the attributes that were set, e.g. #<UserSchema name: "Joe", age: 30>
26
+ def inspect
27
+ details = set_field_options.map do |field_options|
28
+ "#{field_options[:name]}: #{public_send(field_options[:getter]).inspect}"
29
+ end
30
+ "#<#{self.class.name || self.class}#{" #{details.join(', ')}" unless details.empty?}>"
31
+ end
32
+
33
+ private
34
+
35
+ def set_field_options
36
+ self.class.schema.each_value.select do |field_options|
37
+ !field_options[:alias_of] && instance_variable_defined?(field_options[:instance_variable])
38
+ end
39
+ end
40
+ end
41
+ 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