Commit 1f37dee0 authored by Adam Hegyi's avatar Adam Hegyi

Add AR concern for defining loose foreign keys

This change adds AR concern for defining loose foreign keys. The module
itself is currently not doing anything. The actual implementation
(record cleanup) is going to be implemented in a separate change.
parent 8274b874
# frozen_string_literal: true
module LooseForeignKey
extend ActiveSupport::Concern
# This concern adds loose foreign key support to ActiveRecord models.
# Loose foreign keys allow delayed processing of associated database records
# with similar guarantees than a database foreign key.
#
# TODO: finalize this later once the async job is in place
#
# Prerequisites:
#
# To start using the concern, you'll need to install a database trigger to the parent
# table in a standard DB migration (not post-migration).
#
# > add_loose_foreign_key_support(:projects, :gitlab_main)
#
# Usage:
#
# > class Ci::Build < ApplicationRecord
# >
# > loose_foreign_key :security_scans, :build_id, on_delete: :async_delete, gitlab_schema: :gitlab_main
# >
# > # associations can be still defined, the dependent options is no longer necessary:
# > has_many :security_scans, class_name: 'Security::Scan'
# >
# > end
#
# Options for on_delete:
#
# - :async_delete - deletes the children rows via an asynchronous process.
# - :async_nullify - sets the foreign key column to null via an asynchronous process.
#
# Options for gitlab_schema:
#
# - :gitlab_ci
# - :gitlab_main
#
# The value can be determined by calling `Model.gitlab_schema` where the Model represents
# the model for the child table.
#
# How it works:
#
# When adding loose foreign key support to the table, a DELETE trigger is installed
# which tracks the record deletions (stores primary key value of the deleted row) in
# a database table.
#
# These deletion records are processed asynchronously and records are cleaned up
# according to the loose foreign key definitions described in the model.
#
# The cleanup happens in batches, which reduces the likelyhood of statement timeouts.
#
# When all associations related to the deleted record are cleaned up, the record itself
# is deleted.
included do
class_attribute :loose_foreign_key_definitions, default: []
end
class_methods do
def loose_foreign_key(to_table, column, options)
symbolized_options = options.symbolize_keys
unless base_class?
raise <<~MSG
loose_foreign_key can be only used on base classes, inherited classes are not supported.
Please define the loose_foreign_key on the #{base_class.name} class.
MSG
end
on_delete_options = %i[async_delete async_nullify]
gitlab_schema_options = [ApplicationRecord.gitlab_schema, Ci::ApplicationRecord.gitlab_schema]
unless on_delete_options.include?(symbolized_options[:on_delete]&.to_sym)
raise "Invalid on_delete option given: #{symbolized_options[:on_delete]}. Valid options: #{on_delete_options.join(', ')}"
end
unless gitlab_schema_options.include?(symbolized_options[:gitlab_schema]&.to_sym)
raise "Invalid gitlab_schema option given: #{symbolized_options[:gitlab_schema]}. Valid options: #{gitlab_schema_options.join(', ')}"
end
definition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
table_name.to_s,
to_table.to_s,
{
column: column.to_s,
on_delete: symbolized_options[:on_delete].to_sym,
gitlab_schema: symbolized_options[:gitlab_schema].to_sym
}
)
self.loose_foreign_key_definitions += [definition]
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe LooseForeignKey do
let(:project_klass) do
Class.new(ApplicationRecord) do
include LooseForeignKey
self.table_name = 'projects'
loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main
loose_foreign_key 'merge_requests', 'project_id', 'on_delete' => 'async_nullify', 'gitlab_schema' => :gitlab_main
end
end
it 'exposes the loose foreign key definitions' do
definitions = project_klass.loose_foreign_key_definitions
tables = definitions.map(&:to_table)
expect(tables).to eq(%w[issues merge_requests])
end
it 'casts strings to symbol' do
definition = project_klass.loose_foreign_key_definitions.last
expect(definition.from_table).to eq('projects')
expect(definition.to_table).to eq('merge_requests')
expect(definition.column).to eq('project_id')
expect(definition.on_delete).to eq(:async_nullify)
expect(definition.options[:gitlab_schema]).to eq(:gitlab_main)
end
context 'validation' do
context 'on_delete validation' do
let(:invalid_class) do
Class.new(ApplicationRecord) do
include LooseForeignKey
self.table_name = 'projects'
loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main
loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify, gitlab_schema: :gitlab_main
loose_foreign_key :merge_requests, :project_id, on_delete: :destroy, gitlab_schema: :gitlab_main
end
end
it 'raises error when invalid `on_delete` option was given' do
expect { invalid_class }.to raise_error /Invalid on_delete option given: destroy/
end
end
context 'gitlab_schema validation' do
let(:invalid_class) do
Class.new(ApplicationRecord) do
include LooseForeignKey
self.table_name = 'projects'
loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify, gitlab_schema: :unknown
end
end
it 'raises error when invalid `gitlab_schema` option was given' do
expect { invalid_class }.to raise_error /Invalid gitlab_schema option given: unknown/
end
end
context 'inheritance validation' do
let(:inherited_project_class) do
Class.new(Project) do
include LooseForeignKey
loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main
end
end
it 'raises error when loose_foreign_key is defined in a child ActiveRecord model' do
expect { inherited_project_class }.to raise_error /Please define the loose_foreign_key on the Project class/
end
end
end
end
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