mirror of
https://github.com/discourse/discourse.git
synced 2025-06-04 20:44:40 +08:00
FEATURE: protect against accidental column or table drops
Often we need to amend our schema, it is tempting to use drop_table, rename_column and drop_column to amned schema trouble though is that existing code that is running in production can depend on the existance of previous schema leading to application breaking until new code base is deployed. The commit enforces new rules to ensure we can never drop tables or columns in migrations and instead use Migration::ColumnDropper and Migration::TableDropper to defer drop the db objects
This commit is contained in:
140
spec/components/migration/column_dropper_spec.rb
Normal file
140
spec/components/migration/column_dropper_spec.rb
Normal file
@ -0,0 +1,140 @@
|
||||
require 'rails_helper'
|
||||
require_dependency 'migration/column_dropper'
|
||||
|
||||
RSpec.describe Migration::ColumnDropper do
|
||||
|
||||
def has_column?(table, column)
|
||||
Topic.exec_sql("SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
table_schema = 'public' AND
|
||||
table_name = :table AND
|
||||
column_name = :column
|
||||
",
|
||||
table: table, column: column
|
||||
).to_a.length == 1
|
||||
end
|
||||
|
||||
it "can correctly drop columns after correct delay" do
|
||||
Topic.exec_sql "ALTER TABLE topics ADD COLUMN junk int"
|
||||
name = Topic
|
||||
.exec_sql("SELECT name FROM schema_migration_details LIMIT 1")
|
||||
.getvalue(0, 0)
|
||||
|
||||
Topic.exec_sql("UPDATE schema_migration_details SET created_at = :created_at WHERE name = :name",
|
||||
name: name, created_at: 15.minutes.ago)
|
||||
|
||||
dropped_proc_called = false
|
||||
|
||||
Migration::ColumnDropper.drop(
|
||||
table: 'topics',
|
||||
after_migration: name,
|
||||
columns: ['junk'],
|
||||
delay: 20.minutes,
|
||||
on_drop: ->() { dropped_proc_called = true }
|
||||
)
|
||||
|
||||
expect(has_column?('topics', 'junk')).to eq(true)
|
||||
expect(dropped_proc_called).to eq(false)
|
||||
|
||||
Migration::ColumnDropper.drop(
|
||||
table: 'topics',
|
||||
after_migration: name,
|
||||
columns: ['junk'],
|
||||
delay: 10.minutes,
|
||||
on_drop: ->() { dropped_proc_called = true }
|
||||
)
|
||||
|
||||
expect(has_column?('topics', 'junk')).to eq(false)
|
||||
expect(dropped_proc_called).to eq(true)
|
||||
|
||||
end
|
||||
|
||||
describe '.mark_readonly' do
|
||||
let(:table_name) { "table_with_readonly_column" }
|
||||
|
||||
before do
|
||||
ActiveRecord::Base.exec_sql <<~SQL
|
||||
CREATE TABLE #{table_name} (topic_id INTEGER, email TEXT);
|
||||
|
||||
INSERT INTO #{table_name} (topic_id, email)
|
||||
VALUES (1, 'something@email.com');
|
||||
SQL
|
||||
|
||||
Migration::ColumnDropper.mark_readonly(table_name, 'email')
|
||||
end
|
||||
|
||||
after do
|
||||
ActiveRecord::Base.connection.reset!
|
||||
|
||||
ActiveRecord::Base.exec_sql <<~SQL
|
||||
DROP TABLE IF EXISTS #{table_name};
|
||||
DROP TRIGGER IF EXISTS #{table_name}_email_readonly ON #{table_name};
|
||||
SQL
|
||||
end
|
||||
|
||||
it 'should be droppable' do
|
||||
name = Topic
|
||||
.exec_sql("SELECT name FROM schema_migration_details LIMIT 1")
|
||||
.getvalue(0, 0)
|
||||
|
||||
dropped_proc_called = false
|
||||
Migration::ColumnDropper.drop(
|
||||
table: table_name,
|
||||
after_migration: name,
|
||||
columns: ['email'],
|
||||
delay: 0.minutes,
|
||||
on_drop: ->() { dropped_proc_called = true }
|
||||
)
|
||||
|
||||
expect(dropped_proc_called).to eq(true)
|
||||
|
||||
end
|
||||
it 'should prevent updates to the readonly column' do
|
||||
expect do
|
||||
ActiveRecord::Base.connection.raw_connection.exec <<~SQL
|
||||
UPDATE #{table_name}
|
||||
SET email = 'testing@email.com'
|
||||
WHERE topic_id = 1;
|
||||
SQL
|
||||
end.to raise_error(
|
||||
PG::RaiseException,
|
||||
/Discourse: email in #{table_name} is readonly/
|
||||
)
|
||||
end
|
||||
|
||||
it 'should allow updates to the other columns' do
|
||||
ActiveRecord::Base.exec_sql <<~SQL
|
||||
UPDATE #{table_name}
|
||||
SET topic_id = 2
|
||||
WHERE topic_id = 1
|
||||
SQL
|
||||
|
||||
expect(
|
||||
ActiveRecord::Base.exec_sql("SELECT * FROM #{table_name};").values
|
||||
).to include([2, "something@email.com"])
|
||||
end
|
||||
|
||||
it 'should prevent insertions to the readonly column' do
|
||||
expect do
|
||||
ActiveRecord::Base.connection.raw_connection.exec <<~SQL
|
||||
INSERT INTO #{table_name} (topic_id, email)
|
||||
VALUES (2, 'something@email.com');
|
||||
SQL
|
||||
end.to raise_error(
|
||||
PG::RaiseException,
|
||||
/Discourse: email in table_with_readonly_column is readonly/
|
||||
)
|
||||
end
|
||||
|
||||
it 'should allow insertions to the other columns' do
|
||||
ActiveRecord::Base.exec_sql <<~SQL
|
||||
INSERT INTO #{table_name} (topic_id)
|
||||
VALUES (2);
|
||||
SQL
|
||||
|
||||
expect(
|
||||
ActiveRecord::Base.exec_sql("SELECT * FROM #{table_name} WHERE topic_id = 2;").values
|
||||
).to include([2, nil])
|
||||
end
|
||||
end
|
||||
end
|
84
spec/components/migration/safe_migrate_spec.rb
Normal file
84
spec/components/migration/safe_migrate_spec.rb
Normal file
@ -0,0 +1,84 @@
|
||||
require 'rails_helper'
|
||||
require_dependency 'migration/safe_migrate'
|
||||
|
||||
describe Migration::SafeMigrate do
|
||||
before do
|
||||
Migration::SafeMigrate::SafeMigration.disable_safe!
|
||||
end
|
||||
|
||||
after do
|
||||
Migration::SafeMigrate.disable!
|
||||
Migration::SafeMigrate::SafeMigration.enable_safe!
|
||||
end
|
||||
|
||||
def capture_stdout
|
||||
old_stdout = $stdout
|
||||
io = StringIO.new
|
||||
$stdout = io
|
||||
yield
|
||||
io.string
|
||||
ensure
|
||||
$stdout = old_stdout
|
||||
end
|
||||
|
||||
it "bans all table removal" do
|
||||
Migration::SafeMigrate.enable!
|
||||
|
||||
path = File.expand_path "#{Rails.root}/spec/fixtures/migrate/drop_table"
|
||||
|
||||
output = capture_stdout do
|
||||
expect(lambda do
|
||||
ActiveRecord::Migrator.up([path])
|
||||
end).to raise_error(StandardError)
|
||||
end
|
||||
|
||||
expect(output).to include("TableDropper")
|
||||
|
||||
expect(User.first).not_to eq(nil)
|
||||
end
|
||||
|
||||
it "bans all column removal" do
|
||||
Migration::SafeMigrate.enable!
|
||||
|
||||
path = File.expand_path "#{Rails.root}/spec/fixtures/migrate/remove_column"
|
||||
|
||||
output = capture_stdout do
|
||||
expect(lambda do
|
||||
ActiveRecord::Migrator.up([path])
|
||||
end).to raise_error(StandardError)
|
||||
end
|
||||
|
||||
expect(output).to include("ColumnDropper")
|
||||
|
||||
expect(User.first).not_to eq(nil)
|
||||
end
|
||||
|
||||
it "bans all column renames" do
|
||||
Migration::SafeMigrate.enable!
|
||||
|
||||
path = File.expand_path "#{Rails.root}/spec/fixtures/migrate/rename_column"
|
||||
|
||||
output = capture_stdout do
|
||||
expect(lambda do
|
||||
ActiveRecord::Migrator.up([path])
|
||||
end).to raise_error(StandardError)
|
||||
end
|
||||
|
||||
expect(output).to include("ColumnDropper")
|
||||
|
||||
expect(User.first).not_to eq(nil)
|
||||
end
|
||||
|
||||
it "supports being disabled" do
|
||||
Migration::SafeMigrate.enable!
|
||||
Migration::SafeMigrate.disable!
|
||||
|
||||
path = File.expand_path "#{Rails.root}/spec/fixtures/migrate/drop_table"
|
||||
|
||||
output = capture_stdout do
|
||||
ActiveRecord::Migrator.up([path])
|
||||
end
|
||||
|
||||
expect(output).to include("drop_table(:users)")
|
||||
end
|
||||
end
|
66
spec/components/migration/table_dropper_spec.rb
Normal file
66
spec/components/migration/table_dropper_spec.rb
Normal file
@ -0,0 +1,66 @@
|
||||
require 'rails_helper'
|
||||
require_dependency 'migration/table_dropper'
|
||||
|
||||
describe Migration::TableDropper do
|
||||
|
||||
def table_exists?(table_name)
|
||||
sql = <<-SQL
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE table_schema = 'public' AND
|
||||
table_name = '#{table_name}'
|
||||
SQL
|
||||
|
||||
ActiveRecord::Base.exec_sql(sql).to_a.length > 0
|
||||
end
|
||||
|
||||
describe '#delayed_drop' do
|
||||
it "can drop a table after correct delay and when new table exists" do
|
||||
ActiveRecord::Base.exec_sql "CREATE TABLE table_with_old_name (topic_id INTEGER)"
|
||||
|
||||
name = ActiveRecord::Base
|
||||
.exec_sql("SELECT name FROM schema_migration_details LIMIT 1")
|
||||
.getvalue(0, 0)
|
||||
|
||||
Topic.exec_sql("UPDATE schema_migration_details SET created_at = :created_at WHERE name = :name",
|
||||
name: name, created_at: 15.minutes.ago)
|
||||
|
||||
dropped_proc_called = false
|
||||
|
||||
described_class.delayed_drop(
|
||||
old_name: 'table_with_old_name',
|
||||
new_name: 'table_with_new_name',
|
||||
after_migration: name,
|
||||
delay: 20.minutes,
|
||||
on_drop: ->() { dropped_proc_called = true }
|
||||
)
|
||||
|
||||
expect(table_exists?('table_with_old_name')).to eq(true)
|
||||
expect(dropped_proc_called).to eq(false)
|
||||
|
||||
described_class.delayed_drop(
|
||||
old_name: 'table_with_old_name',
|
||||
new_name: 'table_with_new_name',
|
||||
after_migration: name,
|
||||
delay: 10.minutes,
|
||||
on_drop: ->() { dropped_proc_called = true }
|
||||
)
|
||||
|
||||
expect(table_exists?('table_with_old_name')).to eq(true)
|
||||
expect(dropped_proc_called).to eq(false)
|
||||
|
||||
ActiveRecord::Base.exec_sql "CREATE TABLE table_with_new_name (topic_id INTEGER)"
|
||||
|
||||
described_class.delayed_drop(
|
||||
old_name: 'table_with_old_name',
|
||||
new_name: 'table_with_new_name',
|
||||
after_migration: name,
|
||||
delay: 10.minutes,
|
||||
on_drop: ->() { dropped_proc_called = true }
|
||||
)
|
||||
|
||||
expect(table_exists?('table_with_old_name')).to eq(false)
|
||||
expect(dropped_proc_called).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user