rails-on-sorbet 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7a88d217e8a2bfe112e807a62dbef1b05bd767b5d5ddac6f243ab14998aa04a9
4
+ data.tar.gz: 2aa3723b81525f0edc65c484c5aa461a38d10345367a4ee61eecee6687f48716
5
+ SHA512:
6
+ metadata.gz: a5b9053282ba1f6336c7720ad0678dd916db7a4da8200e3baf17194a6bd441129771603dbe834e126792ead836dcd299c706a3a5a56ad8d8d3a3f82223d2f6ce
7
+ data.tar.gz: 80613347aef5646f00f0fe5a7a3a2e67cc236278b8fe41a6418a1f86d26b9de89714f28d2a994680ba4a2beb14320c3bb45f32d0110148ecfb64ff66ee248ca9
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-09-17
4
+
5
+ - Initial release
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Espago
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,204 @@
1
+ # Rails::On::Sorbet
2
+
3
+ This gem is a Rails extension that enhances sorbet support in Rails.
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ ```bash
10
+ bundle add rails-on-sorbet
11
+ ```
12
+
13
+ If bundler is not being used to manage dependencies, install the gem by executing:
14
+
15
+ ```bash
16
+ gem install rails-on-sorbet
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ This gem adds additional signatures to Rails types and builtin Ruby types that
22
+ make using sorbet easier in Rails projects.
23
+
24
+ It also adds some new utility types and modifies certain Rails DSLs
25
+ to make it possible to strictly type methods defined through metaprogramming.
26
+
27
+ ### Timelike
28
+
29
+ Rails and Ruby lack a unified type for representing datetime.
30
+ We have `Time`, `DateTime` and `ActiveSupport::TimeWithZone` which basically
31
+ cover really similar use cases but have no common ancestor.
32
+ This makes working with time incredibly frustrating.
33
+
34
+ We introduced a new utility type called `Timelike` to alleviate this burden.
35
+
36
+ Definition:
37
+ ```rb
38
+ Timelike = T.type_alias { T.any(Time, DateTime, ActiveSupport::TimeWithZone) }
39
+ ```
40
+
41
+ ### Map
42
+
43
+ Rails introduced two hashlike types: `ActionController::Parameters`, `ActiveSupport::HashWithIndifferentAccess`.
44
+ For the most part they behave like `Hash` but they don't inherit from it.
45
+ This causes a lot of issues in sorbet.
46
+
47
+ Because of that we introduced the `Map` interface.
48
+ It defines all methods that are common to `Hash`, `ActionController::Parameters` and `ActiveSupport::HashWithIndifferentAccess`.
49
+
50
+ Right now there is a bug in sorbet that causes the typechecker to hang and crash when you include a generic interface/module in `Hash`, `Array`, `Set`, `Range` etc.
51
+ So we couldn't implement `Map` directly in `Hash` :(
52
+
53
+ Instead, we introduced a casting function.
54
+
55
+ Example:
56
+ ```rb
57
+ # Use the `Map` type in a signature
58
+ #
59
+ #: (Map[String, Integer]) -> void
60
+ def foo(m)
61
+ m["foo"] #=> Integer?
62
+ end
63
+
64
+ hash = { "foo" => 4 } #: Hash[String, Integer]
65
+ m = Map(hash) #=> Map[String, Integer]
66
+ foo(m) # OK
67
+
68
+ hash = { "foo" => 4 }.with_indifferent_access # => ActiveSupport::HashWithIndifferentAccess
69
+ m = Map(hash) #=> Map[String, untyped]
70
+ foo(m) # OK
71
+
72
+ params = ActionController::Parameters.new # => ActionController::Parameters
73
+ m = Map(params) #=> Map[String, untyped]
74
+ foo(m) # OK
75
+ ```
76
+
77
+ ### ActiveRecord::Base::alias_association
78
+
79
+ This gem adds a new method called `alias_association` on ActiveRecord classes.
80
+ It lets you define aliases for getters and setters of `belongs_to` and `has_one` associations. There is also a tapioca compiler that makes sorbet aware of these aliases.
81
+
82
+ Example:
83
+ ```rb
84
+ class Foo < ApplicationRecord
85
+ belongs_to :user
86
+ alias_association :owner, :user
87
+ end
88
+
89
+ f = Foo.last
90
+ f.owner == f.user #=> true
91
+ ```
92
+
93
+ ### ActiveSupport::CurrentAttributes
94
+
95
+ New optional keyword arguments have been added to `ActiveSupport::CurrentAttributes::attribute`:
96
+ - `type`: the sorbet type of the getter/setter
97
+ - `doc`: a docstring whose content will be used to generate a comment above the rbi signature created by tapioca
98
+
99
+ Example:
100
+ ```rb
101
+ class Current < ActiveSupport::CurrentAttributes
102
+ attribute :session_counter, type: T.nilable(Integer), doc: <<~DOC
103
+ A counter that gets incremented when a new session is created.
104
+ DOC
105
+ end
106
+ ```
107
+
108
+ The tapioca compiler will generate an RBI file like this:
109
+
110
+ ```rb
111
+ # typed: true
112
+
113
+ # DO NOT EDIT MANUALLY
114
+ # This is an autogenerated file for dynamic methods in `Current`.
115
+ # Please instead update this file by running `bin/tapioca dsl Current`.
116
+
117
+ class Current
118
+ include RailsOnSorbetCurrentAttributeMethods
119
+ extend RailsOnSorbetCurrentAttributeMethods
120
+
121
+ module RailsOnSorbetCurrentAttributeMethods
122
+ # A counter that gets incremented when a new session is created.
123
+ sig { returns(T.nilable(::Integer)) }
124
+ def session_counter; end
125
+
126
+ # A counter that gets incremented when a new session is created.
127
+ sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
128
+ def session_counter=(value); end
129
+ end
130
+ end
131
+ ```
132
+
133
+ ### ActiveRecord::Base::serialize
134
+
135
+ Tapioca now has the ability to generate strictly typed and accurate getters and setters for Rails serializers.
136
+
137
+ New optional keyword arguments have been added to `ActiveRecord::Base::serialize`:
138
+ - `return_type`: the sorbet type returned by the getter
139
+ - `setter_type`: the sorbet type of the parameter of the setter
140
+ - `doc`: a docstring whose content will be used to generate a comment above the rbi signature created by tapioca
141
+
142
+ If no `return_type` or `setter_type` is given in the arguments, tapioca will try to call `return_type` and `setter_type` on the `coder` given to the serializer.
143
+
144
+ Example:
145
+ ```rb
146
+ module IntegerCoder
147
+ class << self
148
+ def return_type = Integer
149
+
150
+ #: (String?) -> Integer?
151
+ def load(val)
152
+ val&.to_i
153
+ end
154
+
155
+ #: (Integer?) -> String?
156
+ def dump(val)
157
+ val&.to_s
158
+ end
159
+ end
160
+ end
161
+
162
+ class Foo < ActiveRecord::Base
163
+ serialize :bar, coder: IntegerCoder
164
+ serialize :baz, coder: YAML, return_type: T::Hash[String, String], doc: <<~DOC
165
+ Additional BAR data for finalizing orders.
166
+ DOC
167
+ end
168
+ ```
169
+
170
+ The tapioca compiler will generate the following RBI file
171
+
172
+ ```rb
173
+ # typed: true
174
+
175
+ # DO NOT EDIT MANUALLY
176
+ # This is an autogenerated file for dynamic methods in `Foo`.
177
+ # Please instead update this file by running `bin/tapioca dsl Foo`.
178
+
179
+ class Foo
180
+ sig { returns(T.nilable(Integer)) }
181
+ def bar; end
182
+
183
+ sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
184
+ def bar=(value); end
185
+
186
+ # Additional BAR data for finalizing orders.
187
+ sig { returns(T.nilable(T::Hash[::String, ::String])) }
188
+ def baz; end
189
+
190
+ # Additional BAR data for finalizing orders.
191
+ sig { params(value: T.nilable(T::Hash[::String, ::String])).returns(T.nilable(T::Hash[::String, ::String])) }
192
+ def baz=(value); end
193
+ end
194
+ ```
195
+
196
+ ## Development
197
+
198
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
199
+
200
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
201
+
202
+ ## Contributing
203
+
204
+ Bug reports and pull requests are welcome on GitHub at https://github.com/espago/rails-on-sorbet.
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ ::Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = ::FileList['test/**/*_test.rb']
10
+ end
11
+
12
+ require 'rubocop/rake_task'
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ task default: %i[test]
@@ -0,0 +1,77 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rails::On::Sorbet
5
+ # @requires_ancestor: singleton(ActiveRecord::Base)
6
+ module ActiveRecordSerializer
7
+ # Contains the data of a serializer definition
8
+ class Definition
9
+ #: Symbol
10
+ attr_reader :name
11
+ #: untyped
12
+ attr_reader :coder
13
+ #: Object
14
+ attr_reader :return_type
15
+ #: Object
16
+ attr_reader :setter_type
17
+ #: String?
18
+ attr_reader :doc
19
+
20
+ #: (name: Symbol, coder: untyped, ?return_type: untyped, ?setter_type: untyped, ?doc: String?) -> void
21
+ def initialize(name:, coder:, return_type: nil, setter_type: nil, doc: nil)
22
+ @name = name
23
+ @coder = coder
24
+ @return_type = return_type
25
+ @setter_type = setter_type
26
+ @doc = doc
27
+ end
28
+ end
29
+
30
+ #: (
31
+ #| Symbol,
32
+ #| ?coder: untyped,
33
+ #| ?type: untyped,
34
+ #| ?yaml: Hash[Symbol, untyped],
35
+ #| ?return_type: untyped,
36
+ #| ?setter_type: untyped,
37
+ #| ?doc: String?,
38
+ #| **untyped
39
+ #| ) ?{ -> void } -> void
40
+ def serialize(
41
+ attr_name,
42
+ coder: nil,
43
+ type: Object,
44
+ yaml: {},
45
+ return_type: nil,
46
+ setter_type: nil,
47
+ doc: nil,
48
+ **kwargs,
49
+ &block
50
+ )
51
+ super(attr_name, coder: coder, type: type, yaml: yaml, **kwargs, &block)
52
+
53
+ _sorbet_serializer_definitions[attr_name] = Definition.new(
54
+ name: attr_name,
55
+ coder: coder,
56
+ return_type: return_type,
57
+ setter_type: setter_type,
58
+ doc: doc,
59
+ )
60
+ end
61
+
62
+ #: -> Hash[Symbol, Definition]
63
+ def _sorbet_serializer_definitions
64
+ @_sorbet_serializer_definitions ||= {}
65
+ end
66
+ end
67
+
68
+ end
69
+
70
+ require 'active_record'
71
+ require 'active_record/base'
72
+
73
+ module ActiveRecord
74
+ class Base # rubocop:disable Style/StaticClass
75
+ extend Rails::On::Sorbet::ActiveRecordSerializer
76
+ end
77
+ end
@@ -0,0 +1,46 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rails::On::Sorbet
5
+ # @requires_ancestor: singleton(ActiveRecord::Base)
6
+ module AliasAssociation
7
+ # Create an alias for an association like `belongs_to` or `has_one`
8
+ #
9
+ # class Foo < ApplicationRecord
10
+ # belongs_to :user
11
+ # alias_association :owner, :user
12
+ # end
13
+ # f = Foo.last
14
+ # f.owner == f.user #=> true
15
+ #
16
+ #: (Symbol, Symbol) -> void
17
+ def alias_association(alias_name, target_name)
18
+ @_alias_association_definitions ||= []
19
+ @_alias_association_definitions << [alias_name, target_name]
20
+
21
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
22
+ def #{alias_name} # def service
23
+ self.#{target_name} # self.merchant_service
24
+ end # end
25
+
26
+ def #{alias_name}=(val) # def service=(val)
27
+ self.#{target_name} = val # self.merchant_service = val
28
+ end # end
29
+ RUBY
30
+ end
31
+
32
+ #: -> Array[[Symbol, Symbol]]
33
+ def _alias_association_definitions
34
+ @_alias_association_definitions || []
35
+ end
36
+ end
37
+ end
38
+
39
+ require 'active_record'
40
+ require 'active_record/base'
41
+
42
+ module ActiveRecord
43
+ class Base # rubocop:disable Style/StaticClass
44
+ extend Rails::On::Sorbet::AliasAssociation
45
+ end
46
+ end
@@ -0,0 +1,53 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Rails::On::Sorbet
5
+ # Shim for ActiveSupport::CurrentAttributes to support sorbet
6
+ #
7
+ # @requires_ancestor: singleton(ActiveSupport::CurrentAttributes)
8
+ module CurrentAttributes
9
+ # Holds the data of a single attribute definition
10
+ class Attribute
11
+ #: Symbol
12
+ attr_accessor :name
13
+
14
+ #: Object
15
+ attr_accessor :type
16
+
17
+ #: String?
18
+ attr_accessor :doc
19
+
20
+ #: (Symbol name, Object type, String? doc) -> void
21
+ def initialize(name, type, doc)
22
+ @name = name
23
+ @type = type
24
+ @doc = doc
25
+ end
26
+ end
27
+
28
+ # Get a map with all defined attributes and their types
29
+ #
30
+ #: -> Hash[Symbol, Attribute]
31
+ def attribute_map
32
+ @attribute_map ||= {}
33
+ end
34
+
35
+ # Declare a new attribute with a sorbet type
36
+ #
37
+ #: (*Symbol, ?type: untyped, ?doc: String?, ?default: untyped) -> void
38
+ def attribute(*names, type: nil, doc: nil, default: ::ActiveSupport::CurrentAttributes::NOT_SET)
39
+ names.each do |name|
40
+ attribute_map[name] = Attribute.new(name, type, doc)
41
+ end
42
+ super(*names, default: default)
43
+ end
44
+ end
45
+ end
46
+
47
+ require 'active_support'
48
+
49
+ module ActiveSupport
50
+ class CurrentAttributes # rubocop:disable Style/StaticClass
51
+ extend Rails::On::Sorbet::CurrentAttributes
52
+ end
53
+ end
@@ -0,0 +1,21 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'action_controller'
5
+ require 'active_support'
6
+
7
+ module Map
8
+ extend T::Generic
9
+ end
10
+
11
+ def Map(val) = val # rubocop:disable Style/TopLevelMethodDefinition,Naming/MethodName
12
+
13
+ #: [K = String, V = untyped]
14
+ class ActionController::Parameters
15
+ include Map
16
+ end
17
+
18
+ #: [K = String, V = untyped]
19
+ class ActiveSupport::HashWithIndifferentAccess
20
+ include Map
21
+ end