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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +140 -6
- data/lib/schema/active_model_validations.rb +11 -0
- data/lib/schema/attribute_values.rb +6 -0
- data/lib/schema/deep_copy.rb +9 -3
- data/lib/schema/utils.rb +11 -1
- data/lib/schema/version.rb +1 -1
- data/lib/schema_validator.rb +9 -8
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a95ca01d369cc20699a43f7a13b46fe567768c6ba5b8281cfb12800c56324e0
|
|
4
|
+
data.tar.gz: c185ad7251b43193473da560d837bd4f87fc00558c540e635fa6618a8719773f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
|
412
|
-
a.attribute_values
|
|
413
|
-
a.
|
|
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
|
|
549
|
+
copy = a.deep_dup # copies nested models, arrays, hashes, strings and parsing errors
|
|
416
550
|
copy.name << '!'
|
|
417
|
-
a.name
|
|
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
|
data/lib/schema/deep_copy.rb
CHANGED
|
@@ -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
|
-
|
|
20
|
-
next unless
|
|
19
|
+
copy_instance_variable_from(source, field_options[:instance_variable])
|
|
20
|
+
next unless field_options[:association] && field_options[:default]
|
|
21
21
|
|
|
22
|
-
|
|
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]}
|
|
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
|
)
|
data/lib/schema/version.rb
CHANGED
data/lib/schema_validator.rb
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|