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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/AGENTS.md +15 -0
  4. data/CHANGELOG.md +24 -0
  5. data/CLAUDE.md +1 -0
  6. data/Gemfile +4 -1
  7. data/Gemfile.lock +51 -9
  8. data/README.md +84 -0
  9. data/benchmarks/helpers.rb +0 -1
  10. data/benchmarks/many_fields_serialization.rb +0 -1
  11. data/benchmarks/simple_serialization.rb +0 -1
  12. data/lib/sorbet-schema/t/struct.rb +10 -0
  13. data/lib/sorbet-schema/version.rb +1 -1
  14. data/lib/sorbet-schema.rb +3 -1
  15. data/lib/typed/active_record_serializer.rb +85 -0
  16. data/lib/typed/csv_serializer.rb +57 -0
  17. data/lib/typed/message_pack_serializer.rb +40 -0
  18. data/lib/typed/schema.rb +33 -0
  19. data/lib/typed/yml_serializer.rb +46 -0
  20. data/sorbet/rbi/gems/activemodel@8.1.2.rbi +9 -0
  21. data/sorbet/rbi/gems/activerecord@8.1.2.rbi +9 -0
  22. data/sorbet/rbi/gems/activesupport@8.1.2.rbi +11 -0
  23. data/sorbet/rbi/gems/base64@0.3.0.rbi +9 -0
  24. data/sorbet/rbi/gems/concurrent-ruby@1.3.6.rbi +9 -0
  25. data/sorbet/rbi/gems/connection_pool@3.0.2.rbi +9 -0
  26. data/sorbet/rbi/gems/csv@3.3.6.rbi +4810 -0
  27. data/sorbet/rbi/gems/drb@2.2.3.rbi +9 -0
  28. data/sorbet/rbi/gems/i18n@1.14.8.rbi +9 -0
  29. data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
  30. data/sorbet/rbi/gems/msgpack@1.8.4.rbi +320 -0
  31. data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
  32. data/sorbet/rbi/gems/sqlite3@2.9.0.rbi +1901 -0
  33. data/sorbet/rbi/gems/timeout@0.6.0.rbi +194 -0
  34. data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +9 -0
  35. data/sorbet/rbi/gems/uri@1.1.1.rbi +2447 -0
  36. data/sorbet/rbi/shims/active_record.rbi +37 -0
  37. data/sorbet/rbi/shims/active_support.rbi +9 -0
  38. data/sorbet/tapioca/require.rb +2 -0
  39. metadata +56 -14
  40. data/sorbet/rbi/gems/erubi@1.13.0.rbi +0 -150
  41. data/sorbet/rbi/gems/netrc@0.11.0.rbi +0 -159
  42. data/sorbet/rbi/gems/prism@0.30.0.rbi +0 -39212
  43. data/sorbet/rbi/gems/thor@1.3.1.rbi +0 -4352
  44. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +0 -435
  45. 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: b0c3bb89fee8f842f919e0cf1f53d8f97fb12e4464effb88c7e821134c531a1f
4
+ data.tar.gz: a78684855bb1bd76e6688b0daa04f026fcb55c23fd2f354daaea8e7a2f0f5cd3
5
5
  SHA512:
6
- metadata.gz: c0a12cf7754d2e13eaebb5a0b042a2abea848dc79aeda84b2887f0c949b523c2aa8a1b3a842562ee053c15dcb3b5a13c6caf1e760bbf9c7f297ea39285c5e1e3
7
- data.tar.gz: b72bc80795f41444f06b2aa9bc7186f3669993c95cb7e3e500e021fc2c053c2ac5088d2e3a0efae32aa4e1bd0d35c138602de08fba9f31ded554b43ae7aefd4d
6
+ metadata.gz: 28656d268e7861ae7e069ff30b024a2d063ee33f5824ca9bc1319cbe91555b2e80b402ee2438b975d299c58f624c4e8afba1ba8649a62c8d05a6efc3fa10fbac
7
+ data.tar.gz: 9faf4f7458d56d9a66645d1d25aa4d70a83eea9abba3e1021a5fe58a5978d0c309a987d20e5af1dfc538f5d0a550f40ed1afa6a35c39ff2e5173a9b44a6cd118
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.3.4
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.9.3)
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 (0.30.0)
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.1.13)
49
- prism (>= 0.18.0, < 1.0.0)
50
- sorbet-runtime (>= 0.5.9204)
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.15.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 (>= 0.1.4, < 0.2)
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.5.15
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!
@@ -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
-
@@ -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
@@ -1,5 +1,5 @@
1
1
  # typed: strict
2
2
 
3
3
  module SorbetSchema
4
- VERSION = "0.9.3"
4
+ VERSION = "0.10.0"
5
5
  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