Commit 55271d2e authored by pbair's avatar pbair

Use before_{create,update} callbacks to set iids

Convert AtomicInternalId to set iid values in before_create or
before_update callbacks, rather than before_validation.
parent 933a0b75
...@@ -45,7 +45,7 @@ module Ci ...@@ -45,7 +45,7 @@ module Ci
belongs_to :external_pull_request belongs_to :external_pull_request
belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines
has_internal_id :iid, scope: :project, presence: false, has_internal_id :iid, scope: :project,
track_if: -> { !importing? }, track_if: -> { !importing? },
ensure_if: -> { !importing? }, ensure_if: -> { !importing? },
init: ->(pipeline, scope) do init: ->(pipeline, scope) do
......
...@@ -28,18 +28,22 @@ module AtomicInternalId ...@@ -28,18 +28,22 @@ module AtomicInternalId
class_methods do class_methods do
def has_internal_id( # rubocop:disable Naming/PredicateName def has_internal_id( # rubocop:disable Naming/PredicateName
column, scope:, init: :not_given, ensure_if: nil, track_if: nil, column, scope:, init: :not_given, ensure_if: nil, track_if: nil, hook_names: :create)
presence: true, backfill: false, hook_names: :create)
raise "has_internal_id init must not be nil if given." if init.nil? raise "has_internal_id init must not be nil if given." if init.nil?
raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope) raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope)
init = infer_init(scope) if init == :not_given init = infer_init(scope) if init == :not_given
before_validation :"track_#{scope}_#{column}!", on: hook_names, if: track_if callback_names = Array.wrap(hook_names).map { |hook_name| :"before_#{hook_name}" }
before_validation :"ensure_#{scope}_#{column}!", on: hook_names, if: ensure_if callback_names.each do |callback_name|
validates column, presence: presence # rubocop:disable GitlabSecurity/PublicSend
public_send(callback_name, :"track_#{scope}_#{column}!", if: track_if)
public_send(callback_name, :"ensure_#{scope}_#{column}!", if: ensure_if)
# rubocop:enable GitlabSecurity/PublicSend
end
after_rollback :"clear_#{scope}_#{column}!", on: hook_names, if: ensure_if
define_singleton_internal_id_methods(scope, column, init) define_singleton_internal_id_methods(scope, column, init)
define_instance_internal_id_methods(scope, column, init, backfill) define_instance_internal_id_methods(scope, column, init)
end end
private private
...@@ -62,10 +66,8 @@ module AtomicInternalId ...@@ -62,10 +66,8 @@ module AtomicInternalId
# - track_{scope}_{column}! # - track_{scope}_{column}!
# - reset_{scope}_{column} # - reset_{scope}_{column}
# - {column}= # - {column}=
def define_instance_internal_id_methods(scope, column, init, backfill) def define_instance_internal_id_methods(scope, column, init)
define_method("ensure_#{scope}_#{column}!") do define_method("ensure_#{scope}_#{column}!") do
return if backfill && self.class.where(column => nil).exists?
scope_value = internal_id_read_scope(scope) scope_value = internal_id_read_scope(scope)
value = read_attribute(column) value = read_attribute(column)
return value unless scope_value return value unless scope_value
...@@ -128,6 +130,12 @@ module AtomicInternalId ...@@ -128,6 +130,12 @@ module AtomicInternalId
read_attribute(column) read_attribute(column)
end end
define_method("clear_#{scope}_#{column}!") do
return unless public_send(:"#{column}_previously_changed?") # rubocop:disable GitlabSecurity/PublicSend
write_attribute(column, nil)
end
end end
# Defines class methods: # Defines class methods:
......
...@@ -27,7 +27,7 @@ module DesignManagement ...@@ -27,7 +27,7 @@ module DesignManagement
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_internal_id :iid, scope: :project, presence: true, has_internal_id :iid, scope: :project,
hook_names: %i[create update], # Deal with old records hook_names: %i[create update], # Deal with old records
track_if: -> { !importing? } track_if: -> { !importing? }
......
...@@ -13,7 +13,7 @@ module Operations ...@@ -13,7 +13,7 @@ module Operations
has_many :strategy_user_lists has_many :strategy_user_lists
has_many :strategies, through: :strategy_user_lists has_many :strategies, through: :strategy_user_lists
has_internal_id :iid, scope: :project, presence: true has_internal_id :iid, scope: :project
validates :project, presence: true validates :project, presence: true
validates :name, validates :name,
......
...@@ -11,7 +11,6 @@ RSpec.describe EE::Issuable do ...@@ -11,7 +11,6 @@ RSpec.describe EE::Issuable do
allow(InternalId).to receive(:generate_next).and_return(nil) allow(InternalId).to receive(:generate_next).and_return(nil)
end end
it { is_expected.to validate_presence_of(:iid) }
it { is_expected.to validate_presence_of(:author) } it { is_expected.to validate_presence_of(:author) }
it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_length_of(:title).is_at_most(::Issuable::TITLE_LENGTH_MAX) } it { is_expected.to validate_length_of(:title).is_at_most(::Issuable::TITLE_LENGTH_MAX) }
......
...@@ -1873,9 +1873,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do ...@@ -1873,9 +1873,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
has_internal_id :iid, has_internal_id :iid,
scope: :project, scope: :project,
init: ->(s, _scope) { s&.project&.issues&.maximum(:iid) }, init: ->(s, _scope) { s&.project&.issues&.maximum(:iid) }
backfill: true,
presence: false
end end
end end
...@@ -1928,258 +1926,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do ...@@ -1928,258 +1926,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(issue_b.iid).to eq(3) expect(issue_b.iid).to eq(3)
end end
context 'when the new code creates a row post deploy but before the migration runs' do
it 'does not change the row iid' do
project = setup
issue = Issue.create!(project_id: project.id)
model.backfill_iids('issues')
expect(issue.reload.iid).to eq(1)
end
it 'backfills iids for rows already in the database' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
model.backfill_iids('issues')
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_c.reload.iid).to eq(3)
end
it 'backfills iids across multiple projects' do
project_a = setup
project_b = setup
issue_a = issues.create!(project_id: project_a.id)
issue_b = issues.create!(project_id: project_b.id)
issue_c = Issue.create!(project_id: project_a.id)
issue_d = Issue.create!(project_id: project_b.id)
model.backfill_iids('issues')
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(1)
expect(issue_c.reload.iid).to eq(2)
expect(issue_d.reload.iid).to eq(2)
end
it 'generates iids properly for models created after the migration' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
model.backfill_iids('issues')
issue_d = Issue.create!(project_id: project.id)
issue_e = Issue.create!(project_id: project.id)
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_c.reload.iid).to eq(3)
expect(issue_d.iid).to eq(4)
expect(issue_e.iid).to eq(5)
end
it 'backfills iids and properly generates iids for new models across multiple projects' do
project_a = setup
project_b = setup
issue_a = issues.create!(project_id: project_a.id)
issue_b = issues.create!(project_id: project_b.id)
issue_c = Issue.create!(project_id: project_a.id)
issue_d = Issue.create!(project_id: project_b.id)
model.backfill_iids('issues')
issue_e = Issue.create!(project_id: project_a.id)
issue_f = Issue.create!(project_id: project_b.id)
issue_g = Issue.create!(project_id: project_a.id)
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(1)
expect(issue_c.reload.iid).to eq(2)
expect(issue_d.reload.iid).to eq(2)
expect(issue_e.iid).to eq(3)
expect(issue_f.iid).to eq(3)
expect(issue_g.iid).to eq(4)
end
end
context 'when the new code creates a model and then old code creates a model post deploy but before the migration runs' do
it 'backfills iids' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = Issue.create!(project_id: project.id)
issue_c = issues.create!(project_id: project.id)
model.backfill_iids('issues')
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_c.reload.iid).to eq(3)
end
it 'generates an iid for a new model after the migration' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
issue_d = issues.create!(project_id: project.id)
model.backfill_iids('issues')
issue_e = Issue.create!(project_id: project.id)
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_c.reload.iid).to eq(3)
expect(issue_d.reload.iid).to eq(4)
expect(issue_e.iid).to eq(5)
end
end
context 'when the new code and old code alternate creating models post deploy but before the migration runs' do
it 'backfills iids' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = Issue.create!(project_id: project.id)
issue_c = issues.create!(project_id: project.id)
issue_d = Issue.create!(project_id: project.id)
model.backfill_iids('issues')
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_c.reload.iid).to eq(3)
expect(issue_d.reload.iid).to eq(4)
end
it 'generates an iid for a new model after the migration' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
issue_d = issues.create!(project_id: project.id)
issue_e = Issue.create!(project_id: project.id)
model.backfill_iids('issues')
issue_f = Issue.create!(project_id: project.id)
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_c.reload.iid).to eq(3)
expect(issue_d.reload.iid).to eq(4)
expect(issue_e.reload.iid).to eq(5)
expect(issue_f.iid).to eq(6)
end
end
context 'when the new code creates and deletes a model post deploy but before the migration runs' do
it 'backfills iids for rows already in the database' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
issue_c.delete
model.backfill_iids('issues')
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
end
it 'successfully creates a new model after the migration' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
issue_c.delete
model.backfill_iids('issues')
issue_d = Issue.create!(project_id: project.id)
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_d.iid).to eq(3)
end
end
context 'when the new code creates and deletes a model and old code creates a model post deploy but before the migration runs' do
it 'backfills iids' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
issue_c.delete
issue_d = issues.create!(project_id: project.id)
model.backfill_iids('issues')
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_d.reload.iid).to eq(3)
end
it 'successfully creates a new model after the migration' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
issue_c.delete
issue_d = issues.create!(project_id: project.id)
model.backfill_iids('issues')
issue_e = Issue.create!(project_id: project.id)
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_d.reload.iid).to eq(3)
expect(issue_e.iid).to eq(4)
end
end
context 'when the new code creates and deletes a model and then creates another model post deploy but before the migration runs' do
it 'successfully generates an iid for a new model after the migration' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
issue_c.delete
issue_d = Issue.create!(project_id: project.id)
model.backfill_iids('issues')
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_d.reload.iid).to eq(3)
end
it 'successfully generates an iid for a new model after the migration' do
project = setup
issue_a = issues.create!(project_id: project.id)
issue_b = issues.create!(project_id: project.id)
issue_c = Issue.create!(project_id: project.id)
issue_c.delete
issue_d = Issue.create!(project_id: project.id)
model.backfill_iids('issues')
issue_e = Issue.create!(project_id: project.id)
expect(issue_a.reload.iid).to eq(1)
expect(issue_b.reload.iid).to eq(2)
expect(issue_d.reload.iid).to eq(3)
expect(issue_e.iid).to eq(4)
end
end
context 'when the first model is created for a project after the migration' do context 'when the first model is created for a project after the migration' do
it 'generates an iid' do it 'generates an iid' do
project_a = setup project_a = setup
......
...@@ -592,7 +592,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do ...@@ -592,7 +592,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end end
describe 'modules' do describe 'modules' do
it_behaves_like 'AtomicInternalId', validate_presence: false do it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid } let(:internal_id_attribute) { :iid }
let(:instance) { build(:ci_pipeline) } let(:instance) { build(:ci_pipeline) }
let(:scope) { :project } let(:scope) { :project }
......
...@@ -32,6 +32,81 @@ RSpec.describe AtomicInternalId do ...@@ -32,6 +32,81 @@ RSpec.describe AtomicInternalId do
milestone.save! milestone.save!
end end
end end
context 'when the save is rolled back' do
context 'when no ensure_if condition is given' do
it 'clears the instance IID' do
expect(milestone).to receive(:clear_project_iid!).and_call_original
ActiveRecord::Base.transaction(requires_new: true) do
milestone.save!
expect(milestone.iid).to eq(external_iid)
raise ActiveRecord::Rollback
end
expect(milestone.iid).to be_nil
end
end
context 'when an ensure_if condition is given' do
let(:test_class) do
Class.new(ApplicationRecord) do
include AtomicInternalId
include Importable
self.table_name = :milestones
belongs_to :project
has_internal_id :iid, scope: :project, track_if: -> { !importing }, ensure_if: -> { !importing }
def self.name
'TestClass'
end
end
end
let(:instance) { test_class.new(milestone.attributes) }
context 'when the ensure_if condition evaluates to false' do
it 'clears the instance IID' do
expect(instance).to receive(:clear_project_iid!).and_call_original
ActiveRecord::Base.transaction(requires_new: true) do
instance.save!
expect(instance.iid).not_to be_nil
raise ActiveRecord::Rollback
end
expect(instance.iid).to be_nil
end
end
context 'when the ensure_if condition evaluates to true' do
before do
instance.importing = true
end
it 'does not clear the instance IID' do
expect(instance).not_to receive(:clear_project_iid!)
ActiveRecord::Base.transaction(requires_new: true) do
instance.save!
expect(instance.iid).not_to be_nil
raise ActiveRecord::Rollback
end
expect(instance.iid).not_to be_nil
end
end
end
end
end end
end end
......
...@@ -45,7 +45,6 @@ RSpec.describe Issuable do ...@@ -45,7 +45,6 @@ RSpec.describe Issuable do
end end
it { is_expected.to validate_presence_of(:project) } it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:iid) }
it { is_expected.to validate_presence_of(:author) } it { is_expected.to validate_presence_of(:author) }
it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_length_of(:title).is_at_most(described_class::TITLE_LENGTH_MAX) } it { is_expected.to validate_length_of(:title).is_at_most(described_class::TITLE_LENGTH_MAX) }
......
...@@ -11,7 +11,7 @@ RSpec.describe DesignManagement::Design do ...@@ -11,7 +11,7 @@ RSpec.describe DesignManagement::Design do
let_it_be(:design3) { create(:design, :with_versions, issue: issue, versions_count: 1) } let_it_be(:design3) { create(:design, :with_versions, issue: issue, versions_count: 1) }
let_it_be(:deleted_design) { create(:design, :with_versions, deleted: true) } let_it_be(:deleted_design) { create(:design, :with_versions, deleted: true) }
it_behaves_like 'AtomicInternalId', validate_presence: true do it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid } let(:internal_id_attribute) { :iid }
let(:instance) { build(:design, issue: issue) } let(:instance) { build(:design, issue: issue) }
let(:scope) { :project } let(:scope) { :project }
......
...@@ -64,7 +64,7 @@ RSpec.describe Operations::FeatureFlag do ...@@ -64,7 +64,7 @@ RSpec.describe Operations::FeatureFlag do
end end
end end
it_behaves_like 'AtomicInternalId', validate_presence: true do it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid } let(:internal_id_attribute) { :iid }
let(:instance) { build(:operations_feature_flag) } let(:instance) { build(:operations_feature_flag) }
let(:scope) { :project } let(:scope) { :project }
......
# frozen_string_literal: true # frozen_string_literal: true
RSpec.shared_examples 'AtomicInternalId' do |validate_presence: true| RSpec.shared_examples 'AtomicInternalId' do
describe '.has_internal_id' do describe '.has_internal_id' do
describe 'Module inclusion' do describe 'Module inclusion' do
subject { described_class } subject { described_class }
...@@ -8,34 +8,6 @@ RSpec.shared_examples 'AtomicInternalId' do |validate_presence: true| ...@@ -8,34 +8,6 @@ RSpec.shared_examples 'AtomicInternalId' do |validate_presence: true|
it { is_expected.to include_module(AtomicInternalId) } it { is_expected.to include_module(AtomicInternalId) }
end end
describe 'Validation' do
before do
allow_any_instance_of(described_class).to receive(:"ensure_#{scope}_#{internal_id_attribute}!")
instance.valid?
end
context 'when presence validation is required' do
before do
skip unless validate_presence
end
it 'validates presence' do
expect(instance.errors[internal_id_attribute]).to include("can't be blank")
end
end
context 'when presence validation is not required' do
before do
skip if validate_presence
end
it 'does not validate presence' do
expect(instance.errors[internal_id_attribute]).to be_empty
end
end
end
describe 'Creating an instance' do describe 'Creating an instance' do
subject { instance.save! } subject { instance.save! }
...@@ -76,6 +48,41 @@ RSpec.shared_examples 'AtomicInternalId' do |validate_presence: true| ...@@ -76,6 +48,41 @@ RSpec.shared_examples 'AtomicInternalId' do |validate_presence: true|
end end
end end
describe 'unsetting the instance internal id on rollback' do
context 'when the internal id has been changed' do
it 'clears it on the instance' do
ActiveRecord::Base.transaction(requires_new: true) do
instance.save!
expect(read_internal_id).not_to be_nil
raise ActiveRecord::Rollback
end
expect(read_internal_id).to be_nil
end
end
context 'when the internal id has not been changed' do
it 'preserves the value on the instance' do
instance.save!
original_id = read_internal_id
expect(original_id).not_to be_nil
ActiveRecord::Base.transaction(requires_new: true) do
instance.save!
expect(read_internal_id).not_to be_nil
raise ActiveRecord::Rollback
end
expect(read_internal_id).to eq(original_id)
end
end
end
describe 'supply of internal ids' do describe 'supply of internal ids' do
let(:scope_value) { scope_attrs.each_value.first } let(:scope_value) { scope_attrs.each_value.first }
let(:method_name) { :"with_#{scope}_#{internal_id_attribute}_supply" } let(:method_name) { :"with_#{scope}_#{internal_id_attribute}_supply" }
......
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