2020-06-23 00:09:42 +05:30
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
module Gitlab
|
|
|
|
module Database
|
|
|
|
module PartitioningMigrationHelpers
|
|
|
|
module ForeignKeyHelpers
|
|
|
|
include ::Gitlab::Database::SchemaHelpers
|
|
|
|
|
2021-04-29 21:17:54 +05:30
|
|
|
# Adds a foreign key with only minimal locking on the tables involved.
|
|
|
|
#
|
|
|
|
# In concept it works similarly to add_concurrent_foreign_key, but we have
|
|
|
|
# to add a special helper for partitioned tables for the following reasons:
|
|
|
|
# - add_concurrent_foreign_key sets the constraint to `NOT VALID`
|
|
|
|
# before validating it
|
|
|
|
# - Setting an FK to NOT VALID is not supported currently in Postgres (up to PG13)
|
|
|
|
# - Also, PostgreSQL will currently ignore NOT VALID constraints on partitions
|
|
|
|
# when adding a valid FK to the partitioned table, so they have to
|
|
|
|
# also be validated before we can add the final FK.
|
|
|
|
# Solution:
|
|
|
|
# - Add the foreign key first to each partition by using
|
|
|
|
# add_concurrent_foreign_key and validating it
|
|
|
|
# - Once all partitions have a foreign key, add it also to the partitioned
|
|
|
|
# table (there will be no need for a validation at that level)
|
|
|
|
# For those reasons, this method does not include an option to delay the
|
|
|
|
# validation, we have to force validate: true.
|
|
|
|
#
|
|
|
|
# source - The source (partitioned) table containing the foreign key.
|
|
|
|
# target - The target table the key points to.
|
|
|
|
# column - The name of the column to create the foreign key on.
|
|
|
|
# on_delete - The action to perform when associated data is removed,
|
|
|
|
# defaults to "CASCADE".
|
|
|
|
# name - The name of the foreign key.
|
|
|
|
#
|
|
|
|
def add_concurrent_partitioned_foreign_key(source, target, column:, on_delete: :cascade, name: nil)
|
|
|
|
partition_options = {
|
|
|
|
column: column,
|
|
|
|
on_delete: on_delete,
|
|
|
|
|
|
|
|
# We'll use the same FK name for all partitions and match it to
|
|
|
|
# the name used for the partitioned table to follow the convention
|
|
|
|
# used by PostgreSQL when adding FKs to new partitions
|
|
|
|
name: name.presence || concurrent_partitioned_foreign_key_name(source, column),
|
|
|
|
|
|
|
|
# Force the FK validation to true for partitions (and the partitioned table)
|
|
|
|
validate: true
|
|
|
|
}
|
|
|
|
|
|
|
|
if foreign_key_exists?(source, target, **partition_options)
|
|
|
|
warning_message = "Foreign key not created because it exists already " \
|
|
|
|
"(this may be due to an aborted migration or similar): " \
|
|
|
|
"source: #{source}, target: #{target}, column: #{partition_options[:column]}, "\
|
|
|
|
"name: #{partition_options[:name]}, on_delete: #{partition_options[:on_delete]}"
|
|
|
|
|
|
|
|
Gitlab::AppLogger.warn warning_message
|
|
|
|
|
|
|
|
return
|
|
|
|
end
|
|
|
|
|
|
|
|
partitioned_table = find_partitioned_table(source)
|
|
|
|
|
|
|
|
partitioned_table.postgres_partitions.order(:name).each do |partition|
|
|
|
|
add_concurrent_foreign_key(partition.identifier, target, **partition_options)
|
|
|
|
end
|
|
|
|
|
|
|
|
with_lock_retries do
|
|
|
|
add_foreign_key(source, target, **partition_options)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# Returns the name for a concurrent partitioned foreign key.
|
|
|
|
#
|
|
|
|
# Similar to concurrent_foreign_key_name (Gitlab::Database::MigrationHelpers)
|
|
|
|
# we just keep a separate method in case we want a different behavior
|
|
|
|
# for partitioned tables
|
|
|
|
#
|
|
|
|
def concurrent_partitioned_foreign_key_name(table, column, prefix: 'fk_rails_')
|
|
|
|
identifier = "#{table}_#{column}_fk"
|
|
|
|
hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
|
|
|
|
|
|
|
|
"#{prefix}#{hashed_identifier}"
|
|
|
|
end
|
|
|
|
|
2020-06-23 00:09:42 +05:30
|
|
|
# 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
|
2020-11-24 15:15:51 +05:30
|
|
|
Gitlab::AppLogger.warn "foreign key not added because it already exists: #{specified_key}"
|
2020-06-23 00:09:42 +05:30
|
|
|
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
|
2020-11-24 15:15:51 +05:30
|
|
|
Gitlab::AppLogger.warn "foreign key not removed because it doesn't exist: #{specified_key}"
|
2020-06-23 00:09:42 +05:30
|
|
|
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)
|
2020-07-28 23:09:34 +05:30
|
|
|
create_trigger(to_table, trigger_name, fn_name, fires: 'AFTER DELETE')
|
2020-06-23 00:09:42 +05:30
|
|
|
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
|
|
|
|
end
|