sorbet-schema 0.10.0 → 0.11.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 +4 -4
- data/AGENTS.md +2 -1
- data/CHANGELOG.md +26 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +37 -28
- data/README.md +89 -17
- data/Rakefile +1 -0
- data/lib/sorbet-schema/serialize_value.rb +1 -1
- data/lib/sorbet-schema/t/struct.rb +47 -28
- data/lib/sorbet-schema/version.rb +1 -1
- data/lib/sorbet-schema.rb +3 -0
- data/lib/typed/active_record_serializer.rb +44 -13
- data/lib/typed/coercion/enum_coercer.rb +1 -1
- data/lib/typed/coercion/struct_coercer.rb +7 -7
- data/lib/typed/coercion/typed_hash_coercer.rb +3 -2
- data/lib/typed/csv_serializer.rb +5 -4
- data/lib/typed/field.rb +33 -4
- data/lib/typed/hash_serializer.rb +17 -3
- data/lib/typed/json_schema.rb +151 -0
- data/lib/typed/json_serializer.rb +5 -3
- data/lib/typed/message_pack_serializer.rb +5 -4
- data/lib/typed/schema.rb +63 -28
- data/lib/typed/serializer.rb +21 -10
- data/lib/typed/yml_serializer.rb +4 -3
- data/rbi/sorbet-schema.rbi +22 -2
- data/sorbet/rbi/gems/{minitest@5.24.1.rbi → minitest@5.27.0.rbi} +522 -657
- data/sorbet/rbi/gems/parallel@1.28.0.rbi +270 -0
- data/sorbet/rbi/gems/sorbet-result@1.1.0.rbi +1 -1
- metadata +4 -3
- data/sorbet/rbi/gems/parallel@1.25.1.rbi +0 -287
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ae3eaa249b0881483d4fe8edd8c295ada54733bb78d106525767506e83f20bb
|
|
4
|
+
data.tar.gz: f36595f7d0831a83234453acccb941dc3bcff703562a87a52dbb1b2dbfd0dce0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ff4723d4f1a749d3d2ee7255b4c024c783b10acf7701240889d9bc25b3682df9c308a6a525c6a055bb8b4a627ce8214d45dfda6c359b064f90beb43693fe5bc
|
|
7
|
+
data.tar.gz: f3352237a1b10e95747c313c4937869c2baac94e45cedd1eb29c3b550d5a7c41b731b07505fbb61d7645e7d46efec986d95a95ed2d3472bf9e65cac9403340bf
|
data/AGENTS.md
CHANGED
|
@@ -4,7 +4,8 @@ This file is the project's committed home for project-intrinsic agent knowledge:
|
|
|
4
4
|
|
|
5
5
|
- Add durable project-specific notes here as they are discovered through real work.
|
|
6
6
|
- Ruby 3.4+ unbundles former stdlib gems (e.g. `csv`) from the default load path, so `require "csv"` is not free — it needs the gem actually installed.
|
|
7
|
-
- Format-specific serializers
|
|
7
|
+
- Format-specific serializers keep their backing gems optional: load the gem in `initialize`, raise a clear `ArgumentError` on `LoadError`, and keep class-body signatures safe to reflect without the gem installed. See `lib/typed/csv_serializer.rb` and `lib/typed/active_record_serializer.rb`; the latter uses a generic model output instead of referencing `ActiveRecord::Base` in signatures. Verify eager loading changes with `bundle exec tapioca gem` in a consumer bundle without `activerecord`, and keep the factory's missing-gem behavior consistent.
|
|
8
|
+
- Every `Typed::Serializer` subclass (all six format serializers) and `Typed::Schema` are generic over the struct type via a `StructT = type_member { {upper: T::InexactStruct} }` re-declared in each subclass — `T::InexactStruct` is Sorbet's common superclass for `T::Struct` and `T::ImmutableStruct`; Sorbet cannot propagate their union as a type-member bound. This lets every deserialize path (`deserialize_from`, `.serializer(:x).deserialize`, `.schema.from_x`, direct serializer construction) return the concrete struct type with no `T.cast`. `Typed::Schema#target` is `T.all(T::Class[StructT], StructClass)`, not the bare `T::Class[StructT]`: Sorbet runtime erases `T::Class[X]` to `T::Class[T.untyped]`, while the `StructClass` intersection restores the runtime restriction to `T::Struct`/`T::ImmutableStruct` descendants. See `lib/typed/serializer.rb`, `lib/typed/schema.rb`, and `lib/typed/hash_serializer.rb`'s `self.new` override (the one class that needs it, for direct-construction type inference) for the exact shape when adding a new serializer.
|
|
8
9
|
- After adding a new *required* runtime gem dependency (not this optional-serializer case), run `bundle exec tapioca gem` (add the gem to `sorbet/tapioca/require.rb` first if it isn't required anywhere else) so `bundle exec rake` (which runs `srb tc`) has an RBI to check against.
|
|
9
10
|
|
|
10
11
|
## Maintaining this file
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.11.0](https://github.com/maxveldink/sorbet-schema/compare/v0.10.0...v0.11.0) (2026-10-11)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### ⚠ BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* **typed:** ActiveRecord serializer annotations now take the model type first and struct type second: `Typed::ActiveRecordSerializer[PersonModel, Person]`. Direct `.new(schema: Person.schema, model_class: PersonModel)` infers both types. Regenerate consumer gem RBIs after upgrading.
|
|
13
|
+
* **typed:** `Typed::Serializer::DeserializeResult` and `Typed::Coercion::StructCoercer::Target` now accept immutable as well as mutable structs. `DeserializeResult` is `Typed::Result[Typed::StructValue, Typed::DeserializeError]`, where `Typed::StructValue` is the union of `T::Struct` and `T::ImmutableStruct`. Update annotations that assume these aliases only contain `T::Struct`; use `Typed::StructValue` when handling both, or obtain a concrete struct result through a parameterized schema or serializer.
|
|
14
|
+
* **typed:** Props with a `name:` option now use that name for both input and output in Hash, JSON, CSV, YAML, and MessagePack. For `const :event_id, String, name: "eventId"`, migrate payload keys and CSV headers from `event_id` to `eventId`. The old key no longer populates the field: required fields fail validation, while optional fields may use their default or nil. ActiveRecord continues to use Ruby attribute names.
|
|
15
|
+
* **typed:** Schema and serializers now require a struct type argument in Sorbet annotations. Use `Typed::Schema[Person]`, `Typed::HashSerializer[Person]` (and the equivalent JSON, CSV, YML, and MessagePack types), and `Typed::Serializer[Input, Output, Person]` instead of the former unparameterized classes or two-argument base serializer. Custom serializer subclasses must redeclare `StructT = type_member { {upper: T::InexactStruct} }` and use it in their schema, serialize, and deserialize signatures (including `Typed::Schema[StructT]` and `Typed::Result[StructT, Typed::DeserializeError]`). Regenerate consumer gem RBIs and update custom `.schema` overrides to return a parameterized schema. Prefer `Schema.from_struct(Person)` or `Typed::Schema[Person].new(...)` to preserve concrete type inference.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **typed:** generate a JSON Schema from a schema ([#152](https://github.com/maxveldink/sorbet-schema/issues/152)) ([575ae72](https://github.com/maxveldink/sorbet-schema/commit/575ae72eabd7f0a3ade6299732128a586041b5bf))
|
|
20
|
+
* **typed:** honor the prop name: option as the serialized key ([08490d2](https://github.com/maxveldink/sorbet-schema/commit/08490d26c04385cd00a651b9e10506ad75ce960f))
|
|
21
|
+
* **typed:** make Serializer and Schema generic over struct type ([086e867](https://github.com/maxveldink/sorbet-schema/commit/086e8671aeda7868e6dde98b8d23621918059e4b))
|
|
22
|
+
* **typed:** support immutable nested JSON Schema ([#162](https://github.com/maxveldink/sorbet-schema/issues/162)) ([d74d6cd](https://github.com/maxveldink/sorbet-schema/commit/d74d6cdd63017a268f1ecd78f1e68314460767c9))
|
|
23
|
+
* **typed:** support immutable structs ([288c972](https://github.com/maxveldink/sorbet-schema/commit/288c972cd87cb194b19743d3eb5bb7c2e9f7f617))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* **activerecord:** load the optional gem lazily and raise `ArgumentError` when unavailable, including direct construction that previously raised `NameError` ([bf7e894](https://github.com/maxveldink/sorbet-schema/commit/bf7e8948353b58a692f9ad7d115440edc7b1ebb5))
|
|
29
|
+
* **json:** return `Failure(ParseError)` for non-object JSON roots (arrays, scalars, and null); update callers that rescue exceptions for these inputs ([bf7e894](https://github.com/maxveldink/sorbet-schema/commit/bf7e8948353b58a692f9ad7d115440edc7b1ebb5))
|
|
30
|
+
* **schema:** preserve optionality, nullability, defaults, serialized names, and descriptions when updating a field with `add_serializer` ([bf7e894](https://github.com/maxveldink/sorbet-schema/commit/bf7e8948353b58a692f9ad7d115440edc7b1ebb5))
|
|
31
|
+
* **typed:** harden JSON parsing, field updates, and ActiveRecord loading ([bf7e894](https://github.com/maxveldink/sorbet-schema/commit/bf7e8948353b58a692f9ad7d115440edc7b1ebb5))
|
|
32
|
+
|
|
7
33
|
## [0.10.0](https://github.com/maxveldink/sorbet-schema/compare/v0.9.3...v0.10.0) (2026-08-07)
|
|
8
34
|
|
|
9
35
|
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
sorbet-schema (0.
|
|
4
|
+
sorbet-schema (0.11.0)
|
|
5
5
|
sorbet-result (~> 1.1)
|
|
6
6
|
sorbet-runtime (~> 0.5)
|
|
7
7
|
sorbet-struct-comparable (~> 1.3)
|
|
@@ -32,6 +32,7 @@ GEM
|
|
|
32
32
|
ansi (1.5.0)
|
|
33
33
|
ast (2.4.2)
|
|
34
34
|
base64 (0.3.0)
|
|
35
|
+
benchmark (0.5.0)
|
|
35
36
|
benchmark-ips (2.14.0)
|
|
36
37
|
bigdecimal (3.1.8)
|
|
37
38
|
builder (3.3.0)
|
|
@@ -42,7 +43,7 @@ GEM
|
|
|
42
43
|
irb (~> 1.10)
|
|
43
44
|
reline (>= 0.3.8)
|
|
44
45
|
drb (2.2.3)
|
|
45
|
-
erubi (1.13.
|
|
46
|
+
erubi (1.13.1)
|
|
46
47
|
i18n (1.14.8)
|
|
47
48
|
concurrent-ruby (~> 1.0)
|
|
48
49
|
io-console (0.7.2)
|
|
@@ -53,7 +54,7 @@ GEM
|
|
|
53
54
|
language_server-protocol (3.17.0.3)
|
|
54
55
|
lint_roller (1.1.0)
|
|
55
56
|
logger (1.7.0)
|
|
56
|
-
minitest (5.
|
|
57
|
+
minitest (5.27.0)
|
|
57
58
|
minitest-focus (1.4.0)
|
|
58
59
|
minitest (>= 4, < 6)
|
|
59
60
|
minitest-reporters (1.7.1)
|
|
@@ -63,26 +64,29 @@ GEM
|
|
|
63
64
|
ruby-progressbar
|
|
64
65
|
msgpack (1.8.4)
|
|
65
66
|
netrc (0.11.0)
|
|
66
|
-
parallel (1.
|
|
67
|
+
parallel (1.28.0)
|
|
67
68
|
parser (3.3.3.0)
|
|
68
69
|
ast (~> 2.4.1)
|
|
69
70
|
racc
|
|
70
|
-
prism (1.
|
|
71
|
+
prism (1.9.0)
|
|
71
72
|
psych (5.1.2)
|
|
72
73
|
stringio
|
|
73
74
|
racc (1.8.0)
|
|
74
75
|
rainbow (3.1.1)
|
|
75
76
|
rake (13.2.1)
|
|
76
|
-
rbi (0.3
|
|
77
|
+
rbi (0.4.3)
|
|
77
78
|
prism (~> 1.0)
|
|
78
|
-
rbs (>=
|
|
79
|
-
rbs (
|
|
79
|
+
rbs (>= 4.0.1)
|
|
80
|
+
rbs (4.2.0)
|
|
80
81
|
logger
|
|
82
|
+
prism (>= 1.6.0)
|
|
83
|
+
tsort
|
|
81
84
|
rdoc (6.7.0)
|
|
82
85
|
psych (>= 4.0.0)
|
|
83
86
|
regexp_parser (2.9.2)
|
|
84
87
|
reline (0.5.9)
|
|
85
88
|
io-console (~> 0.5)
|
|
89
|
+
require-hooks (0.5.1)
|
|
86
90
|
rexml (3.3.1)
|
|
87
91
|
strscan
|
|
88
92
|
rubocop (1.64.1)
|
|
@@ -104,22 +108,27 @@ GEM
|
|
|
104
108
|
rubocop-sorbet (0.7.8)
|
|
105
109
|
rubocop (>= 0.90.0)
|
|
106
110
|
ruby-progressbar (1.13.0)
|
|
111
|
+
rubydex (0.4.1-arm64-darwin)
|
|
112
|
+
rubydex (0.4.1-x86_64-linux)
|
|
107
113
|
securerandom (0.4.1)
|
|
108
|
-
sorbet (0.
|
|
109
|
-
sorbet-static (= 0.
|
|
114
|
+
sorbet (0.6.13543)
|
|
115
|
+
sorbet-static (= 0.6.13543)
|
|
110
116
|
sorbet-result (1.1.0)
|
|
111
117
|
sorbet-runtime (~> 0.5)
|
|
112
|
-
sorbet-runtime (0.
|
|
113
|
-
sorbet-static (0.
|
|
114
|
-
sorbet-static (0.
|
|
115
|
-
sorbet-static-and-runtime (0.
|
|
116
|
-
sorbet (= 0.
|
|
117
|
-
sorbet-runtime (= 0.
|
|
118
|
+
sorbet-runtime (0.6.13543)
|
|
119
|
+
sorbet-static (0.6.13543-universal-darwin)
|
|
120
|
+
sorbet-static (0.6.13543-x86_64-linux)
|
|
121
|
+
sorbet-static-and-runtime (0.6.13543)
|
|
122
|
+
sorbet (= 0.6.13543)
|
|
123
|
+
sorbet-runtime (= 0.6.13543)
|
|
118
124
|
sorbet-struct-comparable (1.3.0)
|
|
119
125
|
sorbet-runtime (>= 0.5)
|
|
120
|
-
spoom (1.
|
|
126
|
+
spoom (1.8.10)
|
|
121
127
|
erubi (>= 1.10.0)
|
|
122
128
|
prism (>= 0.28.0)
|
|
129
|
+
rbi (>= 0.4.2)
|
|
130
|
+
rbs (>= 4.0.0.dev.5)
|
|
131
|
+
rexml (>= 3.2.6)
|
|
123
132
|
sorbet-static-and-runtime (>= 0.5.10187)
|
|
124
133
|
thor (>= 0.19.2)
|
|
125
134
|
sqlite3 (2.9.0-arm64-darwin)
|
|
@@ -141,25 +150,25 @@ GEM
|
|
|
141
150
|
rubocop-sorbet (~> 0.7.0)
|
|
142
151
|
stringio (3.1.1)
|
|
143
152
|
strscan (3.1.0)
|
|
144
|
-
tapioca (0.
|
|
153
|
+
tapioca (0.20.0)
|
|
154
|
+
benchmark
|
|
145
155
|
bundler (>= 2.2.25)
|
|
146
156
|
netrc (>= 0.11.0)
|
|
147
157
|
parallel (>= 1.21.0)
|
|
148
|
-
rbi (
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
rbi (>= 0.4.1)
|
|
159
|
+
require-hooks (>= 0.2.2)
|
|
160
|
+
rubydex (>= 0.1.0.beta10)
|
|
161
|
+
sorbet-static-and-runtime (>= 0.6.12698)
|
|
162
|
+
spoom (>= 1.7.16)
|
|
151
163
|
thor (>= 1.2.0)
|
|
152
|
-
|
|
153
|
-
thor (1.
|
|
164
|
+
tsort
|
|
165
|
+
thor (1.5.0)
|
|
154
166
|
timeout (0.6.0)
|
|
167
|
+
tsort (0.2.0)
|
|
155
168
|
tzinfo (2.0.6)
|
|
156
169
|
concurrent-ruby (~> 1.0)
|
|
157
170
|
unicode-display_width (2.5.0)
|
|
158
171
|
uri (1.1.1)
|
|
159
|
-
yard (0.9.36)
|
|
160
|
-
yard-sorbet (0.9.0)
|
|
161
|
-
sorbet-runtime
|
|
162
|
-
yard
|
|
163
172
|
zeitwerk (2.6.16)
|
|
164
173
|
|
|
165
174
|
PLATFORMS
|
|
@@ -187,7 +196,7 @@ DEPENDENCIES
|
|
|
187
196
|
standard
|
|
188
197
|
standard-performance
|
|
189
198
|
standard-sorbet
|
|
190
|
-
tapioca (~> 0.
|
|
199
|
+
tapioca (~> 0.20)
|
|
191
200
|
|
|
192
201
|
BUNDLED WITH
|
|
193
202
|
2.7.2
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sorbet Schema
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Serialize and deserialize Sorbet `T::Struct` and `T::ImmutableStruct` values.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -14,11 +14,11 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
|
-
Sorbet Schema
|
|
17
|
+
Sorbet Schema supports both `T::Struct` and `T::ImmutableStruct`, and addresses common pitfalls when deserializing and serializing either kind of struct.
|
|
18
18
|
|
|
19
19
|
### Getting Started
|
|
20
20
|
|
|
21
|
-
While you can directly define a `Typed::Schema` to be used for your serialization needs, you'll typically use the provided helper class method to generate a `Schema` from an existing `T::Struct`.
|
|
21
|
+
While you can directly define a `Typed::Schema` to be used for your serialization needs, you'll typically use the provided helper class method to generate a `Schema` from an existing `T::Struct` or `T::ImmutableStruct`.
|
|
22
22
|
|
|
23
23
|
```ruby
|
|
24
24
|
class Person < T::Struct
|
|
@@ -44,7 +44,7 @@ result = json_serializer.serialize(max)
|
|
|
44
44
|
result.payload # == '{"name":"Max","age":29}'
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Alternatively,
|
|
47
|
+
Alternatively, use the built-in helper methods on either struct class to quickly use the built-in serializers.
|
|
48
48
|
|
|
49
49
|
```ruby
|
|
50
50
|
result = Person.deserialize_from(:json, '{"name":"Max","age":29}')
|
|
@@ -81,6 +81,29 @@ result = json_serializer.deserialize('{"name":"Max","age":"29"}')
|
|
|
81
81
|
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
### Immutable structs
|
|
85
|
+
|
|
86
|
+
Use `T::ImmutableStruct` when deserialization must create a frozen struct:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
class ImmutablePerson < T::ImmutableStruct
|
|
90
|
+
const :name, String
|
|
91
|
+
const :age, Integer
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
result = ImmutablePerson.deserialize_from(:json, '{"name":"Ada","age":37}')
|
|
95
|
+
ada = result.payload
|
|
96
|
+
ada.frozen? # => true
|
|
97
|
+
|
|
98
|
+
ada.serialize_to(:json).payload # => '{"name":"Ada","age":37}'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The helper, serializer factory, and schema APIs retain the concrete immutable
|
|
102
|
+
result type in Sorbet. Nested fields, array elements, and hash values declared
|
|
103
|
+
as immutable structs are constructed as frozen instances as well. Sorbet does
|
|
104
|
+
not recursively freeze strings, arrays, hashes, or mutable child structs.
|
|
105
|
+
`T::InexactStruct` and its other subclasses remain unsupported.
|
|
106
|
+
|
|
84
107
|
### Rails Example
|
|
85
108
|
|
|
86
109
|
Here's an extended example of how Sorbet Schema can be combined with a normal Rails request to easily convert between formats.
|
|
@@ -119,7 +142,7 @@ See [Getting Started](#getting-started) for more information on how to use the J
|
|
|
119
142
|
|
|
120
143
|
#### HashSerializer
|
|
121
144
|
|
|
122
|
-
While not strictly serialization, converting
|
|
145
|
+
While not strictly serialization, converting structs to and from Ruby `Hash`es has traditionally had many pitfalls ([well-documented](https://sorbet.org/docs/tstruct#legacy-code-and-historical-context) in the Sorbet docs). The `Typed::HashSerializer` supports both struct classes with the same `Result` handling for invalid or missing data and coercion behavior.
|
|
123
146
|
|
|
124
147
|
To use it, simply instantiate and use it like the `JSONSerializer`:
|
|
125
148
|
|
|
@@ -149,7 +172,7 @@ result = csv_serializer.serialize(max)
|
|
|
149
172
|
result.payload # == "name,age\nMax,29\n"
|
|
150
173
|
```
|
|
151
174
|
|
|
152
|
-
CSV is a flat, row-based format, so nested
|
|
175
|
+
CSV is a flat, row-based format, so nested structs, `Hash`es, and `Array`s cannot be represented as their own columns. Rather than writing a lossy representation into a cell that can never be parsed back correctly, `CSVSerializer#serialize` returns a `Typed::SerializeError` naming the offending field(s). Prefer this serializer for schemas with scalar fields only, or use an `inline_serializer` (see [Inline Serializers](#inline-serializers)) to flatten a nested field into a scalar before serializing it to CSV.
|
|
153
176
|
|
|
154
177
|
#### YMLSerializer
|
|
155
178
|
|
|
@@ -183,14 +206,15 @@ result = message_pack_serializer.serialize(max)
|
|
|
183
206
|
result.payload # == MessagePack.pack({"name" => "Max", "age" => 29})
|
|
184
207
|
```
|
|
185
208
|
|
|
186
|
-
Unlike `CSVSerializer`, MessagePack natively supports nested maps and arrays, so nested
|
|
209
|
+
Unlike `CSVSerializer`, MessagePack natively supports nested maps and arrays, so nested structs, `Hash`es, and `Array`s round-trip without any `inline_serializer` needed.
|
|
187
210
|
|
|
188
211
|
#### ActiveRecordSerializer
|
|
189
212
|
|
|
190
|
-
Requires the `activerecord` gem
|
|
213
|
+
Requires the `activerecord` gem in your application's Gemfile. The gem is loaded lazily when a serializer is constructed; both `.serializer(:activerecord)` and direct construction raise a clear `ArgumentError` if it is unavailable. Loading or reflecting on the serializer class does not require ActiveRecord to be installed. The `Typed::ActiveRecordSerializer` converts between `ActiveRecord::Base` model instances and either supported struct class, matching fields by attribute name and mapping associated records through the model's declared associations. It requires a `model_class` option, in addition to `schema`, so it knows which `ActiveRecord::Base` subclass to deserialize from and serialize to:
|
|
191
214
|
|
|
192
215
|
```ruby
|
|
193
216
|
ar_serializer = Typed::ActiveRecordSerializer.new(schema: Person.schema, model_class: PersonModel)
|
|
217
|
+
# Sorbet infers ActiveRecordSerializer[PersonModel, Person], retaining both result types.
|
|
194
218
|
|
|
195
219
|
# Deserialize from target format
|
|
196
220
|
result = ar_serializer.deserialize(PersonModel.new(name: "Max", age: 29))
|
|
@@ -202,7 +226,7 @@ result.payload # == PersonModel instance with name: "Max", age: 29
|
|
|
202
226
|
|
|
203
227
|
When serializing, only fields that map to a column (or to an association's declared foreign key) on `model_class` are assigned; the rest are ignored.
|
|
204
228
|
|
|
205
|
-
Nested `belongs_to` associations are deserialized recursively to arbitrary depth,
|
|
229
|
+
Nested `belongs_to` associations are deserialized recursively to arbitrary depth, including immutable struct fields backed by a `belongs_to` association.
|
|
206
230
|
|
|
207
231
|
Deserializing reads through the model's association readers, so it is subject to the same N+1 query behavior as any other Rails association access. Preloading with `includes` works transparently and is the recommended way to avoid a query per record per association when deserializing a collection:
|
|
208
232
|
|
|
@@ -213,6 +237,48 @@ PersonModel.includes(:location).find_each do |person_model|
|
|
|
213
237
|
end
|
|
214
238
|
```
|
|
215
239
|
|
|
240
|
+
### Differing Serialized Keys
|
|
241
|
+
|
|
242
|
+
Sorbet's `name:` prop option is honored, so a struct can keep snake_case props while reading and writing the key an external payload actually uses:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
class WebhookPayload < T::Struct
|
|
246
|
+
const :event_id, String, name: "eventId"
|
|
247
|
+
const :email_address, String, name: "emailAddress"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
WebhookPayload.deserialize_from(:hash, {eventId: "evt-1", emailAddress: "user@example.com"})
|
|
251
|
+
# <Typed::Success value=#<WebhookPayload event_id="evt-1", email_address="user@example.com">>
|
|
252
|
+
|
|
253
|
+
WebhookPayload.new(event_id: "evt-1", email_address: "user@example.com").serialize_to(:hash)
|
|
254
|
+
# {eventId: "evt-1", emailAddress: "user@example.com"}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`Typed::Field` exposes this as `serialized_name`, which defaults to `name` when the prop declares no `name:` option. Hand-built schemas can set it directly: `Typed::Field.new(name: :event_id, type: String, serialized_name: :eventId)`.
|
|
258
|
+
|
|
259
|
+
### JSON Schema
|
|
260
|
+
|
|
261
|
+
A schema can describe the hash it deserializes from as a JSON Schema (draft 2020-12), e.g. to hand it to an API client or an LLM tool definition:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
class Job < T::Struct
|
|
265
|
+
const :title, String, extra: {description: "Job title"}
|
|
266
|
+
const :start_date, T.nilable(Date)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
Job.schema.to_json_schema
|
|
270
|
+
# => {
|
|
271
|
+
# "type" => "object",
|
|
272
|
+
# "properties" => {
|
|
273
|
+
# "title" => {"type" => "string", "description" => "Job title"},
|
|
274
|
+
# "start_date" => {"type" => ["string", "null"], "format" => "date"}
|
|
275
|
+
# },
|
|
276
|
+
# "required" => ["title"]
|
|
277
|
+
# }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Properties are keyed by the serialized name, values are in serialized form (a `T::Enum` as the `enum` of its serialized values, a `Date` as a string), and a field is required when it is neither nilable nor defaulted. Deserialization ignores unknown keys, so objects are open by default; `to_json_schema(additional_properties: false)` closes every struct object, e.g. to catch typos in a config file or for LLM structured-output APIs that require it. Nested `T::Struct` and `T::ImmutableStruct` values are described through their own `schema`; pass `struct_schema:` to `Typed::JSONSchema.generate` to resolve them differently. A type without a JSON form raises `Typed::JSONSchema::UnsupportedTypeError`.
|
|
281
|
+
|
|
216
282
|
### Customization
|
|
217
283
|
|
|
218
284
|
From the get-go, Sorbet Schema is designed to be extensible to model more complex data validation requirements and many serialization formats. We try out best to include built-in, battle-tested coercers and serializers from real world use cases and would love to see/upstream any customizations that the community have found useful!
|
|
@@ -230,13 +296,13 @@ class DateCoercer < Coercer
|
|
|
230
296
|
Target = type_member { {fixed: Date} }
|
|
231
297
|
|
|
232
298
|
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
|
|
233
|
-
def used_for_type?(type)
|
|
299
|
+
def self.used_for_type?(type)
|
|
234
300
|
T::Utils.coerce(type) == T::Utils.coerce(Date)
|
|
235
301
|
end
|
|
236
302
|
|
|
237
303
|
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
|
|
238
304
|
def coerce(type:, value:)
|
|
239
|
-
return Failure.new(CoercionError.new("Type must be a Date.")) unless used_for_type?(type)
|
|
305
|
+
return Failure.new(CoercionError.new("Type must be a Date.")) unless self.class.used_for_type?(type)
|
|
240
306
|
|
|
241
307
|
return Success.new(value) if value.is_a?(Date)
|
|
242
308
|
|
|
@@ -270,11 +336,15 @@ The serializer can be used when creating a `Schema` and defining its `fields`, o
|
|
|
270
336
|
```ruby
|
|
271
337
|
my_date_serializer = ->(date) { date.strftime("%Y/%m") }
|
|
272
338
|
|
|
273
|
-
# use directly on a Schema
|
|
274
|
-
|
|
339
|
+
# use directly on a Schema - `Typed::Schema` is generic over its target struct,
|
|
340
|
+
# so prefer `from_struct`, which infers the type argument, over `Schema.new`
|
|
341
|
+
Typed::Schema.from_struct(SchemaWithDateField).add_serializer(:date, my_date_serializer)
|
|
342
|
+
|
|
343
|
+
# or, if you need custom `fields` up front, specify the type argument explicitly
|
|
344
|
+
Typed::Schema[SchemaWithDateField].new(
|
|
275
345
|
target: SchemaWithDateField,
|
|
276
346
|
fields: [
|
|
277
|
-
Typed::Field.new(name: :date, type: Date,
|
|
347
|
+
Typed::Field.new(name: :date, type: Date, inline_serializer: my_date_serializer)
|
|
278
348
|
]
|
|
279
349
|
)
|
|
280
350
|
|
|
@@ -292,10 +362,12 @@ require "json"
|
|
|
292
362
|
class JSONSerializer < Serializer
|
|
293
363
|
Input = type_member { {fixed: String} }
|
|
294
364
|
Output = type_member { {fixed: String} }
|
|
365
|
+
StructT = type_member { {upper: T::InexactStruct} }
|
|
295
366
|
|
|
296
|
-
sig { override.params(source: Input).returns(Result[
|
|
367
|
+
sig { override.params(source: Input).returns(Result[StructT, DeserializeError]) }
|
|
297
368
|
def deserialize(source)
|
|
298
369
|
parsed_json = JSON.parse(source)
|
|
370
|
+
return Failure.new(ParseError.new(format: :json)) unless parsed_json.is_a?(Hash)
|
|
299
371
|
|
|
300
372
|
creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
|
|
301
373
|
hsh[field.name] = parsed_json[field.name.to_s]
|
|
@@ -306,7 +378,7 @@ class JSONSerializer < Serializer
|
|
|
306
378
|
Failure.new(ParseError.new(format: :json))
|
|
307
379
|
end
|
|
308
380
|
|
|
309
|
-
sig { override.params(struct:
|
|
381
|
+
sig { override.params(struct: StructT).returns(Result[Output, SerializeError]) }
|
|
310
382
|
def serialize(struct)
|
|
311
383
|
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target
|
|
312
384
|
|
|
@@ -315,7 +387,7 @@ class JSONSerializer < Serializer
|
|
|
315
387
|
end
|
|
316
388
|
```
|
|
317
389
|
|
|
318
|
-
Since `Serializer` is a generic class, we need to define our `Input` and `
|
|
390
|
+
Since `Serializer` is a generic class, we need to define our `Input`, `Output`, and `StructT` types. For JSON, deserialization and serialization both use JSON strings, so `Input`/`Output` are both `String`. `StructT` is the struct type the serializer round-trips to/from - it must always be re-declared as `type_member { {upper: T::InexactStruct} }` in the subclass (Sorbet requires each generic type member to be re-declared down the inheritance chain). Schemas themselves continue to accept only `T::Struct` and `T::ImmutableStruct` descendants, while using `StructT` in `deserialize`/`serialize` preserves the concrete type at every deserialize entry point.
|
|
319
391
|
|
|
320
392
|
Next, the `deserialize` and `serialize` methods must be implemented. Notice that both of these return `Result`s.
|
|
321
393
|
|
data/Rakefile
CHANGED
|
@@ -9,7 +9,7 @@ class SerializeValue
|
|
|
9
9
|
HashTransformer.serialize_values(value)
|
|
10
10
|
elsif value.is_a?(Array)
|
|
11
11
|
value.map { |item| serialize(item) }
|
|
12
|
-
elsif value.is_a?(T::Struct)
|
|
12
|
+
elsif value.is_a?(T::Struct) || value.is_a?(T::ImmutableStruct)
|
|
13
13
|
value.serialize_to(:hash, options: {should_serialize_values: true}).payload_or(value)
|
|
14
14
|
elsif value.respond_to?(:serialize)
|
|
15
15
|
value.serialize
|
|
@@ -1,40 +1,59 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
|
|
3
3
|
module T
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def serializer(type, options: {})
|
|
11
|
-
case type
|
|
12
|
-
when :hash
|
|
13
|
-
Typed::HashSerializer.new(**T.unsafe({schema:, **options}))
|
|
14
|
-
when :json
|
|
15
|
-
Typed::JSONSerializer.new(schema:)
|
|
16
|
-
when :csv
|
|
17
|
-
Typed::CSVSerializer.new(schema:)
|
|
18
|
-
when :yml
|
|
19
|
-
Typed::YMLSerializer.new(schema:)
|
|
20
|
-
when :msgpack
|
|
21
|
-
Typed::MessagePackSerializer.new(schema:)
|
|
22
|
-
when :activerecord
|
|
23
|
-
raise ArgumentError, "activerecord gem is required for ActiveRecord serialization" unless defined?(ActiveRecord)
|
|
4
|
+
module StructClassMethods
|
|
5
|
+
def schema
|
|
6
|
+
T.bind(self, Typed::StructClass)
|
|
7
|
+
Typed::Schema.from_struct(self)
|
|
8
|
+
end
|
|
24
9
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
10
|
+
def serializer(type, options: {})
|
|
11
|
+
T.bind(self, Typed::StructClass)
|
|
12
|
+
# The RBI shim promises a serializer parameterized by the attached class,
|
|
13
|
+
# while this case has different serializer subclasses on each branch.
|
|
14
|
+
T.unsafe(case type
|
|
15
|
+
when :hash
|
|
16
|
+
Typed::HashSerializer.new(**T.unsafe({schema:, **options}))
|
|
17
|
+
when :json
|
|
18
|
+
Typed::JSONSerializer.new(schema:)
|
|
19
|
+
when :csv
|
|
20
|
+
Typed::CSVSerializer.new(schema:)
|
|
21
|
+
when :yml
|
|
22
|
+
Typed::YMLSerializer.new(schema:)
|
|
23
|
+
when :msgpack
|
|
24
|
+
Typed::MessagePackSerializer.new(schema:)
|
|
25
|
+
when :activerecord
|
|
26
|
+
begin
|
|
27
|
+
require "active_record" unless defined?(ActiveRecord::Base)
|
|
28
|
+
rescue LoadError
|
|
29
|
+
raise ArgumentError, "activerecord gem is required for ActiveRecord serialization - add it to your Gemfile"
|
|
28
30
|
end
|
|
29
|
-
end
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
Typed::ActiveRecordSerializer.new(**T.unsafe({schema:, **options}))
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "unknown serializer for #{type}"
|
|
35
|
+
end)
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
def deserialize_from(serializer_type, source, options: {})
|
|
39
|
+
T.unsafe(serializer(serializer_type, options:).deserialize(source))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module StructInstanceMethods
|
|
36
44
|
def serialize_to(serializer_type, options: {})
|
|
37
|
-
|
|
45
|
+
T.bind(self, Typed::StructValue)
|
|
46
|
+
T.unsafe(self.class.serializer(serializer_type, options:)).serialize(self)
|
|
38
47
|
end
|
|
39
48
|
end
|
|
49
|
+
|
|
50
|
+
class Struct
|
|
51
|
+
extend StructClassMethods
|
|
52
|
+
include StructInstanceMethods
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class ImmutableStruct
|
|
56
|
+
extend StructClassMethods
|
|
57
|
+
include StructInstanceMethods
|
|
58
|
+
end
|
|
40
59
|
end
|
data/lib/sorbet-schema.rb
CHANGED
|
@@ -12,6 +12,7 @@ loader.push_dir(__dir__.to_s)
|
|
|
12
12
|
loader.ignore(__FILE__)
|
|
13
13
|
loader.ignore("#{__dir__}/sorbet-schema/**/*.rb")
|
|
14
14
|
loader.inflector.inflect(
|
|
15
|
+
"json_schema" => "JSONSchema",
|
|
15
16
|
"json_serializer" => "JSONSerializer",
|
|
16
17
|
"csv_serializer" => "CSVSerializer",
|
|
17
18
|
"yml_serializer" => "YMLSerializer"
|
|
@@ -33,4 +34,6 @@ require_relative "sorbet-schema/t/struct"
|
|
|
33
34
|
# Sorbet-aware namespace to super-charge your projects
|
|
34
35
|
module Typed
|
|
35
36
|
Value = T.type_alias { T.untyped }
|
|
37
|
+
StructValue = T.type_alias { T.any(T::Struct, T::ImmutableStruct) }
|
|
38
|
+
StructClass = T.type_alias { T.any(T.class_of(T::Struct), T.class_of(T::ImmutableStruct)) }
|
|
36
39
|
end
|