Commit ee816a4c authored by Dylan Griffith's avatar Dylan Griffith

Merge branch '334289-remove-pg11-partitioned-foreign-keys' into 'master'

Remove code for PG11 custom foreign keys

See merge request gitlab-org/gitlab!64658
parents c5a28073 5af8afcd
......@@ -79,135 +79,6 @@ module Gitlab
"#{prefix}#{hashed_identifier}"
end
# Creates a "foreign key" that references a partitioned table. Because foreign keys referencing partitioned
# tables are not supported in PG11, this does not create a true database foreign key, but instead implements the
# same functionality at the database level by using triggers.
#
# Example:
#
# add_partitioned_foreign_key :issues, :projects
#
# Available options:
#
# :column - name of the referencing column (otherwise inferred from the referenced table name)
# :primary_key - name of the primary key in the referenced table (defaults to id)
# :on_delete - supports either :cascade for ON DELETE CASCADE or :nullify for ON DELETE SET NULL
#
def add_partitioned_foreign_key(from_table, to_table, column: nil, primary_key: :id, on_delete: :cascade)
cascade_delete = extract_cascade_option(on_delete)
update_foreign_keys(from_table, to_table, column, primary_key, cascade_delete) do |current_keys, existing_key, specified_key|
if existing_key.nil?
unless specified_key.save
raise "failed to create foreign key: #{specified_key.errors.full_messages.to_sentence}"
end
current_keys << specified_key
else
Gitlab::AppLogger.warn "foreign key not added because it already exists: #{specified_key}"
current_keys
end
end
end
# Drops a "foreign key" that references a partitioned table. This method ONLY applies to foreign keys previously
# created through the `add_partitioned_foreign_key` method. Standard database foreign keys should be managed
# through the familiar Rails helpers.
#
# Example:
#
# remove_partitioned_foreign_key :issues, :projects
#
# Available options:
#
# :column - name of the referencing column (otherwise inferred from the referenced table name)
# :primary_key - name of the primary key in the referenced table (defaults to id)
#
def remove_partitioned_foreign_key(from_table, to_table, column: nil, primary_key: :id)
update_foreign_keys(from_table, to_table, column, primary_key) do |current_keys, existing_key, specified_key|
if existing_key
existing_key.delete
current_keys.delete(existing_key)
else
Gitlab::AppLogger.warn "foreign key not removed because it doesn't exist: #{specified_key}"
end
current_keys
end
end
private
def fk_function_name(table)
object_name(table, 'fk_cascade_function')
end
def fk_trigger_name(table)
object_name(table, 'fk_cascade_trigger')
end
def fk_from_spec(from_table, to_table, from_column, to_column, cascade_delete)
PartitionedForeignKey.new(from_table: from_table.to_s, to_table: to_table.to_s, from_column: from_column.to_s,
to_column: to_column.to_s, cascade_delete: cascade_delete)
end
def update_foreign_keys(from_table, to_table, from_column, to_column, cascade_delete = nil)
assert_not_in_transaction_block(scope: 'partitioned foreign key')
from_column ||= "#{to_table.to_s.singularize}_id"
specified_key = fk_from_spec(from_table, to_table, from_column, to_column, cascade_delete)
current_keys = PartitionedForeignKey.by_referenced_table(to_table).to_a
existing_key = find_existing_key(current_keys, specified_key)
final_keys = yield current_keys, existing_key, specified_key
fn_name = fk_function_name(to_table)
trigger_name = fk_trigger_name(to_table)
with_lock_retries do
drop_trigger(to_table, trigger_name, if_exists: true)
if final_keys.empty?
drop_function(fn_name, if_exists: true)
else
create_or_replace_fk_function(fn_name, final_keys)
create_trigger(to_table, trigger_name, fn_name, fires: 'AFTER DELETE')
end
end
end
def extract_cascade_option(on_delete)
case on_delete
when :cascade then true
when :nullify then false
else raise ArgumentError, "invalid option #{on_delete} for :on_delete"
end
end
def find_existing_key(keys, key)
keys.find { |k| k.from_table == key.from_table && k.from_column == key.from_column }
end
def create_or_replace_fk_function(fn_name, fk_specs)
create_trigger_function(fn_name, replace: true) do
cascade_statements = build_cascade_statements(fk_specs)
cascade_statements << 'RETURN OLD;'
cascade_statements.join("\n")
end
end
def build_cascade_statements(foreign_keys)
foreign_keys.map do |fks|
if fks.cascade_delete?
"DELETE FROM #{fks.from_table} WHERE #{fks.from_column} = OLD.#{fks.to_column};"
else
"UPDATE #{fks.from_table} SET #{fks.from_column} = NULL WHERE #{fks.from_column} = OLD.#{fks.to_column};"
end
end
end
end
end
end
......
# frozen_string_literal: true
module Gitlab
module Database
module PartitioningMigrationHelpers
class PartitionedForeignKey < ApplicationRecord
validates_with PartitionedForeignKeyValidator
scope :by_referenced_table, ->(table) { where(to_table: table) }
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Database
module PartitioningMigrationHelpers
class PartitionedForeignKeyValidator < ActiveModel::Validator
def validate(record)
validate_key_part(record, :from_table, :from_column)
validate_key_part(record, :to_table, :to_column)
end
private
def validate_key_part(record, table_field, column_field)
if !connection.table_exists?(record[table_field])
record.errors.add(table_field, 'must be a valid table')
elsif !connection.column_exists?(record[table_field], record[column_field])
record.errors.add(column_field, 'must be a valid column')
end
end
def connection
ActiveRecord::Base.connection
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::PartitionedForeignKey do
let(:foreign_key) do
described_class.new(
to_table: 'issues',
from_table: 'issue_assignees',
from_column: 'issue_id',
to_column: 'id',
cascade_delete: true)
end
describe 'validations' do
it 'allows keys that reference valid tables and columns' do
expect(foreign_key).to be_valid
end
it 'does not allow keys without a valid to_table' do
foreign_key.to_table = 'this_is_not_a_real_table'
expect(foreign_key).not_to be_valid
expect(foreign_key.errors[:to_table].first).to eq('must be a valid table')
end
it 'does not allow keys without a valid from_table' do
foreign_key.from_table = 'this_is_not_a_real_table'
expect(foreign_key).not_to be_valid
expect(foreign_key.errors[:from_table].first).to eq('must be a valid table')
end
it 'does not allow keys without a valid to_column' do
foreign_key.to_column = 'this_is_not_a_real_fk'
expect(foreign_key).not_to be_valid
expect(foreign_key.errors[:to_column].first).to eq('must be a valid column')
end
it 'does not allow keys without a valid from_column' do
foreign_key.from_column = 'this_is_not_a_real_pk'
expect(foreign_key).not_to be_valid
expect(foreign_key.errors[:from_column].first).to eq('must be a valid column')
end
end
end
......@@ -43,6 +43,14 @@ module Database
expect(index_exists_by_name(name, schema: schema)).to be_nil
end
def expect_foreign_key_to_exist(table_name, name, schema: nil)
expect(foreign_key_exists_by_name(table_name, name, schema: schema)).to eq(true)
end
def expect_foreign_key_not_to_exist(table_name, name, schema: nil)
expect(foreign_key_exists_by_name(table_name, name, schema: schema)).to be_nil
end
def expect_check_constraint(table_name, name, definition, schema: nil)
expect(check_constraint_definition(table_name, name, schema: schema)).to eq("CHECK ((#{definition}))")
end
......@@ -133,6 +141,18 @@ module Database
SQL
end
def foreign_key_exists_by_name(table_name, foreign_key_name, schema: nil)
table_name = schema ? "#{schema}.#{table_name}" : table_name
connection.select_value(<<~SQL)
SELECT true
FROM pg_catalog.pg_constraint
WHERE pg_constraint.conrelid = '#{table_name}'::regclass
AND pg_constraint.contype = 'f'
AND pg_constraint.conname = '#{foreign_key_name}'
SQL
end
def check_constraint_definition(table_name, constraint_name, schema: nil)
table_name = schema ? "#{schema}.#{table_name}" : table_name
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment