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 0000000..0388d76 --- /dev/null +++ b/lib/active_record/tenanted/cross_tenant_associations.rb @@ -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) + @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 diff --git a/lib/active_record/tenanted/subtenant.rb b/lib/active_record/tenanted/subtenant.rb index 8175f12..d4a7a02 100644 --- a/lib/active_record/tenanted/subtenant.rb +++ b/lib/active_record/tenanted/subtenant.rb @@ -6,6 +6,8 @@ module Subtenant extend ActiveSupport::Concern class_methods do + include CrossTenantAssociations::ClassMethods + def tenanted? true end diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 89a2d9b..750f397 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 @@ -228,6 +230,7 @@ def _create_tenanted_pool(schema_version_check: true) # :nodoc: pool end + private def retrieve_connection_pool(strict:) role = current_role diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index ed5ea03..69aae86 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -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: "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 + + 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 + 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 + + 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 + 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 + + 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 for_each_scenario do