From 684404ea5daadd882a44caa8097f4d54463948d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Tue, 3 Nov 2026 14:36:16 +0300 Subject: [PATCH 1/2] Fix IdentityCache multi-fetch return types --- lib/tapioca/dsl/compilers/identity_cache.rb | 17 +++++++++++++---- .../dsl/compilers/identity_cache_spec.rb | 9 +++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/tapioca/dsl/compilers/identity_cache.rb b/lib/tapioca/dsl/compilers/identity_cache.rb index 5c1629f28..c7926cc83 100644 --- a/lib/tapioca/dsl/compilers/identity_cache.rb +++ b/lib/tapioca/dsl/compilers/identity_cache.rb @@ -131,7 +131,7 @@ def create_fetch_by_methods(field, klass) is_cache_index = field.instance_variable_defined?(:@attribute_proc) # Both `cache_index` and `cache_attribute` generate aliased methods - create_aliased_fetch_by_methods(field, klass) + create_aliased_fetch_by_methods(field, klass, is_cache_index: is_cache_index) # If the method used was `cache_index` a few extra methods are created create_index_fetch_by_methods(field, klass) if is_cache_index @@ -182,8 +182,8 @@ def create_index_fetch_by_methods(field, klass) ) end - #: (untyped field, RBI::Scope klass) -> void - def create_aliased_fetch_by_methods(field, klass) + #: (untyped field, RBI::Scope klass, is_cache_index: bool) -> void + def create_aliased_fetch_by_methods(field, klass, is_cache_index:) type, _ = Helpers::ActiveRecordColumnTypeHelper.new( constant, column_type_option: Helpers::ActiveRecordColumnTypeHelper::ColumnTypeOption::Nilable, @@ -195,6 +195,15 @@ def create_aliased_fetch_by_methods(field, klass) create_param(arg.to_s, type: "T.untyped") end + multi_return_type = + if is_cache_index + COLLECTION_TYPE.call(multi_type) + elsif field.unique + "T::Hash[T.untyped, #{multi_type}]" + else + "T::Hash[T.untyped, #{COLLECTION_TYPE.call(multi_type)}]" + end + klass.create_method( "fetch_#{suffix}", class_method: true, @@ -206,7 +215,7 @@ def create_aliased_fetch_by_methods(field, klass) "fetch_multi_#{suffix}", class_method: true, parameters: [create_param("keys", type: "T::Enumerable[T.untyped]")], - return_type: COLLECTION_TYPE.call(multi_type), + return_type: multi_return_type, ) end end diff --git a/spec/tapioca/dsl/compilers/identity_cache_spec.rb b/spec/tapioca/dsl/compilers/identity_cache_spec.rb index 1acc4b826..1e8f69cef 100644 --- a/spec/tapioca/dsl/compilers/identity_cache_spec.rb +++ b/spec/tapioca/dsl/compilers/identity_cache_spec.rb @@ -401,6 +401,7 @@ def fetch_user; end ActiveRecord::Schema.define do create_table :posts do |t| t.string :author + t.string :name end end end @@ -412,6 +413,7 @@ class Post < ActiveRecord::Base cache_attribute :author, by: :id cache_attribute :author, by: [:id, :author] + cache_attribute :name, by: :author, unique: false end RUBY @@ -426,11 +428,14 @@ def fetch_author_by_id(id); end sig { params(id: T.untyped, author: T.untyped).returns(T.nilable(::String)) } def fetch_author_by_id_and_author(id, author); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::String]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, ::String]) } def fetch_multi_author_by_id(keys); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::String]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, ::String]) } def fetch_multi_author_by_id_and_author(keys); end + + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T::Array[::String]]) } + def fetch_multi_name_by_author(keys); end end end RBI From c6c5bfc2e4c2966b6e9380a0eb4431e0da88c47e Mon Sep 17 00:00:00 2001 From: Efe Date: Tue, 3 Nov 2026 15:11:19 +0300 Subject: [PATCH 2/2] Fix IdentityCache multi-fetch nilability and index aliases --- lib/tapioca/dsl/compilers/identity_cache.rb | 12 ++-- .../dsl/compilers/identity_cache_spec.rb | 59 +++++++++++++++---- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/lib/tapioca/dsl/compilers/identity_cache.rb b/lib/tapioca/dsl/compilers/identity_cache.rb index c7926cc83..4a2dfbe81 100644 --- a/lib/tapioca/dsl/compilers/identity_cache.rb +++ b/lib/tapioca/dsl/compilers/identity_cache.rb @@ -195,13 +195,13 @@ def create_aliased_fetch_by_methods(field, klass, is_cache_index:) create_param(arg.to_s, type: "T.untyped") end - multi_return_type = - if is_cache_index + multi_value_type = + if field.unique + type + elsif is_cache_index COLLECTION_TYPE.call(multi_type) - elsif field.unique - "T::Hash[T.untyped, #{multi_type}]" else - "T::Hash[T.untyped, #{COLLECTION_TYPE.call(multi_type)}]" + COLLECTION_TYPE.call(type) end klass.create_method( @@ -215,7 +215,7 @@ def create_aliased_fetch_by_methods(field, klass, is_cache_index:) "fetch_multi_#{suffix}", class_method: true, parameters: [create_param("keys", type: "T::Enumerable[T.untyped]")], - return_type: multi_return_type, + return_type: "T::Hash[T.untyped, #{multi_value_type}]", ) end end diff --git a/spec/tapioca/dsl/compilers/identity_cache_spec.rb b/spec/tapioca/dsl/compilers/identity_cache_spec.rb index 1e8f69cef..b3292a963 100644 --- a/spec/tapioca/dsl/compilers/identity_cache_spec.rb +++ b/spec/tapioca/dsl/compilers/identity_cache_spec.rb @@ -102,10 +102,10 @@ def fetch_multi_by_blog_id(index_values, includes: nil); end sig { params(index_values: T::Enumerable[T.untyped], includes: T.untyped).returns(T::Array[::Post]) } def fetch_multi_by_title(index_values, includes: nil); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::Integer]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T::Array[::Integer]]) } def fetch_multi_id_by_blog_id(keys); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::Integer]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T::Array[::Integer]]) } def fetch_multi_id_by_title(keys); end end end @@ -160,10 +160,10 @@ def fetch_multi_by_blog_id(index_values, includes: nil); end sig { params(index_values: T::Enumerable[T.untyped], includes: T.untyped).returns(T::Array[::Post]) } def fetch_multi_by_title(index_values, includes: nil); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::Integer]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T::Array[::Integer]]) } def fetch_multi_id_by_blog_id(keys); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::Integer]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T.nilable(::Integer)]) } def fetch_multi_id_by_title(keys); end end end @@ -205,7 +205,7 @@ def fetch_id_by_blog_id_and_title(blog_id, title); end sig { params(index_values: T::Enumerable[T.untyped], includes: T.untyped).returns(T::Array[::Post]) } def fetch_multi_by_blog_id_and_title(index_values, includes: nil); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::Integer]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T::Array[::Integer]]) } def fetch_multi_id_by_blog_id_and_title(keys); end end end @@ -260,10 +260,10 @@ def fetch_multi_by_title(index_values, includes: nil); end sig { params(index_values: T::Enumerable[T.untyped], includes: T.untyped).returns(T::Array[::Post]) } def fetch_multi_by_title_and_review_date(index_values, includes: nil); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::Integer]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T::Array[::Integer]]) } def fetch_multi_id_by_title(keys); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Array[::Integer]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T.nilable(::Integer)]) } def fetch_multi_id_by_title_and_review_date(keys); end end end @@ -428,14 +428,53 @@ def fetch_author_by_id(id); end sig { params(id: T.untyped, author: T.untyped).returns(T.nilable(::String)) } def fetch_author_by_id_and_author(id, author); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, ::String]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T.nilable(::String)]) } def fetch_multi_author_by_id(keys); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, ::String]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T.nilable(::String)]) } def fetch_multi_author_by_id_and_author(keys); end - sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T::Array[::String]]) } + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T::Array[::T.nilable(::String)]]) } def fetch_multi_name_by_author(keys); end + + sig { params(author: T.untyped).returns(T::Array[::T.nilable(::String)]) } + def fetch_name_by_author(author); end + end + end + RBI + + assert_equal(expected, rbi_for(:Post)) + end + + it "allows missing unique cache attributes for non-nullable columns" do + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :posts do |t| + t.string :author, null: false + end + end + end + RUBY + + add_ruby_file("post.rb", <<~RUBY) + class Post < ActiveRecord::Base + include IdentityCache + + cache_attribute :author, by: :id + end + RUBY + + expected = <<~RBI + # typed: strong + + class Post + class << self + sig { params(id: T.untyped).returns(T.nilable(::String)) } + def fetch_author_by_id(id); end + + sig { params(keys: T::Enumerable[T.untyped]).returns(T::Hash[T.untyped, T.nilable(::String)]) } + def fetch_multi_author_by_id(keys); end end end RBI