typed_attrs 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +355 -0
- data/lib/active_model/validations/typed_attrs_validator.rb +48 -0
- data/lib/tapioca/dsl/compilers/typed_attribute.rb +87 -0
- data/lib/typed_attrs/discriminated_union.rb +51 -0
- data/lib/typed_attrs/type_helpers.rb +45 -0
- data/lib/typed_attrs/type_processors/array.rb +88 -0
- data/lib/typed_attrs/type_processors/base.rb +45 -0
- data/lib/typed_attrs/type_processors/boolean.rb +39 -0
- data/lib/typed_attrs/type_processors/boolean_primitive.rb +39 -0
- data/lib/typed_attrs/type_processors/date.rb +47 -0
- data/lib/typed_attrs/type_processors/discriminated_union.rb +211 -0
- data/lib/typed_attrs/type_processors/float.rb +42 -0
- data/lib/typed_attrs/type_processors/hash.rb +116 -0
- data/lib/typed_attrs/type_processors/integer.rb +39 -0
- data/lib/typed_attrs/type_processors/nilable_type.rb +53 -0
- data/lib/typed_attrs/type_processors/registry.rb +35 -0
- data/lib/typed_attrs/type_processors/simple_type.rb +38 -0
- data/lib/typed_attrs/type_processors/string.rb +39 -0
- data/lib/typed_attrs/type_processors/struct.rb +70 -0
- data/lib/typed_attrs/type_processors/symbol.rb +41 -0
- data/lib/typed_attrs/type_processors/time.rb +47 -0
- data/lib/typed_attrs/type_processors.rb +36 -0
- data/lib/typed_attrs/types/type.rb +51 -0
- data/lib/typed_attrs/version.rb +6 -0
- data/lib/typed_attrs.rb +103 -0
- metadata +117 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 11ede186057fb2f00a926b578dfa7ea647842bf76a1f3f3904b71b3b7f8cbff1
|
4
|
+
data.tar.gz: 7a1e677adbc23fa563fc600bd842fbe1ba82531c1b5f743f804f32582c2b450e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1b1c68d5519e6b4ed951264bbcc60e276c3378e853f448f4cf92d903af0aaa2d967d48954ec392a94aed5b6ad102479cedff76baab530c3eeb1a77cc8fa7a2bf
|
7
|
+
data.tar.gz: 5062ae18a4d829f802b1593f21be45fb98da5258563ecdbfc75641c7de7ab331806360f597a0bd23a73c68cff4e0b49cddde24d766c16bc1c18c32fb68313fb4
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## [Unreleased]
|
9
|
+
|
10
|
+
## [0.1.0] - 2025-01-05
|
11
|
+
|
12
|
+
### Added
|
13
|
+
- Initial release
|
14
|
+
- Type-safe ActiveRecord attributes using Sorbet T::Struct
|
15
|
+
- Support for JSON/JSONB columns (PostgreSQL, MySQL, SQLite)
|
16
|
+
- TypeProcessor architecture with Registry pattern
|
17
|
+
- Support for nested T::Struct, arrays, hashes, and union types (discriminated unions)
|
18
|
+
- T.nilable(...) support for optional types
|
19
|
+
- ActiveModel::Validations integration with indexed error messages
|
20
|
+
- Tapioca DSL compiler for automatic RBI generation
|
21
|
+
- Comprehensive test suite with 92%+ code coverage
|
22
|
+
- Database integration tests for SQLite3, MySQL, and PostgreSQL
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Speria
|
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,355 @@
|
|
1
|
+
# TypedAttrs
|
2
|
+
|
3
|
+
A Ruby gem that brings type-safe structured data to ActiveRecord using Sorbet's `T::Struct`. Store complex, nested objects in JSON/JSONB columns with full type safety, validation, and seamless integration with Sorbet's type system.
|
4
|
+
|
5
|
+
## Key Features
|
6
|
+
|
7
|
+
- 🎯 **Rich Types**: Nested structs, arrays, hashes, union types (discriminated unions), and nilable types
|
8
|
+
- ✅ **Validation**: Automatic validation with indexed error messages for nested structures
|
9
|
+
- 🗄️ **Database Agnostic**: PostgreSQL, MySQL, and SQLite support
|
10
|
+
- 🔄 **Transparent Conversion**: Seamless conversion between `T::Struct` and JSON
|
11
|
+
- 🛠️ **Tapioca Integration**: Automatic RBI file generation
|
12
|
+
|
13
|
+
## Installation
|
14
|
+
|
15
|
+
Add this line to your application's Gemfile:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem 'typed_attrs'
|
19
|
+
```
|
20
|
+
|
21
|
+
And then execute:
|
22
|
+
|
23
|
+
```bash
|
24
|
+
bundle install
|
25
|
+
```
|
26
|
+
|
27
|
+
## Usage
|
28
|
+
|
29
|
+
### Basic Usage (Single T::Struct)
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
# Define your Sorbet struct
|
33
|
+
class Product < T::Struct
|
34
|
+
const :name, String
|
35
|
+
const :price, Integer
|
36
|
+
end
|
37
|
+
|
38
|
+
# Use it as a typed attribute
|
39
|
+
class Order < ActiveRecord::Base
|
40
|
+
attribute :product, TypedAttrs.to_type(Product)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Assign with Hash
|
44
|
+
order = Order.new
|
45
|
+
order.product = { name: "Ruby Book", price: 3000 }
|
46
|
+
|
47
|
+
# Assign with T::Struct
|
48
|
+
order.product = Product.new(name: "Ruby Book", price: 3000)
|
49
|
+
|
50
|
+
# Access as T::Struct
|
51
|
+
order.product.name # => "Ruby Book"
|
52
|
+
order.product.price # => 3000
|
53
|
+
|
54
|
+
# Save to database (stored as jsonb)
|
55
|
+
order.save
|
56
|
+
```
|
57
|
+
|
58
|
+
### Array Type
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
class Color < T::Struct
|
62
|
+
const :name, String
|
63
|
+
const :hex, String
|
64
|
+
end
|
65
|
+
|
66
|
+
class Palette < ActiveRecord::Base
|
67
|
+
attribute :colors, TypedAttrs.to_type(T::Array[Color])
|
68
|
+
end
|
69
|
+
|
70
|
+
palette = Palette.new
|
71
|
+
palette.colors = [
|
72
|
+
{ name: "Red", hex: "#FF0000" },
|
73
|
+
{ name: "Blue", hex: "#0000FF" }
|
74
|
+
]
|
75
|
+
|
76
|
+
palette.colors[0].name # => "Red"
|
77
|
+
```
|
78
|
+
|
79
|
+
### Hash Type
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
class BoxSize < T::Struct
|
83
|
+
const :width, Integer
|
84
|
+
const :height, Integer
|
85
|
+
end
|
86
|
+
|
87
|
+
class Layout < ActiveRecord::Base
|
88
|
+
attribute :box_sizes, TypedAttrs.to_type(T::Hash[String, BoxSize])
|
89
|
+
end
|
90
|
+
|
91
|
+
layout = Layout.new
|
92
|
+
layout.box_sizes = {
|
93
|
+
"small" => { width: 100, height: 50 },
|
94
|
+
"large" => { width: 200, height: 100 }
|
95
|
+
}
|
96
|
+
|
97
|
+
layout.box_sizes["small"].width # => 100
|
98
|
+
```
|
99
|
+
|
100
|
+
### Union Type (Discriminated Union)
|
101
|
+
|
102
|
+
```ruby
|
103
|
+
module PetType
|
104
|
+
extend T::Helpers
|
105
|
+
extend TypedAttrs::DiscriminatedUnion
|
106
|
+
|
107
|
+
sealed!
|
108
|
+
discriminator_key "animal_type"
|
109
|
+
end
|
110
|
+
|
111
|
+
class Dog < T::Struct
|
112
|
+
include PetType
|
113
|
+
|
114
|
+
def self.discriminator = "dog"
|
115
|
+
|
116
|
+
const :name, String
|
117
|
+
const :breed, String
|
118
|
+
end
|
119
|
+
|
120
|
+
class Cat < T::Struct
|
121
|
+
include PetType
|
122
|
+
|
123
|
+
def self.discriminator = "cat"
|
124
|
+
|
125
|
+
const :name, String
|
126
|
+
const :lives, Integer
|
127
|
+
end
|
128
|
+
|
129
|
+
class PetOwner < ActiveRecord::Base
|
130
|
+
attribute :pet, TypedAttrs.to_type(PetType)
|
131
|
+
end
|
132
|
+
|
133
|
+
owner = PetOwner.new
|
134
|
+
owner.pet = { animal_type: "dog", name: "Buddy", breed: "Golden Retriever" }
|
135
|
+
# => #<Dog name="Buddy" breed="Golden Retriever">
|
136
|
+
|
137
|
+
owner.pet = { animal_type: "cat", name: "Whiskers", lives: 9 }
|
138
|
+
# => #<Cat name="Whiskers" lives=9>
|
139
|
+
```
|
140
|
+
|
141
|
+
### Nested Structs
|
142
|
+
|
143
|
+
```ruby
|
144
|
+
class Address < T::Struct
|
145
|
+
const :street, String
|
146
|
+
const :city, String
|
147
|
+
end
|
148
|
+
|
149
|
+
class Company < T::Struct
|
150
|
+
const :name, String
|
151
|
+
const :address, Address
|
152
|
+
end
|
153
|
+
|
154
|
+
class Employee < ActiveRecord::Base
|
155
|
+
attribute :company, TypedAttrs.to_type(Company)
|
156
|
+
end
|
157
|
+
|
158
|
+
employee = Employee.new
|
159
|
+
employee.company = {
|
160
|
+
name: "Acme Corp",
|
161
|
+
address: { street: "123 Main St", city: "Springfield" }
|
162
|
+
}
|
163
|
+
|
164
|
+
employee.company.address.city # => "Springfield"
|
165
|
+
```
|
166
|
+
|
167
|
+
### Default Values
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
class Settings < T::Struct
|
171
|
+
const :theme, String, default: "light"
|
172
|
+
const :notifications, T::Boolean, default: true
|
173
|
+
const :page_size, Integer, default: 20
|
174
|
+
end
|
175
|
+
|
176
|
+
class User < ActiveRecord::Base
|
177
|
+
attribute :settings, TypedAttrs.to_type(Settings)
|
178
|
+
end
|
179
|
+
|
180
|
+
user = User.new
|
181
|
+
user.settings = {}
|
182
|
+
user.settings.theme # => "light"
|
183
|
+
user.settings.notifications # => true
|
184
|
+
user.settings.page_size # => 20
|
185
|
+
```
|
186
|
+
|
187
|
+
### Validation
|
188
|
+
|
189
|
+
TypedAttrs integrates with ActiveModel::Validations:
|
190
|
+
|
191
|
+
```ruby
|
192
|
+
class Product < T::Struct
|
193
|
+
include ActiveModel::Validations
|
194
|
+
|
195
|
+
const :name, String
|
196
|
+
const :price, Integer
|
197
|
+
|
198
|
+
validates :name, presence: true
|
199
|
+
validates :price, numericality: { greater_than: 0 }
|
200
|
+
end
|
201
|
+
|
202
|
+
class Order < ActiveRecord::Base
|
203
|
+
attribute :product, TypedAttrs.to_type(Product)
|
204
|
+
validates :product, typed_attrs: true
|
205
|
+
end
|
206
|
+
|
207
|
+
order = Order.new
|
208
|
+
order.product = { name: "", price: -100 }
|
209
|
+
order.valid? # => false
|
210
|
+
order.errors.full_messages
|
211
|
+
# => ["Product.name can't be blank", "Product.price must be greater than 0"]
|
212
|
+
```
|
213
|
+
|
214
|
+
Array elements are validated with indexed error keys:
|
215
|
+
|
216
|
+
```ruby
|
217
|
+
class Tag < T::Struct
|
218
|
+
include ActiveModel::Validations
|
219
|
+
|
220
|
+
const :name, String
|
221
|
+
validates :name, presence: true
|
222
|
+
end
|
223
|
+
|
224
|
+
class Article < T::Struct
|
225
|
+
const :title, String
|
226
|
+
const :tags, T::Array[Tag]
|
227
|
+
end
|
228
|
+
|
229
|
+
class Post < ActiveRecord::Base
|
230
|
+
attribute :article, TypedAttrs.to_type(Article)
|
231
|
+
validates :article, typed_attrs: true
|
232
|
+
end
|
233
|
+
|
234
|
+
post = Post.new
|
235
|
+
post.article = { title: "Hello", tags: [{ name: "ruby" }, { name: "" }] }
|
236
|
+
post.valid? # => false
|
237
|
+
post.errors.full_messages
|
238
|
+
# => ["Article.tags[1].name can't be blank"]
|
239
|
+
```
|
240
|
+
|
241
|
+
## Supported Features
|
242
|
+
|
243
|
+
- ✅ Type-safe attributes using Sorbet `T::Struct`
|
244
|
+
- ✅ PostgreSQL, MySQL, and SQLite (json/jsonb columns)
|
245
|
+
- ✅ Primitive types: `String`, `Integer`, `Float`, `Boolean`, `Symbol`, `Date`, `Time`
|
246
|
+
- ✅ Complex types: `T::Array`, `T::Hash`, Union types, `T.nilable`
|
247
|
+
- ✅ Nested structs with arbitrary depth
|
248
|
+
- ✅ Default values
|
249
|
+
- ✅ Validation with indexed error messages
|
250
|
+
- ✅ Automatic RBI generation via Tapioca
|
251
|
+
|
252
|
+
## Type Mapping
|
253
|
+
|
254
|
+
TypedAttrs automatically converts between Ruby/Sorbet types and JSON for database storage:
|
255
|
+
|
256
|
+
| Ruby/Sorbet Type | JSON Type | Notes |
|
257
|
+
|-----------------|-----------|-------|
|
258
|
+
| `String` | `string` | Direct mapping |
|
259
|
+
| `Integer` | `number` | Direct mapping |
|
260
|
+
| `Float` | `number` | Direct mapping |
|
261
|
+
| `T::Boolean` | `boolean` | Direct mapping |
|
262
|
+
| `Symbol` | `string` | Serialized to string, deserialized back to symbol |
|
263
|
+
| `Date` | `string` | ISO 8601 format (e.g., `"2025-10-08"`) |
|
264
|
+
| `Time` | `string` | ISO 8601 with timezone (e.g., `"2025-10-08T15:30:00+09:00"`) |
|
265
|
+
| `T::Struct` | `object` | Nested object with properties |
|
266
|
+
| `T::Array[T]` | `array` | Array of specified type |
|
267
|
+
| `T::Hash[String, T]` | `object` | Object with string keys and typed values |
|
268
|
+
| `T.nilable(T)` | `null` or type | Allows null values |
|
269
|
+
| Union (discriminated) | `object` | Object with discriminator key |
|
270
|
+
|
271
|
+
### Examples
|
272
|
+
|
273
|
+
```ruby
|
274
|
+
# Symbol type - stored as string in JSON
|
275
|
+
class Tag < T::Struct
|
276
|
+
const :name, String
|
277
|
+
const :status, Symbol # :active, :archived, etc.
|
278
|
+
end
|
279
|
+
|
280
|
+
# Database: { "name": "ruby", "status": "active" }
|
281
|
+
# Ruby: Tag.new(name: "ruby", status: :active)
|
282
|
+
|
283
|
+
# Date and Time types - stored as ISO 8601 strings
|
284
|
+
class Event < T::Struct
|
285
|
+
const :title, String
|
286
|
+
const :date, Date
|
287
|
+
const :start_time, Time
|
288
|
+
end
|
289
|
+
|
290
|
+
# Database: { "title": "Meeting", "date": "2025-10-08", "start_time": "2025-10-08T15:30:00+09:00" }
|
291
|
+
# Ruby: Event.new(title: "Meeting", date: Date.new(2025, 10, 8), start_time: Time.new(2025, 10, 8, 15, 30, 0, "+09:00"))
|
292
|
+
|
293
|
+
# Nested struct - stored as nested object
|
294
|
+
class Address < T::Struct
|
295
|
+
const :street, String
|
296
|
+
const :city, String
|
297
|
+
end
|
298
|
+
|
299
|
+
class Company < T::Struct
|
300
|
+
const :name, String
|
301
|
+
const :address, Address
|
302
|
+
end
|
303
|
+
|
304
|
+
# Database: { "name": "Acme", "address": { "street": "123 Main", "city": "NYC" } }
|
305
|
+
# Ruby: Company.new(name: "Acme", address: Address.new(...))
|
306
|
+
|
307
|
+
# Array type - stored as JSON array
|
308
|
+
attribute :colors, TypedAttrs.to_type(T::Array[Color])
|
309
|
+
# Database: [{ "name": "Red", "hex": "#FF0000" }, ...]
|
310
|
+
# Ruby: [Color.new(name: "Red", hex: "#FF0000"), ...]
|
311
|
+
|
312
|
+
# Hash type - stored as JSON object
|
313
|
+
attribute :sizes, TypedAttrs.to_type(T::Hash[String, BoxSize])
|
314
|
+
# Database: { "small": { "width": 100, "height": 50 }, ... }
|
315
|
+
# Ruby: { "small" => BoxSize.new(width: 100, height: 50), ... }
|
316
|
+
```
|
317
|
+
|
318
|
+
## Development
|
319
|
+
|
320
|
+
After checking out the repo, run `bundle install` to install dependencies.
|
321
|
+
|
322
|
+
### Running Tests
|
323
|
+
|
324
|
+
```bash
|
325
|
+
# Run all tests and linters (default task)
|
326
|
+
bundle exec rake
|
327
|
+
|
328
|
+
# Run tests only
|
329
|
+
bundle exec rspec
|
330
|
+
|
331
|
+
# Run unit tests only
|
332
|
+
bundle exec rspec --tag type:unit
|
333
|
+
|
334
|
+
# Run integration tests only (with SQLite, MySQL, PostgreSQL)
|
335
|
+
bundle exec rspec --tag type:integration
|
336
|
+
```
|
337
|
+
|
338
|
+
### Code Quality and Type Checking
|
339
|
+
|
340
|
+
```bash
|
341
|
+
# Run RuboCop (linter)
|
342
|
+
bundle exec rubocop
|
343
|
+
bundle exec rubocop -a # Auto-fix
|
344
|
+
|
345
|
+
# Run Sorbet type checker
|
346
|
+
bundle exec srb tc
|
347
|
+
bundle exec rake sorbet:tc
|
348
|
+
|
349
|
+
# Update RBI files (Tapioca)
|
350
|
+
bundle exec rake sorbet:update
|
351
|
+
```
|
352
|
+
|
353
|
+
## License
|
354
|
+
|
355
|
+
MIT License. See LICENSE file for details.
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module ActiveModel
|
5
|
+
module Validations
|
6
|
+
class TypedAttrsValidator < 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_typed_attribute_type(record, attribute)
|
14
|
+
return unless attribute_type
|
15
|
+
|
16
|
+
processor = attribute_type.processor
|
17
|
+
processor.traverse(value, path: "") 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 { params(attribute: T.any(::String, Symbol), path: ::String, error_attribute: Symbol).returns(::String) }
|
31
|
+
def build_error_key(attribute, path, error_attribute)
|
32
|
+
if path.empty?
|
33
|
+
"#{attribute}.#{error_attribute}"
|
34
|
+
else
|
35
|
+
"#{attribute}#{path}.#{error_attribute}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
sig { params(record: T.untyped, attribute: T.any(String, Symbol)).returns(T.nilable(TypedAttrs::Types::Type)) }
|
40
|
+
def get_typed_attribute_type(record, attribute)
|
41
|
+
return nil unless record.class.respond_to?(:attribute_types)
|
42
|
+
|
43
|
+
attribute_type = record.class.attribute_types[attribute.to_s]
|
44
|
+
attribute_type.is_a?(TypedAttrs::Types::Type) ? attribute_type : nil
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
return unless defined?(Tapioca)
|
5
|
+
return unless defined?(ActiveRecord::Base)
|
6
|
+
|
7
|
+
require "tapioca/dsl"
|
8
|
+
|
9
|
+
module Tapioca
|
10
|
+
module Dsl
|
11
|
+
module Compilers
|
12
|
+
class TypedAttribute < Tapioca::Dsl::Compiler
|
13
|
+
extend T::Sig
|
14
|
+
|
15
|
+
ConstantType = type_member { { fixed: T.class_of(ActiveRecord::Base) } }
|
16
|
+
|
17
|
+
sig { override.void }
|
18
|
+
def decorate
|
19
|
+
attributes = T.let(
|
20
|
+
constant.attribute_types.select { |_name, type| typed_attribute_type?(type) },
|
21
|
+
T::Hash[String, ActiveModel::Type::Value]
|
22
|
+
)
|
23
|
+
|
24
|
+
return if attributes.empty?
|
25
|
+
|
26
|
+
root.create_path(constant) do |klass|
|
27
|
+
attributes.each do |attr_name, type|
|
28
|
+
create_attribute_methods(klass, attr_name, type)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
sig { override.returns(T::Enumerable[Module]) }
|
34
|
+
def self.gather_constants
|
35
|
+
descendants = T.cast(
|
36
|
+
ActiveRecord::Base.descendants.reject(&:abstract_class?),
|
37
|
+
T::Array[T.class_of(ActiveRecord::Base)]
|
38
|
+
)
|
39
|
+
|
40
|
+
descendants.select do |klass|
|
41
|
+
klass.attribute_types.values.any? { |type| typed_attribute_type?(type) }
|
42
|
+
rescue ActiveRecord::StatementInvalid
|
43
|
+
false
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class << self
|
48
|
+
def typed_attribute_type?(type)
|
49
|
+
type.is_a?(TypedAttrs::Types::Type)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
sig { params(type: ActiveModel::Type::Value).returns(T::Boolean) }
|
56
|
+
def typed_attribute_type?(type)
|
57
|
+
self.class.typed_attribute_type?(type)
|
58
|
+
end
|
59
|
+
|
60
|
+
sig { params(klass: RBI::Scope, attr_name: String, type: ActiveModel::Type::Value).void }
|
61
|
+
def create_attribute_methods(klass, attr_name, type)
|
62
|
+
case type
|
63
|
+
when TypedAttrs::Types::Type
|
64
|
+
create_type_attribute_methods(klass, attr_name, type)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
sig { params(klass: RBI::Scope, attr_name: String, type: TypedAttrs::Types::Type).void }
|
69
|
+
def create_type_attribute_methods(klass, attr_name, type)
|
70
|
+
type_spec = type.instance_variable_get(:@type_spec)
|
71
|
+
type_string = type_spec.name
|
72
|
+
|
73
|
+
klass.create_method(
|
74
|
+
attr_name,
|
75
|
+
return_type: type_string
|
76
|
+
)
|
77
|
+
|
78
|
+
klass.create_method(
|
79
|
+
"#{attr_name}=",
|
80
|
+
parameters: [create_param("value", type: "T.untyped")],
|
81
|
+
return_type: type_string
|
82
|
+
)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module TypedAttrs
|
5
|
+
# To use DiscriminatedUnion, follow these steps:
|
6
|
+
#
|
7
|
+
# 1. Create a union module by extending T::Helpers and DiscriminatedUnion
|
8
|
+
# 2. Call sealed! and discriminator_key to configure the union
|
9
|
+
# 3. For each union member, include the union module
|
10
|
+
# 4. Define self.discriminator method to return the discriminator value
|
11
|
+
#
|
12
|
+
# Example:
|
13
|
+
# module AnimalUnion
|
14
|
+
# extend T::Helpers
|
15
|
+
# extend TypedAttrs::DiscriminatedUnion
|
16
|
+
#
|
17
|
+
# sealed!
|
18
|
+
# discriminator_key "animal_type"
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# class Dog < T::Struct
|
22
|
+
# include AnimalUnion
|
23
|
+
#
|
24
|
+
# def self.discriminator = "dog"
|
25
|
+
# end
|
26
|
+
module DiscriminatedUnion
|
27
|
+
extend T::Sig
|
28
|
+
extend T::Helpers
|
29
|
+
|
30
|
+
sig { params(key: String).void }
|
31
|
+
def discriminator_key(key)
|
32
|
+
@discriminator_key = T.let(key, T.nilable(String))
|
33
|
+
end
|
34
|
+
|
35
|
+
sig { returns(T.nilable(String)) }
|
36
|
+
def _get_discriminator_key
|
37
|
+
@discriminator_key
|
38
|
+
end
|
39
|
+
|
40
|
+
sig { params(base: T.untyped).void }
|
41
|
+
def included(base)
|
42
|
+
super
|
43
|
+
|
44
|
+
union_module = self
|
45
|
+
|
46
|
+
base.define_singleton_method(:discriminator_key) do
|
47
|
+
union_module._get_discriminator_key
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module TypedAttrs
|
5
|
+
module TypeHelpers
|
6
|
+
include Kernel
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
# Check if a class is a T::Struct subclass
|
10
|
+
sig { params(klass: T.untyped).returns(T::Boolean) }
|
11
|
+
def struct_class?(klass)
|
12
|
+
return false unless klass.is_a?(Class)
|
13
|
+
return false unless klass.respond_to?(:<)
|
14
|
+
|
15
|
+
!!(klass < T::Struct)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Check if a type object represents T.nilable(...)
|
19
|
+
sig { params(type_object: T.untyped).returns(T::Boolean) }
|
20
|
+
def nilable_type?(type_object)
|
21
|
+
return false unless type_object.is_a?(T::Types::Union)
|
22
|
+
return false unless type_object.respond_to?(:types)
|
23
|
+
|
24
|
+
types = T.unsafe(type_object).types
|
25
|
+
# Union must contain NilClass and exactly one non-nil type
|
26
|
+
has_nil = types.any? { |t| t.respond_to?(:raw_type) && t.raw_type == NilClass }
|
27
|
+
non_nil_count = types.count { |t| !t.respond_to?(:raw_type) || t.raw_type != NilClass }
|
28
|
+
|
29
|
+
has_nil && non_nil_count == 1
|
30
|
+
end
|
31
|
+
|
32
|
+
# Extract the inner type from T.nilable(Foo) -> Foo
|
33
|
+
sig { params(nilable_type: T.untyped).returns(T.untyped) }
|
34
|
+
def extract_nilable_inner_type(nilable_type)
|
35
|
+
raise ArgumentError, "Not a nilable type" unless nilable_type?(nilable_type)
|
36
|
+
|
37
|
+
types = T.unsafe(nilable_type).types
|
38
|
+
non_nil_types = types.reject { |t| t.respond_to?(:raw_type) && t.raw_type == NilClass }
|
39
|
+
|
40
|
+
raise ArgumentError, "T.nilable must have at least one non-nil type" if non_nil_types.empty?
|
41
|
+
|
42
|
+
non_nil_types.first
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|