From ac6862228742e9dc077ff2db549159707f8a4280 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Fri, 26 Sep 2025 20:01:32 -0300 Subject: [PATCH 01/15] feat: add cross-tenant association support for has_one relationships and initial test --- lib/active_record/tenanted/subtenant.rb | 22 +++++++++ lib/active_record/tenanted/tenant.rb | 24 ++++++++++ test/unit/tenant_test.rb | 63 +++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/lib/active_record/tenanted/subtenant.rb b/lib/active_record/tenanted/subtenant.rb index 8175f125..1d9ec3ff 100644 --- a/lib/active_record/tenanted/subtenant.rb +++ b/lib/active_record/tenanted/subtenant.rb @@ -6,6 +6,11 @@ module Subtenant extend ActiveSupport::Concern class_methods do + def has_one(name, scope = nil, **options) + enhanced_scope = enhance_cross_tenant_association(name, scope, options, :has_one) + super(name, enhanced_scope, **options) + end + def tenanted? true end @@ -21,6 +26,23 @@ def tenanted_subtenant_of end delegate :current_tenant, :connection_pool, to: :tenanted_subtenant_of + + private + + def enhance_cross_tenant_association(name, scope, options, association_type) + target_class = options[:class_name]&.constantize || name.to_s.classify.constantize + + unless target_class.tenanted? + tenant_column = options[:tenant_column] || :tenant_id + + return ->(record) { + base_scope = scope ? target_class.instance_exec(&scope) : target_class.all + base_scope.where(tenant_column => record.tenant) + } + end + + scope + end end prepended do diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index dc91b979..db3ac372 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -231,6 +231,15 @@ def _create_tenanted_pool(schema_version_check: true) # :nodoc: pool end + def has_one(name, scope = nil, **options) + p "HELLO" + p name + p scope + p options + enhanced_scope = enhance_cross_tenant_association(name, scope, options, :has_one) + super(name, enhanced_scope, **options) + end + private def retrieve_connection_pool(strict:) role = current_role @@ -260,6 +269,21 @@ def log_tenant_tag(tenant_name, &block) yield end end + + def enhance_cross_tenant_association(name, scope, options, association_type) + target_class = options[:class_name]&.constantize || name.to_s.classify.constantize + + unless target_class.tenanted? + tenant_column = options[:tenant_column] || :tenant_id + + return ->(record) { + base_scope = scope ? target_class.instance_exec(&scope) : target_class.all + base_scope.where(tenant_column => record.tenant) + } + end + + scope + end end prepended do diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index d77fd243..c54b1ecd 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -581,6 +581,69 @@ end end + describe "cross-tenant associations" do + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + User.has_one :announcement + Announcement.belongs_to :user + end + + test "has_one automatically scopes by tenant_id" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Foo announcement", tenant_id: "foo", user: user) + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user@bar.example.org") + + assert_nil user.announcement + end + + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_not_nil user.announcement + assert_equal "Foo announcement", user.announcement.message + end + end + end + end + + describe "cross-tenant associations with scope" do + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + User.has_one :announcement, -> { where(message: "Special announcement") } + Announcement.belongs_to :user + end + + test "has_one automatically scopes by tenant_id and scope" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Special announcement", tenant_id: "foo", user: user) + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user@bar.example.org") + + assert_nil user.announcement + end + + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_not_nil user.announcement + assert_equal "Special announcement", user.announcement.message + end + end + end + end describe ".without_tenant" do for_each_scenario do From b2e28f595e3f7992c501ee08465cefbee60c03f2 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 27 Sep 2025 13:47:55 -0300 Subject: [PATCH 02/15] extracting methods to CrossTenantAssociations module --- .../tenanted/cross_tenant_associations.rb | 32 +++++++++++++++++++ lib/active_record/tenanted/subtenant.rb | 22 +------------ lib/active_record/tenanted/tenant.rb | 25 ++------------- 3 files changed, 35 insertions(+), 44 deletions(-) create mode 100644 lib/active_record/tenanted/cross_tenant_associations.rb diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb new file mode 100644 index 00000000..5ca96083 --- /dev/null +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module CrossTenantAssociations + extend ActiveSupport::Concern + + class_methods do + def has_one(name, scope = nil, **options) + enhanced_scope = enhance_cross_tenant_association(name, scope, options, :has_one) + super(name, enhanced_scope, **options) + end + + private + def enhance_cross_tenant_association(name, scope, options, association_type) + target_class = options[:class_name]&.constantize || name.to_s.classify.constantize + + unless target_class.tenanted? + tenant_column = options[:tenant_column] || :tenant_id + + return ->(record) { + base_scope = scope ? target_class.instance_exec(&scope) : target_class.all + base_scope.where(tenant_column => record.tenant) + } + end + + scope + end + end + end + end +end diff --git a/lib/active_record/tenanted/subtenant.rb b/lib/active_record/tenanted/subtenant.rb index 1d9ec3ff..d4a7a02f 100644 --- a/lib/active_record/tenanted/subtenant.rb +++ b/lib/active_record/tenanted/subtenant.rb @@ -6,10 +6,7 @@ module Subtenant extend ActiveSupport::Concern class_methods do - def has_one(name, scope = nil, **options) - enhanced_scope = enhance_cross_tenant_association(name, scope, options, :has_one) - super(name, enhanced_scope, **options) - end + include CrossTenantAssociations::ClassMethods def tenanted? true @@ -26,23 +23,6 @@ def tenanted_subtenant_of end delegate :current_tenant, :connection_pool, to: :tenanted_subtenant_of - - private - - def enhance_cross_tenant_association(name, scope, options, association_type) - target_class = options[:class_name]&.constantize || name.to_s.classify.constantize - - unless target_class.tenanted? - tenant_column = options[:tenant_column] || :tenant_id - - return ->(record) { - base_scope = scope ? target_class.instance_exec(&scope) : target_class.all - base_scope.where(tenant_column => record.tenant) - } - end - - scope - end end prepended do diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index db3ac372..d20e1cd6 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -85,6 +85,8 @@ def to_s CONNECTION_POOL_CREATION_LOCK = Thread::Mutex.new # :nodoc: class_methods do + include CrossTenantAssociations::ClassMethods + def tenanted? true end @@ -231,14 +233,6 @@ def _create_tenanted_pool(schema_version_check: true) # :nodoc: pool end - def has_one(name, scope = nil, **options) - p "HELLO" - p name - p scope - p options - enhanced_scope = enhance_cross_tenant_association(name, scope, options, :has_one) - super(name, enhanced_scope, **options) - end private def retrieve_connection_pool(strict:) @@ -269,21 +263,6 @@ def log_tenant_tag(tenant_name, &block) yield end end - - def enhance_cross_tenant_association(name, scope, options, association_type) - target_class = options[:class_name]&.constantize || name.to_s.classify.constantize - - unless target_class.tenanted? - tenant_column = options[:tenant_column] || :tenant_id - - return ->(record) { - base_scope = scope ? target_class.instance_exec(&scope) : target_class.all - base_scope.where(tenant_column => record.tenant) - } - end - - scope - end end prepended do From 31c8b09231fc6d35f59d0da8bb508ef008d38d25 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 27 Sep 2025 15:05:11 -0300 Subject: [PATCH 03/15] feat: add support for custom tenant column in cross-tenant associations --- .../tenanted/cross_tenant_associations.rb | 17 ++++++++-- test/unit/tenant_test.rb | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb index 5ca96083..cac4b3ce 100644 --- a/lib/active_record/tenanted/cross_tenant_associations.rb +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -6,8 +6,21 @@ module CrossTenantAssociations extend ActiveSupport::Concern class_methods do + # I think we can have more configs later + def cross_tenant_config(**config) + @cross_tenant_config = config + end + + def get_cross_tenant_config + @cross_tenant_config ||= {} + end + def has_one(name, scope = nil, **options) - enhanced_scope = enhance_cross_tenant_association(name, scope, options, :has_one) + config = get_cross_tenant_config + tenant_column = config[:tenant_column] || :tenant_id + custom_options = options.merge(tenant_column: tenant_column) + + enhanced_scope = enhance_cross_tenant_association(name, scope, custom_options, :has_one) super(name, enhanced_scope, **options) end @@ -16,7 +29,7 @@ def enhance_cross_tenant_association(name, scope, options, association_type) target_class = options[:class_name]&.constantize || name.to_s.classify.constantize unless target_class.tenanted? - tenant_column = options[:tenant_column] || :tenant_id + tenant_column = options[:tenant_column] return ->(record) { base_scope = scope ? target_class.instance_exec(&scope) : target_class.all diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index c54b1ecd..426f8f0f 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -645,6 +645,39 @@ end end + describe "cross-tenant associations with custom tenant column and class name" do + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :custom_tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + User.cross_tenant_config(tenant_column: :custom_tenant_id) + User.has_one :announcement, class_name: "Announcement" + Announcement.belongs_to :user + end + + test "has_one automatically scopes by custom tenant id and class name" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Special announcement", custom_tenant_id: "foo", user: user) + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user@bar.example.org") + + assert_nil user.announcement + end + + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_not_nil user.announcement + assert_equal "Special announcement", user.announcement.message + end + end + end + end + describe ".without_tenant" do for_each_scenario do setup do From 5cd9edaef9aa4abd070cb3accaf01fd40810c013 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 27 Sep 2025 16:06:54 -0300 Subject: [PATCH 04/15] test: add scenarios for has_many associations --- test/unit/tenant_test.rb | 104 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 426f8f0f..070ef5b6 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -611,6 +611,41 @@ end end end + + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + User.has_many :announcements + Announcement.belongs_to :user + end + + test "has_many automatically scopes by tenant_id" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Foo announcement", tenant_id: "foo", user: user) + Announcement.create!(message: "Another Foo announcement", tenant_id: "foo", user: user) + Announcement.create!(message: "Yet another Foo announcement", tenant_id: "foo", user: user) + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user@bar.example.org") + + assert_equal 0, user.announcements.count + end + + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_not_nil user.announcements + assert_equal 3, user.announcements.count + assert_equal "Foo announcement", user.announcements.first.message + assert_equal "Another Foo announcement", user.announcements.second.message + assert_equal "Yet another Foo announcement", user.announcements.third.message + end + end + end end describe "cross-tenant associations with scope" do @@ -643,6 +678,39 @@ end end end + + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + User.has_many :announcements, -> { where(message: "Special announcement") } + Announcement.belongs_to :user + end + + test "has_many automatically scopes by tenant_id and scope" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Special announcement", tenant_id: "foo", user: user) + Announcement.create!(message: "Another announcement", tenant_id: "foo", user: user) + Announcement.create!(message: "Yet another announcement", tenant_id: "foo", user: user) + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user@bar.example.org") + + assert_equal 0, user.announcements.count + end + + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_not_nil user.announcements + assert_equal 1, user.announcements.count + assert_equal "Special announcement", user.announcements.first.message + end + end + end end describe "cross-tenant associations with custom tenant column and class name" do @@ -676,6 +744,42 @@ end end end + + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :custom_tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + User.cross_tenant_config(tenant_column: :custom_tenant_id) + User.has_many :announcements, class_name: "Announcement" + Announcement.belongs_to :user + end + + test "has_many automatically scopes by custom tenant id and class name" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Foo announcement", custom_tenant_id: "foo", user: user) + Announcement.create!(message: "Another Foo announcement", custom_tenant_id: "foo", user: user) + Announcement.create!(message: "Yet another Foo announcement", custom_tenant_id: "foo", user: user) + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user@bar.example.org") + + assert_equal 0, user.announcements.count + end + + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_not_nil user.announcements + assert_equal 3, user.announcements.count + assert_equal "Foo announcement", user.announcements.first.message + assert_equal "Another Foo announcement", user.announcements.second.message + assert_equal "Yet another Foo announcement", user.announcements.third.message + end + end + end end describe ".without_tenant" do From 2f9fcea39382eb360f5bf2a24d3a10829f508679 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 27 Sep 2025 16:07:07 -0300 Subject: [PATCH 05/15] refactor: streamline has_one and has_many association methods --- .../tenanted/cross_tenant_associations.rb | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb index cac4b3ce..9b1f2433 100644 --- a/lib/active_record/tenanted/cross_tenant_associations.rb +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -16,16 +16,25 @@ def get_cross_tenant_config end def has_one(name, scope = nil, **options) - config = get_cross_tenant_config - tenant_column = config[:tenant_column] || :tenant_id - custom_options = options.merge(tenant_column: tenant_column) + define_enhanced_association(:has_one, name, scope, **options) + end - enhanced_scope = enhance_cross_tenant_association(name, scope, custom_options, :has_one) - super(name, enhanced_scope, **options) + def has_many(name, scope = nil, **options) + define_enhanced_association(:has_many, name, scope, **options) end private - def enhance_cross_tenant_association(name, scope, options, association_type) + # For now association methods are identical + def define_enhanced_association(association_type, name, scope, **options) + config = get_cross_tenant_config + tenant_column = config[:tenant_column] || :tenant_id + custom_options = options.merge(tenant_column: tenant_column) + + enhanced_scope = enhance_cross_tenant_association(name, scope, custom_options) + method(association_type).super_method.call(name, enhanced_scope, **options) + end + + def enhance_cross_tenant_association(name, scope, options) target_class = options[:class_name]&.constantize || name.to_s.classify.constantize unless target_class.tenanted? From d55bb3c0bdbd1147a7445aad674356cf9e39b977 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 27 Sep 2025 16:20:45 -0300 Subject: [PATCH 06/15] fix: handles when class not loaded during Rails initialization --- lib/active_record/tenanted/cross_tenant_associations.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb index 9b1f2433..6234cc9e 100644 --- a/lib/active_record/tenanted/cross_tenant_associations.rb +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -35,7 +35,12 @@ def define_enhanced_association(association_type, name, scope, **options) end def enhance_cross_tenant_association(name, scope, options) - target_class = options[:class_name]&.constantize || name.to_s.classify.constantize + begin + target_class = options[:class_name]&.constantize || name.to_s.classify.constantize + rescue NameError + # Class not yet loaded during Rails initialization, skip enhancement + return scope + end unless target_class.tenanted? tenant_column = options[:tenant_column] From e3f5e7c01895abf10cd9e0af39ff8eb873e28f89 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 27 Sep 2025 16:27:51 -0300 Subject: [PATCH 07/15] improve handle class loading --- lib/active_record/tenanted/cross_tenant_associations.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb index 6234cc9e..0388d765 100644 --- a/lib/active_record/tenanted/cross_tenant_associations.rb +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -35,12 +35,9 @@ def define_enhanced_association(association_type, name, scope, **options) end def enhance_cross_tenant_association(name, scope, options) - begin - target_class = options[:class_name]&.constantize || name.to_s.classify.constantize - rescue NameError - # Class not yet loaded during Rails initialization, skip enhancement - return scope - end + target_class = options[:class_name]&.safe_constantize || name.to_s.classify.safe_constantize + + return scope unless target_class unless target_class.tenanted? tenant_column = options[:tenant_column] From 9066334ea3e613ad8772ae55511b21b7e5ab4276 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 18 Oct 2025 12:19:20 -0300 Subject: [PATCH 08/15] refactor: remove cross tenant configuration --- .../tenanted/cross_tenant_associations.rb | 18 ++++-------------- test/unit/tenant_test.rb | 6 ++---- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb index 0388d765..3bae798b 100644 --- a/lib/active_record/tenanted/cross_tenant_associations.rb +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -6,15 +6,6 @@ module CrossTenantAssociations extend ActiveSupport::Concern class_methods do - # I think we can have more configs later - def cross_tenant_config(**config) - @cross_tenant_config = config - end - - def get_cross_tenant_config - @cross_tenant_config ||= {} - end - def has_one(name, scope = nil, **options) define_enhanced_association(:has_one, name, scope, **options) end @@ -26,9 +17,8 @@ def has_many(name, scope = nil, **options) private # For now association methods are identical def define_enhanced_association(association_type, name, scope, **options) - config = get_cross_tenant_config - tenant_column = config[:tenant_column] || :tenant_id - custom_options = options.merge(tenant_column: tenant_column) + tenant_key = options.delete(:tenant_key) + custom_options = { tenant_key: tenant_key || :tenant_id } enhanced_scope = enhance_cross_tenant_association(name, scope, custom_options) method(association_type).super_method.call(name, enhanced_scope, **options) @@ -40,11 +30,11 @@ def enhance_cross_tenant_association(name, scope, options) return scope unless target_class unless target_class.tenanted? - tenant_column = options[:tenant_column] + tenant_key = options[:tenant_key] return ->(record) { base_scope = scope ? target_class.instance_exec(&scope) : target_class.all - base_scope.where(tenant_column => record.tenant) + base_scope.where(tenant_key => record.tenant) } end diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 070ef5b6..5778e07c 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -719,8 +719,7 @@ ActiveRecord::Base.connection.add_column :announcements, :custom_tenant_id, :string ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer - User.cross_tenant_config(tenant_column: :custom_tenant_id) - User.has_one :announcement, class_name: "Announcement" + User.has_one :announcement, class_name: "Announcement", tenant_key: :custom_tenant_id Announcement.belongs_to :user end @@ -750,8 +749,7 @@ ActiveRecord::Base.connection.add_column :announcements, :custom_tenant_id, :string ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer - User.cross_tenant_config(tenant_column: :custom_tenant_id) - User.has_many :announcements, class_name: "Announcement" + User.has_many :announcements, class_name: "Announcement", tenant_key: :custom_tenant_id Announcement.belongs_to :user end From 9e09bee39a77b9f1888539d798a526f51522504d Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 18 Oct 2025 14:17:05 -0300 Subject: [PATCH 09/15] feat: add belongs_to association with tenant key auto-population and tests --- .../tenanted/cross_tenant_associations.rb | 15 ++++++ test/unit/tenant_test.rb | 51 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb index 3bae798b..8183f232 100644 --- a/lib/active_record/tenanted/cross_tenant_associations.rb +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -14,6 +14,21 @@ def has_many(name, scope = nil, **options) define_enhanced_association(:has_many, name, scope, **options) end + def belongs_to(name, scope = nil, **options) + tenant_key = options.delete(:tenant_key) + + super(name, scope, **options) + + if tenant_key + define_method("#{name}=") do |value| + super(value) + if value.respond_to?(:tenant) + self.send("#{tenant_key}=", value.tenant) + end + end + end + end + private # For now association methods are identical def define_enhanced_association(association_type, name, scope, **options) diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 5778e07c..3828355c 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -780,6 +780,57 @@ end end + describe "cross-tenant associations with belongs_to" do + for_each_scenario do + setup do + with_migration "20250830152220_create_posts.rb" + + Post.belongs_to :author, class_name: "User", foreign_key: "user_id", tenant_key: :author_tenant_id + end + + test "belongs_to automatically populates tenant column when association is set" do + TenantedApplicationRecord.create_tenant("foo") do + Post.connection.add_column :posts, :author_tenant_id, :string + + user = User.create!(email: "author@foo.example.org") + + post = Post.new(title: "Test Post") + post.author = user + + assert_equal "foo", post.author_tenant_id + + post.save! + + saved_post = Post.find(post.id) + assert_equal "foo", saved_post.author_tenant_id + end + end + + test "belongs_to auto-population works when creating with association in constructor" do + TenantedApplicationRecord.create_tenant("bar") do + Post.connection.add_column :posts, :author_tenant_id, :string + + user = User.create!(email: "author@bar.example.org") + + post = Post.create!(title: "Test Post", author: user) + + assert_equal "bar", post.author_tenant_id + end + end + + test "belongs_to auto-population handles nil association" do + TenantedApplicationRecord.create_tenant("baz") do + Post.connection.add_column :posts, :author_tenant_id, :string + + post = Post.new(title: "Test Post") + post.author = nil + + assert_nil post.author_tenant_id + end + end + end + end + describe ".without_tenant" do for_each_scenario do setup do From 28f8fff5cf5537c2845cab3463aa5cdbb44f41b4 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 18 Oct 2025 14:26:21 -0300 Subject: [PATCH 10/15] test: add more tests for belongs_to associations --- test/unit/tenant_test.rb | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 3828355c..5c594df9 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -828,6 +828,65 @@ assert_nil post.author_tenant_id end end + + test "belongs_to updates tenant column when reassigning to different user" do + TenantedApplicationRecord.create_tenant("foo") do + Post.connection.add_column :posts, :author_tenant_id, :string + + user1 = User.create!(email: "author1@foo.example.org") + user2 = User.create!(email: "author2@foo.example.org") + + post = Post.new(title: "Test Post") + post.author = user1 + + assert_equal "foo", post.author_tenant_id + + post.author = user2 + + assert_equal "foo", post.author_tenant_id + end + end + + test "belongs_to without tenant_key does not auto-populate" do + TenantedApplicationRecord.create_tenant("foo") do + Post.connection.add_column :posts, :some_tenant_column, :string + + Post.belongs_to :editor, class_name: "User", foreign_key: "user_id" + + user = User.create!(email: "editor@foo.example.org") + post = Post.new(title: "Test Post", some_tenant_column: nil) + post.editor = user + + assert_nil post.some_tenant_column + end + end + + test "multiple belongs_to associations with different tenant_keys work independently" do + TenantedApplicationRecord.create_tenant("foo") do + Post.connection.add_column :posts, :author_tenant_id, :string + Post.connection.add_column :posts, :reviewer_tenant_id, :string + Post.connection.add_column :posts, :reviewer_id, :integer + + Post.belongs_to :author, class_name: "User", foreign_key: "user_id", tenant_key: :author_tenant_id + Post.belongs_to :reviewer, class_name: "User", foreign_key: "reviewer_id", tenant_key: :reviewer_tenant_id + + author = User.create!(email: "author@foo.example.org") + reviewer = User.create!(email: "reviewer@foo.example.org") + + post = Post.new(title: "Test Post") + post.author = author + post.reviewer = reviewer + + assert_equal "foo", post.author_tenant_id + assert_equal "foo", post.reviewer_tenant_id + + post.save! + + saved_post = Post.find(post.id) + assert_equal "foo", saved_post.author_tenant_id + assert_equal "foo", saved_post.reviewer_tenant_id + end + end end end From 85425495198cc7692b9a5882fab66f85dbf0447c Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Sat, 18 Oct 2025 14:31:12 -0300 Subject: [PATCH 11/15] test: add test for belongs_to auto-population during record updates --- test/unit/tenant_test.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 5c594df9..9161930e 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -887,6 +887,22 @@ assert_equal "foo", saved_post.reviewer_tenant_id end end + + test "belongs_to auto-population works when updating existing record" do + TenantedApplicationRecord.create_tenant("foo") do + Post.connection.add_column :posts, :author_tenant_id, :string + + user1 = User.create!(email: "author1@foo.example.org") + user2 = User.create!(email: "author2@foo.example.org") + + post = Post.create!(title: "Test Post", author: user1) + assert_equal "foo", post.author_tenant_id + + post.update!(author: user2) + + assert_equal "foo", post.reload.author_tenant_id + end + end end end From 02ca9e110a43b661ed114928b168633e681b0be8 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Tue, 14 Apr 2026 16:35:39 -0300 Subject: [PATCH 12/15] enhance belongs_to association with tenant key handling --- lib/active_record/tenanted/base.rb | 35 +++++++++++++++++++ .../tenanted/cross_tenant_associations.rb | 15 -------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/lib/active_record/tenanted/base.rb b/lib/active_record/tenanted/base.rb index aeb02fae..3bd42e3c 100644 --- a/lib/active_record/tenanted/base.rb +++ b/lib/active_record/tenanted/base.rb @@ -34,6 +34,41 @@ def tenanted? false end + def belongs_to(name, scope = nil, **options) + tenant_key = options.delete(:tenant_key) + super(name, scope, **options) + + if tenant_key + define_method("#{name}=") do |value| + super(value) + if value.respond_to?(:tenant) + self.send("#{tenant_key}=", value.tenant) + end + end + + unless tenanted? + define_method(name) do + tenant_value = send(tenant_key) + return nil unless tenant_value + target_klass = self.class.reflect_on_association(name).klass + if target_klass.tenanted? + tenant_klass = if target_klass.respond_to?(:with_tenant) + target_klass + else + target_klass.tenanted_subtenant_of + end + + tenant_klass.prohibit_shard_swapping(false) do + tenant_klass.with_tenant(tenant_value) { super() } + end + else + super() + end + end + end + end + end + def table_exists? super rescue ActiveRecord::Tenanted::NoTenantError diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb index 8183f232..3bae798b 100644 --- a/lib/active_record/tenanted/cross_tenant_associations.rb +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -14,21 +14,6 @@ def has_many(name, scope = nil, **options) define_enhanced_association(:has_many, name, scope, **options) end - def belongs_to(name, scope = nil, **options) - tenant_key = options.delete(:tenant_key) - - super(name, scope, **options) - - if tenant_key - define_method("#{name}=") do |value| - super(value) - if value.respond_to?(:tenant) - self.send("#{tenant_key}=", value.tenant) - end - end - end - end - private # For now association methods are identical def define_enhanced_association(association_type, name, scope, **options) From 2117244b26f1bda21434e95c1848cf497ab00c72 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Tue, 14 Apr 2026 17:59:56 -0300 Subject: [PATCH 13/15] add tests for cross-tenant belongs_to associations from untenanted to tenanted --- test/unit/tenant_test.rb | 123 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 9161930e..9bfa4713 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -906,6 +906,129 @@ end end + describe "cross-tenant belongs_to from untenanted to tenanted" do + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + Announcement.belongs_to :user, tenant_key: :tenant_id, optional: true + end + + test "belongs_to read automatically switches to correct tenant" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Foo announcement", tenant_id: "foo", user_id: user.id) + end + + announcement = Announcement.find_by(message: "Foo announcement") + + TenantedApplicationRecord.with_tenant("foo") do + assert_not_nil announcement.user + assert_equal "user@foo.example.org", announcement.user.email + end + end + + test "belongs_to read returns nil when tenant_key is nil" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Test announcement", tenant_id: "foo", user_id: user.id) + end + + announcement = Announcement.find_by(message: "Test announcement") + announcement.update_column(:tenant_id, nil) + + assert_nil announcement.user + end + + test "belongs_to read works outside of any tenant context" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Foo announcement", tenant_id: "foo", user_id: user.id) + end + + announcement = Announcement.find_by(message: "Foo announcement") + + assert_not_nil announcement.user + assert_equal "user@foo.example.org", announcement.user.email + end + + test "belongs_to read transparently switches when in a different tenant context" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Foo announcement", tenant_id: "foo", user_id: user.id) + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user@bar.example.org") + end + + announcement = Announcement.find_by(message: "Foo announcement") + + TenantedApplicationRecord.with_tenant("bar") do + loaded_user = announcement.user + + assert_not_nil loaded_user + assert_equal "user@foo.example.org", loaded_user.email + assert_equal "foo", loaded_user.tenant + end + end + + test "belongs_to write auto-populates tenant column from untenanted model" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + + announcement = Announcement.new(message: "Test announcement") + announcement.user = user + + assert_equal "foo", announcement.tenant_id + end + end + + test "belongs_to write auto-populates tenant column when creating with association" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + + announcement = Announcement.create!(message: "Test announcement", user: user) + + assert_equal "foo", announcement.tenant_id + assert_equal user.id, announcement.user_id + end + end + + test "belongs_to with custom tenant_key reads from correct tenant" do + ActiveRecord::Base.connection.add_column :announcements, :author_tenant, :string + + Announcement.belongs_to :author, class_name: "User", foreign_key: "user_id", tenant_key: :author_tenant, optional: true + + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "author@foo.example.org") + Announcement.create!(message: "Custom key announcement", author_tenant: "foo", user_id: user.id) + end + + announcement = Announcement.find_by(message: "Custom key announcement") + + assert_not_nil announcement.author + assert_equal "author@foo.example.org", announcement.author.email + end + + test "belongs_to with custom tenant_key auto-populates on write" do + ActiveRecord::Base.connection.add_column :announcements, :author_tenant, :string + + Announcement.belongs_to :author, class_name: "User", foreign_key: "user_id", tenant_key: :author_tenant, optional: true + + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "author@foo.example.org") + + announcement = Announcement.new(message: "Custom key announcement") + announcement.author = user + + assert_equal "foo", announcement.author_tenant + end + end + end + end + describe ".without_tenant" do for_each_scenario do setup do From 4c1e288f939543d2464f5f8881b79cbe96ba7b90 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Tue, 14 Apr 2026 19:14:40 -0300 Subject: [PATCH 14/15] streamline association definition and enhance_cross_tenant_association methods --- .../tenanted/cross_tenant_associations.rb | 27 +++++++++---------- lib/active_record/tenanted/tenant.rb | 1 - 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/lib/active_record/tenanted/cross_tenant_associations.rb b/lib/active_record/tenanted/cross_tenant_associations.rb index 3bae798b..9bf23503 100644 --- a/lib/active_record/tenanted/cross_tenant_associations.rb +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -18,27 +18,24 @@ def has_many(name, scope = nil, **options) # For now association methods are identical def define_enhanced_association(association_type, name, scope, **options) tenant_key = options.delete(:tenant_key) - custom_options = { tenant_key: tenant_key || :tenant_id } - - enhanced_scope = enhance_cross_tenant_association(name, scope, custom_options) + class_name = options[:class_name] + enhanced_scope = enhance_cross_tenant_association(name, scope, tenant_key: tenant_key || :tenant_id, class_name: class_name) method(association_type).super_method.call(name, enhanced_scope, **options) end - def enhance_cross_tenant_association(name, scope, options) - target_class = options[:class_name]&.safe_constantize || name.to_s.classify.safe_constantize - - return scope unless target_class + def enhance_cross_tenant_association(name, scope, tenant_key:, class_name: nil) + resolved_class_name = class_name || name.to_s.classify - unless target_class.tenanted? - tenant_key = options[:tenant_key] + ->(record) { + target_class = resolved_class_name.constantize + base_scope = scope ? target_class.instance_exec(&scope) : target_class.all - return ->(record) { - base_scope = scope ? target_class.instance_exec(&scope) : target_class.all + if target_class.tenanted? + base_scope + else base_scope.where(tenant_key => record.tenant) - } - end - - scope + end + } end end end diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index d20e1cd6..bb8b844a 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -233,7 +233,6 @@ def _create_tenanted_pool(schema_version_check: true) # :nodoc: pool end - private def retrieve_connection_pool(strict:) role = current_role From 4a10cde401caa7c61ec508c0397de93b08791280 Mon Sep 17 00:00:00 2001 From: Miguel Marcondes Date: Tue, 14 Apr 2026 19:15:38 -0300 Subject: [PATCH 15/15] add tests for cross-tenant associations with non-standard association names --- test/unit/tenant_test.rb | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 9bfa4713..6998f574 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -780,6 +780,66 @@ end end + describe "cross-tenant associations with non-standard association name" do + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + User.has_one :notice, class_name: "Announcement", foreign_key: "user_id", tenant_key: :tenant_id + end + + test "has_one resolves class_name correctly when association name differs" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Notice test", tenant_id: "foo", user_id: user.id) + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user@bar.example.org") + + assert_nil user.notice + end + + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_not_nil user.notice + assert_equal "Notice test", user.notice.message + end + end + end + + for_each_scenario do + setup do + ActiveRecord::Base.connection.add_column :announcements, :tenant_id, :string + ActiveRecord::Base.connection.add_column :announcements, :user_id, :integer + + User.has_many :notices, class_name: "Announcement", foreign_key: "user_id", tenant_key: :tenant_id + end + + test "has_many resolves class_name correctly when association name differs" do + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user@foo.example.org") + Announcement.create!(message: "Notice 1", tenant_id: "foo", user_id: user.id) + Announcement.create!(message: "Notice 2", tenant_id: "foo", user_id: user.id) + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user@bar.example.org") + + assert_equal 0, user.notices.count + end + + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_equal 2, user.notices.count + end + end + end + end + describe "cross-tenant associations with belongs_to" do for_each_scenario do setup do