tapioca 0.3.1 → 0.4.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +25 -1
  3. data/README.md +23 -2
  4. data/Rakefile +15 -4
  5. data/lib/tapioca.rb +8 -2
  6. data/lib/tapioca/cli.rb +32 -3
  7. data/lib/tapioca/compilers/dsl/action_controller_helpers.rb +129 -0
  8. data/lib/tapioca/compilers/dsl/action_mailer.rb +65 -0
  9. data/lib/tapioca/compilers/dsl/active_record_associations.rb +267 -0
  10. data/lib/tapioca/compilers/dsl/active_record_columns.rb +393 -0
  11. data/lib/tapioca/compilers/dsl/active_record_enum.rb +112 -0
  12. data/lib/tapioca/compilers/dsl/active_record_identity_cache.rb +213 -0
  13. data/lib/tapioca/compilers/dsl/active_record_scope.rb +100 -0
  14. data/lib/tapioca/compilers/dsl/active_record_typed_store.rb +170 -0
  15. data/lib/tapioca/compilers/dsl/active_resource.rb +140 -0
  16. data/lib/tapioca/compilers/dsl/active_support_current_attributes.rb +126 -0
  17. data/lib/tapioca/compilers/dsl/base.rb +165 -0
  18. data/lib/tapioca/compilers/dsl/frozen_record.rb +96 -0
  19. data/lib/tapioca/compilers/dsl/protobuf.rb +144 -0
  20. data/lib/tapioca/compilers/dsl/smart_properties.rb +173 -0
  21. data/lib/tapioca/compilers/dsl/state_machines.rb +378 -0
  22. data/lib/tapioca/compilers/dsl/url_helpers.rb +92 -0
  23. data/lib/tapioca/compilers/dsl_compiler.rb +121 -0
  24. data/lib/tapioca/compilers/requires_compiler.rb +67 -0
  25. data/lib/tapioca/compilers/sorbet.rb +34 -0
  26. data/lib/tapioca/compilers/symbol_table/symbol_generator.rb +171 -26
  27. data/lib/tapioca/compilers/symbol_table/symbol_loader.rb +1 -20
  28. data/lib/tapioca/compilers/todos_compiler.rb +32 -0
  29. data/lib/tapioca/config.rb +14 -6
  30. data/lib/tapioca/config_builder.rb +22 -9
  31. data/lib/tapioca/constant_locator.rb +1 -0
  32. data/lib/tapioca/core_ext/class.rb +23 -0
  33. data/lib/tapioca/gemfile.rb +32 -9
  34. data/lib/tapioca/generator.rb +231 -23
  35. data/lib/tapioca/loader.rb +30 -9
  36. data/lib/tapioca/version.rb +1 -1
  37. metadata +32 -39
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81caba98bdbde19ffbb78a1860f994a8b5c39a82dcd48832ce0daabcd9f6d77b
4
- data.tar.gz: 803fe5c9b0ac3838f984455adf53fd2a470e13a877ea218d80bb74b2c309afbe
3
+ metadata.gz: f9b10c318332131210706b023ccbe3a63a5889153e3b365a9c454ee7311d2498
4
+ data.tar.gz: 6c5a5ef82c6a952b535f6f04b3c3127968bca4d8d101e321e13e8e5968ea3222
5
5
  SHA512:
6
- metadata.gz: 75c2b49c410a6b9f558470a5ab6b59f464b41e4b03a88344fd8967d06e3b36b8b29669bd92c8103d106e234a91fb4ead51d617a365059342e03542cfe113c497
7
- data.tar.gz: 4261f1657b92a65ba119f95e1e9be3211af056fe2ba5a5b225b0d920a2adda1fd7619d9e21bb8e034c3946b4cb931d8ef45beebd3bb429c2c5d8446bce09efa8
6
+ metadata.gz: f1df6d85dd2924d25e3d6460749cc5a5ce75022bf999e663c813d6dd483e130b6a1c9873872f2412046d6d15f87effc125bc2f3941323f0ac3eca27a0bfa89a7
7
+ data.tar.gz: f6b81c3d32e1cec689ed022c17f57ada8655c0594c944c396d26a481e32dce029b77ed8f877063bfee138405019f6bb5408f2de3148d437a2b8db01a699063c5
data/Gemfile CHANGED
@@ -7,5 +7,29 @@ gemspec
7
7
  gem 'rubocop-shopify', require: false
8
8
 
9
9
  group(:deployment, :development) do
10
- gem("rake", "~> 12.3")
10
+ gem("rake")
11
+ end
12
+
13
+ gem("bundler", "~> 1.17")
14
+ gem("pry-byebug")
15
+ gem("minitest")
16
+ gem("minitest-hooks")
17
+ gem("minitest-fork_executor")
18
+ gem("minitest-reporters")
19
+ gem("sorbet")
20
+
21
+ group(:development, :test) do
22
+ gem("smart_properties", ">= 1.15.0", require: false)
23
+ gem("frozen_record", ">= 0.17", require: false)
24
+ gem("sprockets", "~> 3.7", require: false)
25
+ gem("rails", "~> 5.2", require: false)
26
+ gem("state_machines", "~> 0.5.0", require: false)
27
+ gem("activerecord-typedstore", "~> 1.3", require: false)
28
+ gem("sqlite3")
29
+ gem("identity_cache", "~> 1.0", require: false)
30
+ gem('cityhash', git: 'https://github.com/csfrancis/cityhash.git',
31
+ ref: '3cfc7d01f333c01811d5e834f1495eaa29f87c36', require: false)
32
+ gem("activemodel-serializers-xml", "~> 1.0", require: false)
33
+ gem("activeresource", "~> 5.1", require: false)
34
+ gem("google-protobuf", "~>3.12.0", require: false)
11
35
  end
data/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  [![Build Status](https://travis-ci.org/Shopify/tapioca.svg?branch=master)](https://travis-ci.org/Shopify/tapioca)
4
4
 
5
- Tapioca is a library used to generate RBI (Ruby interface) files for use with [Sorbet](https://sorbet.org). RBI files provide the structure (classes, modules, methods, parameters) of the gem/library to Sorbet to assist with typechecking.
5
+ Tapioca is a library used to generate RBI (Ruby interface) files for use with [Sorbet](https://sorbet.org). RBI files provide the structure (classes, modules, methods, parameters) of the gem/library to Sorbet to assist with typechecking.
6
6
 
7
7
  As yet, no gem exports type information in a consumable format and it would be a huge effort to manually maintain such an interface file for all the gems that your codebase depends on. Thus, there is a need for an automated way to generate the appropriate RBI file for a given gem. The `tapioca` gem, developed at Shopify, is able to do exactly that to almost 99% accuracy. It can generate the definitions for all statically defined types and most of the runtime defined types exported from Ruby gems (non-Ruby gems are not handled yet).
8
8
 
9
9
  When you run `tapioca sync` in a project, `tapioca` loads all the gems that are in your dependency list from the Gemfile into memory. It then performs runtime introspection on the loaded types to understand their structure and generates an appropriate RBI file for each gem with a versioned filename.
10
10
 
11
+ ## Manual gem requires
12
+
11
13
  For gems that have a normal default `require` and load all of their constants through such a require, everything works seamlessly. However, for gems that are marked as `require: false` in the Gemfile, or for gems that export optionally loaded types via different requires, where a single require does not load the whole gem code into memory, `tapioca` will not be able to load some of the types into memory and, thus, won't be able to generate complete RBIs for them. For this reason, we need to keep a small external file named `sorbet/tapioca/require.rb` that is executed after all the gems in the Gemfile have been required and before generation of gem RBIs have started. This file is responsible for adding the requires for additional files from gems, which are not covered by the default require.
12
14
 
13
15
  For example, suppose you are using the class `BetterHtml::Parser` exported from the `better_html` gem. Just doing a `require "better_html"` (which is the default require) does not load that type:
@@ -27,10 +29,17 @@ from (pry):3:in `__pry__`
27
29
  => BetterHtml::Parser
28
30
  ```
29
31
 
30
- In order to make sure that `tapioca` can reflect on that type, we need to add the line `require "better_html/parser"` to the `sorbet/tapioca/require.rb` file. This will make sure `BetterHtml::Parser` is loaded into memory and a type annotation is generated for it in the `better_html.rbi` file. If this extra `require` line is not added to `sorbet/tapioca/require.rb` file, then the definition for that type will be missing from the RBI file.
32
+ In order to make sure that `tapioca` can reflect on that type, we need to add the line `require "better_html/parser"` to the `sorbet/tapioca/require.rb` file. This will make sure `BetterHtml::Parser` is loaded into memory and a type annotation is generated for it in the `better_html.rbi` file. If this extra `require` line is not added to `sorbet/tapioca/require.rb` file, then the definition for that type will be missing from the RBI file.
31
33
 
32
34
  If you ever run into a case, where you add a gem or update the version of a gem and run `tapioca sync` but don't have some types you expect in the generated gem RBI files, you will need to make sure you have added the necessary requires to the `sorbet/tapioca/require.rb` file.
33
35
 
36
+ You can use the command `tapioca require` to auto-populate the `sorbet/tapioca/require.rb` file with all the requires found
37
+ in your application. Once the file generated, you should review it, remove all unnecessary requires and commit it.
38
+
39
+ ## How does tapioca compare to "srb rbi gems" ?
40
+
41
+ [Please see the detailed answer on our wiki](https://github.com/Shopify/tapioca/wiki/How-does-tapioca-compare-to-%22srb-rbi-gems%22-%3F)
42
+
34
43
  ## Installation
35
44
 
36
45
  Add this line to your application's `Gemfile`:
@@ -49,7 +58,9 @@ Commands:
49
58
  tapioca generate [gem...] # generate RBIs from gems
50
59
  tapioca help [COMMAND] # Describe available commands or one specific command
51
60
  tapioca init # initializes folder structure
61
+ tapioca require # generate the list of files to be required by tapioca
52
62
  tapioca sync # sync RBIs to Gemfile
63
+ tapioca todo # generate the list of unresolved constants
53
64
 
54
65
  Options:
55
66
  --pre, -b, [--prerequire=file] # A file to be required before Bundler.require is called
@@ -80,6 +91,12 @@ Command: `tapioca sync`
80
91
 
81
92
  This will sync the RBIs with the gems in the Gemfile and will add, update, and remove RBIs as necessary.
82
93
 
94
+ ### Generate the list of all unresolved constants
95
+
96
+ Command: `tapioca todo`
97
+
98
+ This will generate the file `sorbet/rbi/todo.rbi` defining all unresolved constants as empty modules.
99
+
83
100
  ### Flags
84
101
 
85
102
  - `--prerequire [file]`: A file to be required before `Bundler.require` is called.
@@ -88,6 +105,10 @@ This will sync the RBIs with the gems in the Gemfile and will add, update, and r
88
105
  - `--generate-command [command]`: The command to run to regenerate RBI files (used in header comment of the RBI files), defaults to the current command.
89
106
  - `--typed-overrides [gem:level]`: Overrides typed sigils for generated gem RBIs for gem `gem` to level `level` (`level` can be one of `ignore`, `false`, `true`, `strict`, or `strong`, see [the Sorbet docs](https://sorbet.org/docs/static#file-level-granularity-strictness-levels) for more details).
90
107
 
108
+ ### Strong typing option for ActiveRecord column methods
109
+
110
+ `tapioca` gives you the option to generate stricter type signatures for your ActiveRecord column types. By default, methods generated for columns that are defined in the schema have signatures of T.untyped. However, if the object extends a module with name StrongTypeGeneration, tapioca will generate stricter signatures that follow closely with the types defined in the schema. Expectation is the StrongTypeGeneration module you define in your application won't allow objects to be initialized with "bad state". It will check all the attributes that are not nillable to ensure they are not nil.
111
+
91
112
  ## Contributing
92
113
 
93
114
  Bug reports and pull requests are welcome on GitHub at https://github.com/Shopify/tapioca. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](https://github.com/Shopify/tapioca/blob/master/CODE_OF_CONDUCT.md) code of conduct.
data/Rakefile CHANGED
@@ -3,10 +3,21 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
5
 
6
- begin
7
- require 'rspec/core/rake_task'
8
- RSpec::Core::RakeTask.new(:spec)
9
- rescue LoadError # rubocop:disable Lint/SuppressedException
6
+ Rake.application.options.trace = false
7
+
8
+ Rake::TestTask.new do |t|
9
+ t.libs << "lib"
10
+ t.libs << "spec"
11
+ t.warning = false
12
+ t.test_files = FileList['spec/**/*_spec.rb']
13
+ end
14
+
15
+ task(:spec) do
16
+ begin
17
+ Rake::Task[:test].execute
18
+ rescue RuntimeError
19
+ exit(1)
20
+ end
10
21
  end
11
22
 
12
23
  task(default: :spec)
@@ -4,10 +4,12 @@
4
4
  require "sorbet-runtime"
5
5
 
6
6
  module Tapioca
7
- def self.silence_warnings
7
+ def self.silence_warnings(&blk)
8
8
  original_verbosity = $VERBOSE
9
9
  $VERBOSE = nil
10
- yield
10
+ Gem::DefaultUserInteraction.use_ui(Gem::SilentUI.new) do
11
+ blk.call
12
+ end
11
13
  ensure
12
14
  $VERBOSE = original_verbosity
13
15
  end
@@ -35,7 +37,11 @@ require "tapioca/config_builder"
35
37
  require "tapioca/generator"
36
38
  require "tapioca/cli"
37
39
  require "tapioca/gemfile"
40
+ require "tapioca/compilers/sorbet"
41
+ require "tapioca/compilers/requires_compiler"
38
42
  require "tapioca/compilers/symbol_table_compiler"
39
43
  require "tapioca/compilers/symbol_table/symbol_generator"
40
44
  require "tapioca/compilers/symbol_table/symbol_loader"
45
+ require "tapioca/compilers/todos_compiler"
46
+ require "tapioca/compilers/dsl_compiler"
41
47
  require "tapioca/version"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
- # typed: false
2
+ # typed: true
3
3
 
4
4
  require 'thor'
5
5
 
@@ -44,14 +44,40 @@ module Tapioca
44
44
  end
45
45
  create_file(Config::DEFAULT_POSTREQUIRE, skip: true) do
46
46
  <<~CONTENT
47
- # frozen_string_literal: true
48
47
  # typed: false
48
+ # frozen_string_literal: true
49
49
 
50
50
  # Add your extra requires here
51
51
  CONTENT
52
52
  end
53
53
  end
54
54
 
55
+ desc "require", "generate the list of files to be required by tapioca"
56
+ def require
57
+ Tapioca.silence_warnings do
58
+ generator.build_requires
59
+ end
60
+ end
61
+
62
+ desc "todo", "generate the list of unresolved constants"
63
+ def todo
64
+ Tapioca.silence_warnings do
65
+ generator.build_todos
66
+ end
67
+ end
68
+
69
+ desc "dsl [constant...]", "generate RBIs for dynamic methods"
70
+ option :generators,
71
+ type: :array,
72
+ aliases: ["--gen", "-g"],
73
+ banner: "generator [generator ...]",
74
+ desc: "Only run supplied DSL generators"
75
+ def dsl(*constants)
76
+ Tapioca.silence_warnings do
77
+ generator.build_dsl(constants)
78
+ end
79
+ end
80
+
55
81
  desc "generate [gem...]", "generate RBIs from gems"
56
82
  def generate(*gems)
57
83
  Tapioca.silence_warnings do
@@ -72,7 +98,10 @@ module Tapioca
72
98
  end
73
99
 
74
100
  def generator
75
- @generator ||= Generator.new(ConfigBuilder.from_options(options))
101
+ current_command = T.must(current_command_chain.first)
102
+ @generator ||= Generator.new(
103
+ ConfigBuilder.from_options(current_command, options)
104
+ )
76
105
  end
77
106
  end
78
107
  end
@@ -0,0 +1,129 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "parlour"
5
+
6
+ begin
7
+ require "action_controller"
8
+ rescue LoadError
9
+ return
10
+ end
11
+
12
+ module Tapioca
13
+ module Compilers
14
+ module Dsl
15
+ # `Tapioca::Compilers::Dsl::ActionControllerHelpers` decorates RBI files for all
16
+ # subclasses of `::ActionController::Base`
17
+ # to add helper methods (see https://api.rubyonrails.org/classes/ActionController/Helpers.html).
18
+ #
19
+ # For example, with the following `MyHelper` module:
20
+ #
21
+ # ~~~rb
22
+ # module MyHelper
23
+ # def greet(user)
24
+ # # ...
25
+ # end
26
+ #
27
+ # def localized_time
28
+ # # ...
29
+ # end
30
+ # end
31
+ # ~~~
32
+ #
33
+ # and the following controller:
34
+ #
35
+ # ~~~rb
36
+ # class UserController < ActionController::Base
37
+ # helper MyHelper
38
+ # helper { def age(user) "99" end }
39
+ # helper_method :current_user_name
40
+ #
41
+ # def current_user_name
42
+ # # ...
43
+ # end
44
+ # end
45
+ #
46
+ # this generator will produce an RBI file `user_controller.rbi` with the following content:
47
+ #
48
+ # ~~~rbi
49
+ # # user_controller.rbi
50
+ # # typed: strong
51
+ # class UserController
52
+ # sig { returns(UserController::HelperProxy) }
53
+ # def helpers; end
54
+ # end
55
+ #
56
+ # module UserController::HelperMethods
57
+ # include MyHelper
58
+ #
59
+ # sig { params(user: T.untyped).returns(T.untyped) }
60
+ # def age(user); end
61
+ #
62
+ # sig { returns(T.untyped) }
63
+ # def current_user_name; end
64
+ # end
65
+ #
66
+ # class UserController::HelperProxy < ::ActionView::Base
67
+ # include UserController::HelperMethods
68
+ # end
69
+ # ~~~
70
+ class ActionControllerHelpers < Base
71
+ extend T::Sig
72
+
73
+ sig do
74
+ override
75
+ .params(root: Parlour::RbiGenerator::Namespace, constant: T.class_of(::ActionController::Base))
76
+ .void
77
+ end
78
+ def decorate(root, constant)
79
+ helper_proxy_name = "#{constant}::HelperProxy"
80
+ helper_methods_name = "#{constant}::HelperMethods"
81
+ proxied_helper_methods = constant._helper_methods.map(&:to_s).map(&:to_sym)
82
+
83
+ # Create helper method module
84
+ root.create_module(helper_methods_name) do |helper_methods|
85
+ helpers_module = constant._helpers
86
+
87
+ gather_includes(helpers_module).each do |ancestor|
88
+ helper_methods.create_include(ancestor)
89
+ end
90
+
91
+ helpers_module.instance_methods(false).each do |method_name|
92
+ method = if proxied_helper_methods.include?(method_name)
93
+ constant.instance_method(method_name)
94
+ else
95
+ helpers_module.instance_method(method_name)
96
+ end
97
+ create_method_from_def(helper_methods, method)
98
+ end
99
+ end
100
+
101
+ # Create helper proxy class
102
+ root.create_class(helper_proxy_name, superclass: "::ActionView::Base") do |proxy|
103
+ proxy.create_include(helper_methods_name)
104
+ end
105
+
106
+ # Define the helpers method
107
+ root.path(constant) do |controller|
108
+ create_method(controller, 'helpers', return_type: helper_proxy_name)
109
+ end
110
+ end
111
+
112
+ sig { override.returns(T::Enumerable[Module]) }
113
+ def gather_constants
114
+ ::ActionController::Base.descendants.reject(&:abstract?)
115
+ end
116
+
117
+ private
118
+
119
+ sig { params(mod: Module).returns(T::Array[String]) }
120
+ def gather_includes(mod)
121
+ mod.ancestors
122
+ .reject { |ancestor| ancestor.is_a?(Class) || ancestor == mod || ancestor.name.nil? }
123
+ .map { |ancestor| T.must(ancestor.name) }
124
+ .reverse
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,65 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "parlour"
5
+
6
+ begin
7
+ require "action_mailer"
8
+ rescue LoadError
9
+ return
10
+ end
11
+
12
+ module Tapioca
13
+ module Compilers
14
+ module Dsl
15
+ # `Tapioca::Compilers::Dsl::ActionMailer` generates RBI files for subclasses of `ActionMailer::Base`
16
+ # (see https://api.rubyonrails.org/classes/ActionMailer/Base.html).
17
+ #
18
+ # For example, with the following `ActionMailer` subclass:
19
+ #
20
+ # ~~~rb
21
+ # class NotifierMailer < ActionMailer::Base
22
+ # def notify_customer(customer_id)
23
+ # # ...
24
+ # end
25
+ # end
26
+ # ~~~
27
+ #
28
+ # this generator will produce the RBI file `notifier_mailer.rbi` with the following content:
29
+ #
30
+ # ~~~rbi
31
+ # # notifier_mailer.rbi
32
+ # # typed: true
33
+ # class NotifierMailer
34
+ # sig { params(customer_id: T.untyped).returns(::ActionMailer::MessageDelivery) }
35
+ # def self.notify_customer(customer_id); end
36
+ # end
37
+ # ~~~
38
+ class ActionMailer < Base
39
+ extend T::Sig
40
+
41
+ sig { override.params(root: Parlour::RbiGenerator::Namespace, constant: T.class_of(::ActionMailer::Base)).void }
42
+ def decorate(root, constant)
43
+ root.path(constant) do |k|
44
+ constant.action_methods.to_a.each do |mailer_method|
45
+ method_def = constant.instance_method(mailer_method)
46
+ parameters = compile_method_parameters_to_parlour(method_def)
47
+ create_method(
48
+ k,
49
+ mailer_method,
50
+ parameters: parameters,
51
+ return_type: '::ActionMailer::MessageDelivery',
52
+ class_method: true
53
+ )
54
+ end
55
+ end
56
+ end
57
+
58
+ sig { override.returns(T::Enumerable[Module]) }
59
+ def gather_constants
60
+ ::ActionMailer::Base.descendants.reject(&:abstract?)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,267 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "parlour"
5
+
6
+ begin
7
+ require "active_record"
8
+ rescue LoadError
9
+ return
10
+ end
11
+
12
+ module Tapioca
13
+ module Compilers
14
+ module Dsl
15
+ # `Tapioca::Compilers::Dsl::ActiveRecordAssociations` refines RBI files for subclasses of `ActiveRecord::Base`
16
+ # (see https://api.rubyonrails.org/classes/ActiveRecord/Base.html). This generator is only
17
+ # responsible for defining the methods that would be created for the association that
18
+ # are defined in the Active Record model.
19
+ #
20
+ # For example, with the following model class:
21
+ #
22
+ # ~~~rb
23
+ # class Post < ActiveRecord::Base
24
+ # belongs_to :category
25
+ # has_many :comments
26
+ # has_one :author, class_name: "User"
27
+ # end
28
+ # ~~~
29
+ #
30
+ # this generator will produce the following methods in the RBI file
31
+ # `post.rbi`:
32
+ #
33
+ # ~~~rbi
34
+ # # post.rbi
35
+ # # typed: true
36
+ #
37
+ # class Post
38
+ # include Post::GeneratedAssociationMethods
39
+ # end
40
+ #
41
+ # module Post::GeneratedAssociationMethods
42
+ # sig { returns(T.nilable(::User)) }
43
+ # def author; end
44
+ #
45
+ # sig { params(value: T.nilable(::User)).void }
46
+ # def author=(value); end
47
+ #
48
+ # sig { params(args: T.untyped, blk: T.untyped).returns(T.nilable(::User)) }
49
+ # def build_author(*args, &blk); end
50
+ #
51
+ # sig { params(args: T.untyped, blk: T.untyped).returns(T.nilable(::Category)) }
52
+ # def build_category(*args, &blk); end
53
+ #
54
+ # sig { returns(T.nilable(::Category)) }
55
+ # def category; end
56
+ #
57
+ # sig { params(value: T.nilable(::Category)).void }
58
+ # def category=(value); end
59
+ #
60
+ # sig { returns(T::Array[T.untyped]) }
61
+ # def comment_ids; end
62
+ #
63
+ # sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
64
+ # def comment_ids=(ids); end
65
+ #
66
+ # sig { returns(::ActiveRecord::Associations::CollectionProxy[Comment]) }
67
+ # def comments; end
68
+ #
69
+ # sig { params(value: T::Enumerable[::Comment]).void }
70
+ # def comments=(value); end
71
+ #
72
+ # sig { params(args: T.untyped, blk: T.untyped).returns(T.nilable(::User)) }
73
+ # def create_author(*args, &blk); end
74
+ #
75
+ # sig { params(args: T.untyped, blk: T.untyped).returns(T.nilable(::User)) }
76
+ # def create_author!(*args, &blk); end
77
+ #
78
+ # sig { params(args: T.untyped, blk: T.untyped).returns(T.nilable(::Category)) }
79
+ # def create_category(*args, &blk); end
80
+ #
81
+ # sig { params(args: T.untyped, blk: T.untyped).returns(T.nilable(::Category)) }
82
+ # def create_category!(*args, &blk); end
83
+ #
84
+ # sig { returns(T.nilable(::User)) }
85
+ # def reload_author; end
86
+ #
87
+ # sig { returns(T.nilable(::Category)) }
88
+ # def reload_category; end
89
+ # end
90
+ # ~~~
91
+ class ActiveRecordAssociations < Base
92
+ extend T::Sig
93
+
94
+ ReflectionType = T.type_alias do
95
+ T.any(::ActiveRecord::Reflection::ThroughReflection, ::ActiveRecord::Reflection::AssociationReflection)
96
+ end
97
+
98
+ sig { override.params(root: Parlour::RbiGenerator::Namespace, constant: T.class_of(ActiveRecord::Base)).void }
99
+ def decorate(root, constant)
100
+ return if constant.reflections.empty?
101
+
102
+ module_name = "#{constant}::GeneratedAssociationMethods"
103
+ root.create_module(module_name) do |mod|
104
+ constant.reflections.each do |association_name, reflection|
105
+ if reflection.collection?
106
+ populate_collection_assoc_getter_setter(mod, constant, association_name, reflection)
107
+ else
108
+ populate_single_assoc_getter_setter(mod, constant, association_name, reflection)
109
+ end
110
+ end
111
+ end
112
+
113
+ root.path(constant) do |klass|
114
+ klass.create_include(module_name)
115
+ end
116
+ end
117
+
118
+ sig { override.returns(T::Enumerable[Module]) }
119
+ def gather_constants
120
+ ActiveRecord::Base.descendants.reject(&:abstract_class?)
121
+ end
122
+
123
+ private
124
+
125
+ sig do
126
+ params(
127
+ klass: Parlour::RbiGenerator::Namespace,
128
+ constant: T.class_of(ActiveRecord::Base),
129
+ association_name: T.any(String, Symbol),
130
+ reflection: ReflectionType
131
+ ).void
132
+ end
133
+ def populate_single_assoc_getter_setter(klass, constant, association_name, reflection)
134
+ association_class = type_for(constant, reflection)
135
+ association_type = "T.nilable(#{association_class})"
136
+
137
+ create_method(
138
+ klass,
139
+ association_name.to_s,
140
+ return_type: association_type,
141
+ )
142
+ create_method(
143
+ klass,
144
+ "#{association_name}=",
145
+ parameters: [
146
+ Parlour::RbiGenerator::Parameter.new("value", type: association_type),
147
+ ],
148
+ return_type: nil
149
+ )
150
+ create_method(
151
+ klass,
152
+ "reload_#{association_name}",
153
+ return_type: association_type,
154
+ )
155
+ if reflection.constructable?
156
+ create_method(
157
+ klass,
158
+ "build_#{association_name}",
159
+ parameters: [
160
+ Parlour::RbiGenerator::Parameter.new("*args", type: "T.untyped"),
161
+ Parlour::RbiGenerator::Parameter.new("&blk", type: "T.untyped"),
162
+ ],
163
+ return_type: association_type
164
+ )
165
+ create_method(
166
+ klass,
167
+ "create_#{association_name}",
168
+ parameters: [
169
+ Parlour::RbiGenerator::Parameter.new("*args", type: "T.untyped"),
170
+ Parlour::RbiGenerator::Parameter.new("&blk", type: "T.untyped"),
171
+ ],
172
+ return_type: association_type
173
+ )
174
+ create_method(
175
+ klass,
176
+ "create_#{association_name}!",
177
+ parameters: [
178
+ Parlour::RbiGenerator::Parameter.new("*args", type: "T.untyped"),
179
+ Parlour::RbiGenerator::Parameter.new("&blk", type: "T.untyped"),
180
+ ],
181
+ return_type: association_type
182
+ )
183
+ end
184
+ end
185
+
186
+ sig do
187
+ params(
188
+ klass: Parlour::RbiGenerator::Namespace,
189
+ constant: T.class_of(ActiveRecord::Base),
190
+ association_name: T.any(String, Symbol),
191
+ reflection: ReflectionType
192
+ ).void
193
+ end
194
+ def populate_collection_assoc_getter_setter(klass, constant, association_name, reflection)
195
+ association_class = type_for(constant, reflection)
196
+ relation_class = relation_type_for(constant, reflection)
197
+
198
+ create_method(
199
+ klass,
200
+ association_name.to_s,
201
+ return_type: relation_class,
202
+ )
203
+ create_method(
204
+ klass,
205
+ "#{association_name}=",
206
+ parameters: [
207
+ Parlour::RbiGenerator::Parameter.new("value", type: "T::Enumerable[#{association_class}]"),
208
+ ],
209
+ return_type: nil,
210
+ )
211
+ create_method(
212
+ klass,
213
+ "#{association_name.to_s.singularize}_ids",
214
+ return_type: "T::Array[T.untyped]"
215
+ )
216
+ create_method(
217
+ klass,
218
+ "#{association_name.to_s.singularize}_ids=",
219
+ parameters: [
220
+ Parlour::RbiGenerator::Parameter.new("ids", type: "T::Array[T.untyped]"),
221
+ ],
222
+ return_type: "T::Array[T.untyped]"
223
+ )
224
+ end
225
+
226
+ sig do
227
+ params(
228
+ constant: T.class_of(ActiveRecord::Base),
229
+ reflection: ReflectionType
230
+ ).returns(String)
231
+ end
232
+ def type_for(constant, reflection)
233
+ return "T.untyped" if !constant.table_exists? || polymorphic_association?(reflection)
234
+
235
+ "::#{reflection.klass.name}"
236
+ end
237
+
238
+ sig do
239
+ params(
240
+ constant: T.class_of(ActiveRecord::Base),
241
+ reflection: ReflectionType
242
+ ).returns(String)
243
+ end
244
+ def relation_type_for(constant, reflection)
245
+ "ActiveRecord::Associations::CollectionProxy" if !constant.table_exists? ||
246
+ polymorphic_association?(reflection)
247
+
248
+ # Change to: "::#{reflection.klass.name}::ActiveRecord_Associations_CollectionProxy"
249
+ "::ActiveRecord::Associations::CollectionProxy[#{reflection.klass.name}]"
250
+ end
251
+
252
+ sig do
253
+ params(
254
+ reflection: ReflectionType
255
+ ).returns(T::Boolean)
256
+ end
257
+ def polymorphic_association?(reflection)
258
+ if reflection.through_reflection?
259
+ polymorphic_association?(reflection.source_reflection)
260
+ else
261
+ !!reflection.polymorphic?
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end