schema-model 0.11.0 → 0.12.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: 2f4f7d2224dda7f2a0ced1da41e8597e6374df27a22fd739c1bd86a0b02441c3
4
- data.tar.gz: 4ff7d3bcf5f8e80d241d7e85b25c84f0a7f651c1e43beeafe923537eeb3aa880
3
+ metadata.gz: d02407b3970af8db7c2bde92ebe9bb6e557e9f6415b503dbee29961938d15144
4
+ data.tar.gz: d475d956180eabe9455e40c482f44735217ddcff04d1d121e5e86de3a4071686
5
5
  SHA512:
6
- metadata.gz: c113bc5af055eae0d038c42113fade624505848db7945576fd642845e7071791bf041db388e8ef8ff8b076a27a82b2de94beb8cf91ec80d8f02b478152d7ce4d
7
- data.tar.gz: 77f0728d85424f45a5cf5c034f4b7a7510a54357a0e55bebfdd95a4f1c3da7a32517e4bc5d33d86d776d0d5245581fea71c2d163ff419da53d41d12b1ae8c6a2
6
+ metadata.gz: 0df082a47d8e30b845f36fd72c35dd73164a365a5cf67e76f7bfcb0d97000d4ef1f9581b46377ec7129a9253c3aa39984a7e28a068a8137d0a229eda427556cd
7
+ data.tar.gz: fda0a963a0e4b5de8800977b85d90c981ed16885b6659d620bbef5360d0c5f72ebd8e0ed34d764f9ca9e07b915a9bb7674fba2c891cac9cefd0245fe60045a03
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/dougyouch/schema/compare/v0.11.0...v0.12.0) (2026-10-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * with Schema::All, a parsing error's type is now the code symbol (e.g. :invalid) instead of the message string; messages are unchanged.
9
+
10
+ ### Features
11
+
12
+ * decimal type, datetime alias, association was_set? and parsing error codes ([e93e683](https://github.com/dougyouch/schema/commit/e93e6836e98efcdf01da656818f70ec305f59871))
13
+
3
14
  ## [0.11.0](https://github.com/dougyouch/schema/compare/v0.10.0...v0.11.0) (2026-10-05)
4
15
 
5
16
 
data/README.md CHANGED
@@ -181,7 +181,7 @@ def update
181
181
  end
182
182
  ```
183
183
 
184
- 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.
184
+ Associations in `set_attribute_values` are schema models; call `as_json` on them if the record expects hashes. For a single attribute or association, `input.name_was_set?` answers the same question.
185
185
 
186
186
  For nested input, `as_json(only_set: true)` keeps only the fields that were sent, at every level, so it can be merged into a JSON column without wiping fields the client left out:
187
187
 
@@ -201,18 +201,22 @@ record.data = record.data.deep_merge(input.as_json(only_set: true).deep_stringif
201
201
  attribute :name, :string # String values
202
202
  attribute :count, :integer # Integer values (parses "123", " 123 " and "0123" to 123)
203
203
  attribute :price, :float # Float values (parses "9.99" to 9.99, also "1e+5")
204
+ attribute :total, :decimal # BigDecimal (parses "9.99" exactly); with Schema::All
204
205
  attribute :active, :boolean # see below
205
206
  attribute :notes, :string_or_nil # String, but nil when empty or whitespace-only
206
207
  ```
207
208
 
208
209
  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.
209
210
 
210
- 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.
211
+ `:decimal` needs the `bigdecimal` gem on Ruby 3.4+; ActiveModel already depends on it.
212
+
213
+ For `:integer`, `:float`, `:decimal`, `: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.
211
214
 
212
215
  ### Date and Time Types
213
216
 
214
217
  ```ruby
215
218
  attribute :created_at, :time # ISO 8601 date and time (Time.xmlschema)
219
+ attribute :updated_at, :datetime # same as :time, matching ActiveRecord's type name
216
220
  attribute :birth_date, :date # ISO 8601 (Date.iso8601), e.g. "2024-01-31"; free text like "May 1" is invalid
217
221
  attribute :us_date, :american_date # MM/DD/YYYY format
218
222
  attribute :us_time, :american_time # MM/DD/YYYY HH:MM:SS format
@@ -255,7 +259,7 @@ Each read of a default returns a fresh copy (frozen values are shared), so mutat
255
259
 
256
260
  ### Checking If Attribute Was Set
257
261
 
258
- Every attribute generates a `_was_set?` predicate method:
262
+ Every attribute and association generates a `_was_set?` predicate method:
259
263
 
260
264
  ```ruby
261
265
  user = UserSchema.from_hash(name: 'John')
@@ -462,6 +466,8 @@ user.parsing_errors.full_messages # => ["Age is invalid"]
462
466
  | `unknown_attribute` | is an unknown attribute | `schema.parsing_errors.unknown_attribute` |
463
467
  | `unhandled_type` | is an unhandled type | `schema.parsing_errors.unhandled_type` |
464
468
 
469
+ The code is kept as each error's type, so `user.parsing_errors.details # => { age: [{ error: :invalid }] }`. Register codes for your own parsers with `Schema::ActiveModelParsingErrors.add_message(:read_only, 'is read only')`; other strings added to `parsing_errors` are kept as messages.
470
+
465
471
  A plain `Schema::Model` uses `Schema::Errors`, which stores the codes themselves (`user.parsing_errors[:age] # => ["invalid"]`).
466
472
 
467
473
  ### Checking Everything at Once
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ module Schema
6
+ # One parsing error in {ActiveModelParsingErrors}: its type is the code (e.g. :invalid) and its
7
+ # message is resolved when it's added. A plain ActiveModel::Error would resolve the message by
8
+ # reading the attribute, which unknown keys and nested markers ("items:0") don't have.
9
+ class ActiveModelParsingError < ActiveModel::Error
10
+ # @return [String]
11
+ def message
12
+ options[:message]
13
+ end
14
+ end
15
+ end
@@ -3,10 +3,12 @@
3
3
  require 'active_model'
4
4
 
5
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.
6
+ # Schema::ActiveModelParsingErrors gives parsing errors readable messages, e.g. "Age is invalid"
7
+ # instead of "Age invalid", while keeping the code as the error's type, so
8
+ # `parsing_errors.details` reports `{ error: :invalid }`. Override messages with I18n keys under
9
+ # schema.parsing_errors, and register more codes with {.add_message}.
8
10
  class ActiveModelParsingErrors < ActiveModel::Errors
9
- # Default English message for each parsing error code.
11
+ # Default English message for each built-in parsing error code.
10
12
  MESSAGES = {
11
13
  ::Schema::ParsingErrors::INVALID => 'is invalid',
12
14
  ::Schema::ParsingErrors::INCOMPATIBLE => 'is an incompatible type',
@@ -15,18 +17,37 @@ module Schema
15
17
  ::Schema::ParsingErrors::UNHANDLED_TYPE => 'is an unhandled type'
16
18
  }.freeze
17
19
 
18
- # parsing error keys (e.g. "items:0" or an unknown key) often aren't attributes on the model,
19
- # so codes are added as message strings rather than ActiveModel error types
20
+ # @return [Hash{String => String}] every known code and its default message
21
+ def self.messages
22
+ @messages ||= MESSAGES.dup
23
+ end
24
+
25
+ # Registers a parsing error code, e.g. for a custom parser.
26
+ # @example
27
+ # Schema::ActiveModelParsingErrors.add_message(:read_only, 'is read only')
28
+ # model.parsing_errors.add(:id, :read_only) # => "Id is read only", details { error: :read_only }
29
+ # @param code [Symbol, String]
30
+ # @param message [String] default message; an I18n key schema.parsing_errors.<code> overrides it
31
+ # @return [void]
32
+ def self.add_message(code, message)
33
+ messages[code.to_s] = message
34
+ end
35
+
36
+ # Known codes are stored as the error's type with a readable message. Anything else is kept
37
+ # as a message string, since parsing error keys (e.g. "items:0") often aren't model attributes.
20
38
  def add(attribute, type = :invalid, **)
21
- super(attribute, message_for(type), **)
39
+ code = type.to_s
40
+ return super unless self.class.messages.key?(code)
41
+
42
+ error = ActiveModelParsingError.new(@base, attribute, code.to_sym, message: message_for(code))
43
+ @errors.append(error)
44
+ error
22
45
  end
23
46
 
24
47
  private
25
48
 
26
- def message_for(type)
27
- return type unless MESSAGES.key?(type)
28
-
29
- I18n.t("schema.parsing_errors.#{type}", default: MESSAGES[type])
49
+ def message_for(code)
50
+ I18n.t("schema.parsing_errors.#{code}", default: self.class.messages[code])
30
51
  end
31
52
  end
32
53
  end
data/lib/schema/all.rb CHANGED
@@ -15,6 +15,7 @@ module Schema
15
15
  # parsers
16
16
  base.schema_include ::Schema::Parsers::American
17
17
  base.schema_include ::Schema::Parsers::Array
18
+ base.schema_include ::Schema::Parsers::Decimal
18
19
  base.schema_include ::Schema::Parsers::Hash
19
20
  base.schema_include ::Schema::Parsers::Json
20
21
 
@@ -26,6 +26,10 @@ module Schema
26
26
  #{options[:instance_variable]}
27
27
  end
28
28
 
29
+ def #{options[:getter]}_was_set?
30
+ instance_variable_defined?(:#{options[:instance_variable]})
31
+ end
32
+
29
33
  def #{name}_schema_creator
30
34
  @#{name}_schema_creator ||= ::Schema::Associations::SchemaCreator.new(self, #{name.inspect})
31
35
  end
@@ -25,6 +25,10 @@ module Schema
25
25
  #{options[:instance_variable]}
26
26
  end
27
27
 
28
+ def #{options[:getter]}_was_set?
29
+ instance_variable_defined?(:#{options[:instance_variable]})
30
+ end
31
+
28
32
  def #{name}_schema_creator
29
33
  @#{name}_schema_creator ||= ::Schema::Associations::SchemaCreator.new(self, #{name.inspect})
30
34
  end
@@ -119,6 +119,11 @@ module Schema
119
119
  end
120
120
  end
121
121
 
122
+ # @!method parse_datetime(field_name, parsing_errors, value)
123
+ # Same as {#parse_time}, so `attribute :created_at, :datetime` matches ActiveRecord's type name.
124
+ # @return [Time, nil]
125
+ alias parse_datetime parse_time
126
+
122
127
  # Parses ISO 8601 dates (Date.iso8601).
123
128
  # @return [Date, nil]
124
129
  def parse_date(field_name, parsing_errors, value)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Schema
6
+ module Parsers
7
+ # Schema::Parsers::Decimal parses :decimal attributes into BigDecimal, for money and other
8
+ # exact values. Included by Schema::All. On Ruby 3.4+ bigdecimal is a bundled gem; ActiveModel
9
+ # already depends on it, otherwise add it to your Gemfile.
10
+ module Decimal
11
+ include StringValue
12
+
13
+ # Accepted decimal strings, e.g. "12", "-0.50", "1.5e3".
14
+ DECIMAL_REGEX = /\A[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][-+]?\d+)?\z/
15
+
16
+ # Parses decimals from strings and numbers; hashes and arrays are incompatible.
17
+ # @return [BigDecimal, nil]
18
+ def parse_decimal(field_name, parsing_errors, value)
19
+ case value
20
+ when BigDecimal then value
21
+ when Integer, Rational then BigDecimal(value, 0)
22
+ when Float then parse_float_as_decimal(field_name, parsing_errors, value)
23
+ when String
24
+ parse_string_value(field_name, parsing_errors, value) { |str| BigDecimal(str) if DECIMAL_REGEX.match?(str) }
25
+ when nil then nil
26
+ when ::Hash, ::Array then add_decimal_error(field_name, parsing_errors, ::Schema::ParsingErrors::INCOMPATIBLE)
27
+ else add_decimal_error(field_name, parsing_errors, ::Schema::ParsingErrors::UNHANDLED_TYPE)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # floats go through their shortest string form, so 1.1 becomes 1.1 and not 1.100000000000000088...
34
+ def parse_float_as_decimal(field_name, parsing_errors, value)
35
+ return add_decimal_error(field_name, parsing_errors, ::Schema::ParsingErrors::INCOMPATIBLE) unless value.finite?
36
+
37
+ BigDecimal(value.to_s)
38
+ end
39
+
40
+ def add_decimal_error(field_name, parsing_errors, code)
41
+ parsing_errors.add(field_name, code)
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Schema
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.11.0'
5
+ VERSION = '0.12.0'
6
6
  end
data/lib/schema-model.rb CHANGED
@@ -26,6 +26,7 @@ module Schema
26
26
 
27
27
  autoload :ActiveModelValidations, 'schema/active_model_validations'
28
28
  autoload :All, 'schema/all'
29
+ autoload :ActiveModelParsingError, 'schema/active_model_parsing_error'
29
30
  autoload :ActiveModelParsingErrors, 'schema/active_model_parsing_errors'
30
31
  autoload :ArrayHeaders, 'schema/array_headers'
31
32
  autoload :Arrays, 'schema/arrays'
@@ -43,6 +44,7 @@ module Schema
43
44
  autoload :American, 'schema/parsers/american'
44
45
  autoload :Array, 'schema/parsers/array'
45
46
  autoload :Common, 'schema/parsers/common'
47
+ autoload :Decimal, 'schema/parsers/decimal'
46
48
  autoload :Hash, 'schema/parsers/hash'
47
49
  autoload :Json, 'schema/parsers/json'
48
50
  autoload :StringValue, 'schema/parsers/string_value'
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.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -45,6 +45,7 @@ files:
45
45
  - README.md
46
46
  - bin/schema-json2csv
47
47
  - lib/schema-model.rb
48
+ - lib/schema/active_model_parsing_error.rb
48
49
  - lib/schema/active_model_parsing_errors.rb
49
50
  - lib/schema/active_model_validations.rb
50
51
  - lib/schema/all.rb
@@ -63,6 +64,7 @@ files:
63
64
  - lib/schema/parsers/american.rb
64
65
  - lib/schema/parsers/array.rb
65
66
  - lib/schema/parsers/common.rb
67
+ - lib/schema/parsers/decimal.rb
66
68
  - lib/schema/parsers/hash.rb
67
69
  - lib/schema/parsers/json.rb
68
70
  - lib/schema/parsers/string_value.rb