Skip to content
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

Fix the tenant scoping for update_all #223

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,55 @@ def delete_all
# Call the original delete_all method if the current tenant is identified by an ID
return super if MultiTenant.current_tenant_is_id? || MultiTenant.current_tenant.nil?

stmt = Arel::DeleteManager.new.from(table)
stmt.wheres = [generate_in_condition_subquery]

# Execute the delete statement using the connection and return the result
klass.connection.delete(stmt, "#{klass} Delete All").tap { reset }
end

# Overrides the update_all method to include tenant scoping
def update_all(updates)
# Call the original update_all method if the current tenant is identified by an ID
return super if MultiTenant.current_tenant_is_id? || MultiTenant.current_tenant.nil?

stmt = Arel::UpdateManager.new
stmt.table(table)
stmt.set Arel.sql(@klass.send(:sanitize_sql_for_assignment, updates))
Copy link
Contributor

Choose a reason for hiding this comment

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

@Amit909Singh @gurkanindibay In original Rails 7.2 implementation here is condition to if updates.is_a?(Hash) and then there is different logic:

https://github.com/rails/rails/blob/69c86f9606e490b24e8628dc47f34fbec6b321d1/activerecord/lib/active_record/relation.rb#L581

This now creates issue for me when using Rails 7.2 with counter_cache and I tracked it down to this line.
In Rails 7.2 the condition looks like column_count = COALESCE(column_count, 0) + 1 because it is not sanitized with sanitize_sql_for_assignment.

Here with multitenant gem it is always sanitized with sanitize_sql_for_assignment which creates column_count = NULL query.

Do you think we should copy-paste the logic from the original Rails's update_all method?

stmt.wheres = [generate_in_condition_subquery]

klass.connection.update(stmt, "#{klass} Update All").tap { reset }
end

private

# The generate_in_condition_subquery method generates a subquery that selects
# records associated with the current tenant.
def generate_in_condition_subquery
# Get the tenant key and tenant ID based on the current tenant
tenant_key = MultiTenant.partition_key(MultiTenant.current_tenant_class)
tenant_id = MultiTenant.current_tenant_id

# Build an Arel query
arel = eager_loading? ? apply_join_dependency.arel : build_arel
arel.source.left = table

# If the tenant ID is present and the tenant key is a column in the model,
# add a condition to only include records where the tenant key equals the tenant ID
if tenant_id && klass.column_names.include?(tenant_key)
# Check if the tenant key is present in the model's column names
tenant_condition = table[tenant_key].eq(tenant_id)
# Add the tenant condition to the arel query if it is not already present
unless arel.constraints.any? { |node| node.to_sql.include?(tenant_condition.to_sql) }
arel = arel.where(tenant_condition)
end
end

# Clone the query, clear its projections, and set its projection to the primary key of the table
subquery = arel.clone
subquery.projections.clear
subquery = subquery.project(table[primary_key])
in_condition = Arel::Nodes::In.new(table[primary_key], subquery.ast)
stmt = Arel::DeleteManager.new.from(table)
stmt.wheres = [in_condition]

# Execute the delete statement using the connection and return the result
klass.connection.delete(stmt, "#{klass} Delete All").tap { reset }
# Create an IN condition node with the primary key of the table and the subquery
Arel::Nodes::In.new(table[primary_key], subquery.ast)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/activerecord_multi_tenant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@
require_relative 'activerecord-multi-tenant/table_node'
require_relative 'activerecord-multi-tenant/version'
require_relative 'activerecord-multi-tenant/habtm'
require_relative 'activerecord-multi-tenant/delete_operations'
require_relative 'activerecord-multi-tenant/relation_extension'
102 changes: 102 additions & 0 deletions spec/activerecord-multi-tenant/query_rewriter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
require 'spec_helper'

describe 'Query Rewriter' do
before(:each) do
@queries = []
ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, _started, _finished, _unique_id, payload|
@queries << payload[:sql]
end
end

context 'when bulk updating' do
let!(:account) { Account.create!(name: 'Test Account') }
let!(:project) { Project.create(name: 'Project 1', account: account) }
Expand Down Expand Up @@ -35,6 +42,67 @@
project.update(name: 'New Name')
end.to change { project.reload.name }.from('Project 1').to('New Name')
end

it 'update_all the records with expected query' do
expected_query = <<-SQL.strip
UPDATE "projects" SET "name" = 'New Name' WHERE "projects"."id" IN
(SELECT "projects"."id" FROM "projects"
INNER JOIN "managers" ON "managers"."project_id" = "projects"."id"
and "managers"."account_id" = :account_id
WHERE "projects"."account_id" = :account_id
)
AND "projects"."account_id" = :account_id
SQL

expect do
MultiTenant.with(account) do
Project.joins(:manager).update_all(name: 'New Name')
end
end.to change { project.reload.name }.from('Project 1').to('New Name')

@queries.each do |actual_query|
next unless actual_query.include?('UPDATE "projects" SET "name"')

expect(format_sql(actual_query)).to eq(format_sql(expected_query.gsub(':account_id', account.id.to_s)))
end
end

it 'updates a limited number of records with expected query' do
# create 2 more projects
Project.create(name: 'project2', account: account)
Project.create(name: 'project3', account: account)
new_name = 'New Name'
limit = 2
expected_query = <<-SQL
UPDATE
"projects"
SET
"name" = 'New Name'
WHERE
"projects"."id" IN (
SELECT
"projects"."id"
FROM
"projects"
WHERE
"projects"."account_id" = #{account.id} LIMIT #{limit}
)
AND "projects"."account_id" = #{account.id}
SQL

expect do
MultiTenant.with(account) do
Project.limit(limit).update_all(name: new_name)
end
end.to change { Project.where(name: new_name).count }.from(0).to(limit)

@queries.each do |actual_query|
next unless actual_query.include?('UPDATE "projects" SET "name"')

expect(format_sql(actual_query.gsub('$1',
limit.to_s)).strip).to eq(format_sql(expected_query).strip)
end
end
end

context 'when bulk deleting' do
Expand Down Expand Up @@ -102,6 +170,40 @@
end.to change { Project.count }.from(3).to(1)
end

it 'deletes a limited number of records with expected query' do
# create 2 more projects
Project.create(name: 'project2', account: account)
Project.create(name: 'project3', account: account)
limit = 2
expected_query = <<-SQL
DELETE FROM
"projects"
WHERE
"projects"."id" IN (
SELECT
"projects"."id"
FROM
"projects"
WHERE
"projects"."account_id" = #{account.id} LIMIT #{limit}
)
AND "projects"."account_id" = #{account.id}
SQL

expect do
MultiTenant.with(account) do
Project.limit(limit).delete_all
end
end.to change { Project.count }.by(-limit)

@queries.each do |actual_query|
next unless actual_query.include?('DELETE FROM "projects"')

expect(format_sql(actual_query.gsub('$1',
limit.to_s)).strip).to eq(format_sql(expected_query).strip)
end
end

it 'destroy the record' do
expect do
MultiTenant.with(account) do
Expand Down