Skip to content

Commit 2fe1ab6

Browse files
authored
Replacing validate uniqueness with db constraint on Buildpack model (#4942)
* feat: db migrations created for creating unique on name stack lifecycle fields * feat: validate_uniqueness removed from validate list, around saved added instead * fix: number of tests decreased because of rubocop offense * feat: concurrent migration for postgres db * fix: rubocop ofens is fixed * fix: typo is fixed
1 parent 22ac0ea commit 2fe1ab6

File tree

4 files changed

+152
-8
lines changed

4 files changed

+152
-8
lines changed

app/models/runtime/buildpack.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,16 @@ def self.at_last_position
4343
where(position: max(:position)).first
4444
end
4545

46+
def around_save
47+
yield
48+
rescue Sequel::UniqueConstraintViolation => e
49+
raise e unless e.message.include?('buildpacks_name_stack_lifecycle_index')
50+
51+
errors.add(%i[name stack lifecycle], :unique)
52+
raise validation_failed_error
53+
end
54+
4655
def validate
47-
validates_unique %i[name stack lifecycle]
4856
validates_format(/\A(\w|-)+\z/, :name, message: 'can only contain alphanumeric characters')
4957

5058
validate_stack_existence
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
Sequel.migration do
2+
no_transaction # required for concurrently option on postgres
3+
up do
4+
transaction do
5+
# Remove duplicate entries if they exist
6+
duplicates = self[:buildpacks].select(:name, :stack, :lifecycle).group(:name, :stack, :lifecycle).having { count(1) > 1 }
7+
8+
duplicates.each do |dup|
9+
ids_to_remove = self[:buildpacks].
10+
where(name: dup[:name], stack: dup[:stack], lifecycle: dup[:lifecycle]).
11+
select(:id).
12+
order(:id).
13+
offset(1).
14+
map(:id)
15+
16+
self[:buildpacks].where(id: ids_to_remove).delete
17+
end
18+
end
19+
20+
if database_type == :postgres
21+
VCAP::Migration.with_concurrent_timeout(self) do
22+
drop_index :buildpacks, nil,
23+
name: :unique_name_and_stack,
24+
concurrently: true,
25+
if_exists: true
26+
add_index :buildpacks, %i[name stack lifecycle],
27+
name: :buildpacks_name_stack_lifecycle_index,
28+
unique: true,
29+
concurrently: true,
30+
if_not_exists: true
31+
end
32+
else
33+
alter_table(:buildpacks) do
34+
# rubocop:disable Sequel/ConcurrentIndex -- MySQL does not support concurrent index operations
35+
drop_index %i[name stack], name: :unique_name_and_stack if @db.indexes(:buildpacks).key?(:unique_name_and_stack)
36+
unless @db.indexes(:buildpacks).key?(:buildpacks_name_stack_lifecycle_index)
37+
add_index %i[name stack lifecycle], unique: true,
38+
name: :buildpacks_name_stack_lifecycle_index
39+
end
40+
# rubocop:enable Sequel/ConcurrentIndex
41+
end
42+
end
43+
end
44+
45+
down do
46+
if database_type == :postgres
47+
VCAP::Migration.with_concurrent_timeout(self) do
48+
drop_index :buildpacks, nil, name: :buildpacks_name_stack_lifecycle_index, concurrently: true, if_exists: true
49+
add_index :buildpacks, %i[name stack], name: :unique_name_and_stack, unique: true, concurrently: true, if_not_exists: true
50+
end
51+
else
52+
alter_table(:buildpacks) do
53+
# rubocop:disable Sequel/ConcurrentIndex -- MySQL does not support concurrent index operations
54+
drop_index %i[name stack lifecycle], name: :buildpacks_name_stack_lifecycle_index if @db.indexes(:buildpacks).key?(:buildpacks_name_stack_lifecycle_index)
55+
add_index %i[name stack], unique: true, name: :unique_name_and_stack unless @db.indexes(:buildpacks).key?(:unique_name_and_stack)
56+
# rubocop:enable Sequel/ConcurrentIndex
57+
end
58+
end
59+
end
60+
end
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
require 'spec_helper'
2+
require 'migrations/helpers/migration_shared_context'
3+
4+
RSpec.describe 'add unique constraint to buildpacks', isolation: :truncation, type: :migration do
5+
include_context 'migration' do
6+
let(:migration_filename) { '20260318083940_add_unique_constraint_to_buildpacks.rb' }
7+
end
8+
9+
describe 'buildpacks table' do
10+
it 'removes duplicates, swaps indexes, and handles idempotency' do
11+
# Drop old unique index so we can insert duplicates
12+
db.alter_table(:buildpacks) { drop_index %i[name stack], name: :unique_name_and_stack }
13+
14+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'ruby', stack: 'cflinuxfs3', lifecycle: 'buildpack', position: 1)
15+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'ruby', stack: 'cflinuxfs3', lifecycle: 'buildpack', position: 2)
16+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'ruby', stack: 'cflinuxfs3', lifecycle: 'cnb', position: 3)
17+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'ruby', stack: 'cflinuxfs4', lifecycle: 'buildpack', position: 4)
18+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'go', stack: 'cflinuxfs3', lifecycle: 'buildpack', position: 5)
19+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'go', stack: 'cflinuxfs3', lifecycle: 'buildpack', position: 6)
20+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'go', stack: 'cflinuxfs3', lifecycle: 'buildpack', position: 7)
21+
22+
expect(db[:buildpacks].where(name: 'ruby', stack: 'cflinuxfs3', lifecycle: 'buildpack').count).to eq(2)
23+
expect(db[:buildpacks].where(name: 'go', stack: 'cflinuxfs3', lifecycle: 'buildpack').count).to eq(3)
24+
25+
# === UP MIGRATION ===
26+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
27+
28+
# Verify duplicates are removed, keeping one per (name, stack, lifecycle)
29+
expect(db[:buildpacks].where(name: 'ruby', stack: 'cflinuxfs3', lifecycle: 'buildpack').count).to eq(1)
30+
expect(db[:buildpacks].where(name: 'ruby', stack: 'cflinuxfs3', lifecycle: 'cnb').count).to eq(1)
31+
expect(db[:buildpacks].where(name: 'ruby', stack: 'cflinuxfs4', lifecycle: 'buildpack').count).to eq(1)
32+
expect(db[:buildpacks].where(name: 'go', stack: 'cflinuxfs3', lifecycle: 'buildpack').count).to eq(1)
33+
34+
# Verify old index is dropped and new index is added
35+
expect(db.indexes(:buildpacks)).not_to include(:unique_name_and_stack)
36+
expect(db.indexes(:buildpacks)).to include(:buildpacks_name_stack_lifecycle_index)
37+
38+
# Test up migration idempotency
39+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
40+
41+
# === DOWN MIGRATION ===
42+
# First remove test data that would conflict with the old (name, stack) unique index
43+
db[:buildpacks].delete
44+
45+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) }.not_to raise_error
46+
47+
# Verify new index is dropped and old index is restored
48+
expect(db.indexes(:buildpacks)).not_to include(:buildpacks_name_stack_lifecycle_index)
49+
expect(db.indexes(:buildpacks)).to include(:unique_name_and_stack)
50+
51+
# Verify the restored index enforces uniqueness on (name, stack)
52+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'ruby', stack: 'cflinuxfs3', lifecycle: 'buildpack', position: 1)
53+
expect do
54+
db[:buildpacks].insert(guid: SecureRandom.uuid, name: 'ruby', stack: 'cflinuxfs3', lifecycle: 'cnb', position: 2)
55+
end.to raise_error(Sequel::UniqueConstraintViolation)
56+
db[:buildpacks].delete
57+
58+
# Test down migration idempotency
59+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) }.not_to raise_error
60+
expect(db.indexes(:buildpacks)).not_to include(:buildpacks_name_stack_lifecycle_index)
61+
end
62+
end
63+
end

spec/unit/models/runtime/buildpack_spec.rb

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ def ordered_buildpacks
99
it { is_expected.to have_timestamp_columns }
1010

1111
describe 'Validations' do
12-
it { is_expected.to validate_uniqueness %i[name stack lifecycle] }
13-
1412
describe 'stack' do
1513
let(:stack) { Stack.make(name: 'happy') }
1614

@@ -37,11 +35,6 @@ def ordered_buildpacks
3735
expect(buildpack.errors.on(:stack)).to include(:buildpack_stack_does_not_exist)
3836
end
3937

40-
it 'validates duplicate buildpacks with the same name and stack' do
41-
Buildpack.create(name: 'oscar', stack: stack.name)
42-
expect(Buildpack.new(name: 'oscar', stack: stack.name)).not_to be_valid
43-
end
44-
4538
it 'validates duplicate buildpacks with the same name and nil stack' do
4639
Buildpack.create(name: 'oscar', stack: nil)
4740
expect(Buildpack.new(name: 'oscar', stack: nil)).not_to be_valid
@@ -84,6 +77,26 @@ def ordered_buildpacks
8477
expect(buildpack.errors.on(:lifecycle)).to include(:buildpack_cant_change_lifecycle)
8578
end
8679
end
80+
81+
context 'when unique constraint violation occures' do
82+
let(:stack) { Stack.make }
83+
84+
it 'raises validation error when name, stack and lifecyle is same' do
85+
Buildpack.create(name: 'oscar', stack: stack.name, lifecycle: 'cnb')
86+
expect do
87+
Buildpack.create(name: 'oscar', stack: stack.name, lifecycle: 'cnb')
88+
end.to raise_error(Sequel::ValidationFailed, /unique/) do |e|
89+
expect(e.errors.on(%i[name stack lifecycle])).to include(:unique)
90+
end
91+
end
92+
93+
it 'raises validation error different than the unique name, stack and lifecyle' do
94+
existing = Buildpack.create(name: 'oscar', stack: stack.name, lifecycle: 'cnb')
95+
duplicate_guid = Buildpack.new(name: 'other', stack: stack.name, lifecycle: 'cnb')
96+
duplicate_guid.guid = existing.guid
97+
expect { duplicate_guid.save }.to raise_error(Sequel::UniqueConstraintViolation)
98+
end
99+
end
87100
end
88101

89102
describe 'Serialization' do

0 commit comments

Comments
 (0)