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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/AGENTS.md +16 -0
  4. data/CHANGELOG.md +50 -0
  5. data/CLAUDE.md +1 -0
  6. data/Gemfile +4 -1
  7. data/Gemfile.lock +80 -29
  8. data/README.md +169 -13
  9. data/Rakefile +1 -0
  10. data/benchmarks/helpers.rb +0 -1
  11. data/benchmarks/many_fields_serialization.rb +0 -1
  12. data/benchmarks/simple_serialization.rb +0 -1
  13. data/lib/sorbet-schema/serialize_value.rb +1 -1
  14. data/lib/sorbet-schema/t/struct.rb +47 -18
  15. data/lib/sorbet-schema/version.rb +1 -1
  16. data/lib/sorbet-schema.rb +6 -1
  17. data/lib/typed/active_record_serializer.rb +116 -0
  18. data/lib/typed/coercion/enum_coercer.rb +1 -1
  19. data/lib/typed/coercion/struct_coercer.rb +7 -7
  20. data/lib/typed/coercion/typed_hash_coercer.rb +3 -2
  21. data/lib/typed/csv_serializer.rb +58 -0
  22. data/lib/typed/field.rb +33 -4
  23. data/lib/typed/hash_serializer.rb +17 -3
  24. data/lib/typed/json_schema.rb +151 -0
  25. data/lib/typed/json_serializer.rb +5 -3
  26. data/lib/typed/message_pack_serializer.rb +41 -0
  27. data/lib/typed/schema.rb +84 -16
  28. data/lib/typed/serializer.rb +21 -10
  29. data/lib/typed/yml_serializer.rb +47 -0
  30. data/rbi/sorbet-schema.rbi +22 -2
  31. data/sorbet/rbi/gems/activemodel@8.1.2.rbi +9 -0
  32. data/sorbet/rbi/gems/activerecord@8.1.2.rbi +9 -0
  33. data/sorbet/rbi/gems/activesupport@8.1.2.rbi +11 -0
  34. data/sorbet/rbi/gems/base64@0.3.0.rbi +9 -0
  35. data/sorbet/rbi/gems/concurrent-ruby@1.3.6.rbi +9 -0
  36. data/sorbet/rbi/gems/connection_pool@3.0.2.rbi +9 -0
  37. data/sorbet/rbi/gems/csv@3.3.6.rbi +4810 -0
  38. data/sorbet/rbi/gems/drb@2.2.3.rbi +9 -0
  39. data/sorbet/rbi/gems/i18n@1.14.8.rbi +9 -0
  40. data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
  41. data/sorbet/rbi/gems/{minitest@5.24.1.rbi → minitest@5.27.0.rbi} +522 -657
  42. data/sorbet/rbi/gems/msgpack@1.8.4.rbi +320 -0
  43. data/sorbet/rbi/gems/parallel@1.28.0.rbi +270 -0
  44. data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
  45. data/sorbet/rbi/gems/sorbet-result@1.1.0.rbi +1 -1
  46. data/sorbet/rbi/gems/sqlite3@2.9.0.rbi +1901 -0
  47. data/sorbet/rbi/gems/timeout@0.6.0.rbi +194 -0
  48. data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +9 -0
  49. data/sorbet/rbi/gems/uri@1.1.1.rbi +2447 -0
  50. data/sorbet/rbi/shims/active_record.rbi +37 -0
  51. data/sorbet/rbi/shims/active_support.rbi +9 -0
  52. data/sorbet/tapioca/require.rb +2 -0
  53. metadata +59 -16
  54. data/sorbet/rbi/gems/erubi@1.13.0.rbi +0 -150
  55. data/sorbet/rbi/gems/netrc@0.11.0.rbi +0 -159
  56. data/sorbet/rbi/gems/parallel@1.25.1.rbi +0 -287
  57. data/sorbet/rbi/gems/prism@0.30.0.rbi +0 -39212
  58. data/sorbet/rbi/gems/thor@1.3.1.rbi +0 -4352
  59. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +0 -435
  60. 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: d6e96bccc5924a52824327d2d5bc3b8e79b887ac31d13b292581c347c1d43480
4
- data.tar.gz: 36896e42540d07c714b8075c7c2b89b4d47c5d09d1e098e83ecf050f6c68fa50
3
+ metadata.gz: 8ae3eaa249b0881483d4fe8edd8c295ada54733bb78d106525767506e83f20bb
4
+ data.tar.gz: f36595f7d0831a83234453acccb941dc3bcff703562a87a52dbb1b2dbfd0dce0
5
5
  SHA512:
6
- metadata.gz: c0a12cf7754d2e13eaebb5a0b042a2abea848dc79aeda84b2887f0c949b523c2aa8a1b3a842562ee053c15dcb3b5a13c6caf1e760bbf9c7f297ea39285c5e1e3
7
- data.tar.gz: b72bc80795f41444f06b2aa9bc7186f3669993c95cb7e3e500e021fc2c053c2ac5088d2e3a0efae32aa4e1bd0d35c138602de08fba9f31ded554b43ae7aefd4d
6
+ metadata.gz: 1ff4723d4f1a749d3d2ee7255b4c024c783b10acf7701240889d9bc25b3682df9c308a6a525c6a055bb8b4a627ce8214d45dfda6c359b064f90beb43693fe5bc
7
+ data.tar.gz: f3352237a1b10e95747c313c4937869c2baac94e45cedd1eb29c3b550d5a7c41b731b07505fbb61d7645e7d46efec986d95a95ed2d3472bf9e65cac9403340bf
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.3.4
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.9.3)
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
- erubi (1.13.0)
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
- minitest (5.24.1)
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.25.1)
67
+ parallel (1.28.0)
39
68
  parser (3.3.3.0)
40
69
  ast (~> 2.4.1)
41
70
  racc
42
- prism (0.30.0)
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.1.13)
49
- prism (>= 0.18.0, < 1.0.0)
50
- sorbet-runtime (>= 0.5.9204)
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
- sorbet (0.5.11475)
78
- sorbet-static (= 0.5.11475)
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.5.11475)
82
- sorbet-static (0.5.11475-universal-darwin)
83
- sorbet-static (0.5.11475-x86_64-linux)
84
- sorbet-static-and-runtime (0.5.11475)
85
- sorbet (= 0.5.11475)
86
- sorbet-runtime (= 0.5.11475)
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.3.3)
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.15.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.1.4, < 0.2)
116
- sorbet-static-and-runtime (>= 0.5.11087)
117
- spoom (>= 1.2.0)
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
- yard-sorbet
120
- thor (1.3.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
- yard (0.9.36)
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.5.15
202
+ 2.7.2
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sorbet Schema
2
2
 
3
- Extendable serialization and deserialization to various formats for Sorbet `T::Struct`s.
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 is designed to be compatible with Sorbet's `T::Struct` class, and seeks to update many of the common pitfalls developers encountering when deserializing to and serializing from a `T::Struct`.
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, you can use the built-in helper methods added to `T::Struct`s to quickly use the built-in serializers.
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 `T::Struct`s 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` aims to address several common issues, while providing the same `Result` handling for invalid or missing data and coercion behavior.
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
- Typed::Schema.new(
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, serializer: my_date_serializer)
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[T::Struct, DeserializeError]) }
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: T::Struct).returns(Result[Output, SerializeError]) }
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 `Output` types. For JSON, deserialization and serialization both use JSON strings, so these are both strings.
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
@@ -4,6 +4,7 @@ require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
5
 
6
6
  Minitest::TestTask.create do |t|
7
+ t.framework = %(require "test_helper")
7
8
  t.test_globs = ["test/**/*_test.rb"]
8
9
  end
9
10
 
@@ -236,4 +236,3 @@ module BenchmarkHelpers
236
236
  }
237
237
  end
238
238
  end
239
-
@@ -24,4 +24,3 @@ Benchmark.ips do |x|
24
24
 
25
25
  x.compare!
26
26
  end
27
-
@@ -24,4 +24,3 @@ Benchmark.ips do |x|
24
24
 
25
25
  x.compare!
26
26
  end
27
-
@@ -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