skit 0.1.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +469 -0
  4. data/exe/skit +31 -0
  5. data/lib/active_model/validations/skit_validator.rb +54 -0
  6. data/lib/skit/attribute.rb +63 -0
  7. data/lib/skit/json_schema/class_name_path.rb +67 -0
  8. data/lib/skit/json_schema/cli.rb +166 -0
  9. data/lib/skit/json_schema/code_generator.rb +132 -0
  10. data/lib/skit/json_schema/config.rb +67 -0
  11. data/lib/skit/json_schema/definitions/array_property_type.rb +36 -0
  12. data/lib/skit/json_schema/definitions/const_type.rb +68 -0
  13. data/lib/skit/json_schema/definitions/enum_type.rb +71 -0
  14. data/lib/skit/json_schema/definitions/hash_property_type.rb +36 -0
  15. data/lib/skit/json_schema/definitions/module.rb +54 -0
  16. data/lib/skit/json_schema/definitions/property_type.rb +39 -0
  17. data/lib/skit/json_schema/definitions/property_types.rb +13 -0
  18. data/lib/skit/json_schema/definitions/struct.rb +99 -0
  19. data/lib/skit/json_schema/definitions/struct_property.rb +75 -0
  20. data/lib/skit/json_schema/definitions/union_property_type.rb +40 -0
  21. data/lib/skit/json_schema/naming_utils.rb +25 -0
  22. data/lib/skit/json_schema/schema_analyzer.rb +407 -0
  23. data/lib/skit/json_schema/types/const.rb +69 -0
  24. data/lib/skit/json_schema.rb +77 -0
  25. data/lib/skit/serialization/errors.rb +23 -0
  26. data/lib/skit/serialization/path.rb +69 -0
  27. data/lib/skit/serialization/processor/array.rb +65 -0
  28. data/lib/skit/serialization/processor/base.rb +47 -0
  29. data/lib/skit/serialization/processor/boolean.rb +35 -0
  30. data/lib/skit/serialization/processor/date.rb +40 -0
  31. data/lib/skit/serialization/processor/enum.rb +54 -0
  32. data/lib/skit/serialization/processor/float.rb +36 -0
  33. data/lib/skit/serialization/processor/hash.rb +93 -0
  34. data/lib/skit/serialization/processor/integer.rb +31 -0
  35. data/lib/skit/serialization/processor/json_schema_const.rb +55 -0
  36. data/lib/skit/serialization/processor/nilable.rb +87 -0
  37. data/lib/skit/serialization/processor/simple_type.rb +51 -0
  38. data/lib/skit/serialization/processor/string.rb +31 -0
  39. data/lib/skit/serialization/processor/struct.rb +84 -0
  40. data/lib/skit/serialization/processor/symbol.rb +36 -0
  41. data/lib/skit/serialization/processor/time.rb +40 -0
  42. data/lib/skit/serialization/processor/union.rb +120 -0
  43. data/lib/skit/serialization/registry.rb +33 -0
  44. data/lib/skit/serialization.rb +60 -0
  45. data/lib/skit/version.rb +6 -0
  46. data/lib/skit.rb +46 -0
  47. data/lib/tapioca/dsl/compilers/skit.rb +105 -0
  48. metadata +135 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e8cc8c9595a7c97a181d67e3c183726e25dc6d5aea07510bcb09fd325ed6aae9
4
+ data.tar.gz: 8c6b40877f2949364e61015b761fc0d00376498917f28331f0d44f9154283685
5
+ SHA512:
6
+ metadata.gz: 868a602fc15e934970fa4259eb6eccc19a91a0633ed7539671a490b7af0fa6e67ed4c78710acb6500c99cfbe64e28a347acc1fcd08bc23cc995d6ca58f24793c
7
+ data.tar.gz: 8cc83bc6d79f59ff448b0c113b53f100a8291a6f305c335976baaded471a1360cb877f6143ae99a4e6f63e9b2d55909f92f047c6d05703a8bc03d6d30bd206fc
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Speria, inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,469 @@
1
+ # Skit
2
+
3
+ A Ruby gem that integrates JSON Schema with Sorbet T::Struct. Generate type-safe Ruby code from JSON Schema, serialize/deserialize JSON data to T::Struct, and store complex objects in ActiveRecord JSON/JSONB columns.
4
+
5
+ ## Key Features
6
+
7
+ - **JSON Schema to Code**: Generate Sorbet T::Struct definitions from JSON Schema
8
+ - **Type-Safe Serialization**: Seamless conversion between T::Struct and JSON
9
+ - **ActiveRecord Integration**: Store T::Struct in JSON/JSONB columns with full type safety
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem "skit"
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### 1. Generate T::Struct from JSON Schema
28
+
29
+ #### CLI Tool
30
+
31
+ ```bash
32
+ # Basic usage
33
+ skit generate schema.json
34
+
35
+ # Specify class name
36
+ skit generate -c User user_schema.json
37
+
38
+ # Specify module name
39
+ skit generate -m MyModule user_schema.json
40
+
41
+ # Output to file
42
+ skit generate -o lib/types/user.rb user_schema.json
43
+
44
+ # Combine options
45
+ skit generate -m MyApp::Types -c User -o user.rb user_schema.json
46
+ ```
47
+
48
+ #### Programmatic API
49
+
50
+ ```ruby
51
+ require "skit"
52
+
53
+ schema = {
54
+ "type" => "object",
55
+ "properties" => {
56
+ "name" => { "type" => "string" },
57
+ "age" => { "type" => "integer" }
58
+ },
59
+ "required" => ["name"]
60
+ }
61
+
62
+ code = Skit::JsonSchema.generate(schema, class_name: "User", module_name: "MyApp")
63
+ puts code
64
+ ```
65
+
66
+ Output:
67
+
68
+ ```ruby
69
+ # typed: strict
70
+ # frozen_string_literal: true
71
+
72
+ require "sorbet-runtime"
73
+
74
+ module MyApp
75
+ class User < T::Struct
76
+ prop :name, String
77
+ prop :age, T.nilable(Integer)
78
+ end
79
+ end
80
+ ```
81
+
82
+ #### Enum Support
83
+
84
+ JSON Schema `enum` generates `T::Enum` classes:
85
+
86
+ ```json
87
+ {
88
+ "type": "object",
89
+ "properties": {
90
+ "status": {
91
+ "type": "string",
92
+ "enum": ["pending", "active", "completed"]
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ Generates:
99
+
100
+ ```ruby
101
+ class Status < T::Enum
102
+ enums do
103
+ Pending = new("pending")
104
+ Active = new("active")
105
+ Completed = new("completed")
106
+ end
107
+ end
108
+
109
+ class Root < T::Struct
110
+ prop :status, T.nilable(Status)
111
+ end
112
+ ```
113
+
114
+ #### Const Support
115
+
116
+ JSON Schema `const` generates type-safe constant classes for discriminated unions:
117
+
118
+ ```json
119
+ {
120
+ "type": "object",
121
+ "properties": {
122
+ "type": { "const": "dog" },
123
+ "breed": { "type": "string" }
124
+ }
125
+ }
126
+ ```
127
+
128
+ Generates:
129
+
130
+ ```ruby
131
+ class TypeDog < Skit::JsonSchema::Types::Const
132
+ VALUE = "dog"
133
+ end
134
+
135
+ class Root < T::Struct
136
+ prop :type, T.nilable(TypeDog)
137
+ prop :breed, T.nilable(String)
138
+ end
139
+ ```
140
+
141
+ #### Discriminated Unions (oneOf with objects)
142
+
143
+ JSON Schema `oneOf` with object types generates union types:
144
+
145
+ ```json
146
+ {
147
+ "properties": {
148
+ "animal": {
149
+ "oneOf": [
150
+ { "type": "object", "properties": { "type": { "const": "dog" }, "breed": { "type": "string" } } },
151
+ { "type": "object", "properties": { "type": { "const": "cat" }, "color": { "type": "string" } } }
152
+ ]
153
+ }
154
+ }
155
+ }
156
+ ```
157
+
158
+ Generates:
159
+
160
+ ```ruby
161
+ class AnimalVariant0 < T::Struct
162
+ prop :type, T.nilable(TypeDog)
163
+ prop :breed, T.nilable(String)
164
+ end
165
+
166
+ class AnimalVariant1 < T::Struct
167
+ prop :type, T.nilable(TypeCat)
168
+ prop :color, T.nilable(String)
169
+ end
170
+
171
+ class Root < T::Struct
172
+ prop :animal, T.any(AnimalVariant0, AnimalVariant1)
173
+ end
174
+ ```
175
+
176
+ ### 2. Serialize/Deserialize T::Struct
177
+
178
+ Use your own T::Struct definitions directly:
179
+
180
+ ```ruby
181
+ class Product < T::Struct
182
+ const :name, String
183
+ const :price, Integer
184
+ const :tags, T::Array[String], default: []
185
+ end
186
+
187
+ # Deserialize: Hash -> T::Struct
188
+ data = { "name" => "Ruby Book", "price" => 3000, "tags" => ["programming", "ruby"] }
189
+ product = Skit.deserialize(data, Product)
190
+
191
+ product.name # => "Ruby Book"
192
+ product.price # => 3000
193
+ product.tags # => ["programming", "ruby"]
194
+
195
+ # Serialize: T::Struct -> Hash
196
+ hash = Skit.serialize(product)
197
+ # => {"name" => "Ruby Book", "price" => 3000, "tags" => ["programming", "ruby"]}
198
+ ```
199
+
200
+ #### Union Types (T.any)
201
+
202
+ Union types with T::Struct variants are automatically resolved during deserialization:
203
+
204
+ ```ruby
205
+ class TypeDog < Skit::JsonSchema::Types::Const
206
+ VALUE = "dog"
207
+ end
208
+
209
+ class TypeCat < Skit::JsonSchema::Types::Const
210
+ VALUE = "cat"
211
+ end
212
+
213
+ class Dog < T::Struct
214
+ const :type, TypeDog
215
+ const :breed, String
216
+ end
217
+
218
+ class Cat < T::Struct
219
+ const :type, TypeCat
220
+ const :color, String
221
+ end
222
+
223
+ class Pet < T::Struct
224
+ const :animal, T.any(Dog, Cat)
225
+ end
226
+
227
+ # Deserialize: tries each variant, Const values discriminate the match
228
+ data = { "animal" => { "type" => "dog", "breed" => "Shiba" } }
229
+ pet = Skit.deserialize(data, Pet)
230
+ pet.animal # => Dog instance
231
+
232
+ # Serialize: detects the actual struct class
233
+ hash = Skit.serialize(pet)
234
+ # => {"animal" => {"type" => "dog", "breed" => "Shiba"}}
235
+ ```
236
+
237
+ ### 3. ActiveRecord JSONB Integration
238
+
239
+ ```ruby
240
+ class Address < T::Struct
241
+ const :city, String
242
+ const :zip, T.nilable(String)
243
+ end
244
+
245
+ class Customer < ActiveRecord::Base
246
+ attribute :address, Skit::Attribute[Address]
247
+ end
248
+
249
+ # Assign with Hash
250
+ customer = Customer.new
251
+ customer.address = { city: "Tokyo", zip: "100-0001" }
252
+
253
+ # Assign with T::Struct
254
+ customer.address = Address.new(city: "Tokyo", zip: "100-0001")
255
+
256
+ # Access as T::Struct
257
+ customer.address.city # => "Tokyo"
258
+ customer.address.zip # => "100-0001"
259
+
260
+ # Save to database (stored as json)
261
+ customer.save
262
+ ```
263
+
264
+ ### Array Type
265
+
266
+ ```ruby
267
+ class Tag < T::Struct
268
+ const :name, String
269
+ const :color, String
270
+ end
271
+
272
+ class Article < ActiveRecord::Base
273
+ attribute :tags, Skit::Attribute[T::Array[Tag]]
274
+ end
275
+
276
+ article = Article.new
277
+ article.tags = [
278
+ { name: "Ruby", color: "red" },
279
+ { name: "Rails", color: "red" }
280
+ ]
281
+
282
+ article.tags[0].name # => "Ruby"
283
+ ```
284
+
285
+ ### Hash Type
286
+
287
+ ```ruby
288
+ class BoxSize < T::Struct
289
+ const :width, Integer
290
+ const :height, Integer
291
+ end
292
+
293
+ class Layout < ActiveRecord::Base
294
+ attribute :sizes, Skit::Attribute[T::Hash[String, BoxSize]]
295
+ end
296
+
297
+ layout = Layout.new
298
+ layout.sizes = {
299
+ "small" => { width: 100, height: 50 },
300
+ "large" => { width: 200, height: 100 }
301
+ }
302
+
303
+ layout.sizes["small"].width # => 100
304
+ ```
305
+
306
+ ### Nested Structs
307
+
308
+ ```ruby
309
+ class Address < T::Struct
310
+ const :street, String
311
+ const :city, String
312
+ end
313
+
314
+ class Company < T::Struct
315
+ const :name, String
316
+ const :address, Address
317
+ end
318
+
319
+ class Employee < ActiveRecord::Base
320
+ attribute :company, Skit::Attribute[Company]
321
+ end
322
+
323
+ employee = Employee.new
324
+ employee.company = {
325
+ name: "Acme Corp",
326
+ address: { street: "123 Main St", city: "Springfield" }
327
+ }
328
+
329
+ employee.company.address.city # => "Springfield"
330
+ ```
331
+
332
+ ### Validation
333
+
334
+ Skit integrates with ActiveModel::Validations:
335
+
336
+ ```ruby
337
+ class Product < T::Struct
338
+ include ActiveModel::Validations
339
+
340
+ const :name, String
341
+ const :price, Integer
342
+
343
+ validates :name, presence: true
344
+ validates :price, numericality: { greater_than: 0 }
345
+ end
346
+
347
+ class Order < ActiveRecord::Base
348
+ attribute :product, Skit::Attribute[Product]
349
+ validates :product, skit: true
350
+ end
351
+
352
+ order = Order.new
353
+ order.product = { name: "", price: -100 }
354
+ order.valid? # => false
355
+ order.errors[:"product.name"] # => ["can't be blank"]
356
+ order.errors[:"product.price"] # => ["must be greater than 0"]
357
+ ```
358
+
359
+ Array elements are validated with indexed error keys:
360
+
361
+ ```ruby
362
+ class Item < T::Struct
363
+ include ActiveModel::Validations
364
+
365
+ const :name, String
366
+ validates :name, presence: true
367
+ end
368
+
369
+ class Cart < ActiveRecord::Base
370
+ attribute :items, Skit::Attribute[T::Array[Item]]
371
+ validates :items, skit: true
372
+ end
373
+
374
+ cart = Cart.new
375
+ cart.items = [{ name: "Book" }, { name: "" }]
376
+ cart.valid? # => false
377
+ cart.errors[:"items.[1].name"] # => ["can't be blank"]
378
+ ```
379
+
380
+ ## Type Mapping
381
+
382
+ ### JSON Schema to Sorbet
383
+
384
+ | JSON Schema | Sorbet Type |
385
+ |-------------|-------------|
386
+ | `string` | `String` |
387
+ | `string` (format: date) | `Date` |
388
+ | `string` (format: date-time) | `Time` |
389
+ | `string` (format: time) | `Time` |
390
+ | `integer` | `Integer` |
391
+ | `number` | `Float` |
392
+ | `boolean` | `T::Boolean` |
393
+ | `array` | `T::Array[ElementType]` |
394
+ | `object` (with properties) | Custom T::Struct |
395
+ | `object` (no properties) | `T::Hash[String, T.untyped]` |
396
+ | `anyOf`/`oneOf` | `T.any(...)` or `T.nilable(...)` |
397
+ | `anyOf`/`oneOf` (objects) | `T.any(Struct1, Struct2, ...)` |
398
+ | `enum` | `T::Enum` |
399
+ | `const` | `Skit::JsonSchema::Types::Const` subclass |
400
+
401
+ ### Sorbet to JSON (Serialization)
402
+
403
+ | Sorbet Type | JSON Type |
404
+ |-------------|-----------|
405
+ | `String` | `string` |
406
+ | `Integer`, `Float` | `number` |
407
+ | `T::Boolean` | `boolean` |
408
+ | `Symbol` | `string` |
409
+ | `Date` | `string` (ISO 8601: `"2025-01-15"`) |
410
+ | `Time` | `string` (ISO 8601: `"2025-01-15T10:30:00+09:00"`) |
411
+ | `T::Struct` | `object` |
412
+ | `T::Array[T]` | `array` |
413
+ | `T::Hash[String, T]` | `object` |
414
+ | `T.nilable(T)` | type or `null` |
415
+ | `T.any(Struct1, Struct2)` | `object` (resolved by matching variant) |
416
+ | `T::Enum` | serialized value (e.g. `"active"`) |
417
+ | `Skit::JsonSchema::Types::Const` | constant value (e.g. `"dog"`) |
418
+
419
+ ## CLI Reference
420
+
421
+ ```bash
422
+ skit generate [OPTIONS] SCHEMA_FILE
423
+
424
+ Options:
425
+ -c, --class-name NAME Root class name (default: from schema title or "GeneratedClass")
426
+ -m, --module-name NAME Module name to wrap generated classes
427
+ -o, --output FILE Output file path (default: stdout)
428
+ --typed LEVEL Sorbet strictness level (default: "strict")
429
+ -h, --help Show help message
430
+ -v, --version Show version
431
+ ```
432
+
433
+ ## Development
434
+
435
+ After checking out the repo, run `bundle install` to install dependencies.
436
+
437
+ ### Running Tests
438
+
439
+ ```bash
440
+ # Run all tests and linters (default task)
441
+ bundle exec rake
442
+
443
+ # Run tests only
444
+ bundle exec rspec
445
+
446
+ # Run unit tests only
447
+ bundle exec rspec --tag type:unit
448
+
449
+ # Run integration tests only
450
+ bundle exec rspec --tag type:integration
451
+ ```
452
+
453
+ ### Code Quality
454
+
455
+ ```bash
456
+ # Run RuboCop (linter)
457
+ bundle exec rubocop
458
+ bundle exec rubocop -a # Auto-fix
459
+
460
+ # Run Sorbet type checker
461
+ bundle exec srb tc
462
+
463
+ # Update RBI files (Tapioca)
464
+ bundle exec rake sorbet:update
465
+ ```
466
+
467
+ ## License
468
+
469
+ MIT License. See LICENSE file for details.
data/exe/skit ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "skit"
5
+
6
+ if ARGV.empty? || ARGV[0] == "--help" || ARGV[0] == "-h"
7
+ puts "Usage: skit <command> [options]"
8
+ puts ""
9
+ puts "Commands:"
10
+ puts " generate Generate Sorbet T::Struct from JSON Schema"
11
+ puts ""
12
+ puts "Run 'skit <command> --help' for more information on a command."
13
+ exit 0
14
+ end
15
+
16
+ if ["--version", "-v"].include?(ARGV[0])
17
+ puts "skit #{Skit::VERSION}"
18
+ exit 0
19
+ end
20
+
21
+ command = ARGV.shift
22
+
23
+ case command
24
+ when "generate"
25
+ cli = Skit::JsonSchema::CLI.new
26
+ exit cli.run(ARGV)
27
+ else
28
+ warn "Unknown command: #{command}"
29
+ warn "Run 'skit --help' for usage information."
30
+ exit 1
31
+ end
@@ -0,0 +1,54 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveModel
5
+ module Validations
6
+ class SkitValidator < ActiveModel::EachValidator
7
+ extend T::Sig
8
+
9
+ sig { params(record: T.untyped, attribute: T.any(String, Symbol), value: T.untyped).void }
10
+ def validate_each(record, attribute, value)
11
+ return if value.nil?
12
+
13
+ attribute_type = get_skit_attribute_type(record, attribute)
14
+ return unless attribute_type
15
+
16
+ processor = attribute_type.processor
17
+ processor.traverse(value) do |_type_spec, node, path|
18
+ next unless node.respond_to?(:valid?)
19
+ next if node.valid?
20
+
21
+ node.errors.each do |error|
22
+ error_key = build_error_key(attribute, path, error.attribute)
23
+ record.errors.add(error_key, error.message)
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ sig do
31
+ params(
32
+ attribute: T.any(::String, Symbol),
33
+ path: Skit::Serialization::Path,
34
+ error_attribute: Symbol
35
+ ).returns(::String)
36
+ end
37
+ def build_error_key(attribute, path, error_attribute)
38
+ if path.empty?
39
+ "#{attribute}.#{error_attribute}"
40
+ else
41
+ "#{attribute}.#{path}.#{error_attribute}"
42
+ end
43
+ end
44
+
45
+ sig { params(record: T.untyped, attribute: T.any(String, Symbol)).returns(T.nilable(Skit::Attribute)) }
46
+ def get_skit_attribute_type(record, attribute)
47
+ return nil unless record.class.respond_to?(:attribute_types)
48
+
49
+ attribute_type = record.class.attribute_types[attribute.to_s]
50
+ attribute_type.is_a?(Skit::Attribute) ? attribute_type : nil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,63 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "active_model"
5
+ require "active_support/json"
6
+
7
+ module Skit
8
+ class Attribute < ActiveModel::Type::Value
9
+ extend T::Sig
10
+
11
+ sig { params(type_spec: T.untyped).returns(Attribute) }
12
+ def self.[](type_spec)
13
+ new(type_spec)
14
+ end
15
+
16
+ sig { params(type_spec: T.untyped).void }
17
+ def initialize(type_spec)
18
+ super()
19
+ @type_spec = type_spec
20
+ @processor = T.let(
21
+ Serialization.default_registry.processor_for(type_spec),
22
+ Serialization::Processor::Base
23
+ )
24
+ end
25
+
26
+ sig { returns(Serialization::Processor::Base) }
27
+ attr_reader :processor
28
+
29
+ # Cast is called when assigning a value to the attribute
30
+ # e.g., record.data = { width: 100, height: 200 }
31
+ sig { params(value: T.untyped).returns(T.untyped) }
32
+ def cast(value)
33
+ return nil if value.nil?
34
+
35
+ @processor.deserialize(value)
36
+ end
37
+
38
+ # Serialize is called before saving to the database
39
+ # Returns JSON string for storage
40
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
41
+ def serialize(value)
42
+ return nil if value.nil?
43
+
44
+ serialized = @processor.serialize(value)
45
+ ActiveSupport::JSON.encode(serialized)
46
+ end
47
+
48
+ # Deserialize is called when loading from the database
49
+ # Receives JSON string or Hash (depending on database adapter)
50
+ sig { params(value: T.untyped).returns(T.untyped) }
51
+ def deserialize(value)
52
+ return nil if value.nil?
53
+
54
+ data = if value.is_a?(String)
55
+ ActiveSupport::JSON.decode(value)
56
+ else
57
+ value
58
+ end
59
+
60
+ @processor.deserialize(data)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,67 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Skit
5
+ module JsonSchema
6
+ class ClassNamePath
7
+ extend T::Sig
8
+
9
+ sig { params(parts: T::Array[String]).void }
10
+ def initialize(parts)
11
+ @parts = T.let(parts.dup, T::Array[String])
12
+ end
13
+
14
+ sig { params(title: String).returns(ClassNamePath) }
15
+ def self.title_to_class_name(title)
16
+ # Split existing PascalCase before conversion (APIResponseData -> API_Response_Data)
17
+ with_underscores = title.gsub(/([a-z])([A-Z])/, '\1_\2')
18
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
19
+
20
+ class_name = NamingUtils.to_pascal_case(with_underscores)
21
+
22
+ return default if class_name.empty?
23
+
24
+ ClassNamePath.new([class_name])
25
+ end
26
+
27
+ sig { params(file_path: T.nilable(String)).returns(ClassNamePath) }
28
+ def self.from_file_path(file_path)
29
+ return default unless file_path
30
+
31
+ basename = File.basename(file_path, ".*")
32
+ ClassNamePath.new([NamingUtils.to_pascal_case(basename)])
33
+ end
34
+
35
+ sig { returns(ClassNamePath) }
36
+ def self.default
37
+ ClassNamePath.new(["GeneratedClass"])
38
+ end
39
+
40
+ sig { returns(T::Array[String]) }
41
+ attr_reader :parts
42
+
43
+ sig { params(suffix: String).returns(ClassNamePath) }
44
+ def append(suffix)
45
+ ClassNamePath.new(@parts + [NamingUtils.to_pascal_case(suffix)])
46
+ end
47
+
48
+ sig { returns(T.nilable(String)) }
49
+ def parent_class
50
+ return nil if @parts.length < 2
51
+
52
+ @parts[0]
53
+ end
54
+
55
+ sig { returns(String) }
56
+ def property_name
57
+ T.must(@parts.last)
58
+ end
59
+
60
+ sig { returns(String) }
61
+ def to_class_name
62
+ # Generate class name by converting each part to PascalCase (TestUser + address -> TestUserAddress)
63
+ @parts.join
64
+ end
65
+ end
66
+ end
67
+ end