diff --git a/lib/tapioca/dsl/compilers/active_record_delegated_types.rb b/lib/tapioca/dsl/compilers/active_record_delegated_types.rb index bc783c987..2f3a50782 100644 --- a/lib/tapioca/dsl/compilers/active_record_delegated_types.rb +++ b/lib/tapioca/dsl/compilers/active_record_delegated_types.rb @@ -33,7 +33,7 @@ module Compilers # include GeneratedDelegatedTypeMethods # # module GeneratedDelegatedTypeMethods - # sig { params(args: T.untyped).returns(T.any(Message, Comment)) } + # sig { params(args: T.untyped).returns(T.any(::Message, ::Comment)) } # def build_entryable(*args); end # # sig { returns(Class) } @@ -45,7 +45,7 @@ module Compilers # sig { returns(T::Boolean) } # def message?; end # - # sig { returns(T.nilable(Message)) } + # sig { returns(T.nilable(::Message)) } # def message; end # # sig { returns(T.nilable(Integer)) } @@ -54,7 +54,7 @@ module Compilers # sig { returns(T::Boolean) } # def comment?; end # - # sig { returns(T.nilable(Comment)) } + # sig { returns(T.nilable(::Comment)) } # def comment; end # # sig { returns(T.nilable(Integer)) } @@ -77,8 +77,9 @@ def decorate constant.__tapioca_delegated_types.each do |role, data| types = data.fetch(:types) options = data.fetch(:options, {}) - populate_role_accessors(mod, role, types) - populate_type_helpers(mod, role, types, options) + type_pairs = types.map { |type| [type, qualified_type_name(type, role)] } + populate_role_accessors(mod, role, type_pairs) + populate_type_helpers(mod, role, type_pairs, options) end end @@ -96,8 +97,8 @@ def gather_constants private - #: (RBI::Scope mod, Symbol role, Array[String] types) -> void - def populate_role_accessors(mod, role, types) + #: (RBI::Scope mod, Symbol role, Array[[String, String]] type_pairs) -> void + def populate_role_accessors(mod, role, type_pairs) mod.create_method( "#{role}_name", parameters: [], @@ -113,19 +114,19 @@ def populate_role_accessors(mod, role, types) mod.create_method( "build_#{role}", parameters: [create_rest_param("args", type: "T.untyped")], - return_type: types.size == 1 ? types.first : "T.any(#{types.join(", ")})", + return_type: build_return_type(type_pairs), ) end - #: (RBI::Scope mod, Symbol role, Array[String] types, Hash[Symbol, untyped] options) -> void - def populate_type_helpers(mod, role, types, options) - types.each do |type| - populate_type_helper(mod, role, type, options) + #: (RBI::Scope mod, Symbol role, Array[[String, String]] type_pairs, Hash[Symbol, untyped] options) -> void + def populate_type_helpers(mod, role, type_pairs, options) + type_pairs.each do |type, qualified_type| + populate_type_helper(mod, role, type, qualified_type, options) end end - #: (RBI::Scope mod, Symbol role, String type, Hash[Symbol, untyped] options) -> void - def populate_type_helper(mod, role, type, options) + #: (RBI::Scope mod, Symbol role, String type, String qualified_type, Hash[Symbol, untyped] options) -> void + def populate_type_helper(mod, role, type, qualified_type, options) singular = type.tableize.tr("/", "_").singularize query = "#{singular}?" primary_key = options[:primary_key] || "id" @@ -142,7 +143,7 @@ def populate_type_helper(mod, role, type, options) mod.create_method( singular, parameters: [], - return_type: "T.nilable(#{type})", + return_type: "T.nilable(#{qualified_type})", ) mod.create_method( @@ -151,6 +152,48 @@ def populate_type_helper(mod, role, type, options) return_type: as_nilable_type(getter_type), ) end + + # Collapses to `T.untyped` if any member is `T.untyped`, since `T.any(::Foo, T.untyped)` + # is equivalent to `T.untyped` in Sorbet and the per-type error has already been recorded. + #: (Array[[String, String]] type_pairs) -> String + def build_return_type(type_pairs) + qualified_types = type_pairs.map { |_, qualified_type| qualified_type } + if qualified_types.include?("T.untyped") + "T.untyped" + elsif qualified_types.size == 1 + qualified_types.fetch(0) + else + "T.any(#{qualified_types.join(", ")})" + end + end + + # Resolves a delegated type entry to a fully-qualified constant name. The strings passed + # to `delegated_type(..., types: %w[...])` are written verbatim into the generated RBI, + # but the surrounding `class A::B::C` scope omits `A` and `A::B` from Sorbet's lexical + # nesting, so a bare `D` reference fails to resolve to `A::B::D` even when that constant + # exists. `compute_type` is `ActiveRecord::Base`'s own (private) namespace-walking lookup + # — the same one Rails uses for STI and polymorphic associations — so it resolves both + # bare and fully-qualified names. When the constant can't be resolved (NameError) or its + # qualified name can't be derived (anonymous class) we record a compiler error and emit + # `T.untyped`, which both surfaces the problem and keeps the generated RBI type-checkable. + #: (String type, Symbol role) -> String + def qualified_type_name(type, role) + klass = constant.send(:compute_type, type) + qualified_name = qualified_name_of(klass) + return qualified_name if qualified_name + + add_unresolvable_type_error(type, role) + rescue NameError, LoadError + add_unresolvable_type_error(type, role) + end + + #: (String type, Symbol role) -> String + def add_unresolvable_type_error(type, role) + add_error(<<~MSG.strip) + Cannot generate delegated_type `#{role}` on `#{constant}` since the type `#{type}` could not be resolved. + MSG + "T.untyped" + end end end end diff --git a/manual/compiler_activerecorddelegatedtypes.md b/manual/compiler_activerecorddelegatedtypes.md index 56ee698b8..79a57476a 100644 --- a/manual/compiler_activerecorddelegatedtypes.md +++ b/manual/compiler_activerecorddelegatedtypes.md @@ -24,7 +24,7 @@ class Entry include GeneratedDelegatedTypeMethods module GeneratedDelegatedTypeMethods - sig { params(args: T.untyped).returns(T.any(Message, Comment)) } + sig { params(args: T.untyped).returns(T.any(::Message, ::Comment)) } def build_entryable(*args); end sig { returns(Class) } @@ -36,7 +36,7 @@ class Entry sig { returns(T::Boolean) } def message?; end - sig { returns(T.nilable(Message)) } + sig { returns(T.nilable(::Message)) } def message; end sig { returns(T.nilable(Integer)) } @@ -45,7 +45,7 @@ class Entry sig { returns(T::Boolean) } def comment?; end - sig { returns(T.nilable(Comment)) } + sig { returns(T.nilable(::Comment)) } def comment; end sig { returns(T.nilable(Integer)) } diff --git a/spec/tapioca/dsl/compilers/active_record_delegated_types_spec.rb b/spec/tapioca/dsl/compilers/active_record_delegated_types_spec.rb index b0871ee1b..dda6ac9b8 100644 --- a/spec/tapioca/dsl/compilers/active_record_delegated_types_spec.rb +++ b/spec/tapioca/dsl/compilers/active_record_delegated_types_spec.rb @@ -104,10 +104,10 @@ class Entry include GeneratedDelegatedTypeMethods module GeneratedDelegatedTypeMethods - sig { params(args: T.untyped).returns(T.any(Message, Comment)) } + sig { params(args: T.untyped).returns(T.any(::Message, ::Comment)) } def build_entryable(*args); end - sig { returns(T.nilable(Comment)) } + sig { returns(T.nilable(::Comment)) } def comment; end sig { returns(T::Boolean) } @@ -122,7 +122,7 @@ def entryable_class; end sig { returns(ActiveSupport::StringInquirer) } def entryable_name; end - sig { returns(T.nilable(Message)) } + sig { returns(T.nilable(::Message)) } def message; end sig { returns(T::Boolean) } @@ -172,10 +172,10 @@ class Entry include GeneratedDelegatedTypeMethods module GeneratedDelegatedTypeMethods - sig { params(args: T.untyped).returns(T.any(Message, Comment)) } + sig { params(args: T.untyped).returns(T.any(::Message, ::Comment)) } def build_entryable(*args); end - sig { returns(T.nilable(Comment)) } + sig { returns(T.nilable(::Comment)) } def comment; end sig { returns(T::Boolean) } @@ -190,7 +190,7 @@ def entryable_class; end sig { returns(ActiveSupport::StringInquirer) } def entryable_name; end - sig { returns(T.nilable(Message)) } + sig { returns(T.nilable(::Message)) } def message; end sig { returns(T::Boolean) } @@ -235,7 +235,7 @@ class Entry include GeneratedDelegatedTypeMethods module GeneratedDelegatedTypeMethods - sig { params(args: T.untyped).returns(Message) } + sig { params(args: T.untyped).returns(::Message) } def build_entryable(*args); end sig { returns(T::Class[T.anything]) } @@ -244,7 +244,7 @@ def entryable_class; end sig { returns(ActiveSupport::StringInquirer) } def entryable_name; end - sig { returns(T.nilable(Message)) } + sig { returns(T.nilable(::Message)) } def message; end sig { returns(T::Boolean) } @@ -258,6 +258,327 @@ def message_id; end assert_equal(expected, rbi_for(:Entry)) end + + it "generates RBI file with fully-qualified type names for namespaced types" do + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :entries do |t| + t.string :entryable_type + t.integer :entryable_id + end + end + end + RUBY + + add_ruby_file("models.rb", <<~RUBY) + module Content + class Message < ActiveRecord::Base + self.table_name = "entries" + end + + class Comment < ActiveRecord::Base + self.table_name = "entries" + end + end + + class Content::Entry < ActiveRecord::Base + self.table_name = "entries" + delegated_type :entryable, types: %w[ Message Comment ] + end + RUBY + + expected = <<~RBI + # typed: strong + + class Content::Entry + include GeneratedDelegatedTypeMethods + + module GeneratedDelegatedTypeMethods + sig { params(args: T.untyped).returns(T.any(::Content::Message, ::Content::Comment)) } + def build_entryable(*args); end + + sig { returns(T.nilable(::Content::Comment)) } + def comment; end + + sig { returns(T::Boolean) } + def comment?; end + + sig { returns(T.nilable(::Integer)) } + def comment_id; end + + sig { returns(T::Class[T.anything]) } + def entryable_class; end + + sig { returns(ActiveSupport::StringInquirer) } + def entryable_name; end + + sig { returns(T.nilable(::Content::Message)) } + def message; end + + sig { returns(T::Boolean) } + def message?; end + + sig { returns(T.nilable(::Integer)) } + def message_id; end + end + end + RBI + + assert_equal(expected, rbi_for("Content::Entry")) + end + + it "qualifies module-prefixed type names that resolve outside the parent namespace" do + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :entries do |t| + t.string :entryable_type + t.integer :entryable_id + end + end + end + RUBY + + add_ruby_file("models.rb", <<~RUBY) + module Shared + class Message < ActiveRecord::Base + self.table_name = "entries" + end + + class Comment < ActiveRecord::Base + self.table_name = "entries" + end + end + + module Content; end + + class Content::Entry < ActiveRecord::Base + self.table_name = "entries" + delegated_type :entryable, types: %w[ Shared::Message Shared::Comment ] + end + RUBY + + expected = <<~RBI + # typed: strong + + class Content::Entry + include GeneratedDelegatedTypeMethods + + module GeneratedDelegatedTypeMethods + sig { params(args: T.untyped).returns(T.any(::Shared::Message, ::Shared::Comment)) } + def build_entryable(*args); end + + sig { returns(T::Class[T.anything]) } + def entryable_class; end + + sig { returns(ActiveSupport::StringInquirer) } + def entryable_name; end + + sig { returns(T.nilable(::Shared::Comment)) } + def shared_comment; end + + sig { returns(T::Boolean) } + def shared_comment?; end + + sig { returns(T.nilable(::Integer)) } + def shared_comment_id; end + + sig { returns(T.nilable(::Shared::Message)) } + def shared_message; end + + sig { returns(T::Boolean) } + def shared_message?; end + + sig { returns(T.nilable(::Integer)) } + def shared_message_id; end + end + end + RBI + + assert_equal(expected, rbi_for("Content::Entry")) + end + + it "emits T.untyped and an error when a type cannot be resolved" do + expect_dsl_compiler_errors! + + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :entries do |t| + t.string :entryable_type + t.integer :entryable_id + end + end + end + RUBY + + add_ruby_file("entry.rb", <<~RUBY) + class Entry < ActiveRecord::Base + delegated_type :entryable, types: %w[ Phantom ] + end + RUBY + + expected = <<~RBI + # typed: strong + + class Entry + include GeneratedDelegatedTypeMethods + + module GeneratedDelegatedTypeMethods + sig { params(args: T.untyped).returns(T.untyped) } + def build_entryable(*args); end + + sig { returns(T::Class[T.anything]) } + def entryable_class; end + + sig { returns(ActiveSupport::StringInquirer) } + def entryable_name; end + + sig { returns(T.nilable(T.untyped)) } + def phantom; end + + sig { returns(T::Boolean) } + def phantom?; end + + sig { returns(T.nilable(::Integer)) } + def phantom_id; end + end + end + RBI + + expected_error = "Cannot generate delegated_type `entryable` on `Entry` since " \ + "the type `Phantom` could not be resolved." + + assert_equal(expected, rbi_for(:Entry)) + assert_equal([expected_error], generated_errors) + end + + it "collapses the build return type to T.untyped when only some types resolve" do + expect_dsl_compiler_errors! + + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :entries do |t| + t.string :entryable_type + t.integer :entryable_id + end + end + end + RUBY + + add_ruby_file("message.rb", <<~RUBY) + class Message < ActiveRecord::Base + end + RUBY + + add_ruby_file("entry.rb", <<~RUBY) + class Entry < ActiveRecord::Base + delegated_type :entryable, types: %w[ Message Phantom ] + end + RUBY + + expected = <<~RBI + # typed: strong + + class Entry + include GeneratedDelegatedTypeMethods + + module GeneratedDelegatedTypeMethods + sig { params(args: T.untyped).returns(T.untyped) } + def build_entryable(*args); end + + sig { returns(T::Class[T.anything]) } + def entryable_class; end + + sig { returns(ActiveSupport::StringInquirer) } + def entryable_name; end + + sig { returns(T.nilable(::Message)) } + def message; end + + sig { returns(T::Boolean) } + def message?; end + + sig { returns(T.nilable(::Integer)) } + def message_id; end + + sig { returns(T.nilable(T.untyped)) } + def phantom; end + + sig { returns(T::Boolean) } + def phantom?; end + + sig { returns(T.nilable(::Integer)) } + def phantom_id; end + end + end + RBI + + expected_error = "Cannot generate delegated_type `entryable` on `Entry` since " \ + "the type `Phantom` could not be resolved." + + assert_equal(expected, rbi_for(:Entry)) + assert_equal([expected_error], generated_errors) + end + + it "qualifies a type string that already has a leading `::` without doubling it" do + # The method names mirror Rails' own `tableize.tr("/", "_").singularize` transform, + # which turns `::Message` into `_message`. The point of this test is the return + # type — it must be `::Message`, not `::::Message`. + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :entries do |t| + t.string :entryable_type + t.integer :entryable_id + end + end + end + RUBY + + add_ruby_file("message.rb", <<~RUBY) + class Message < ActiveRecord::Base + end + RUBY + + add_ruby_file("entry.rb", <<~RUBY) + class Entry < ActiveRecord::Base + delegated_type :entryable, types: %w[ ::Message ] + end + RUBY + + expected = <<~RBI + # typed: strong + + class Entry + include GeneratedDelegatedTypeMethods + + module GeneratedDelegatedTypeMethods + sig { returns(T.nilable(::Message)) } + def _message; end + + sig { returns(T::Boolean) } + def _message?; end + + sig { returns(T.nilable(::Integer)) } + def _message_id; end + + sig { params(args: T.untyped).returns(::Message) } + def build_entryable(*args); end + + sig { returns(T::Class[T.anything]) } + def entryable_class; end + + sig { returns(ActiveSupport::StringInquirer) } + def entryable_name; end + end + end + RBI + + assert_equal(expected, rbi_for(:Entry)) + end end end end