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:
Sam
2018-03-20 18:20:50 +11:00
parent 9f216ac182
commit 6a3c8fe69c
21 changed files with 462 additions and 166 deletions

View 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

View 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

View 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