-
Notifications
You must be signed in to change notification settings - Fork 13
#201 | Enhanced tenanted and untenanted associations #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5c0098d
68289f1
8467cd1
66abbfe
b31f354
97e6550
3895dd5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) | ||
@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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
There was a problem hiding this comment.
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:
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:
and