schema-model 0.9.1 → 0.10.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: 1a95ca01d369cc20699a43f7a13b46fe567768c6ba5b8281cfb12800c56324e0
4
- data.tar.gz: c185ad7251b43193473da560d837bd4f87fc00558c540e635fa6618a8719773f
3
+ metadata.gz: b1a3c55a0dd1a3926bbe9a691cd425c4a93ea0187c95fe3eb858b4ade9903993
4
+ data.tar.gz: 2f829fbc58ad07ace2c74109890e245d280bebb677e998c8b364c6561ba0bd7b
5
5
  SHA512:
6
- metadata.gz: e0e993ff5247dcb1acd76acbecaa2f0b65fcdf30e94d4342c8f362bc2ce0c87edf8ca2c511e35cf791a9150be29ff3f6f633f0b8d815ed7c3ae08dfff71d50f5
7
- data.tar.gz: 34c05bb3bb1399be9c9d060617ad41cfcf8c2714c094097c5868fd52b56525c3be2e59af5be28ee28a5369e3e254267c389b40b496011ca8213350168c3ea7d6
6
+ metadata.gz: 5eecf9d66839bfdc331e9b518c9de02576c10f75e88d8a442bb27e9d3b3d16edca98dff7059e1046be4b8d6d23f0b9e000055aee89363c180d212c7f3716071e
7
+ data.tar.gz: b8045d5c982f5920078ea6307a3461a6b6510b509891fce8652c3a6e58563d6bf69491ad53260dbbd71a688bbe53ca2017bc47d790ac3433cee150ef74edf513
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0](https://github.com/dougyouch/schema/compare/v0.9.1...v0.10.0) (2026-10-04)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **parsers:** string_or_nil now returns nil for whitespace-only strings such as " ", matching how blank strings parse for the other types. Other strings are still kept as is.
9
+
10
+ ### Features
11
+
12
+ * **model:** add as_json only_set and handle input that is not a hash ([bdda922](https://github.com/dougyouch/schema/commit/bdda922bd6e4b7fbb2c31e0ada74596b67a3d5e7))
13
+ * **parsers:** treat whitespace-only strings as nil for string_or_nil ([7313c99](https://github.com/dougyouch/schema/commit/7313c99bb0de5e340ad92cc958a1b601a5e3e15c))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **arrays:** raise a clear error for has_many without size ([617cb89](https://github.com/dougyouch/schema/commit/617cb89d9cbb028560d2148d4450e7d77689b877))
19
+ * **cli:** report malformed json without a backtrace ([fc2f1f9](https://github.com/dougyouch/schema/commit/fc2f1f95df5681184cc80850a18d40cffeede121))
20
+ * **csv:** strip the utf-8 byte order mark and handle empty header cells ([ad0216d](https://github.com/dougyouch/schema/commit/ad0216d429c881582d949ee2785e2e729d28bd23))
21
+
3
22
  ## [0.9.1](https://github.com/dougyouch/schema/compare/v0.9.0...v0.9.1) (2026-10-04)
4
23
 
5
24
 
data/README.md CHANGED
@@ -6,6 +6,8 @@ A powerful Ruby gem for data transformation, validation, and type safety. Schema
6
6
  [![Coverage](https://raw.githubusercontent.com/dougyouch/schema/badges/coverage.svg)](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
7
7
  [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/schema/badges/branches.svg)](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
8
8
 
9
+ [API reference](https://rubydoc.info/gems/schema-model) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md)
10
+
9
11
  ## Installation
10
12
 
11
13
  Requires Ruby 3.2 or newer. Add this line to your application's Gemfile:
@@ -181,6 +183,16 @@ end
181
183
 
182
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.
183
185
 
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
+
188
+ ```ruby
189
+ # body {"address": {"zip": null}}
190
+ input.as_json(only_set: true) # => { address: { zip: nil } }
191
+ record.data = record.data.deep_merge(input.as_json(only_set: true).deep_stringify_keys)
192
+ ```
193
+
194
+ `JSON.parse` returns an array, string or number for bodies that aren't JSON objects; `from_hash` records an `incompatible` error on `:base` for those instead of raising, so `parsed_and_valid?` handles them like any other bad input.
195
+
184
196
  ## Data Types
185
197
 
186
198
  ### Basic Types
@@ -190,7 +202,7 @@ attribute :name, :string # String values
190
202
  attribute :count, :integer # Integer values (parses "123", " 123 " and "0123" to 123)
191
203
  attribute :price, :float # Float values (parses "9.99" to 9.99, also "1e+5")
192
204
  attribute :active, :boolean # see below
193
- attribute :notes, :string_or_nil # String, but returns nil if empty
205
+ attribute :notes, :string_or_nil # String, but nil when empty or whitespace-only
194
206
  ```
195
207
 
196
208
  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.
@@ -533,6 +545,9 @@ user.as_json(include_nils: true) # => { name: "John", email: nil }
533
545
  # Filter fields
534
546
  user.as_json(select_filter: ->(name, value, opts) { name == :name })
535
547
  user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
548
+
549
+ # Only the fields present in the input, at every level (nils that were sent are kept)
550
+ user.as_json(only_set: true) # => { name: "John", email: nil }
536
551
  ```
537
552
 
538
553
  ### Comparing, Inspecting and Copying
@@ -619,11 +634,11 @@ end
619
634
  UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
620
635
  ```
621
636
 
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.
637
+ Without `size:`, these methods raise an `ArgumentError` naming the association. `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
638
 
624
639
  ### Schema::CSVParser
625
640
 
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:
641
+ `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. Headers are stripped, and the byte order mark Excel puts at the start of UTF-8 files is removed:
627
642
 
628
643
  ```ruby
629
644
  require 'csv'
data/bin/schema-json2csv CHANGED
@@ -61,7 +61,11 @@ io = csv_file ? File.open(csv_file, 'ab') : $stdout
61
61
 
62
62
  io.print CSV.generate_line(schema_class.to_headers) if write_headers
63
63
 
64
- json_data = JSON.parse(json_data)
64
+ begin
65
+ json_data = JSON.parse(json_data)
66
+ rescue JSON::ParserError => e
67
+ abort("invalid json: #{e.message.lines.first.strip}")
68
+ end
65
69
  json_data = [json_data] unless json_data.is_a?(Array)
66
70
 
67
71
  json_data.each do |data|
@@ -6,6 +6,7 @@ module Schema
6
6
  # Schema::ActiveModelParsingErrors turns parsing error codes into readable messages,
7
7
  # e.g. "Age is invalid" instead of "Age invalid". Override them with I18n keys under schema.parsing_errors.
8
8
  class ActiveModelParsingErrors < ActiveModel::Errors
9
+ # Default English message for each parsing error code.
9
10
  MESSAGES = {
10
11
  ::Schema::ParsingErrors::INVALID => 'is invalid',
11
12
  ::Schema::ParsingErrors::INCOMPATIBLE => 'is an incompatible type',
@@ -20,6 +20,10 @@ module Schema
20
20
  )
21
21
  end
22
22
 
23
+ # Raises unless the model parsed cleanly and passes its validations.
24
+ # @raise [ParsingException] when there are parsing errors
25
+ # @raise [ValidationException] when validations fail
26
+ # @return [void]
23
27
  def valid!
24
28
  parsed!
25
29
  valid_model!
@@ -38,6 +42,7 @@ module Schema
38
42
 
39
43
  # no-doc
40
44
  module OverrideParsingErrors
45
+ # @return [ActiveModelParsingErrors] parsing errors with readable messages
41
46
  def parsing_errors
42
47
  @parsing_errors ||= ActiveModelParsingErrors.new(self)
43
48
  end
@@ -9,6 +9,10 @@ module Schema
9
9
 
10
10
  # adds methods to the class
11
11
  module ClassMethods
12
+ # Maps header names to the attributes they fill.
13
+ # @param headers [Array<String>] the header row
14
+ # @param header_prefix [String, nil] used internally for has_many columns
15
+ # @return [Hash] e.g. `{ name: { index: 0 }, phones: { number: { indexes: [1, 2] } } }`
12
16
  def map_headers_to_attributes(headers, header_prefix = nil)
13
17
  mapped_headers = {}
14
18
  map_headers_to_fields(headers, mapped_headers, header_prefix)
@@ -17,14 +21,24 @@ module Schema
17
21
  mapped_headers
18
22
  end
19
23
 
24
+ # Names of the fields no header was mapped to.
25
+ # @param mapped_headers [Hash] result of {#map_headers_to_attributes}
26
+ # @param header_prefix [String, nil] used internally for nested associations
27
+ # @return [Array<String>]
20
28
  def get_unmapped_field_names(mapped_headers, header_prefix = nil)
21
29
  get_field_names(mapped_headers, header_prefix, false)
22
30
  end
23
31
 
32
+ # Names of the fields the headers were mapped to, as they'd appear in a header row.
33
+ # @param mapped_headers [Hash] result of {#map_headers_to_attributes}
34
+ # @param header_prefix [String, nil] used internally for nested associations
35
+ # @return [Array<String>]
24
36
  def get_mapped_field_names(mapped_headers, header_prefix = nil)
25
37
  get_field_names(mapped_headers, header_prefix, true)
26
38
  end
27
39
 
40
+ # Field names (first alias preferred) that are, or with mapped false are not, in the mapping.
41
+ # @api private
28
42
  def get_field_names(mapped_headers, header_prefix = nil, mapped = true)
29
43
  fields = []
30
44
  schema.each do |field_name, field_options|
@@ -41,6 +55,7 @@ module Schema
41
55
  fields
42
56
  end
43
57
 
58
+ # Highest has_many index looked for in headers such as Phones1Number.
44
59
  MAX_ARRAY_INDEX = 10_000
45
60
 
46
61
  private
data/lib/schema/arrays.rb CHANGED
@@ -7,12 +7,26 @@ module Schema
7
7
  base.extend ClassMethods
8
8
  end
9
9
 
10
+ # the fixed number of entries a has_many takes up in an array
11
+ def self.association_size(field_options)
12
+ field_options[:size] || raise(
13
+ ArgumentError,
14
+ "has_many #{field_options[:name].inspect} needs a size: option to be converted to and from arrays"
15
+ )
16
+ end
17
+
10
18
  # adds from_array method to the class
11
19
  module ClassMethods
20
+ # Builds a model from a row.
21
+ # @param array [Array] row values
22
+ # @param mapped_headers [Hash] result of ArrayHeaders::ClassMethods#map_headers_to_attributes
23
+ # @return [Schema::Model]
12
24
  def from_array(array, mapped_headers)
13
25
  new.update_attributes_with_array(array, mapped_headers)
14
26
  end
15
27
 
28
+ # An array of nils shaped like {Schema::Arrays#to_a}'s output.
29
+ # @return [Array]
16
30
  def to_empty_array
17
31
  data = []
18
32
  schema.each_value do |field_options|
@@ -23,12 +37,15 @@ module Schema
23
37
  when :has_one
24
38
  const_get(field_options[:class_name]).to_empty_array
25
39
  when :has_many
26
- field_options[:size].times.map { const_get(field_options[:class_name]).to_empty_array }
40
+ Arrays.association_size(field_options).times.map { const_get(field_options[:class_name]).to_empty_array }
27
41
  end
28
42
  end
29
43
  data
30
44
  end
31
45
 
46
+ # Header names in array order, e.g. `phones[1].number`.
47
+ # @param prefix [String, nil] used internally for nested associations
48
+ # @return [Array<String>]
32
49
  def to_headers(prefix = nil)
33
50
  headers = []
34
51
  schema.each_value do |field_options|
@@ -39,7 +56,7 @@ module Schema
39
56
  when :has_one
40
57
  const_get(field_options[:class_name]).to_headers("#{prefix}#{field_options[:key]}.")
41
58
  when :has_many
42
- field_options[:size].times.map do |i|
59
+ Arrays.association_size(field_options).times.map do |i|
43
60
  const_get(field_options[:class_name]).to_headers(prefix.to_s + field_options[:key] + "[#{i + 1}].")
44
61
  end
45
62
  else
@@ -50,6 +67,8 @@ module Schema
50
67
  end
51
68
  end
52
69
 
70
+ # The model as a nested array, in {ClassMethods#to_headers} order; has_many entries beyond size: are left out.
71
+ # @return [Array]
53
72
  def to_a
54
73
  data = []
55
74
  self.class.schema.each_value do |field_options|
@@ -62,7 +81,7 @@ module Schema
62
81
  value ? value.to_a : self.class.const_get(field_options[:class_name]).to_empty_array
63
82
  when :has_many
64
83
  values = value || []
65
- field_options[:size].times.map do |idx|
84
+ Arrays.association_size(field_options).times.map do |idx|
66
85
  value = values[idx]
67
86
  value ? value.to_a : self.class.const_get(field_options[:class_name]).to_empty_array
68
87
  end
@@ -73,6 +92,11 @@ module Schema
73
92
  data
74
93
  end
75
94
 
95
+ # Sets attributes from a row using a header mapping.
96
+ # @param array [Array] row values
97
+ # @param mapped_headers [Hash] result of ArrayHeaders::ClassMethods#map_headers_to_attributes
98
+ # @param offset [Integer, nil] has_many entry index, used internally
99
+ # @return [self]
76
100
  def update_attributes_with_array(array, mapped_headers, offset = nil)
77
101
  self.class.schema.each_value do |field_options|
78
102
  next unless (mapped_field = mapped_headers[field_options[:name]])
@@ -95,11 +119,15 @@ module Schema
95
119
  self
96
120
  end
97
121
 
122
+ # Builds nested models from a row.
123
+ # @api private
98
124
  def update_nested_schemas_from_array(array, mapped_headers, current_offset = nil)
99
125
  update_nested_has_one_associations_from_array(array, mapped_headers, current_offset)
100
126
  update_nested_has_many_associations_from_array(array, mapped_headers)
101
127
  end
102
128
 
129
+ # Builds has_one models from a row.
130
+ # @api private
103
131
  def update_nested_has_one_associations_from_array(array, mapped_headers, current_offset = nil)
104
132
  self.class.schema.each_value do |field_options|
105
133
  next unless field_options[:type] == :has_one
@@ -112,6 +140,8 @@ module Schema
112
140
  end
113
141
  end
114
142
 
143
+ # Builds has_many models from a row.
144
+ # @api private
115
145
  def update_nested_has_many_associations_from_array(array, mapped_headers)
116
146
  self.class.schema.each_value do |field_options|
117
147
  next unless field_options[:type] == :has_many
@@ -128,10 +158,14 @@ module Schema
128
158
  end
129
159
  end
130
160
 
161
+ # Builds one nested model from a row.
162
+ # @api private
131
163
  def create_schema_with_array(field_options, array, mapped_model, offset)
132
164
  self.class.const_get(field_options[:class_name]).new.update_attributes_with_array(array, mapped_model, offset)
133
165
  end
134
166
 
167
+ # Number of has_many entries a mapping has columns for.
168
+ # @api private
135
169
  def largest_number_of_indexes_from_map(mapped_model)
136
170
  size = 0
137
171
  mapped_model.each_value do |info|
@@ -19,10 +19,12 @@ module Schema
19
19
  @base_schema_class_name = kls.name
20
20
  end
21
21
 
22
+ # @return [Class] the class that declared this association
22
23
  def base_schema_class
23
24
  @base_schema_class_name ? Object.const_get(@base_schema_class_name) : @base_schema_class
24
25
  end
25
26
 
27
+ # @return [Hash] the association's options in the parent schema
26
28
  def schema_options
27
29
  base_schema_class.schema[schema_name]
28
30
  end
@@ -10,6 +10,11 @@ module Schema
10
10
 
11
11
  # no-doc
12
12
  module ClassMethods
13
+ # Declares a subtype, used when the type field equals type.
14
+ # @param type [String, Symbol] type field value
15
+ # @param options [Hash] `class_name:` to name the generated class
16
+ # @yield attribute definitions for this type
17
+ # @return [Class] the subtype class
13
18
  def add_type(type, options = {}, &block)
14
19
  class_name = options[:class_name] || schema_dynamic_type_class_name(type)
15
20
  kls = Class.new(self)
@@ -19,14 +24,20 @@ module Schema
19
24
  kls
20
25
  end
21
26
 
27
+ # Declares the subtype used when no other type matches.
28
+ # @param options [Hash] see {#add_type}
29
+ # @yield attribute definitions for this type
30
+ # @return [Class] the subtype class
22
31
  def default_type(options = {}, &)
23
32
  add_type(:default, options, &)
24
33
  end
25
34
 
35
+ # @return [Hash] type name => class name, including :default
26
36
  def dynamic_types
27
37
  schema_options[:types]
28
38
  end
29
39
 
40
+ # @return [Array] declared type names, without :default
30
41
  def dynamic_type_names
31
42
  dynamic_types.keys - [:default]
32
43
  end
@@ -10,6 +10,13 @@ module Schema
10
10
 
11
11
  # no-doc
12
12
  module ClassMethods
13
+ # Declares a list of nested models, with a getter, a setter and append_to_<name>.
14
+ # @param name [Symbol]
15
+ # @param options [Hash] `alias:`/`aliases:`, `default: true`, `class_name:`, `base_class:`,
16
+ # `from: :hash` with `hash_key_field:`, `size:` (for Schema::Arrays), and
17
+ # `type_field:`/`external_type_field:`/`type_ignorecase:` for dynamic types
18
+ # @yield attribute definitions for the nested model
19
+ # @return [Class] the nested model class
13
20
  def has_many(name, options = {}, &block)
14
21
  options = ::Schema::Utils.add_association_class(self, name, :has_many, options)
15
22
 
@@ -10,6 +10,12 @@ module Schema
10
10
 
11
11
  # no-doc
12
12
  module ClassMethods
13
+ # Declares a nested model, with a getter and a setter.
14
+ # @param name [Symbol]
15
+ # @param options [Hash] `alias:`/`aliases:`, `default: true`, `class_name:`, `base_class:`, and
16
+ # `type_field:`/`external_type_field:`/`type_ignorecase:` for dynamic types
17
+ # @yield attribute definitions for the nested model
18
+ # @return [Class] the nested model class
13
19
  def has_one(name, options = {}, &block)
14
20
  options = ::Schema::Utils.add_association_class(self, name, :has_one, options)
15
21
 
@@ -16,6 +16,8 @@ module Schema
16
16
  configure_dynamic_schema_options(options)
17
17
  end
18
18
 
19
+ # Builds one nested model, recording parsing errors on base_schema.
20
+ # @api private
19
21
  def create_schema(base_schema, data, error_name = nil, skip_fields = [])
20
22
  if data.is_a?(Hash)
21
23
  unless (schema_class = get_schema_class(base_schema, data))
@@ -31,6 +33,8 @@ module Schema
31
33
  end
32
34
  end
33
35
 
36
+ # Builds a has_many list from an array, or from a hash with `from: :hash`.
37
+ # @api private
34
38
  def create_schemas(base_schema, list, skip_fields = [])
35
39
  if is_list? && list.is_a?(Array)
36
40
  list.each_with_index.map { |data, idx| create_schema(base_schema, data, "#{@schema_name}:#{idx}", skip_fields) }
@@ -46,6 +50,8 @@ module Schema
46
50
  end
47
51
  end
48
52
 
53
+ # The class to build for data, choosing a dynamic type when configured.
54
+ # @api private
49
55
  def get_schema_class(base_schema, data)
50
56
  if dynamic?
51
57
  get_dynamic_schema_class(base_schema, data)
@@ -62,6 +68,8 @@ module Schema
62
68
  @is_list
63
69
  end
64
70
 
71
+ # The dynamic type class matching data's type.
72
+ # @api private
65
73
  def get_dynamic_schema_class(base_schema, data)
66
74
  type = get_dynamic_type(base_schema, data)
67
75
  type = type.to_s.downcase if @ignorecase
@@ -72,12 +80,16 @@ module Schema
72
80
  get_default_dynamic_schema_class(base_schema)
73
81
  end
74
82
 
83
+ # The default_type class, if one was declared.
84
+ # @api private
75
85
  def get_default_dynamic_schema_class(base_schema)
76
86
  return unless (class_name = @types[:default])
77
87
 
78
88
  base_schema.class.const_get(class_name)
79
89
  end
80
90
 
91
+ # The type value from the nested data or the parent's external type field.
92
+ # @api private
81
93
  def get_dynamic_type(base_schema, data)
82
94
  if @type_field
83
95
  type_fields.each do |name|
@@ -98,10 +110,14 @@ module Schema
98
110
  end
99
111
  end
100
112
 
113
+ # Records a parsing error on the parent model.
114
+ # @api private
101
115
  def add_parsing_error(base_schema, error_name, error_msg)
102
116
  base_schema.parsing_errors.add(error_name || @schema_name, error_msg)
103
117
  end
104
118
 
119
+ # Reads the dynamic type options.
120
+ # @api private
105
121
  def configure_dynamic_schema_options(options)
106
122
  @type_field = options[:type_field]
107
123
  @external_type_field = options[:external_type_field]
@@ -18,6 +18,8 @@ module Schema
18
18
  set_field_options.to_h { |field_options| [field_options[:name], public_send(field_options[:getter])] }
19
19
  end
20
20
 
21
+ # @param other [Object]
22
+ # @return [Boolean] true for a model of the same class with equal attribute values
21
23
  def ==(other)
22
24
  other.instance_of?(self.class) && other.attribute_values == attribute_values
23
25
  end
@@ -8,28 +8,45 @@ module Schema
8
8
  def initialize(csv, schema_class, headers = nil)
9
9
  @csv = csv
10
10
  @schema_class = schema_class
11
- @headers = (headers || csv.shift) || []
12
- @headers = @headers.map(&:strip)
11
+ @headers = clean_headers((headers || csv.shift) || [])
13
12
  @mapped_headers = schema_class.map_headers_to_attributes(@headers)
14
13
  end
15
14
 
15
+ # strips whitespace, treats empty header cells as "", and drops the UTF-8 byte order mark
16
+ # that Excel puts before the first header
17
+ def clean_headers(headers)
18
+ headers.map.with_index do |header, idx|
19
+ header = header.to_s.strip
20
+ idx.zero? ? header.delete_prefix("\uFEFF") : header
21
+ end
22
+ end
23
+
24
+ # Required header names the CSV doesn't map.
25
+ # @param required_fields [Array<String>]
26
+ # @return [Array<String>]
16
27
  def missing_fields(required_fields)
17
28
  required_fields - get_mapped_headers(@mapped_headers)
18
29
  end
19
30
  alias missing_headers missing_fields
20
31
 
32
+ # Reads the next row.
33
+ # @return [Schema::Model, nil] nil at the end of the CSV
21
34
  def shift
22
35
  return unless (row = @csv.shift)
23
36
 
24
37
  @schema_class.from_array(row, @mapped_headers)
25
38
  end
26
39
 
40
+ # Yields a model for each remaining row.
41
+ # @yieldparam model [Schema::Model]
27
42
  def each
28
43
  while (schema = shift)
29
44
  yield schema
30
45
  end
31
46
  end
32
47
 
48
+ # Header names covered by a mapping.
49
+ # @api private
33
50
  def get_mapped_headers(mapped_headers)
34
51
  indexed_headers = []
35
52
  mapped_headers.each_value do |info|
@@ -3,6 +3,9 @@
3
3
  module Schema
4
4
  # Schema::DeepCopy copies a model along with its nested models, collections and parsing errors
5
5
  module DeepCopy
6
+ # Copies the model with its nested models, collections, strings and parsing errors,
7
+ # so the copy can be changed without touching the original.
8
+ # @return [Schema::Model]
6
9
  def deep_dup
7
10
  copy = dup
8
11
  copy.copy_attribute_values_from(self)
data/lib/schema/errors.rb CHANGED
@@ -5,16 +5,23 @@ module Schema
5
5
  class Errors
6
6
  attr_reader :errors
7
7
 
8
+ # Returned by {#[]} for names without errors.
8
9
  EMPTY_ARRAY = [].freeze
9
10
 
10
11
  def initialize
11
12
  @errors = {}
12
13
  end
13
14
 
15
+ # @param name [Symbol, String]
16
+ # @return [Array<String>] error codes for name (empty when none)
14
17
  def [](name)
15
18
  @errors[name] || EMPTY_ARRAY
16
19
  end
17
20
 
21
+ # Records an error code for name.
22
+ # @param name [Symbol, String]
23
+ # @param error [String] a {Schema::ParsingErrors} code
24
+ # @return [Array<String>]
18
25
  def add(name, error)
19
26
  @errors[name] ||= []
20
27
  @errors[name] << error
@@ -25,6 +32,7 @@ module Schema
25
32
  @errors.empty?
26
33
  end
27
34
 
35
+ # @return [Array] names that have errors
28
36
  def attribute_names
29
37
  @errors.keys
30
38
  end
data/lib/schema/model.rb CHANGED
@@ -14,6 +14,8 @@ module Schema
14
14
  base.extend ClassMethods
15
15
  end
16
16
 
17
+ # Options every attribute starts with.
18
+ # @api private
17
19
  def self.default_attribute_options(name, type)
18
20
  {
19
21
  key: name.to_s.freeze,
@@ -28,6 +30,7 @@ module Schema
28
30
 
29
31
  # no-doc
30
32
  module ClassMethods
33
+ # @return [Hash{Symbol => Hash}] field name => options, including alias entries
31
34
  def schema
32
35
  {}.freeze
33
36
  end
@@ -41,6 +44,8 @@ module Schema
41
44
  @schema_with_string_keys = current_schema.transform_keys(&:to_s).freeze
42
45
  end
43
46
 
47
+ # Modules to include in nested classes, and the unknown attribute setting.
48
+ # @api private
44
49
  def schema_config
45
50
  {
46
51
  schema_includes: [],
@@ -65,6 +70,12 @@ module Schema
65
70
  schema_config[:capture_unknown_attributes]
66
71
  end
67
72
 
73
+ # Declares an attribute with a getter, a parsing setter and a `<name>_was_set?` predicate.
74
+ # @param name [Symbol]
75
+ # @param type [Symbol] parsed by `parse_<type>`, e.g. :integer
76
+ # @param options [Hash] `alias:`/`aliases:`, `default:`, and parser options such as
77
+ # `separator:`/`data_type:` for :array
78
+ # @return [void]
68
79
  def attribute(name, type, options = {})
69
80
  options[:aliases] = [options[:alias]] if options.key?(:alias)
70
81
 
@@ -79,10 +90,15 @@ module Schema
79
90
  add_aliases(name, options)
80
91
  end
81
92
 
93
+ # Builds a model from data. See {Schema::Model#update_attributes}.
94
+ # @return [Schema::Model]
82
95
  def from_hash(data = nil, skip_fields = [])
83
96
  new.update_attributes(data, skip_fields)
84
97
  end
85
98
 
99
+ # Includes mod here and in every nested association class, current and future.
100
+ # @param mod [Module]
101
+ # @return [void]
86
102
  def schema_include(mod)
87
103
  config = schema_config.dup
88
104
  config[:schema_includes] = config[:schema_includes] + [mod]
@@ -96,6 +112,8 @@ module Schema
96
112
  end
97
113
  end
98
114
 
115
+ # Defines an attribute's getter, setter and predicate.
116
+ # @api private
99
117
  def add_attribute_methods(name, options)
100
118
  parser_check = parser_check_code(name, options)
101
119
  class_eval(
@@ -123,6 +141,8 @@ module Schema
123
141
  "::Schema::Utils.check_parser!(self, #{options[:parser].inspect}, #{name.inspect}, #{options[:type].inspect})"
124
142
  end
125
143
 
144
+ # Adds schema entries and methods for an attribute's aliases.
145
+ # @api private
126
146
  def add_aliases(name, options)
127
147
  return unless options[:aliases]
128
148
 
@@ -134,32 +154,47 @@ module Schema
134
154
  end
135
155
  end
136
156
 
157
+ # Sets attributes, then associations, from data.
158
+ # @param data [Hash, Array, nil] a hash with symbol or string keys, or [key, value] pairs;
159
+ # anything else records an incompatible error on :base
160
+ # @param skip_fields [Array] fields to ignore, e.g. `[:id, { items: [:id] }]`
161
+ # @return [self]
137
162
  def update_attributes(data = nil, skip_fields = [])
138
163
  return self if data.nil?
164
+ return add_incompatible_data_error unless attributes_data?(data)
139
165
 
140
166
  update_model_attributes(data, skip_fields)
141
167
  update_associations(data, skip_fields)
142
168
  self
143
169
  end
144
170
 
171
+ # The model as a hash; nested models become hashes.
172
+ # @param opts [Hash]
173
+ # @option opts [Boolean] :include_nils include nil values
174
+ # @option opts [Boolean] :only_set include only fields present in the input (nils included), at every level
175
+ # @option opts [Proc] :select_filter keep a field when it returns true; called with (name, value, options)
176
+ # @option opts [Proc] :reject_filter drop a field when it returns true; called with (name, value, options)
177
+ # @return [Hash]
145
178
  def as_json(opts = {})
146
179
  self.class.schema.each_with_object({}) do |(field_name, field_options), memo|
147
180
  next if field_options[:alias_of]
181
+ next if opts[:only_set] && !instance_variable_defined?(field_options[:instance_variable])
148
182
 
149
183
  value = public_send(field_options[:getter])
150
- next if value.nil? && !opts[:include_nils]
151
- next if opts[:select_filter] && !opts[:select_filter].call(field_name, value, field_options)
152
- next if opts[:reject_filter]&.call(field_name, value, field_options)
184
+ next unless include_in_json?(field_name, value, field_options, opts)
153
185
 
154
186
  memo[field_name] = value.is_a?(Array) ? value.map { |e| value_as_json(e, opts) } : value_as_json(value, opts)
155
187
  end
156
188
  end
157
189
 
190
+ # {#as_json} with nil values included.
191
+ # @return [Hash]
158
192
  def to_hash
159
193
  as_json(include_nils: true)
160
194
  end
161
195
  alias to_h to_hash
162
196
 
197
+ # @return [Schema::Errors] parsing error codes by field
163
198
  def parsing_errors
164
199
  @parsing_errors ||= Errors.new
165
200
  end
@@ -172,6 +207,26 @@ module Schema
172
207
 
173
208
  private
174
209
 
210
+ # a Hash (or anything with each_pair), or an array of [key, value] pairs
211
+ def attributes_data?(data)
212
+ return true if data.respond_to?(:each_pair)
213
+
214
+ data.is_a?(Array) && data.all? { |pair| pair.is_a?(Array) && pair.size == 2 }
215
+ end
216
+
217
+ def add_incompatible_data_error
218
+ parsing_errors.add(:base, ::Schema::ParsingErrors::INCOMPATIBLE)
219
+ self
220
+ end
221
+
222
+ # only_set keeps nils that came from the input, since they mean "set to null"
223
+ def include_in_json?(field_name, value, field_options, opts)
224
+ return false if value.nil? && !opts[:include_nils] && !opts[:only_set]
225
+ return false if opts[:select_filter] && !opts[:select_filter].call(field_name, value, field_options)
226
+
227
+ !opts[:reject_filter]&.call(field_name, value, field_options)
228
+ end
229
+
175
230
  # values without as_json (no ActiveSupport JSON extension) are returned as is
176
231
  def value_as_json(value, opts)
177
232
  value.respond_to?(:as_json) ? value.as_json(opts) : value
@@ -8,9 +8,13 @@ module Schema
8
8
  module American
9
9
  include StringValue
10
10
 
11
+ # strptime format for :american_date.
11
12
  DATE_FORMAT = '%m/%d/%Y'
13
+ # strptime format for :american_time.
12
14
  TIME_FORMAT = '%m/%d/%Y %H:%M:%S'
13
15
 
16
+ # Parses MM/DD/YYYY strings; blank strings are nil.
17
+ # @return [Date, nil]
14
18
  def parse_american_date(field_name, parsing_errors, value)
15
19
  case value
16
20
  when Date
@@ -27,6 +31,8 @@ module Schema
27
31
  end
28
32
  end
29
33
 
34
+ # Parses MM/DD/YYYY HH:MM:SS strings; blank strings are nil.
35
+ # @return [Time, nil]
30
36
  def parse_american_time(field_name, parsing_errors, value)
31
37
  case value
32
38
  when Time
@@ -4,6 +4,9 @@ module Schema
4
4
  module Parsers
5
5
  # Schema::Parsers::Array adds the array type to schemas
6
6
  module Array
7
+ # Parses arrays, or strings split on the attribute's `separator:`, converting
8
+ # elements with its `data_type:`.
9
+ # @return [Array, nil]
7
10
  def parse_array(field_name, parsing_errors, value)
8
11
  case value
9
12
  when ::Array
@@ -25,12 +28,16 @@ module Schema
25
28
  end
26
29
  end
27
30
 
31
+ # Splits a string on the attribute's separator.
32
+ # @api private
28
33
  def self.parse_string_array(model, field_name, parsing_errors, value, schema_options)
29
34
  return nil unless (separator = schema_options[:separator])
30
35
 
31
36
  convert_array_values(model, field_name, parsing_errors, value.split(separator), schema_options)
32
37
  end
33
38
 
39
+ # Converts elements with the attribute's data_type.
40
+ # @api private
34
41
  def self.convert_array_values(model, field_name, parsing_errors, data, schema_options)
35
42
  return data unless (data_type = schema_options[:data_type])
36
43
 
@@ -10,6 +10,7 @@ module Schema
10
10
 
11
11
  # strings are stripped first; a blank string parses to nil without an error
12
12
  INTEGER_REGEX = /\A[-+]?\d+(?:\.0+)?\z/
13
+ # Accepted float strings, e.g. "1.5", "007.5", "1e+5".
13
14
  FLOAT_REGEX = /\A[-+]?\d+(?:\.\d+)?([Ee][-+]?\d+)?\z/
14
15
  # matched case-insensitively; any other string is invalid
15
16
  BOOLEAN_STRINGS = {
@@ -17,6 +18,8 @@ module Schema
17
18
  '0' => false, 'f' => false, 'false' => false, 'off' => false, 'n' => false, 'no' => false
18
19
  }.freeze
19
20
 
21
+ # Parses integers; whole floats are converted, fractional ones are incompatible.
22
+ # @return [Integer, nil]
20
23
  def parse_integer(field_name, parsing_errors, value)
21
24
  case value
22
25
  when Integer
@@ -35,6 +38,8 @@ module Schema
35
38
  end
36
39
  end
37
40
 
41
+ # Converts a float for parse_integer.
42
+ # @api private
38
43
  def parse_float_as_integer(field_name, parsing_errors, value)
39
44
  unless value.finite?
40
45
  parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE)
@@ -45,6 +50,8 @@ module Schema
45
50
  value.to_i
46
51
  end
47
52
 
53
+ # Converts scalars to strings; hashes and arrays are incompatible.
54
+ # @return [String, nil]
48
55
  def parse_string(field_name, parsing_errors, value)
49
56
  case value
50
57
  when String
@@ -59,11 +66,11 @@ module Schema
59
66
  end
60
67
  end
61
68
 
62
- # if the string is empty return nil
69
+ # a blank (empty or whitespace-only) string returns nil; other strings are kept as is
63
70
  def parse_string_or_nil(field_name, parsing_errors, value)
64
71
  case value
65
72
  when String
66
- value.empty? ? nil : value
73
+ value.strip.empty? ? nil : value
67
74
  when ::Hash, ::Array
68
75
  parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE)
69
76
  nil
@@ -74,6 +81,8 @@ module Schema
74
81
  end
75
82
  end
76
83
 
84
+ # Parses floats.
85
+ # @return [Float, nil]
77
86
  def parse_float(field_name, parsing_errors, value)
78
87
  case value
79
88
  when Float
@@ -92,6 +101,8 @@ module Schema
92
101
  end
93
102
  end
94
103
 
104
+ # Parses ISO 8601 date-times (Time.xmlschema).
105
+ # @return [Time, nil]
95
106
  def parse_time(field_name, parsing_errors, value)
96
107
  case value
97
108
  when Time
@@ -108,6 +119,8 @@ module Schema
108
119
  end
109
120
  end
110
121
 
122
+ # Parses ISO 8601 dates (Date.iso8601).
123
+ # @return [Date, nil]
111
124
  def parse_date(field_name, parsing_errors, value)
112
125
  case value
113
126
  when Date
@@ -124,6 +137,8 @@ module Schema
124
137
  end
125
138
  end
126
139
 
140
+ # Parses booleans; see {BOOLEAN_STRINGS}. Numbers are true unless 0.
141
+ # @return [Boolean, nil]
127
142
  def parse_boolean(field_name, parsing_errors, value)
128
143
  case value
129
144
  when TrueClass, FalseClass
@@ -4,6 +4,8 @@ module Schema
4
4
  module Parsers
5
5
  # Schema::Parsers::Hash adds the hash type to schemas
6
6
  module Hash
7
+ # Accepts hashes; anything else is incompatible.
8
+ # @return [Hash, nil]
7
9
  def parse_hash(field_name, parsing_errors, value)
8
10
  case value
9
11
  when ::Hash
@@ -6,6 +6,8 @@ module Schema
6
6
  module Parsers
7
7
  # Schema::Parsers::Json parse the string as json
8
8
  module Json
9
+ # Parses JSON strings.
10
+ # @return [Object, nil] the parsed JSON
9
11
  def parse_json(field_name, parsing_errors, value)
10
12
  case value
11
13
  when String
@@ -3,10 +3,15 @@
3
3
  module Schema
4
4
  # Schema::ParsingErrors is a collection of parsing error messages
5
5
  module ParsingErrors
6
+ # The value couldn't be parsed.
6
7
  INVALID = 'invalid'
8
+ # The value is the wrong kind, e.g. a hash for a string attribute.
7
9
  INCOMPATIBLE = 'incompatible'
10
+ # No dynamic type matched the type field.
8
11
  UNKNOWN = 'unknown'
12
+ # The key isn't in the schema.
9
13
  UNKNOWN_ATTRIBUTE = 'unknown_attribute'
14
+ # The parser doesn't handle the value's class.
10
15
  UNHANDLED_TYPE = 'unhandled_type'
11
16
  end
12
17
  end
data/lib/schema/utils.rb CHANGED
@@ -13,10 +13,14 @@ module Schema
13
13
  "no #{parser} method (schema_include the parser module that defines it)"
14
14
  end
15
15
 
16
+ # Turns a name into a constant name.
17
+ # @api private
16
18
  def classify_name(name)
17
19
  name.gsub(/[^\da-z_-]/, '').gsub(/(^.|[_|-].)/) { |m| m[-1].upcase }
18
20
  end
19
21
 
22
+ # Creates and registers an association's model class.
23
+ # @api private
20
24
  def create_schema_class(base_schema_class, schema_name, options)
21
25
  base_schema_class.add_value_to_class_method(:schema, schema_name => options)
22
26
  kls = Class.new(options[:base_class] || Object)
@@ -26,6 +30,8 @@ module Schema
26
30
  kls
27
31
  end
28
32
 
33
+ # Includes Schema::Model and the parent's schema_include modules.
34
+ # @api private
29
35
  def include_schema_modules(kls, schema_config)
30
36
  kls.send(:include, ::Schema::Model)
31
37
  schema_config[:schema_includes].each do |mod|
@@ -33,6 +39,8 @@ module Schema
33
39
  end
34
40
  end
35
41
 
42
+ # Builds an association's schema options.
43
+ # @api private
36
44
  def association_options(schema_name, schema_type, options)
37
45
  options[:class_name] ||= "Schema#{classify_name(schema_type.to_s)}#{classify_name(schema_name.to_s)}"
38
46
  options[:association] = true
@@ -41,6 +49,8 @@ module Schema
41
49
  ::Schema::Model.default_attribute_options(schema_name, schema_type).merge(options)
42
50
  end
43
51
 
52
+ # Sets up an association's class and options.
53
+ # @api private
44
54
  def add_association_class(base_schema_class, schema_name, schema_type, options)
45
55
  options = ::Schema::Utils.association_options(schema_name, schema_type, options)
46
56
  kls = ::Schema::Utils.create_schema_class(
@@ -53,12 +63,16 @@ module Schema
53
63
  options
54
64
  end
55
65
 
66
+ # Links an association class to its parent.
67
+ # @api private
56
68
  def add_association_defaults(kls, base_schema_class, schema_name)
57
69
  kls.send(:include, ::Schema::Associations::Base)
58
70
  kls.base_schema_class = base_schema_class
59
71
  kls.schema_name = schema_name
60
72
  end
61
73
 
74
+ # Adds dynamic type support when configured.
75
+ # @api private
62
76
  def add_association_dynamic_types(kls, options)
63
77
  return if !options[:type_field] && !options[:external_type_field]
64
78
 
@@ -92,6 +106,8 @@ module Schema
92
106
  value.dup
93
107
  end
94
108
 
109
+ # Defines an attribute's default method and getter.
110
+ # @api private
95
111
  def add_attribute_default_methods(kls, options)
96
112
  default = options[:default]
97
113
  kls.send(:define_method, options[:default_method]) { ::Schema::Utils.copy_default(default) }
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Schema
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.9.1'
5
+ VERSION = '0.10.0'
6
6
  end
data/lib/schema-model.rb CHANGED
@@ -4,6 +4,7 @@ autoload :SchemaValidator, 'schema_validator'
4
4
 
5
5
  # Schema is a series of tools for transforming data into models
6
6
  module Schema
7
+ # Base error for parsed! and valid_model!; carries the model and its errors.
7
8
  class SchemaException < StandardError
8
9
  attr_reader :schema,
9
10
  :errors
@@ -15,7 +16,9 @@ module Schema
15
16
  end
16
17
  end
17
18
 
19
+ # Raised by parsed! (and valid!) when the model has parsing errors.
18
20
  class ParsingException < SchemaException; end
21
+ # Raised by valid_model! (and valid!) when the model fails its validations.
19
22
  class ValidationException < SchemaException; end
20
23
 
21
24
  # raised when a value is assigned to an attribute whose type has no parse_<type> method
@@ -2,6 +2,8 @@
2
2
 
3
3
  # SchemaValidator validates nested schemas: each one must have no parsing errors and pass its validations
4
4
  class SchemaValidator < ActiveModel::EachValidator
5
+ # Called by ActiveModel for `validates :x, schema: true`.
6
+ # @api private
5
7
  def validate_each(record, attribute, value)
6
8
  record.errors.add(attribute, options.fetch(:message, :invalid)) unless valid_schema?(value)
7
9
  end
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.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: inheritance-helper
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: '0.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: '0.2'
26
26
  description: Declare typed attributes and Schema turns incoming hashes, JSON and CSV
27
27
  rows into Ruby objects. Values are coerced to integers, floats, booleans, dates,
28
28
  times, arrays and hashes, and bad input is recorded as parsing errors instead of
@@ -73,6 +73,7 @@ metadata:
73
73
  source_code_uri: https://github.com/dougyouch/schema
74
74
  changelog_uri: https://github.com/dougyouch/schema/blob/master/CHANGELOG.md
75
75
  bug_tracker_uri: https://github.com/dougyouch/schema/issues
76
+ documentation_uri: https://rubydoc.info/gems/schema-model
76
77
  rdoc_options: []
77
78
  require_paths:
78
79
  - lib