sorbet-schema 0.9.3 → 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/.ruby-version +1 -1
- data/AGENTS.md +16 -0
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +1 -0
- data/Gemfile +4 -1
- data/Gemfile.lock +80 -29
- data/README.md +169 -13
- data/Rakefile +1 -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/serialize_value.rb +1 -1
- data/lib/sorbet-schema/t/struct.rb +47 -18
- data/lib/sorbet-schema/version.rb +1 -1
- data/lib/sorbet-schema.rb +6 -1
- data/lib/typed/active_record_serializer.rb +116 -0
- 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 +58 -0
- 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 +41 -0
- data/lib/typed/schema.rb +84 -16
- data/lib/typed/serializer.rb +21 -10
- data/lib/typed/yml_serializer.rb +47 -0
- data/rbi/sorbet-schema.rbi +22 -2
- 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/{minitest@5.24.1.rbi → minitest@5.27.0.rbi} +522 -657
- data/sorbet/rbi/gems/msgpack@1.8.4.rbi +320 -0
- data/sorbet/rbi/gems/parallel@1.28.0.rbi +270 -0
- data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
- data/sorbet/rbi/gems/sorbet-result@1.1.0.rbi +1 -1
- 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 +59 -16
- 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/parallel@1.25.1.rbi +0 -287
- 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: 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/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.4.7
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
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 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.
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Maintaining this file
|
|
12
|
+
|
|
13
|
+
Keep this file for knowledge useful to almost every future agent session in this project.
|
|
14
|
+
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
|
|
15
|
+
Prefer rewriting or pruning existing entries over appending new ones.
|
|
16
|
+
When updating this file, preserve this bar for all agents and keep entries concise.
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ 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
|
+
|
|
33
|
+
## [0.10.0](https://github.com/maxveldink/sorbet-schema/compare/v0.9.3...v0.10.0) (2026-08-07)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### ⚠ BREAKING CHANGES
|
|
37
|
+
|
|
38
|
+
* bump local Ruby to 3.4, add to matrix ([#138](https://github.com/maxveldink/sorbet-schema/issues/138))
|
|
39
|
+
|
|
40
|
+
### Features
|
|
41
|
+
|
|
42
|
+
* 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))
|
|
43
|
+
* add Typed::MessagePackSerializer ([#145](https://github.com/maxveldink/sorbet-schema/issues/145)) ([d367da8](https://github.com/maxveldink/sorbet-schema/commit/d367da83b2d9e72f5099b70594243178c3df432c))
|
|
44
|
+
* 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))
|
|
45
|
+
* **typed:** add ActiveRecord serializer ([#140](https://github.com/maxveldink/sorbet-schema/issues/140)) ([a815f9f](https://github.com/maxveldink/sorbet-schema/commit/a815f9f42cb36c26ead45630384eb6b5022cd43d))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
### Bug Fixes
|
|
49
|
+
|
|
50
|
+
* **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))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
### Miscellaneous Chores
|
|
54
|
+
|
|
55
|
+
* 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))
|
|
56
|
+
|
|
7
57
|
## [0.9.3](https://github.com/maxveldink/sorbet-schema/compare/v0.9.2...v0.9.3) (2025-12-06)
|
|
8
58
|
|
|
9
59
|
|
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.20", 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.11.0)
|
|
5
5
|
sorbet-result (~> 1.1)
|
|
6
6
|
sorbet-runtime (~> 0.5)
|
|
7
7
|
sorbet-struct-comparable (~> 1.3)
|
|
@@ -10,15 +10,42 @@ 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)
|
|
35
|
+
benchmark (0.5.0)
|
|
15
36
|
benchmark-ips (2.14.0)
|
|
16
37
|
bigdecimal (3.1.8)
|
|
17
38
|
builder (3.3.0)
|
|
39
|
+
concurrent-ruby (1.3.6)
|
|
40
|
+
connection_pool (3.0.2)
|
|
41
|
+
csv (3.3.6)
|
|
18
42
|
debug (1.9.2)
|
|
19
43
|
irb (~> 1.10)
|
|
20
44
|
reline (>= 0.3.8)
|
|
21
|
-
|
|
45
|
+
drb (2.2.3)
|
|
46
|
+
erubi (1.13.1)
|
|
47
|
+
i18n (1.14.8)
|
|
48
|
+
concurrent-ruby (~> 1.0)
|
|
22
49
|
io-console (0.7.2)
|
|
23
50
|
irb (1.14.0)
|
|
24
51
|
rdoc (>= 4.0.0)
|
|
@@ -26,7 +53,8 @@ GEM
|
|
|
26
53
|
json (2.7.2)
|
|
27
54
|
language_server-protocol (3.17.0.3)
|
|
28
55
|
lint_roller (1.1.0)
|
|
29
|
-
|
|
56
|
+
logger (1.7.0)
|
|
57
|
+
minitest (5.27.0)
|
|
30
58
|
minitest-focus (1.4.0)
|
|
31
59
|
minitest (>= 4, < 6)
|
|
32
60
|
minitest-reporters (1.7.1)
|
|
@@ -34,25 +62,31 @@ GEM
|
|
|
34
62
|
builder
|
|
35
63
|
minitest (>= 5.0)
|
|
36
64
|
ruby-progressbar
|
|
65
|
+
msgpack (1.8.4)
|
|
37
66
|
netrc (0.11.0)
|
|
38
|
-
parallel (1.
|
|
67
|
+
parallel (1.28.0)
|
|
39
68
|
parser (3.3.3.0)
|
|
40
69
|
ast (~> 2.4.1)
|
|
41
70
|
racc
|
|
42
|
-
prism (
|
|
71
|
+
prism (1.9.0)
|
|
43
72
|
psych (5.1.2)
|
|
44
73
|
stringio
|
|
45
74
|
racc (1.8.0)
|
|
46
75
|
rainbow (3.1.1)
|
|
47
76
|
rake (13.2.1)
|
|
48
|
-
rbi (0.
|
|
49
|
-
prism (
|
|
50
|
-
|
|
77
|
+
rbi (0.4.3)
|
|
78
|
+
prism (~> 1.0)
|
|
79
|
+
rbs (>= 4.0.1)
|
|
80
|
+
rbs (4.2.0)
|
|
81
|
+
logger
|
|
82
|
+
prism (>= 1.6.0)
|
|
83
|
+
tsort
|
|
51
84
|
rdoc (6.7.0)
|
|
52
85
|
psych (>= 4.0.0)
|
|
53
86
|
regexp_parser (2.9.2)
|
|
54
87
|
reline (0.5.9)
|
|
55
88
|
io-console (~> 0.5)
|
|
89
|
+
require-hooks (0.5.1)
|
|
56
90
|
rexml (3.3.1)
|
|
57
91
|
strscan
|
|
58
92
|
rubocop (1.64.1)
|
|
@@ -74,23 +108,31 @@ GEM
|
|
|
74
108
|
rubocop-sorbet (0.7.8)
|
|
75
109
|
rubocop (>= 0.90.0)
|
|
76
110
|
ruby-progressbar (1.13.0)
|
|
77
|
-
|
|
78
|
-
|
|
111
|
+
rubydex (0.4.1-arm64-darwin)
|
|
112
|
+
rubydex (0.4.1-x86_64-linux)
|
|
113
|
+
securerandom (0.4.1)
|
|
114
|
+
sorbet (0.6.13543)
|
|
115
|
+
sorbet-static (= 0.6.13543)
|
|
79
116
|
sorbet-result (1.1.0)
|
|
80
117
|
sorbet-runtime (~> 0.5)
|
|
81
|
-
sorbet-runtime (0.
|
|
82
|
-
sorbet-static (0.
|
|
83
|
-
sorbet-static (0.
|
|
84
|
-
sorbet-static-and-runtime (0.
|
|
85
|
-
sorbet (= 0.
|
|
86
|
-
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)
|
|
87
124
|
sorbet-struct-comparable (1.3.0)
|
|
88
125
|
sorbet-runtime (>= 0.5)
|
|
89
|
-
spoom (1.
|
|
126
|
+
spoom (1.8.10)
|
|
90
127
|
erubi (>= 1.10.0)
|
|
91
128
|
prism (>= 0.28.0)
|
|
129
|
+
rbi (>= 0.4.2)
|
|
130
|
+
rbs (>= 4.0.0.dev.5)
|
|
131
|
+
rexml (>= 3.2.6)
|
|
92
132
|
sorbet-static-and-runtime (>= 0.5.10187)
|
|
93
133
|
thor (>= 0.19.2)
|
|
134
|
+
sqlite3 (2.9.0-arm64-darwin)
|
|
135
|
+
sqlite3 (2.9.0-x86_64-linux-gnu)
|
|
94
136
|
standard (1.39.1)
|
|
95
137
|
language_server-protocol (~> 3.17.0.2)
|
|
96
138
|
lint_roller (~> 1.0)
|
|
@@ -108,44 +150,53 @@ GEM
|
|
|
108
150
|
rubocop-sorbet (~> 0.7.0)
|
|
109
151
|
stringio (3.1.1)
|
|
110
152
|
strscan (3.1.0)
|
|
111
|
-
tapioca (0.
|
|
153
|
+
tapioca (0.20.0)
|
|
154
|
+
benchmark
|
|
112
155
|
bundler (>= 2.2.25)
|
|
113
156
|
netrc (>= 0.11.0)
|
|
114
157
|
parallel (>= 1.21.0)
|
|
115
|
-
rbi (>= 0.
|
|
116
|
-
|
|
117
|
-
|
|
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)
|
|
118
163
|
thor (>= 1.2.0)
|
|
119
|
-
|
|
120
|
-
thor (1.
|
|
164
|
+
tsort
|
|
165
|
+
thor (1.5.0)
|
|
166
|
+
timeout (0.6.0)
|
|
167
|
+
tsort (0.2.0)
|
|
168
|
+
tzinfo (2.0.6)
|
|
169
|
+
concurrent-ruby (~> 1.0)
|
|
121
170
|
unicode-display_width (2.5.0)
|
|
122
|
-
|
|
123
|
-
yard-sorbet (0.9.0)
|
|
124
|
-
sorbet-runtime
|
|
125
|
-
yard
|
|
171
|
+
uri (1.1.1)
|
|
126
172
|
zeitwerk (2.6.16)
|
|
127
173
|
|
|
128
174
|
PLATFORMS
|
|
129
175
|
arm64-darwin-22
|
|
130
176
|
arm64-darwin-23
|
|
131
177
|
arm64-darwin-25
|
|
178
|
+
arm64-darwin-27
|
|
132
179
|
x86_64-linux
|
|
133
180
|
|
|
134
181
|
DEPENDENCIES
|
|
182
|
+
activerecord (>= 6.0)
|
|
135
183
|
benchmark-ips
|
|
136
184
|
bigdecimal
|
|
185
|
+
csv (~> 3.3)
|
|
137
186
|
debug
|
|
138
187
|
minitest
|
|
139
188
|
minitest-focus
|
|
140
189
|
minitest-reporters
|
|
190
|
+
msgpack (~> 1.7)
|
|
141
191
|
rake
|
|
142
192
|
sorbet
|
|
143
193
|
sorbet-schema!
|
|
144
194
|
sorbet-struct-comparable
|
|
195
|
+
sqlite3
|
|
145
196
|
standard
|
|
146
197
|
standard-performance
|
|
147
198
|
standard-sorbet
|
|
148
|
-
tapioca
|
|
199
|
+
tapioca (~> 0.20)
|
|
149
200
|
|
|
150
201
|
BUNDLED WITH
|
|
151
|
-
2.
|
|
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}')
|
|
@@ -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.
|
|
@@ -79,6 +81,29 @@ result = json_serializer.deserialize('{"name":"Max","age":"29"}')
|
|
|
79
81
|
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
80
82
|
```
|
|
81
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
|
+
|
|
82
107
|
### Rails Example
|
|
83
108
|
|
|
84
109
|
Here's an extended example of how Sorbet Schema can be combined with a normal Rails request to easily convert between formats.
|
|
@@ -109,13 +134,15 @@ end
|
|
|
109
134
|
|
|
110
135
|
These are the currently available serializers. For more information about implementing a custom one (or contributing one back!), see [Custom Coercers](#custom-coercers).
|
|
111
136
|
|
|
137
|
+
`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.
|
|
138
|
+
|
|
112
139
|
#### JSONSerializer
|
|
113
140
|
|
|
114
141
|
See [Getting Started](#getting-started) for more information on how to use the JSONSerializer.
|
|
115
142
|
|
|
116
143
|
#### HashSerializer
|
|
117
144
|
|
|
118
|
-
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.
|
|
119
146
|
|
|
120
147
|
To use it, simply instantiate and use it like the `JSONSerializer`:
|
|
121
148
|
|
|
@@ -129,6 +156,129 @@ max = result.payload # == Person.new(name: "Max", age: 29)
|
|
|
129
156
|
|
|
130
157
|
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
158
|
|
|
159
|
+
#### CSVSerializer
|
|
160
|
+
|
|
161
|
+
Accepts and returns a CSV string representing a single record, with a header row followed by one data row:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
csv_serializer = Typed::CSVSerializer.new(schema: Person.schema)
|
|
165
|
+
|
|
166
|
+
# Deserialize from target format
|
|
167
|
+
result = csv_serializer.deserialize("name,age\nMax,29\n")
|
|
168
|
+
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
169
|
+
|
|
170
|
+
# Serialize to target format
|
|
171
|
+
result = csv_serializer.serialize(max)
|
|
172
|
+
result.payload # == "name,age\nMax,29\n"
|
|
173
|
+
```
|
|
174
|
+
|
|
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.
|
|
176
|
+
|
|
177
|
+
#### YMLSerializer
|
|
178
|
+
|
|
179
|
+
Works just like the `JSONSerializer`, but with YAML strings:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
yml_serializer = Typed::YMLSerializer.new(schema: Person.schema)
|
|
183
|
+
|
|
184
|
+
# Deserialize from target format
|
|
185
|
+
result = yml_serializer.deserialize("---\nname: Max\nage: 29\n")
|
|
186
|
+
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
187
|
+
|
|
188
|
+
# Serialize to target format
|
|
189
|
+
result = yml_serializer.serialize(max)
|
|
190
|
+
result.payload # == "---\nname: Max\nage: 29\n"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### MessagePackSerializer
|
|
194
|
+
|
|
195
|
+
Works just like the `JSONSerializer`, but with [MessagePack](https://msgpack.org/) binary-encoded strings, using the [`msgpack`](https://github.com/msgpack/msgpack-ruby) gem:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
message_pack_serializer = Typed::MessagePackSerializer.new(schema: Person.schema)
|
|
199
|
+
|
|
200
|
+
# Deserialize from target format
|
|
201
|
+
result = message_pack_serializer.deserialize(MessagePack.pack({"name" => "Max", "age" => 29}))
|
|
202
|
+
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
203
|
+
|
|
204
|
+
# Serialize to target format
|
|
205
|
+
result = message_pack_serializer.serialize(max)
|
|
206
|
+
result.payload # == MessagePack.pack({"name" => "Max", "age" => 29})
|
|
207
|
+
```
|
|
208
|
+
|
|
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.
|
|
210
|
+
|
|
211
|
+
#### ActiveRecordSerializer
|
|
212
|
+
|
|
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:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
ar_serializer = Typed::ActiveRecordSerializer.new(schema: Person.schema, model_class: PersonModel)
|
|
217
|
+
# Sorbet infers ActiveRecordSerializer[PersonModel, Person], retaining both result types.
|
|
218
|
+
|
|
219
|
+
# Deserialize from target format
|
|
220
|
+
result = ar_serializer.deserialize(PersonModel.new(name: "Max", age: 29))
|
|
221
|
+
max = result.payload # == Person.new(name: "Max", age: 29)
|
|
222
|
+
|
|
223
|
+
result = ar_serializer.serialize(max)
|
|
224
|
+
result.payload # == PersonModel instance with name: "Max", age: 29
|
|
225
|
+
```
|
|
226
|
+
|
|
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.
|
|
228
|
+
|
|
229
|
+
Nested `belongs_to` associations are deserialized recursively to arbitrary depth, including immutable struct fields backed by a `belongs_to` association.
|
|
230
|
+
|
|
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:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
# 1 query for people + 1 query for locations, instead of 1 + N
|
|
235
|
+
PersonModel.includes(:location).find_each do |person_model|
|
|
236
|
+
ar_serializer.deserialize(person_model)
|
|
237
|
+
end
|
|
238
|
+
```
|
|
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
|
+
|
|
132
282
|
### Customization
|
|
133
283
|
|
|
134
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!
|
|
@@ -146,13 +296,13 @@ class DateCoercer < Coercer
|
|
|
146
296
|
Target = type_member { {fixed: Date} }
|
|
147
297
|
|
|
148
298
|
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
|
|
149
|
-
def used_for_type?(type)
|
|
299
|
+
def self.used_for_type?(type)
|
|
150
300
|
T::Utils.coerce(type) == T::Utils.coerce(Date)
|
|
151
301
|
end
|
|
152
302
|
|
|
153
303
|
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
|
|
154
304
|
def coerce(type:, value:)
|
|
155
|
-
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)
|
|
156
306
|
|
|
157
307
|
return Success.new(value) if value.is_a?(Date)
|
|
158
308
|
|
|
@@ -186,11 +336,15 @@ The serializer can be used when creating a `Schema` and defining its `fields`, o
|
|
|
186
336
|
```ruby
|
|
187
337
|
my_date_serializer = ->(date) { date.strftime("%Y/%m") }
|
|
188
338
|
|
|
189
|
-
# use directly on a Schema
|
|
190
|
-
|
|
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(
|
|
191
345
|
target: SchemaWithDateField,
|
|
192
346
|
fields: [
|
|
193
|
-
Typed::Field.new(name: :date, type: Date,
|
|
347
|
+
Typed::Field.new(name: :date, type: Date, inline_serializer: my_date_serializer)
|
|
194
348
|
]
|
|
195
349
|
)
|
|
196
350
|
|
|
@@ -208,10 +362,12 @@ require "json"
|
|
|
208
362
|
class JSONSerializer < Serializer
|
|
209
363
|
Input = type_member { {fixed: String} }
|
|
210
364
|
Output = type_member { {fixed: String} }
|
|
365
|
+
StructT = type_member { {upper: T::InexactStruct} }
|
|
211
366
|
|
|
212
|
-
sig { override.params(source: Input).returns(Result[
|
|
367
|
+
sig { override.params(source: Input).returns(Result[StructT, DeserializeError]) }
|
|
213
368
|
def deserialize(source)
|
|
214
369
|
parsed_json = JSON.parse(source)
|
|
370
|
+
return Failure.new(ParseError.new(format: :json)) unless parsed_json.is_a?(Hash)
|
|
215
371
|
|
|
216
372
|
creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
|
|
217
373
|
hsh[field.name] = parsed_json[field.name.to_s]
|
|
@@ -222,7 +378,7 @@ class JSONSerializer < Serializer
|
|
|
222
378
|
Failure.new(ParseError.new(format: :json))
|
|
223
379
|
end
|
|
224
380
|
|
|
225
|
-
sig { override.params(struct:
|
|
381
|
+
sig { override.params(struct: StructT).returns(Result[Output, SerializeError]) }
|
|
226
382
|
def serialize(struct)
|
|
227
383
|
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target
|
|
228
384
|
|
|
@@ -231,7 +387,7 @@ class JSONSerializer < Serializer
|
|
|
231
387
|
end
|
|
232
388
|
```
|
|
233
389
|
|
|
234
|
-
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.
|
|
235
391
|
|
|
236
392
|
Next, the `deserialize` and `serialize` methods must be implemented. Notice that both of these return `Result`s.
|
|
237
393
|
|
data/Rakefile
CHANGED
data/benchmarks/helpers.rb
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
|