sorbet-schema 0.9.3 → 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 +4 -4
- data/.ruby-version +1 -1
- data/AGENTS.md +15 -0
- data/CHANGELOG.md +24 -0
- data/CLAUDE.md +1 -0
- data/Gemfile +4 -1
- data/Gemfile.lock +51 -9
- data/README.md +84 -0
- data/benchmarks/helpers.rb +0 -1
- data/benchmarks/many_fields_serialization.rb +0 -1
- data/benchmarks/simple_serialization.rb +0 -1
- data/lib/sorbet-schema/t/struct.rb +10 -0
- data/lib/sorbet-schema/version.rb +1 -1
- data/lib/sorbet-schema.rb +3 -1
- data/lib/typed/active_record_serializer.rb +85 -0
- data/lib/typed/csv_serializer.rb +57 -0
- data/lib/typed/message_pack_serializer.rb +40 -0
- data/lib/typed/schema.rb +33 -0
- data/lib/typed/yml_serializer.rb +46 -0
- data/sorbet/rbi/gems/activemodel@8.1.2.rbi +9 -0
- data/sorbet/rbi/gems/activerecord@8.1.2.rbi +9 -0
- data/sorbet/rbi/gems/activesupport@8.1.2.rbi +11 -0
- data/sorbet/rbi/gems/base64@0.3.0.rbi +9 -0
- data/sorbet/rbi/gems/concurrent-ruby@1.3.6.rbi +9 -0
- data/sorbet/rbi/gems/connection_pool@3.0.2.rbi +9 -0
- data/sorbet/rbi/gems/csv@3.3.6.rbi +4810 -0
- data/sorbet/rbi/gems/drb@2.2.3.rbi +9 -0
- data/sorbet/rbi/gems/i18n@1.14.8.rbi +9 -0
- data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
- data/sorbet/rbi/gems/msgpack@1.8.4.rbi +320 -0
- data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
- data/sorbet/rbi/gems/sqlite3@2.9.0.rbi +1901 -0
- data/sorbet/rbi/gems/timeout@0.6.0.rbi +194 -0
- data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +9 -0
- data/sorbet/rbi/gems/uri@1.1.1.rbi +2447 -0
- data/sorbet/rbi/shims/active_record.rbi +37 -0
- data/sorbet/rbi/shims/active_support.rbi +9 -0
- data/sorbet/tapioca/require.rb +2 -0
- metadata +56 -14
- data/sorbet/rbi/gems/erubi@1.13.0.rbi +0 -150
- data/sorbet/rbi/gems/netrc@0.11.0.rbi +0 -159
- data/sorbet/rbi/gems/prism@0.30.0.rbi +0 -39212
- data/sorbet/rbi/gems/thor@1.3.1.rbi +0 -4352
- data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +0 -435
- data/sorbet/rbi/gems/yard@0.9.36.rbi +0 -18221
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0c3bb89fee8f842f919e0cf1f53d8f97fb12e4464effb88c7e821134c531a1f
|
|
4
|
+
data.tar.gz: a78684855bb1bd76e6688b0daa04f026fcb55c23fd2f354daaea8e7a2f0f5cd3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 28656d268e7861ae7e069ff30b024a2d063ee33f5824ca9bc1319cbe91555b2e80b402ee2438b975d299c58f624c4e8afba1ba8649a62c8d05a6efc3fa10fbac
|
|
7
|
+
data.tar.gz: 9faf4f7458d56d9a66645d1d25aa4d70a83eea9abba3e1021a5fe58a5978d0c309a987d20e5af1dfc538f5d0a550f40ed1afa6a35c39ff2e5173a9b44a6cd118
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.4.7
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Project agent memory
|
|
2
|
+
|
|
3
|
+
This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code.
|
|
4
|
+
|
|
5
|
+
- Add durable project-specific notes here as they are discovered through real work.
|
|
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 (`CSVSerializer`, `MessagePackSerializer`, `ActiveRecordSerializer`) treat their backing gem as optional: no top-level `require`, gem is `add_development_dependency` only (not `add_runtime_dependency`), the `require`/gem-presence check happens lazily in `initialize` with a clear `ArgumentError` on failure, and `T::Struct.serializer`'s factory (`lib/sorbet-schema/t/struct.rb`) mirrors that same guard via `defined?(...)`. Follow this pattern for any new serializer/coercer with a non-stdlib dependency; see `lib/typed/csv_serializer.rb` for the exact shape.
|
|
8
|
+
- 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
|
+
## Maintaining this file
|
|
11
|
+
|
|
12
|
+
Keep this file for knowledge useful to almost every future agent session in this project.
|
|
13
|
+
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
|
|
14
|
+
Prefer rewriting or pruning existing entries over appending new ones.
|
|
15
|
+
When updating this file, preserve this bar for all agents and keep entries concise.
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ 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.10.0](https://github.com/maxveldink/sorbet-schema/compare/v0.9.3...v0.10.0) (2026-08-07)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### ⚠ BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* bump local Ruby to 3.4, add to matrix ([#138](https://github.com/maxveldink/sorbet-schema/issues/138))
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* add Typed::CSVSerializer ([#12](https://github.com/maxveldink/sorbet-schema/issues/12)) ([#144](https://github.com/maxveldink/sorbet-schema/issues/144)) ([b2377f0](https://github.com/maxveldink/sorbet-schema/commit/b2377f083782c0eb39ab442b14cb4bf18a2a8922))
|
|
17
|
+
* add Typed::MessagePackSerializer ([#145](https://github.com/maxveldink/sorbet-schema/issues/145)) ([d367da8](https://github.com/maxveldink/sorbet-schema/commit/d367da83b2d9e72f5099b70594243178c3df432c))
|
|
18
|
+
* add Typed::YMLSerializer ([#13](https://github.com/maxveldink/sorbet-schema/issues/13)) ([#143](https://github.com/maxveldink/sorbet-schema/issues/143)) ([bcc7eb8](https://github.com/maxveldink/sorbet-schema/commit/bcc7eb810f32b46d454df3a0c19468b0de09f498))
|
|
19
|
+
* **typed:** add ActiveRecord serializer ([#140](https://github.com/maxveldink/sorbet-schema/issues/140)) ([a815f9f](https://github.com/maxveldink/sorbet-schema/commit/a815f9f42cb36c26ead45630384eb6b5022cd43d))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **typed:** make csv and msgpack gem dependencies optional ([#146](https://github.com/maxveldink/sorbet-schema/issues/146)) ([db22258](https://github.com/maxveldink/sorbet-schema/commit/db222586207129fc46d981a8efd28413aea99c7b))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Miscellaneous Chores
|
|
28
|
+
|
|
29
|
+
* bump local Ruby to 3.4, add to matrix ([#138](https://github.com/maxveldink/sorbet-schema/issues/138)) ([1cf6a5b](https://github.com/maxveldink/sorbet-schema/commit/1cf6a5bdb77137ee34aa5c1bca66dd1ede37f875))
|
|
30
|
+
|
|
7
31
|
## [0.9.3](https://github.com/maxveldink/sorbet-schema/compare/v0.9.2...v0.9.3) (2025-12-06)
|
|
8
32
|
|
|
9
33
|
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AGENTS.md
|
data/Gemfile
CHANGED
|
@@ -12,7 +12,7 @@ group :development do
|
|
|
12
12
|
gem "standard-performance"
|
|
13
13
|
gem "standard-sorbet"
|
|
14
14
|
gem "sorbet"
|
|
15
|
-
gem "tapioca", require: false
|
|
15
|
+
gem "tapioca", "~> 0.16.6", require: false
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
group :development, :test do
|
|
@@ -24,4 +24,7 @@ group :development, :test do
|
|
|
24
24
|
gem "debug"
|
|
25
25
|
|
|
26
26
|
gem "sorbet-struct-comparable"
|
|
27
|
+
|
|
28
|
+
gem "activerecord", ">= 6.0"
|
|
29
|
+
gem "sqlite3"
|
|
27
30
|
end
|
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.10.0)
|
|
5
5
|
sorbet-result (~> 1.1)
|
|
6
6
|
sorbet-runtime (~> 0.5)
|
|
7
7
|
sorbet-struct-comparable (~> 1.3)
|
|
@@ -10,15 +10,41 @@ PATH
|
|
|
10
10
|
GEM
|
|
11
11
|
remote: https://rubygems.org/
|
|
12
12
|
specs:
|
|
13
|
+
activemodel (8.1.2)
|
|
14
|
+
activesupport (= 8.1.2)
|
|
15
|
+
activerecord (8.1.2)
|
|
16
|
+
activemodel (= 8.1.2)
|
|
17
|
+
activesupport (= 8.1.2)
|
|
18
|
+
timeout (>= 0.4.0)
|
|
19
|
+
activesupport (8.1.2)
|
|
20
|
+
base64
|
|
21
|
+
bigdecimal
|
|
22
|
+
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
23
|
+
connection_pool (>= 2.2.5)
|
|
24
|
+
drb
|
|
25
|
+
i18n (>= 1.6, < 2)
|
|
26
|
+
json
|
|
27
|
+
logger (>= 1.4.2)
|
|
28
|
+
minitest (>= 5.1)
|
|
29
|
+
securerandom (>= 0.3)
|
|
30
|
+
tzinfo (~> 2.0, >= 2.0.5)
|
|
31
|
+
uri (>= 0.13.1)
|
|
13
32
|
ansi (1.5.0)
|
|
14
33
|
ast (2.4.2)
|
|
34
|
+
base64 (0.3.0)
|
|
15
35
|
benchmark-ips (2.14.0)
|
|
16
36
|
bigdecimal (3.1.8)
|
|
17
37
|
builder (3.3.0)
|
|
38
|
+
concurrent-ruby (1.3.6)
|
|
39
|
+
connection_pool (3.0.2)
|
|
40
|
+
csv (3.3.6)
|
|
18
41
|
debug (1.9.2)
|
|
19
42
|
irb (~> 1.10)
|
|
20
43
|
reline (>= 0.3.8)
|
|
44
|
+
drb (2.2.3)
|
|
21
45
|
erubi (1.13.0)
|
|
46
|
+
i18n (1.14.8)
|
|
47
|
+
concurrent-ruby (~> 1.0)
|
|
22
48
|
io-console (0.7.2)
|
|
23
49
|
irb (1.14.0)
|
|
24
50
|
rdoc (>= 4.0.0)
|
|
@@ -26,6 +52,7 @@ GEM
|
|
|
26
52
|
json (2.7.2)
|
|
27
53
|
language_server-protocol (3.17.0.3)
|
|
28
54
|
lint_roller (1.1.0)
|
|
55
|
+
logger (1.7.0)
|
|
29
56
|
minitest (5.24.1)
|
|
30
57
|
minitest-focus (1.4.0)
|
|
31
58
|
minitest (>= 4, < 6)
|
|
@@ -34,20 +61,23 @@ GEM
|
|
|
34
61
|
builder
|
|
35
62
|
minitest (>= 5.0)
|
|
36
63
|
ruby-progressbar
|
|
64
|
+
msgpack (1.8.4)
|
|
37
65
|
netrc (0.11.0)
|
|
38
66
|
parallel (1.25.1)
|
|
39
67
|
parser (3.3.3.0)
|
|
40
68
|
ast (~> 2.4.1)
|
|
41
69
|
racc
|
|
42
|
-
prism (
|
|
70
|
+
prism (1.6.0)
|
|
43
71
|
psych (5.1.2)
|
|
44
72
|
stringio
|
|
45
73
|
racc (1.8.0)
|
|
46
74
|
rainbow (3.1.1)
|
|
47
75
|
rake (13.2.1)
|
|
48
|
-
rbi (0.
|
|
49
|
-
prism (
|
|
50
|
-
|
|
76
|
+
rbi (0.3.7)
|
|
77
|
+
prism (~> 1.0)
|
|
78
|
+
rbs (>= 3.4.4)
|
|
79
|
+
rbs (3.9.5)
|
|
80
|
+
logger
|
|
51
81
|
rdoc (6.7.0)
|
|
52
82
|
psych (>= 4.0.0)
|
|
53
83
|
regexp_parser (2.9.2)
|
|
@@ -74,6 +104,7 @@ GEM
|
|
|
74
104
|
rubocop-sorbet (0.7.8)
|
|
75
105
|
rubocop (>= 0.90.0)
|
|
76
106
|
ruby-progressbar (1.13.0)
|
|
107
|
+
securerandom (0.4.1)
|
|
77
108
|
sorbet (0.5.11475)
|
|
78
109
|
sorbet-static (= 0.5.11475)
|
|
79
110
|
sorbet-result (1.1.0)
|
|
@@ -91,6 +122,8 @@ GEM
|
|
|
91
122
|
prism (>= 0.28.0)
|
|
92
123
|
sorbet-static-and-runtime (>= 0.5.10187)
|
|
93
124
|
thor (>= 0.19.2)
|
|
125
|
+
sqlite3 (2.9.0-arm64-darwin)
|
|
126
|
+
sqlite3 (2.9.0-x86_64-linux-gnu)
|
|
94
127
|
standard (1.39.1)
|
|
95
128
|
language_server-protocol (~> 3.17.0.2)
|
|
96
129
|
lint_roller (~> 1.0)
|
|
@@ -108,17 +141,21 @@ GEM
|
|
|
108
141
|
rubocop-sorbet (~> 0.7.0)
|
|
109
142
|
stringio (3.1.1)
|
|
110
143
|
strscan (3.1.0)
|
|
111
|
-
tapioca (0.
|
|
144
|
+
tapioca (0.16.6)
|
|
112
145
|
bundler (>= 2.2.25)
|
|
113
146
|
netrc (>= 0.11.0)
|
|
114
147
|
parallel (>= 1.21.0)
|
|
115
|
-
rbi (
|
|
148
|
+
rbi (~> 0.2)
|
|
116
149
|
sorbet-static-and-runtime (>= 0.5.11087)
|
|
117
150
|
spoom (>= 1.2.0)
|
|
118
151
|
thor (>= 1.2.0)
|
|
119
152
|
yard-sorbet
|
|
120
153
|
thor (1.3.1)
|
|
154
|
+
timeout (0.6.0)
|
|
155
|
+
tzinfo (2.0.6)
|
|
156
|
+
concurrent-ruby (~> 1.0)
|
|
121
157
|
unicode-display_width (2.5.0)
|
|
158
|
+
uri (1.1.1)
|
|
122
159
|
yard (0.9.36)
|
|
123
160
|
yard-sorbet (0.9.0)
|
|
124
161
|
sorbet-runtime
|
|
@@ -129,23 +166,28 @@ PLATFORMS
|
|
|
129
166
|
arm64-darwin-22
|
|
130
167
|
arm64-darwin-23
|
|
131
168
|
arm64-darwin-25
|
|
169
|
+
arm64-darwin-27
|
|
132
170
|
x86_64-linux
|
|
133
171
|
|
|
134
172
|
DEPENDENCIES
|
|
173
|
+
activerecord (>= 6.0)
|
|
135
174
|
benchmark-ips
|
|
136
175
|
bigdecimal
|
|
176
|
+
csv (~> 3.3)
|
|
137
177
|
debug
|
|
138
178
|
minitest
|
|
139
179
|
minitest-focus
|
|
140
180
|
minitest-reporters
|
|
181
|
+
msgpack (~> 1.7)
|
|
141
182
|
rake
|
|
142
183
|
sorbet
|
|
143
184
|
sorbet-schema!
|
|
144
185
|
sorbet-struct-comparable
|
|
186
|
+
sqlite3
|
|
145
187
|
standard
|
|
146
188
|
standard-performance
|
|
147
189
|
standard-sorbet
|
|
148
|
-
tapioca
|
|
190
|
+
tapioca (~> 0.16.6)
|
|
149
191
|
|
|
150
192
|
BUNDLED WITH
|
|
151
|
-
2.
|
|
193
|
+
2.7.2
|
data/README.md
CHANGED
|
@@ -54,6 +54,8 @@ result = max.serialize_to(:json)
|
|
|
54
54
|
result.payload # == '{"name":"Max","age":29}'
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
`:hash`, `:json`, `:csv`, `:yml`, and `:msgpack` are all supported as the first argument to `deserialize_from`/`serialize_to` (and the equivalent `from_hash`/`from_json`/`from_csv`/`from_yml`/`from_msgpack` methods on `Typed::Schema`), corresponding to the built-in serializers below.
|
|
58
|
+
|
|
57
59
|
Notice that both `deserialize` and `serialize` return `Typed::Result`s (from the [sorbet-result gem](https://github.com/maxveldink/sorbet-result)) that need to be checked for success or failure before being used. Check out that gem's README for more information on how to interact with `Result`s.
|
|
58
60
|
|
|
59
61
|
One benefit of using `Result`s is we can add much more details information about why a format is unsuccessfully deserialized or serialized, to provide call sites with more information for error handling, messaging and formatting.
|
|
@@ -109,6 +111,8 @@ end
|
|
|
109
111
|
|
|
110
112
|
These are the currently available serializers. For more information about implementing a custom one (or contributing one back!), see [Custom Coercers](#custom-coercers).
|
|
111
113
|
|
|
114
|
+
`HashSerializer`, `JSONSerializer`, and `YMLSerializer` work out of the box with no extra dependencies. `CSVSerializer` and `MessagePackSerializer` require you to add the `csv` and `msgpack` gems, respectively, to your own Gemfile - they are not pulled in as runtime dependencies of `sorbet-schema` itself. Referencing `:csv`/`:msgpack` via `serializer`/`deserialize_from`/`serialize_to` without the gem installed raises a clear `ArgumentError` telling you which gem to add.
|
|
115
|
+
|
|
112
116
|
#### JSONSerializer
|
|
113
117
|
|
|
114
118
|
See [Getting Started](#getting-started) for more information on how to use the JSONSerializer.
|
|
@@ -129,6 +133,86 @@ max = result.payload # == Person.new(name: "Max", age: 29)
|
|
|
129
133
|
|
|
130
134
|
By default, the `HashSerializer` will _not_ serialize values when converting to a Hash. For instance, if a field is an `T::Enum` type, when it is serialized to a `Hash` the value will be the `Enum` and not the `String` representation. The `should_serialize_values` option can be passed during initialization to serialize the values when converting to the `Hash`.
|
|
131
135
|
|
|
136
|
+
#### CSVSerializer
|
|
137
|
+
|
|
138
|
+
Accepts and returns a CSV string representing a single record, with a header row followed by one data row:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
csv_serializer = Typed::CSVSerializer.new(schema: Person.schema)
|
|
142
|
+
|
|
143
|
+
# Deserialize from target format
|
|
144
|
+
result = csv_serializer.deserialize("name,age\nMax,29\n")
|
|
145
|
+
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
146
|
+
|
|
147
|
+
# Serialize to target format
|
|
148
|
+
result = csv_serializer.serialize(max)
|
|
149
|
+
result.payload # == "name,age\nMax,29\n"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
CSV is a flat, row-based format, so nested `T::Struct`s, `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) when a struct has one. 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
|
+
|
|
154
|
+
#### YMLSerializer
|
|
155
|
+
|
|
156
|
+
Works just like the `JSONSerializer`, but with YAML strings:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
yml_serializer = Typed::YMLSerializer.new(schema: Person.schema)
|
|
160
|
+
|
|
161
|
+
# Deserialize from target format
|
|
162
|
+
result = yml_serializer.deserialize("---\nname: Max\nage: 29\n")
|
|
163
|
+
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
164
|
+
|
|
165
|
+
# Serialize to target format
|
|
166
|
+
result = yml_serializer.serialize(max)
|
|
167
|
+
result.payload # == "---\nname: Max\nage: 29\n"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### MessagePackSerializer
|
|
171
|
+
|
|
172
|
+
Works just like the `JSONSerializer`, but with [MessagePack](https://msgpack.org/) binary-encoded strings, using the [`msgpack`](https://github.com/msgpack/msgpack-ruby) gem:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
message_pack_serializer = Typed::MessagePackSerializer.new(schema: Person.schema)
|
|
176
|
+
|
|
177
|
+
# Deserialize from target format
|
|
178
|
+
result = message_pack_serializer.deserialize(MessagePack.pack({"name" => "Max", "age" => 29}))
|
|
179
|
+
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
180
|
+
|
|
181
|
+
# Serialize to target format
|
|
182
|
+
result = message_pack_serializer.serialize(max)
|
|
183
|
+
result.payload # == MessagePack.pack({"name" => "Max", "age" => 29})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Unlike `CSVSerializer`, MessagePack natively supports nested maps and arrays, so nested `T::Struct`s, `Hash`es, and `Array`s round-trip without any `inline_serializer` needed.
|
|
187
|
+
|
|
188
|
+
#### ActiveRecordSerializer
|
|
189
|
+
|
|
190
|
+
Requires the `activerecord` gem to be available. This is only checked when going through `.serializer(:activerecord)`, which raises an `ArgumentError` if the gem isn't loaded; calling `Typed::ActiveRecordSerializer.new` directly without ActiveRecord loaded instead raises a `NameError`, since the class itself references the `ActiveRecord` constant. The `Typed::ActiveRecordSerializer` converts between `ActiveRecord::Base` model instances and `T::Struct`s, 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
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
ar_serializer = Typed::ActiveRecordSerializer.new(schema: Person.schema, model_class: PersonModel)
|
|
194
|
+
|
|
195
|
+
# Deserialize from target format
|
|
196
|
+
result = ar_serializer.deserialize(PersonModel.new(name: "Max", age: 29))
|
|
197
|
+
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
198
|
+
|
|
199
|
+
result = ar_serializer.serialize(max)
|
|
200
|
+
result.payload # == PersonModel instance with name: "Max", age: 29
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
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
|
+
|
|
205
|
+
Nested `belongs_to` associations are deserialized recursively to arbitrary depth, so a `T::Struct` field whose type is itself a `T::Struct` backed by a `belongs_to` association will have its own nested associations resolved too.
|
|
206
|
+
|
|
207
|
+
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
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# 1 query for people + 1 query for locations, instead of 1 + N
|
|
211
|
+
PersonModel.includes(:location).find_each do |person_model|
|
|
212
|
+
ar_serializer.deserialize(person_model)
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
132
216
|
### Customization
|
|
133
217
|
|
|
134
218
|
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!
|
data/benchmarks/helpers.rb
CHANGED
|
@@ -13,6 +13,16 @@ module T
|
|
|
13
13
|
Typed::HashSerializer.new(**T.unsafe({schema:, **options}))
|
|
14
14
|
when :json
|
|
15
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)
|
|
24
|
+
|
|
25
|
+
Typed::ActiveRecordSerializer.new(**T.unsafe({schema:, **options}))
|
|
16
26
|
else
|
|
17
27
|
raise ArgumentError, "unknown serializer for #{type}"
|
|
18
28
|
end
|
data/lib/sorbet-schema.rb
CHANGED
|
@@ -12,7 +12,9 @@ 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_serializer" => "JSONSerializer"
|
|
15
|
+
"json_serializer" => "JSONSerializer",
|
|
16
|
+
"csv_serializer" => "CSVSerializer",
|
|
17
|
+
"yml_serializer" => "YMLSerializer"
|
|
16
18
|
)
|
|
17
19
|
loader.setup
|
|
18
20
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
module Typed
|
|
4
|
+
class ActiveRecordSerializer < Serializer
|
|
5
|
+
Input = type_member { {fixed: T.untyped} }
|
|
6
|
+
Output = type_member { {fixed: ActiveRecord::Base} }
|
|
7
|
+
|
|
8
|
+
sig { returns(T.class_of(ActiveRecord::Base)) }
|
|
9
|
+
attr_reader :model_class
|
|
10
|
+
|
|
11
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
12
|
+
attr_reader :associations_by_name
|
|
13
|
+
|
|
14
|
+
sig { params(schema: Schema, model_class: T.class_of(ActiveRecord::Base)).void }
|
|
15
|
+
def initialize(schema:, model_class:)
|
|
16
|
+
@model_class = model_class
|
|
17
|
+
@associations_by_name = T.let(
|
|
18
|
+
model_class.reflect_on_all_associations.each_with_object({}) { |association, hsh| hsh[association.name.to_s] = association },
|
|
19
|
+
T::Hash[String, T.untyped]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
super(schema: schema)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) }
|
|
26
|
+
def deserialize(source)
|
|
27
|
+
return Failure.new(DeserializeError.new("Cannot deserialize a non-ActiveRecord object.")) unless source.is_a?(ActiveRecord::Base)
|
|
28
|
+
|
|
29
|
+
return Failure.new(DeserializeError.new("'#{source.class}' is not an instance of '#{model_class}'.")) unless source.class <= model_class
|
|
30
|
+
|
|
31
|
+
creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
|
|
32
|
+
if source.respond_to?(field.name)
|
|
33
|
+
value = source.send(field.name)
|
|
34
|
+
hsh[field.name] = coerce_ar_value(field:, value:)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
deserialize_from_creation_params(creation_params)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { override.params(struct: T::Struct).returns(Result[Output, SerializeError]) }
|
|
42
|
+
def serialize(struct)
|
|
43
|
+
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target
|
|
44
|
+
|
|
45
|
+
hsh = serialize_from_struct(struct:, should_serialize_values: true)
|
|
46
|
+
column_names = model_class.column_names
|
|
47
|
+
|
|
48
|
+
filtered = hsh.each_with_object(T.let({}, T::Hash[String, T.untyped])) do |(key, value), attrs|
|
|
49
|
+
key_s = key.to_s
|
|
50
|
+
association = associations_by_name[key_s]
|
|
51
|
+
|
|
52
|
+
if association
|
|
53
|
+
attrs[key_s] = association.klass.new(value) if column_names.include?(association.foreign_key)
|
|
54
|
+
elsif column_names.include?(key_s)
|
|
55
|
+
attrs[key_s] = value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Success.new(model_class.new(filtered))
|
|
60
|
+
rescue ActiveRecord::AssociationTypeMismatch => e
|
|
61
|
+
Failure.new(SerializeError.new(e.message))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
sig { params(field: Field, value: T.untyped).returns(T.untyped) }
|
|
67
|
+
def coerce_ar_value(field:, value:)
|
|
68
|
+
return value unless value.is_a?(ActiveRecord::Base)
|
|
69
|
+
|
|
70
|
+
nested_struct_class = struct_class_for(field.type)
|
|
71
|
+
return value.attributes.transform_keys(&:to_sym) unless nested_struct_class
|
|
72
|
+
|
|
73
|
+
nested_result = ActiveRecordSerializer.new(schema: nested_struct_class.schema, model_class: value.class).deserialize(value)
|
|
74
|
+
nested_result.success? ? nested_result.payload : value.attributes.transform_keys(&:to_sym)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { params(type: T::Types::Base).returns(T.nilable(T.class_of(T::Struct))) }
|
|
78
|
+
def struct_class_for(type)
|
|
79
|
+
return nil unless type.respond_to?(:raw_type)
|
|
80
|
+
|
|
81
|
+
raw_type = T.cast(type, T::Types::Simple).raw_type
|
|
82
|
+
(raw_type < T::Struct) ? raw_type : nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
module Typed
|
|
4
|
+
# CSV is a flat, row-based format, so nested structs/hashes/arrays cannot be
|
|
5
|
+
# represented as their own columns. `serialize` fails with a `SerializeError`
|
|
6
|
+
# naming the offending field(s) rather than writing a lossy representation
|
|
7
|
+
# that `deserialize` could never parse back; see README's CSVSerializer
|
|
8
|
+
# section for the caveat.
|
|
9
|
+
class CSVSerializer < Serializer
|
|
10
|
+
Input = type_member { {fixed: String} }
|
|
11
|
+
Output = type_member { {fixed: String} }
|
|
12
|
+
|
|
13
|
+
sig { params(schema: Schema).void }
|
|
14
|
+
def initialize(schema:)
|
|
15
|
+
require "csv"
|
|
16
|
+
super
|
|
17
|
+
rescue LoadError
|
|
18
|
+
raise ArgumentError, "csv gem is required for CSV serialization - add it to your Gemfile"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) }
|
|
22
|
+
def deserialize(source)
|
|
23
|
+
parsed = CSV.parse(source, headers: true)
|
|
24
|
+
return Failure.new(ParseError.new(format: :csv)) unless parsed.is_a?(CSV::Table)
|
|
25
|
+
|
|
26
|
+
row = parsed.first
|
|
27
|
+
return Failure.new(ParseError.new(format: :csv)) unless row.is_a?(CSV::Row)
|
|
28
|
+
|
|
29
|
+
creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
|
|
30
|
+
hsh[field.name] = row[field.name.to_s]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
deserialize_from_creation_params(creation_params)
|
|
34
|
+
rescue CSV::MalformedCSVError
|
|
35
|
+
Failure.new(ParseError.new(format: :csv))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { override.params(struct: T::Struct).returns(Result[Output, SerializeError]) }
|
|
39
|
+
def serialize(struct)
|
|
40
|
+
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target
|
|
41
|
+
|
|
42
|
+
hsh = serialize_from_struct(struct:, should_serialize_values: true)
|
|
43
|
+
|
|
44
|
+
non_scalar_fields = hsh.select { |_key, value| value.is_a?(Hash) || value.is_a?(Array) }.keys
|
|
45
|
+
unless non_scalar_fields.empty?
|
|
46
|
+
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to CSV because field(s) #{non_scalar_fields.join(", ")} are not scalar values."))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
csv_string = CSV.generate do |csv|
|
|
50
|
+
csv << hsh.keys.map(&:to_s)
|
|
51
|
+
csv << hsh.values
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Success.new(csv_string)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
module Typed
|
|
4
|
+
class MessagePackSerializer < Serializer
|
|
5
|
+
# MessagePack packs to a binary (`ASCII-8BIT`) encoded string, not text,
|
|
6
|
+
# but `String` is still the correct Sorbet type for it, same as `Input`/`Output`
|
|
7
|
+
# on the other serializers.
|
|
8
|
+
Input = type_member { {fixed: String} }
|
|
9
|
+
Output = type_member { {fixed: String} }
|
|
10
|
+
|
|
11
|
+
sig { params(schema: Schema).void }
|
|
12
|
+
def initialize(schema:)
|
|
13
|
+
require "msgpack"
|
|
14
|
+
super
|
|
15
|
+
rescue LoadError
|
|
16
|
+
raise ArgumentError, "msgpack gem is required for MessagePack serialization - add it to your Gemfile"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) }
|
|
20
|
+
def deserialize(source)
|
|
21
|
+
parsed_msgpack = MessagePack.unpack(source)
|
|
22
|
+
return Failure.new(ParseError.new(format: :msgpack)) unless parsed_msgpack.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
|
|
25
|
+
hsh[field.name] = parsed_msgpack[field.name.to_s]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
deserialize_from_creation_params(creation_params)
|
|
29
|
+
rescue MessagePack::UnpackError, EOFError
|
|
30
|
+
Failure.new(ParseError.new(format: :msgpack))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { override.params(struct: T::Struct).returns(Result[Output, SerializeError]) }
|
|
34
|
+
def serialize(struct)
|
|
35
|
+
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target
|
|
36
|
+
|
|
37
|
+
Success.new(MessagePack.pack(serialize_from_struct(struct:, should_serialize_values: true)))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/typed/schema.rb
CHANGED
|
@@ -28,6 +28,21 @@ module Typed
|
|
|
28
28
|
json_serializer.deserialize(json)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
sig { params(csv: String).returns(Typed::Serializer::DeserializeResult) }
|
|
32
|
+
def from_csv(csv)
|
|
33
|
+
csv_serializer.deserialize(csv)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(yml: String).returns(Typed::Serializer::DeserializeResult) }
|
|
37
|
+
def from_yml(yml)
|
|
38
|
+
yml_serializer.deserialize(yml)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(msgpack: String).returns(Typed::Serializer::DeserializeResult) }
|
|
42
|
+
def from_msgpack(msgpack)
|
|
43
|
+
message_pack_serializer.deserialize(msgpack)
|
|
44
|
+
end
|
|
45
|
+
|
|
31
46
|
sig { params(field_name: Symbol, serializer: Field::InlineSerializer).returns(Schema) }
|
|
32
47
|
def add_serializer(field_name, serializer)
|
|
33
48
|
self.class.new(
|
|
@@ -55,5 +70,23 @@ module Typed
|
|
|
55
70
|
@json_serializer = T.let(@json_serializer, T.nilable(Typed::JSONSerializer))
|
|
56
71
|
@json_serializer ||= Typed::JSONSerializer.new(schema: self)
|
|
57
72
|
end
|
|
73
|
+
|
|
74
|
+
sig { returns(Typed::CSVSerializer) }
|
|
75
|
+
def csv_serializer
|
|
76
|
+
@csv_serializer = T.let(@csv_serializer, T.nilable(Typed::CSVSerializer))
|
|
77
|
+
@csv_serializer ||= Typed::CSVSerializer.new(schema: self)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sig { returns(Typed::YMLSerializer) }
|
|
81
|
+
def yml_serializer
|
|
82
|
+
@yml_serializer = T.let(@yml_serializer, T.nilable(Typed::YMLSerializer))
|
|
83
|
+
@yml_serializer ||= Typed::YMLSerializer.new(schema: self)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sig { returns(Typed::MessagePackSerializer) }
|
|
87
|
+
def message_pack_serializer
|
|
88
|
+
@message_pack_serializer = T.let(@message_pack_serializer, T.nilable(Typed::MessagePackSerializer))
|
|
89
|
+
@message_pack_serializer ||= Typed::MessagePackSerializer.new(schema: self)
|
|
90
|
+
end
|
|
58
91
|
end
|
|
59
92
|
end
|