diff --git a/lib/tapioca/dsl/compilers/active_record_delegated_types.rb b/lib/tapioca/dsl/compilers/active_record_delegated_types.rb index bc783c987..417baaf0b 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)) } @@ -67,6 +67,9 @@ module Compilers class ActiveRecordDelegatedTypes < Compiler include Helpers::ActiveRecordConstantsHelper + # A delegated type entry paired with the fully-qualified constant name it resolves to. + ResolvedType = Struct.new(:raw_name, :qualified_name, keyword_init: true) + # @override #: -> void def decorate @@ -77,8 +80,11 @@ 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) + resolved_types = types.map do |type| + ResolvedType.new(raw_name: type, qualified_name: qualified_type_name(type, role)) + end + populate_role_accessors(mod, role, resolved_types) + populate_type_helpers(mod, role, resolved_types, options) end end @@ -96,8 +102,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[ResolvedType] resolved_types) -> void + def populate_role_accessors(mod, role, resolved_types) mod.create_method( "#{role}_name", parameters: [], @@ -113,20 +119,20 @@ 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(resolved_types), ) 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[ResolvedType] resolved_types, Hash[Symbol, untyped] options) -> void + def populate_type_helpers(mod, role, resolved_types, options) + resolved_types.each do |resolved_type| + populate_type_helper(mod, role, resolved_type, options) end end - #: (RBI::Scope mod, Symbol role, String type, Hash[Symbol, untyped] options) -> void - def populate_type_helper(mod, role, type, options) - singular = type.tableize.tr("/", "_").singularize + #: (RBI::Scope mod, Symbol role, ResolvedType resolved_type, Hash[Symbol, untyped] options) -> void + def populate_type_helper(mod, role, resolved_type, options) + singular = resolved_type.raw_name.tableize.tr("/", "_").singularize query = "#{singular}?" primary_key = options[:primary_key] || "id" role_id = options[:foreign_key] || "#{role}_id" @@ -142,7 +148,7 @@ def populate_type_helper(mod, role, type, options) mod.create_method( singular, parameters: [], - return_type: "T.nilable(#{type})", + return_type: "T.nilable(#{resolved_type.qualified_name})", ) mod.create_method( @@ -151,6 +157,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[ResolvedType] resolved_types) -> String + def build_return_type(resolved_types) + qualified_types = resolved_types.map(&:qualified_name) + 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..7abe55856 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,214 @@ def message_id; end assert_equal(expected, rbi_for(:Entry)) end + + it "generates RBI file with fully-qualified names for unqualified, namespaced, and root-prefixed 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 + t.string :shareable_type + t.integer :shareable_id + end + end + end + RUBY + + add_ruby_file("models.rb", <<~RUBY) + class Toplevel < ActiveRecord::Base + self.table_name = "entries" + end + + module Shared + class Message < ActiveRecord::Base + self.table_name = "entries" + end + end + + 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" + # `Message`/`Comment` are unqualified and resolve into the parent namespace + # (`Content::*`); `Shared::Message` resolves outside it; `::Toplevel` is + # already root-qualified and must not be doubled into `::::Toplevel`. + delegated_type :entryable, types: %w[ Message Comment ] + delegated_type :shareable, types: %w[ Shared::Message ::Toplevel ] + end + RUBY + + expected = <<~RBI + # typed: strong + + class Content::Entry + include GeneratedDelegatedTypeMethods + + module GeneratedDelegatedTypeMethods + sig { returns(T.nilable(::Toplevel)) } + def _toplevel; end + + sig { returns(T::Boolean) } + def _toplevel?; end + + sig { returns(T.nilable(::Integer)) } + def _toplevel_id; end + + sig { params(args: T.untyped).returns(T.any(::Content::Message, ::Content::Comment)) } + def build_entryable(*args); end + + sig { params(args: T.untyped).returns(T.any(::Shared::Message, ::Toplevel)) } + def build_shareable(*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 + + sig { returns(T::Class[T.anything]) } + def shareable_class; end + + sig { returns(ActiveSupport::StringInquirer) } + def shareable_name; 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 for each type that 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 + t.string :attachable_type + t.integer :attachable_id + end + end + end + RUBY + + add_ruby_file("message.rb", <<~RUBY) + class Message < ActiveRecord::Base + self.table_name = "entries" + end + RUBY + + add_ruby_file("entry.rb", <<~RUBY) + class Entry < ActiveRecord::Base + self.table_name = "entries" + # A wholly-unresolvable role collapses `build_*` to `T.untyped`; a + # partially-resolvable role collapses `T.any(::Message, T.untyped)` the same way. + delegated_type :entryable, types: %w[ Phantom ] + delegated_type :attachable, types: %w[ Message Ghost ] + end + RUBY + + expected = <<~RBI + # typed: strong + + class Entry + include GeneratedDelegatedTypeMethods + + module GeneratedDelegatedTypeMethods + sig { returns(T::Class[T.anything]) } + def attachable_class; end + + sig { returns(ActiveSupport::StringInquirer) } + def attachable_name; end + + sig { params(args: T.untyped).returns(T.untyped) } + def build_attachable(*args); end + + 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 ghost; end + + sig { returns(T::Boolean) } + def ghost?; end + + sig { returns(T.nilable(::Integer)) } + def ghost_id; 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_errors = [ + "Cannot generate delegated_type `entryable` on `Entry` since the type `Phantom` could not be resolved.", + "Cannot generate delegated_type `attachable` on `Entry` since the type `Ghost` could not be resolved.", + ] + + assert_equal(expected, rbi_for(:Entry)) + assert_equal(expected_errors, generated_errors) + end end end end