231 lines
8.6 KiB
Ruby
231 lines
8.6 KiB
Ruby
|
# frozen_string_literal: true
|
||
|
|
||
|
require 'spec_helper'
|
||
|
|
||
|
describe Gitlab::Database::PartitioningMigrationHelpers do
|
||
|
let(:model) do
|
||
|
ActiveRecord::Migration.new.extend(described_class)
|
||
|
end
|
||
|
let_it_be(:connection) { ActiveRecord::Base.connection }
|
||
|
let(:referenced_table) { :issues }
|
||
|
let(:function_name) { model.fk_function_name(referenced_table) }
|
||
|
let(:trigger_name) { model.fk_trigger_name(referenced_table) }
|
||
|
|
||
|
before do
|
||
|
allow(model).to receive(:puts)
|
||
|
end
|
||
|
|
||
|
describe 'adding a foreign key' do
|
||
|
before do
|
||
|
allow(model).to receive(:transaction_open?).and_return(false)
|
||
|
end
|
||
|
|
||
|
context 'when the table has no foreign keys' do
|
||
|
it 'creates a trigger function to handle the single cascade' do
|
||
|
model.add_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
|
||
|
expect_function_to_contain(function_name, 'delete from issue_assignees where issue_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the table already has foreign keys' do
|
||
|
context 'when the foreign key is from a different table' do
|
||
|
before do
|
||
|
model.add_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
end
|
||
|
|
||
|
it 'creates a trigger function to handle the multiple cascades' do
|
||
|
model.add_partitioned_foreign_key :epic_issues, referenced_table
|
||
|
|
||
|
expect_function_to_contain(function_name,
|
||
|
'delete from issue_assignees where issue_id = old.id',
|
||
|
'delete from epic_issues where issue_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the foreign key is from the same table' do
|
||
|
before do
|
||
|
model.add_partitioned_foreign_key :issues, referenced_table, column: :moved_to_id
|
||
|
end
|
||
|
|
||
|
context 'when the foreign key is from a different column' do
|
||
|
it 'creates a trigger function to handle the multiple cascades' do
|
||
|
model.add_partitioned_foreign_key :issues, referenced_table, column: :duplicated_to_id
|
||
|
|
||
|
expect_function_to_contain(function_name,
|
||
|
'delete from issues where moved_to_id = old.id',
|
||
|
'delete from issues where duplicated_to_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the foreign key is from the same column' do
|
||
|
it 'ignores the duplicate and properly recreates the trigger function' do
|
||
|
model.add_partitioned_foreign_key :issues, referenced_table, column: :moved_to_id
|
||
|
|
||
|
expect_function_to_contain(function_name, 'delete from issues where moved_to_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the foreign key is set to nullify' do
|
||
|
it 'creates a trigger function that nullifies the foreign key' do
|
||
|
model.add_partitioned_foreign_key :issue_assignees, referenced_table, on_delete: :nullify
|
||
|
|
||
|
expect_function_to_contain(function_name, 'update issue_assignees set issue_id = null where issue_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the referencing column is a custom value' do
|
||
|
it 'creates a trigger function with the correct column name' do
|
||
|
model.add_partitioned_foreign_key :issues, referenced_table, column: :duplicated_to_id
|
||
|
|
||
|
expect_function_to_contain(function_name, 'delete from issues where duplicated_to_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the referenced column is a custom value' do
|
||
|
let(:referenced_table) { :user_details }
|
||
|
|
||
|
it 'creates a trigger function with the correct column name' do
|
||
|
model.add_partitioned_foreign_key :user_preferences, referenced_table, column: :user_id, primary_key: :user_id
|
||
|
|
||
|
expect_function_to_contain(function_name, 'delete from user_preferences where user_id = old.user_id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the given key definition is invalid' do
|
||
|
it 'raises an error with the appropriate message' do
|
||
|
expect do
|
||
|
model.add_partitioned_foreign_key :issue_assignees, referenced_table, column: :not_a_real_issue_id
|
||
|
end.to raise_error(/From column must be a valid column/)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when run inside a transaction' do
|
||
|
it 'raises an error' do
|
||
|
expect(model).to receive(:transaction_open?).and_return(true)
|
||
|
|
||
|
expect do
|
||
|
model.add_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
end.to raise_error(/can not be run inside a transaction/)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'removing a foreign key' do
|
||
|
before do
|
||
|
allow(model).to receive(:transaction_open?).and_return(false)
|
||
|
end
|
||
|
|
||
|
context 'when the table has multiple foreign keys' do
|
||
|
before do
|
||
|
model.add_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
model.add_partitioned_foreign_key :epic_issues, referenced_table
|
||
|
end
|
||
|
|
||
|
it 'creates a trigger function without the removed cascade' do
|
||
|
expect_function_to_contain(function_name,
|
||
|
'delete from issue_assignees where issue_id = old.id',
|
||
|
'delete from epic_issues where issue_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
|
||
|
model.remove_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
|
||
|
expect_function_to_contain(function_name, 'delete from epic_issues where issue_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the table has only one remaining foreign key' do
|
||
|
before do
|
||
|
model.add_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
end
|
||
|
|
||
|
it 'removes the trigger function altogether' do
|
||
|
expect_function_to_contain(function_name, 'delete from issue_assignees where issue_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
|
||
|
model.remove_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
|
||
|
expect(find_function_def(function_name)).to be_nil
|
||
|
expect(find_trigger_def(trigger_name)).to be_nil
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when the foreign key does not exist' do
|
||
|
before do
|
||
|
model.add_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
end
|
||
|
|
||
|
it 'ignores the invalid key and properly recreates the trigger function' do
|
||
|
expect_function_to_contain(function_name, 'delete from issue_assignees where issue_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
|
||
|
model.remove_partitioned_foreign_key :issues, referenced_table, column: :moved_to_id
|
||
|
|
||
|
expect_function_to_contain(function_name, 'delete from issue_assignees where issue_id = old.id')
|
||
|
expect_valid_function_trigger(trigger_name, function_name)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
context 'when run outside a transaction' do
|
||
|
it 'raises an error' do
|
||
|
expect(model).to receive(:transaction_open?).and_return(true)
|
||
|
|
||
|
expect do
|
||
|
model.remove_partitioned_foreign_key :issue_assignees, referenced_table
|
||
|
end.to raise_error(/can not be run inside a transaction/)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
def expect_function_to_contain(name, *statements)
|
||
|
return_stmt, *body_stmts = parsed_function_statements(name).reverse
|
||
|
|
||
|
expect(return_stmt).to eq('return old')
|
||
|
expect(body_stmts).to contain_exactly(*statements)
|
||
|
end
|
||
|
|
||
|
def expect_valid_function_trigger(name, fn_name)
|
||
|
event, activation, definition = cleaned_trigger_def(name)
|
||
|
|
||
|
expect(event).to eq('delete')
|
||
|
expect(activation).to eq('after')
|
||
|
expect(definition).to eq("execute procedure #{fn_name}()")
|
||
|
end
|
||
|
|
||
|
def parsed_function_statements(name)
|
||
|
cleaned_definition = find_function_def(name)['fn_body'].downcase.gsub(/\s+/, ' ')
|
||
|
statements = cleaned_definition.sub(/\A\s*begin\s*(.*)\s*end\s*\Z/, "\\1")
|
||
|
statements.split(';').map! { |stmt| stmt.strip.presence }.compact!
|
||
|
end
|
||
|
|
||
|
def find_function_def(name)
|
||
|
connection.execute("select prosrc as fn_body from pg_proc where proname = '#{name}';").first
|
||
|
end
|
||
|
|
||
|
def cleaned_trigger_def(name)
|
||
|
find_trigger_def(name).values_at('event', 'activation', 'definition').map!(&:downcase)
|
||
|
end
|
||
|
|
||
|
def find_trigger_def(name)
|
||
|
connection.execute(<<~SQL).first
|
||
|
select
|
||
|
string_agg(event_manipulation, ',') as event,
|
||
|
action_timing as activation,
|
||
|
action_statement as definition
|
||
|
from information_schema.triggers
|
||
|
where trigger_name = '#{name}'
|
||
|
group by 2, 3
|
||
|
SQL
|
||
|
end
|
||
|
end
|