From 4430e3851e19a3b1602c8a1972479fd425082315 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 26 May 2026 12:54:02 -0700 Subject: [PATCH 1/4] Fully qualify type constants in ActiveRecordDelegatedTypes RBI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `delegated_type :role, types: %w[Foo Bar]` is written verbatim into the generated RBI: class Namespace::Owner module GeneratedDelegatedTypeMethods sig { returns(T.nilable(Foo)) } def foo; end end end Sorbet's lexical nesting for `class Namespace::Owner` is just `Namespace::Owner`, so a bare `Foo` reference inside the nested module never reaches `Namespace::Foo` — even when that's the only place the class is defined — and the file fails with "Unable to resolve constant". Resolve each type through `compute_type`, ActiveRecord's namespace-aware lookup (the same one used for STI and polymorphic associations), and emit the constant's qualified name. The fallback to the literal type string preserves today's behavior when the constant can't be loaded at generation time. Matches the FQN convention already used by the associations compiler. Refs https://github.com/Shopify/tapioca/issues/1861 --- .../active_record_delegated_types.rb | 23 ++- .../active_record_delegated_types_spec.rb | 134 ++++++++++++++++-- 2 files changed, 147 insertions(+), 10 deletions(-) diff --git a/lib/tapioca/dsl/compilers/active_record_delegated_types.rb b/lib/tapioca/dsl/compilers/active_record_delegated_types.rb index bc783c987..94563c4f3 100644 --- a/lib/tapioca/dsl/compilers/active_record_delegated_types.rb +++ b/lib/tapioca/dsl/compilers/active_record_delegated_types.rb @@ -98,6 +98,8 @@ def gather_constants #: (RBI::Scope mod, Symbol role, Array[String] types) -> void def populate_role_accessors(mod, role, types) + qualified_types = types.map { |type| qualified_type_name(type) } + mod.create_method( "#{role}_name", parameters: [], @@ -113,7 +115,7 @@ 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: qualified_types.size == 1 ? qualified_types.first : "T.any(#{qualified_types.join(", ")})", ) end @@ -142,7 +144,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_name(type)})", ) mod.create_method( @@ -151,6 +153,23 @@ def populate_type_helper(mod, role, type, options) return_type: as_nilable_type(getter_type), ) 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` mirrors the namespace-walking lookup ActiveRecord uses for STI + # and polymorphic associations, so it resolves both bare and fully-qualified names. We + # emit the constant's qualified name so the RBI references it unambiguously, and fall + # back to the original string when the constant can't be resolved (e.g. it's defined + # elsewhere via an autoload that hasn't fired). + #: (String type) -> String + def qualified_type_name(type) + klass = constant.send(:compute_type, type) + qualified_name_of(klass) || type + rescue NameError + type + end end end end 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..1d3bd6c3f 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,124 @@ 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 "falls back to the literal type string when the constant cannot be resolved" 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("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(Phantom) } + 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(Phantom)) } + def phantom; end + + sig { returns(T::Boolean) } + def phantom?; end + + sig { returns(T.nilable(::Integer)) } + def phantom_id; end + end + end + RBI + + assert_equal(expected, rbi_for(:Entry)) + end end end end From 9b50e467ea9817d6e0342ac689fbb84f20514d5b Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 26 May 2026 13:17:25 -0700 Subject: [PATCH 2/4] Surface unresolvable delegated_type entries via add_error Previously the compiler silently fell back to emitting the literal type string when a `delegated_type` entry couldn't be resolved through `compute_type`. That preserved the old broken output and gave users no signal that something was wrong. Match the diagnostic pattern used by the associations compiler: when resolution fails, call `add_error` with the role and constant name and substitute `T.untyped`. Compute the qualified-name array once at the role level and thread it through both helpers so the resolution (and any error) only fires once per type. Also update the docstring example RBI to reflect the new `::`-prefixed output, add a regression test for types resolving outside the parent namespace (the canonical #1861 case), and adjust the unresolvable- constant test to expect the new `T.untyped` + diagnostic behavior. --- .../active_record_delegated_types.rb | 47 ++++++----- .../active_record_delegated_types_spec.rb | 83 ++++++++++++++++++- 2 files changed, 104 insertions(+), 26 deletions(-) diff --git a/lib/tapioca/dsl/compilers/active_record_delegated_types.rb b/lib/tapioca/dsl/compilers/active_record_delegated_types.rb index 94563c4f3..755bda944 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) + qualified_types = types.map { |type| qualified_type_name(type, role) } + populate_role_accessors(mod, role, qualified_types) + populate_type_helpers(mod, role, types, qualified_types, options) end end @@ -96,10 +97,8 @@ def gather_constants private - #: (RBI::Scope mod, Symbol role, Array[String] types) -> void - def populate_role_accessors(mod, role, types) - qualified_types = types.map { |type| qualified_type_name(type) } - + #: (RBI::Scope mod, Symbol role, Array[String] qualified_types) -> void + def populate_role_accessors(mod, role, qualified_types) mod.create_method( "#{role}_name", parameters: [], @@ -119,15 +118,15 @@ def populate_role_accessors(mod, role, 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[String] types, Array[String] qualified_types, Hash[Symbol, untyped] options) -> void + def populate_type_helpers(mod, role, types, qualified_types, options) + types.each_with_index do |type, index| + populate_type_helper(mod, role, type, qualified_types.fetch(index), 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" @@ -144,7 +143,7 @@ def populate_type_helper(mod, role, type, options) mod.create_method( singular, parameters: [], - return_type: "T.nilable(#{qualified_type_name(type)})", + return_type: "T.nilable(#{qualified_type})", ) mod.create_method( @@ -159,16 +158,18 @@ def populate_type_helper(mod, role, type, options) # 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` mirrors the namespace-walking lookup ActiveRecord uses for STI - # and polymorphic associations, so it resolves both bare and fully-qualified names. We - # emit the constant's qualified name so the RBI references it unambiguously, and fall - # back to the original string when the constant can't be resolved (e.g. it's defined - # elsewhere via an autoload that hasn't fired). - #: (String type) -> String - def qualified_type_name(type) + # and polymorphic associations, so it resolves both bare and fully-qualified names. When + # the constant can't be resolved 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_of(klass) || type rescue NameError - type + add_error(<<~MSG.strip) + Cannot generate delegated_type `#{role}` on `#{constant}` since the type `#{type}` does not exist. + MSG + "T.untyped" end end end 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 1d3bd6c3f..4fedde14e 100644 --- a/spec/tapioca/dsl/compilers/active_record_delegated_types_spec.rb +++ b/spec/tapioca/dsl/compilers/active_record_delegated_types_spec.rb @@ -328,7 +328,80 @@ def message_id; end assert_equal(expected, rbi_for("Content::Entry")) end - it "falls back to the literal type string when the constant cannot be resolved" do + it "qualifies type names that resolve to a constant 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 @@ -353,7 +426,7 @@ class Entry include GeneratedDelegatedTypeMethods module GeneratedDelegatedTypeMethods - sig { params(args: T.untyped).returns(Phantom) } + sig { params(args: T.untyped).returns(T.untyped) } def build_entryable(*args); end sig { returns(T::Class[T.anything]) } @@ -362,7 +435,7 @@ def entryable_class; end sig { returns(ActiveSupport::StringInquirer) } def entryable_name; end - sig { returns(T.nilable(Phantom)) } + sig { returns(T.nilable(T.untyped)) } def phantom; end sig { returns(T::Boolean) } @@ -375,6 +448,10 @@ def phantom_id; end RBI assert_equal(expected, rbi_for(:Entry)) + assert_equal( + ["Cannot generate delegated_type `entryable` on `Entry` since the type `Phantom` does not exist."], + generated_errors, + ) end end end From 22d7dffdfc46edba66ade7057cc7d061cc1e9357 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 26 May 2026 13:49:49 -0700 Subject: [PATCH 3/4] Harden delegated_type qualification against LoadError and partial failures - Rescue `LoadError` alongside `NameError`: when a delegated_type entry uses a `::`-prefixed name and the autoloader fails, surface a compiler error instead of aborting the run. - Treat a nil `qualified_name_of` (anonymous class) the same as an unresolved constant rather than silently writing the user-supplied string into the RBI. - Collapse `build_` to `T.untyped` when any member of the union is unresolvable, since `T.any(::Foo, T.untyped)` is equivalent to `T.untyped` in Sorbet and the per-type error has already been recorded. - Thread `(raw, qualified)` pairs through `populate_*` instead of two parallel arrays + `each_with_index`/`.fetch`. - Note `compute_type` as `ActiveRecord::Base`'s own private STI resolver to justify the `send`. Tests: add coverage for partial-failure union collapse and for a leading-`::` type string (verifies we don't emit `::::Foo`). Rename the cross-namespace test to reflect what it actually exercises. --- .../active_record_delegated_types.rb | 57 +++++--- .../active_record_delegated_types_spec.rb | 136 +++++++++++++++++- 2 files changed, 171 insertions(+), 22 deletions(-) diff --git a/lib/tapioca/dsl/compilers/active_record_delegated_types.rb b/lib/tapioca/dsl/compilers/active_record_delegated_types.rb index 755bda944..2f3a50782 100644 --- a/lib/tapioca/dsl/compilers/active_record_delegated_types.rb +++ b/lib/tapioca/dsl/compilers/active_record_delegated_types.rb @@ -77,9 +77,9 @@ def decorate constant.__tapioca_delegated_types.each do |role, data| types = data.fetch(:types) options = data.fetch(:options, {}) - qualified_types = types.map { |type| qualified_type_name(type, role) } - populate_role_accessors(mod, role, qualified_types) - populate_type_helpers(mod, role, types, qualified_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 @@ -97,8 +97,8 @@ def gather_constants private - #: (RBI::Scope mod, Symbol role, Array[String] qualified_types) -> void - def populate_role_accessors(mod, role, qualified_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: [], @@ -114,14 +114,14 @@ def populate_role_accessors(mod, role, qualified_types) mod.create_method( "build_#{role}", parameters: [create_rest_param("args", type: "T.untyped")], - return_type: qualified_types.size == 1 ? qualified_types.first : "T.any(#{qualified_types.join(", ")})", + return_type: build_return_type(type_pairs), ) end - #: (RBI::Scope mod, Symbol role, Array[String] types, Array[String] qualified_types, Hash[Symbol, untyped] options) -> void - def populate_type_helpers(mod, role, types, qualified_types, options) - types.each_with_index do |type, index| - populate_type_helper(mod, role, type, qualified_types.fetch(index), 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 @@ -153,21 +153,44 @@ def populate_type_helper(mod, role, type, qualified_type, options) ) 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` mirrors the namespace-walking lookup ActiveRecord uses for STI - # and polymorphic associations, so it resolves both bare and fully-qualified names. When - # the constant can't be resolved we record a compiler error and emit `T.untyped`, which - # both surfaces the problem and keeps the generated RBI type-checkable. + # 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_of(klass) || type - rescue NameError + 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}` does not exist. + Cannot generate delegated_type `#{role}` on `#{constant}` since the type `#{type}` could not be resolved. MSG "T.untyped" end 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 4fedde14e..dda6ac9b8 100644 --- a/spec/tapioca/dsl/compilers/active_record_delegated_types_spec.rb +++ b/spec/tapioca/dsl/compilers/active_record_delegated_types_spec.rb @@ -328,7 +328,7 @@ def message_id; end assert_equal(expected, rbi_for("Content::Entry")) end - it "qualifies type names that resolve to a constant outside the parent namespace" do + 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 @@ -447,11 +447,137 @@ def phantom_id; 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)) - assert_equal( - ["Cannot generate delegated_type `entryable` on `Entry` since the type `Phantom` does not exist."], - generated_errors, - ) end end end From 6981711b29865a99c3e3ef0b95956f953d103e5e Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 26 May 2026 13:53:44 -0700 Subject: [PATCH 4/4] Regenerate ActiveRecordDelegatedTypes compiler docs --- manual/compiler_activerecorddelegatedtypes.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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)) }