Skip to content
56 changes: 56 additions & 0 deletions lib/active_record/tenanted/cross_tenant_associations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

module ActiveRecord
module Tenanted
module CrossTenantAssociations
extend ActiveSupport::Concern

class_methods do
# I think we can have more configs later
def cross_tenant_config(**config)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I want to have a general cross-tenant config on a model. I think it would be better to have the config per-association.

For example, if we want to send messages between users we might do something like:

class Message < UntenantedRecord
  belongs_to :sender, class_name: "User", foreign_key: "sender_id"
  belongs_to :recipient, class_name: "User", foreign_key: "recipient_id"
end

class User < TenantedRecord
  has_many :sent_messages, class_name: "Message", inverse_of: :sender, foreign_key: "sender_id"
  has_many :received_messages, class_name: "Message", inverse_of: :recipient, foreign_key: "recipient_id"
end

In this case, I would want separate tenant columns for the 'sender' and 'recipient' associations.

And so it might make more sense for this to be customized on the association like:

belongs_to :sender, class_name: "User",
                    foreign_key: "sender_id",
                    tenant_key: "sender_tenant"

and

  has_many :sent_messages,
           class_name: "Message",
           inverse_of: :sender,
           foreign_key: "sender_id",
           tenant_key: "sender_tenant"

@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

def has_many(name, scope = nil, **options)
define_enhanced_association(:has_many, name, scope, **options)
end

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)

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]&.safe_constantize || name.to_s.classify.safe_constantize

return scope unless target_class

unless target_class.tenanted?
tenant_column = options[:tenant_column]

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
2 changes: 2 additions & 0 deletions lib/active_record/tenanted/subtenant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module Subtenant
extend ActiveSupport::Concern

class_methods do
include CrossTenantAssociations::ClassMethods

def tenanted?
true
end
Expand Down
3 changes: 3 additions & 0 deletions lib/active_record/tenanted/tenant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -228,6 +230,7 @@ def _create_tenanted_pool(schema_version_check: true) # :nodoc:
pool
end


private
def retrieve_connection_pool(strict:)
role = current_role
Expand Down
200 changes: 200 additions & 0 deletions test/unit/tenant_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,206 @@
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: "[email protected]")
Announcement.create!(message: "Foo announcement", tenant_id: "foo", user: user)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally I'd like to be able to do this in an untenanted scope, but it looks like AR does some validation that requires a tenanted connection ...

I'd also like to be able to do this without explicitly setting the tenant id in Announcement, that is:

Announcement.create! user: user

should set the tenant column automatically.

end

TenantedApplicationRecord.create_tenant("bar") do
user = User.create!(email: "[email protected]")

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

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: "[email protected]")
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: "[email protected]")

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
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: "[email protected]")
Announcement.create!(message: "Special announcement", tenant_id: "foo", user: user)
end

TenantedApplicationRecord.create_tenant("bar") do
user = User.create!(email: "[email protected]")

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

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: "[email protected]")
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: "[email protected]")

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
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: "[email protected]")
Announcement.create!(message: "Special announcement", custom_tenant_id: "foo", user: user)
end

TenantedApplicationRecord.create_tenant("bar") do
user = User.create!(email: "[email protected]")

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

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: "[email protected]")
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: "[email protected]")

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
for_each_scenario do
Expand Down