Skip to content

Commit 2f042c9

Browse files
authored
Replacing validate uniqueness with db constraint on SecurityGroup model (#4955)
* feat: unique constraint added to security group table - sg_name_index replaced with unique security_group_name_index on name column. For that db migration is added - around_save hook added in model class to catch such error * feat: concurrent migration for postgres db * fix: add index first, then drop * fix: index name is fixed * fix: comments are fixed
1 parent 1d45df0 commit 2f042c9

4 files changed

Lines changed: 129 additions & 2 deletions

File tree

app/models/runtime/security_group.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,17 @@ class SecurityGroup < Sequel::Model
2222

2323
add_association_dependencies spaces: :nullify, staging_spaces: :nullify
2424

25+
def around_save
26+
yield
27+
rescue Sequel::UniqueConstraintViolation => e
28+
raise e unless e.message.include?('security_groups_name_index')
29+
30+
errors.add(:name, :unique)
31+
raise validation_failed_error
32+
end
33+
2534
def validate
2635
validates_presence :name
27-
validates_unique :name
2836
validates_format SECURITY_GROUP_NAME_REGEX, :name
2937
validate_rules_length
3038
validate_rules
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
Sequel.migration do # rubocop:disable Metrics/BlockLength
2+
no_transaction # required for concurrently option on postgres
3+
4+
up do
5+
transaction do
6+
duplicates = self[:security_groups].select(:name).
7+
group(:name).
8+
having { count(1) > 1 }
9+
10+
duplicates.each do |dup|
11+
ids_to_remove = self[:security_groups].
12+
where(name: dup[:name]).
13+
select(:id).
14+
order(:id).
15+
offset(1).
16+
map(:id)
17+
self[:security_groups].where(id: ids_to_remove).delete
18+
end
19+
end
20+
21+
if database_type == :postgres
22+
VCAP::Migration.with_concurrent_timeout(self) do
23+
add_index :security_groups, :name,
24+
name: :security_groups_name_index,
25+
unique: true,
26+
concurrently: true,
27+
if_not_exists: true
28+
drop_index :security_groups, nil,
29+
name: :sg_name_index,
30+
concurrently: true,
31+
if_exists: true
32+
end
33+
else
34+
alter_table(:security_groups) do
35+
# rubocop:disable Sequel/ConcurrentIndex -- MySQL does not support concurrent index operations
36+
add_index :name, name: :security_groups_name_index, unique: true unless @db.indexes(:security_groups).key?(:security_groups_name_index)
37+
drop_index :name, name: :sg_name_index if @db.indexes(:security_groups).key?(:sg_name_index)
38+
# rubocop:enable Sequel/ConcurrentIndex
39+
end
40+
end
41+
end
42+
43+
down do
44+
if database_type == :postgres
45+
VCAP::Migration.with_concurrent_timeout(self) do
46+
add_index :security_groups, :name,
47+
name: :sg_name_index,
48+
concurrently: true,
49+
if_not_exists: true
50+
drop_index :security_groups, nil,
51+
name: :security_groups_name_index,
52+
concurrently: true,
53+
if_exists: true
54+
end
55+
else
56+
alter_table(:security_groups) do
57+
# rubocop:disable Sequel/ConcurrentIndex -- MySQL does not support concurrent index operations
58+
add_index :name, name: :sg_name_index unless @db.indexes(:security_groups).key?(:sg_name_index)
59+
drop_index :name, name: :security_groups_name_index if @db.indexes(:security_groups).key?(:security_groups_name_index)
60+
# rubocop:enable Sequel/ConcurrentIndex
61+
end
62+
end
63+
end
64+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
require 'spec_helper'
2+
require 'migrations/helpers/migration_shared_context'
3+
RSpec.describe 'add unique constraint to security_groups', isolation: :truncation, type: :migration do
4+
include_context 'migration' do
5+
let(:migration_filename) { '20260323130619_add_unique_constraint_to_security_groups.rb' }
6+
end
7+
8+
it 'remove duplicates, add constraint and revert migration' do
9+
# create duplicate entries
10+
db[:security_groups].insert(guid: SecureRandom.uuid, name: 'sec1')
11+
db[:security_groups].insert(guid: SecureRandom.uuid, name: 'sec1')
12+
expect(db[:security_groups].where(name: 'sec1').count).to eq(2)
13+
14+
# run the migration
15+
Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true)
16+
17+
# verify duplicates are removed and constraint is enforced
18+
expect(db[:security_groups].where(name: 'sec1').count).to eq(1)
19+
expect(db.indexes(:security_groups)).to include(:security_groups_name_index)
20+
expect { db[:security_groups].insert(guid: SecureRandom.uuid, name: 'sec1') }.to raise_error(Sequel::UniqueConstraintViolation)
21+
22+
# running the migration again should not cause any errors
23+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
24+
25+
# roll back the migration
26+
Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true)
27+
28+
# verify constraint is removed and duplicates can be re-inserted
29+
expect(db.indexes(:security_groups)).not_to include(:security_groups_name_index)
30+
expect(db.indexes(:security_groups)).to include(:sg_name_index)
31+
expect { db[:security_groups].insert(guid: SecureRandom.uuid, name: 'sec1') }.not_to raise_error
32+
end
33+
end

spec/unit/models/runtime/security_group_spec.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,6 @@ def build_all_rule(attrs={})
441441

442442
describe 'Validations' do
443443
it { is_expected.to validate_presence :name }
444-
it { is_expected.to validate_uniqueness :name }
445444

446445
context 'name' do
447446
subject(:sec_group) { SecurityGroup.make }
@@ -971,6 +970,29 @@ def build_all_rule(attrs={})
971970
end
972971
end
973972

973+
describe 'security_group_model #around_save' do
974+
it 'raises validation error on unique constraint violation for security group' do
975+
sg = SecurityGroup.make(name: 'sec')
976+
expect do
977+
SecurityGroup.make(name: sg.name)
978+
end.to raise_error(Sequel::ValidationFailed) { |error| expect(error.message).to match(/unique/) }
979+
end
980+
981+
it 'raises the original error on other unique constraint violations' do
982+
sg = SecurityGroup.make(name: 'sec1')
983+
984+
# Unlike other models, security_groups.guid does not have a unique index,
985+
# so we use the primary key (id) to trigger a UniqueConstraintViolation.
986+
# Sequel restricts setting id by default, so unrestrict_primary_key is required.
987+
SecurityGroup.unrestrict_primary_key
988+
expect do
989+
SecurityGroup.create(id: sg.id, name: 'sec2')
990+
end.to raise_error(Sequel::UniqueConstraintViolation)
991+
ensure
992+
SecurityGroup.restrict_primary_key
993+
end
994+
end
995+
974996
describe '.user_visibility_filter' do
975997
let(:security_group) { SecurityGroup.make }
976998
let(:space) { Space.make }

0 commit comments

Comments
 (0)