schema-model 0.8.0 → 0.9.1

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: f672bab800b79f5e9a8b390268466caaf7a42822b0b0632f023f6539fbae48aa
4
- data.tar.gz: 5486532778b3a246d66c0c2350586fd77ebf9955ef311a719c5053d656afe0be
3
+ metadata.gz: 1a95ca01d369cc20699a43f7a13b46fe567768c6ba5b8281cfb12800c56324e0
4
+ data.tar.gz: c185ad7251b43193473da560d837bd4f87fc00558c540e635fa6618a8719773f
5
5
  SHA512:
6
- metadata.gz: ca482dfd23e73cea044bf572e819864415ee68768cdb0b98a756e92fbb06c2d800b7cdd6c2afde4d5c77559d226a7e3cf681f4043a17b118c17ed24fee0cdb9c
7
- data.tar.gz: f555174a32c7adbe3b713bb726038f325168f07820d6309479be0715af91bb5e684d73d51466a0f71cc806ecd7ca98f2006bf8820008c126ee4f25f220ffdcde
6
+ metadata.gz: e0e993ff5247dcb1acd76acbecaa2f0b65fcdf30e94d4342c8f362bc2ce0c87edf8ca2c511e35cf791a9150be29ff3f6f633f0b8d815ed7c3ae08dfff71d50f5
7
+ data.tar.gz: 34c05bb3bb1399be9c9d060617ad41cfcf8c2714c094097c5868fd52b56525c3be2e59af5be28ee28a5369e3e254267c389b40b496011ca8213350168c3ea7d6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.1](https://github.com/dougyouch/schema/compare/v0.9.0...v0.9.1) (2026-10-04)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **associations:** keep association defaults out of the set values ([3d90df3](https://github.com/dougyouch/schema/commit/3d90df3f8e8fa0b5da75cb313137d76a49106091))
9
+
10
+ ## [0.9.0](https://github.com/dougyouch/schema/compare/v0.8.0...v0.9.0) (2026-10-04)
11
+
12
+
13
+ ### Features
14
+
15
+ * **model:** add set_attribute_values, parsed_and_valid? and full_error_messages ([0c8b922](https://github.com/dougyouch/schema/commit/0c8b922cb4417720b70d069a8c51fb80b3b1c644))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **validator:** fail schema validation on nested parsing errors ([b17704a](https://github.com/dougyouch/schema/commit/b17704af2629d3a4a81ee69f748e216a8c5d7488))
21
+
3
22
  ## [0.8.0](https://github.com/dougyouch/schema/compare/v0.7.4...v0.8.0) (2026-10-04)
4
23
 
5
24
 
data/README.md CHANGED
@@ -59,6 +59,128 @@ 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
@@ -330,9 +452,20 @@ user.parsing_errors.full_messages # => ["Age is invalid"]
330
452
 
331
453
  A plain `Schema::Model` uses `Schema::Errors`, which stores the codes themselves (`user.parsing_errors[:age] # => ["invalid"]`).
332
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
+
333
466
  ### Validating Nested Schemas
334
467
 
335
- `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:
336
469
 
337
470
  ```ruby
338
471
  class OrderSchema
@@ -408,13 +541,14 @@ user.as_json(reject_filter: ->(name, value, opts) { value.nil? })
408
541
  a = ContactSchema.from_hash(name: 'John')
409
542
  b = ContactSchema.from_hash('name' => 'John')
410
543
 
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)
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)
414
548
 
415
- copy = a.deep_dup # copies nested models, arrays, hashes, strings and parsing errors
549
+ copy = a.deep_dup # copies nested models, arrays, hashes, strings and parsing errors
416
550
  copy.name << '!'
417
- a.name # => "John"
551
+ a.name # => "John"
418
552
  ```
419
553
 
420
554
  `dup` is Ruby's shallow copy, so nested models are shared with the original.
@@ -25,6 +25,17 @@ 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
@@ -12,6 +12,12 @@ module Schema
12
12
  end
13
13
  end
14
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
+
15
21
  def ==(other)
16
22
  other.instance_of?(self.class) && other.attribute_values == attribute_values
17
23
  end
@@ -16,13 +16,19 @@ module Schema
16
16
  self.class.schema.each_value do |field_options|
17
17
  next if field_options[:alias_of]
18
18
 
19
- ivar = field_options[:instance_variable]
20
- next unless source.instance_variable_defined?(ivar)
19
+ copy_instance_variable_from(source, field_options[:instance_variable])
20
+ next unless field_options[:association] && field_options[:default]
21
21
 
22
- instance_variable_set(ivar, ::Schema::Utils.deep_dup_value(source.instance_variable_get(ivar)))
22
+ copy_instance_variable_from(source, ::Schema::Utils.association_default_instance_variable(field_options))
23
23
  end
24
24
  end
25
25
 
26
+ def copy_instance_variable_from(source, ivar)
27
+ return unless source.instance_variable_defined?(ivar)
28
+
29
+ instance_variable_set(ivar, ::Schema::Utils.deep_dup_value(source.instance_variable_get(ivar)))
30
+ end
31
+
26
32
  def copy_parsing_errors_from(source)
27
33
  @parsing_errors = nil
28
34
  ::Schema::Utils.parsing_error_pairs(source.parsing_errors).each do |attribute, message|
data/lib/schema/utils.rb CHANGED
@@ -108,7 +108,15 @@ module Schema
108
108
  )
109
109
  end
110
110
 
111
+ # where an association's default is kept once read, separate from the value set from the input
112
+ def association_default_instance_variable(options)
113
+ "#{options[:instance_variable]}_default"
114
+ end
115
+
116
+ # the default is memoized so changes to it stick, but in its own instance variable, so reading
117
+ # it doesn't make the association count as set (set_attribute_values, not_set?, inspect)
111
118
  def add_association_default_methods(kls, options)
119
+ default_instance_variable = association_default_instance_variable(options)
112
120
  kls.class_eval(
113
121
  <<-STR, __FILE__, __LINE__ + 1
114
122
  def #{options[:default_method]}
@@ -116,7 +124,9 @@ module Schema
116
124
  end
117
125
 
118
126
  def #{options[:getter]}
119
- #{options[:instance_variable]} ||= #{options[:default_method]}
127
+ return #{options[:instance_variable]} unless #{options[:instance_variable]}.nil?
128
+
129
+ #{default_instance_variable} ||= #{options[:default_method]}
120
130
  end
121
131
  STR
122
132
  )
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Schema
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.8.0'
5
+ VERSION = '0.9.1'
6
6
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # SchemaValidator validates nested schemas
3
+ # SchemaValidator validates nested schemas: each one must have no parsing errors and pass its validations
4
4
  class SchemaValidator < ActiveModel::EachValidator
5
5
  def validate_each(record, attribute, value)
6
6
  record.errors.add(attribute, options.fetch(:message, :invalid)) unless valid_schema?(value)
@@ -11,12 +11,13 @@ class SchemaValidator < ActiveModel::EachValidator
11
11
  def valid_schema?(value)
12
12
  return true unless value
13
13
 
14
- if value.is_a?(Array)
15
- value.all? do |schema|
16
- !schema || schema.valid?
17
- end
18
- else
19
- value.valid?
20
- end
14
+ schemas = value.is_a?(Array) ? value.compact : [value]
15
+ # map before all? so every nested schema runs its validations and has its errors filled in
16
+ schemas.map { |schema| parsed_and_valid?(schema) }.all?
17
+ end
18
+
19
+ def parsed_and_valid?(schema)
20
+ valid = schema.valid?
21
+ valid && schema.parsed?
21
22
  end
22
23
  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.8.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch