diff --git a/README.md b/README.md index d9fcf58..6a2bde5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Designed for Postgres 12 and later. - [Rename table](#rename-table) - [Remove table](#remove-table) - [Add foreign key](#add-foreign-key) + - [Remove foreign key](#remove-foreign-key) - [Columns](#columns) - [Add column](#add-column) - [Alter column](#alter-column) @@ -261,6 +262,19 @@ table = "items" referenced_columns = ["id"] ``` +#### Remove foreign key + +The `remove_foreign_key` action will remove an existing foreign key. The foreign key will only be removed once the migration is completed, which means that your new application must continue to adhere to the foreign key constraint. + +*Example: remove foreign key `items_user_id_fkey` from `users` table* + +```toml +[[actions]] +type = "remove_foreign_key" +table = "items" +foreign_key = "items_user_id_fkey" +``` + ### Columns #### Add column diff --git a/src/migrations/mod.rs b/src/migrations/mod.rs index aa9c318..aed6440 100644 --- a/src/migrations/mod.rs +++ b/src/migrations/mod.rs @@ -42,6 +42,9 @@ pub use remove_enum::RemoveEnum; mod add_foreign_key; pub use add_foreign_key::AddForeignKey; +mod remove_foreign_key; +pub use remove_foreign_key::RemoveForeignKey; + #[derive(Serialize, Deserialize, Debug)] pub struct Migration { pub name: String, diff --git a/src/migrations/remove_foreign_key.rs b/src/migrations/remove_foreign_key.rs new file mode 100644 index 0000000..b36cc96 --- /dev/null +++ b/src/migrations/remove_foreign_key.rs @@ -0,0 +1,91 @@ +use super::{Action, MigrationContext}; +use crate::{ + db::{Conn, Transaction}, + schema::Schema, +}; +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct RemoveForeignKey { + table: String, + foreign_key: String, +} + +#[typetag::serde(name = "remove_foreign_key")] +impl Action for RemoveForeignKey { + fn describe(&self) -> String { + format!( + "Removing foreign key \"{}\" from table \"{}\"", + self.foreign_key, self.table + ) + } + + fn run( + &self, + _ctx: &MigrationContext, + db: &mut dyn Conn, + schema: &Schema, + ) -> anyhow::Result<()> { + // The foreign key is only removed once the migration is completed. + // Removing it earlier would be hard/undesirable for several reasons: + // - Postgres doesn't have an easy way to temporarily disable a foreign key check. + // If it did, we could disable the FK for the new schema. + // - Even if we could, it probably wouldn't be a good idea as it would cause temporary + // inconsistencies for the old schema which still expects the FK to hold. + // - For the same reason, we can't remove the FK when the migration is first applied. + // If the migration was to be aborted, then the FK would have to be recreated with + // the risk that it would no longer be valid. + + // Ensure foreign key exists + let table = schema.get_table(db, &self.table)?; + let fk_exists = !db + .query(&format!( + r#" + SELECT constraint_name + FROM information_schema.table_constraints + WHERE + constraint_type = 'FOREIGN KEY' AND + table_name = '{table_name}' AND + constraint_name = '{foreign_key}' + "#, + table_name = table.real_name, + foreign_key = self.foreign_key, + )) + .context("failed to check for foreign key")? + .is_empty(); + + if !fk_exists { + return Err(anyhow!( + "no foreign key \"{}\" exists on table \"{}\"", + self.foreign_key, + self.table + )); + } + + Ok(()) + } + + fn complete<'a>( + &self, + _ctx: &MigrationContext, + db: &'a mut dyn Conn, + ) -> anyhow::Result>> { + db.run(&format!( + r#" + ALTER TABLE {table} + DROP CONSTRAINT IF EXISTS {foreign_key} + "#, + table = self.table, + foreign_key = self.foreign_key, + )) + .context("failed to remove foreign key")?; + Ok(None) + } + + fn update_schema(&self, _ctx: &MigrationContext, _schema: &mut Schema) {} + + fn abort(&self, _ctx: &MigrationContext, _db: &mut dyn Conn) -> anyhow::Result<()> { + Ok(()) + } +} diff --git a/tests/remove_foreign_key.rs b/tests/remove_foreign_key.rs new file mode 100644 index 0000000..1745597 --- /dev/null +++ b/tests/remove_foreign_key.rs @@ -0,0 +1,124 @@ +mod common; +use common::Test; + +#[test] +fn remove_foreign_key() { + let mut test = Test::new("Remove foreign key"); + + test.first_migration( + r#" + name = "create_tables" + + [[actions]] + type = "create_table" + name = "users" + primary_key = ["id"] + + [[actions.columns]] + name = "id" + type = "INTEGER" + + [[actions]] + type = "create_table" + name = "items" + primary_key = ["id"] + + [[actions.columns]] + name = "id" + type = "INTEGER" + + [[actions.columns]] + name = "user_id" + type = "INTEGER" + + [[actions.foreign_keys]] + columns = ["user_id"] + referenced_table = "users" + referenced_columns = ["id"] + "#, + ); + + test.second_migration( + r#" + name = "remove_foreign_key" + + [[actions]] + type = "remove_foreign_key" + table = "items" + foreign_key = "items_user_id_fkey" + "#, + ); + + test.after_first(|db| { + // Insert some test users + db.simple_query("INSERT INTO users (id) VALUES (1), (2)") + .unwrap(); + }); + + test.intermediate(|old_db, new_db| { + // Ensure items can't be inserted if they don't reference valid users + // The foreign key is only removed when the migration is completed so + // it should still be enforced for the new and old schema. + let result = old_db.simple_query("INSERT INTO items (id, user_id) VALUES (3, 3)"); + assert!( + result.is_err(), + "expected insert against old schema to fail" + ); + + let result = new_db.simple_query("INSERT INTO items (id, user_id) VALUES (3, 3)"); + assert!( + result.is_err(), + "expected insert against new schema to fail" + ); + }); + + test.after_completion(|db| { + // Ensure items can be inserted even if they don't reference valid users + let result = db + .simple_query("INSERT INTO items (id, user_id) VALUES (5, 3)") + .map(|_| ()); + assert!( + result.is_ok(), + "expected insert to not fail, got {:?}", + result + ); + + // Ensure foreign key doesn't exist + let foreign_keys = db + .query( + " + SELECT tc.constraint_name + FROM information_schema.table_constraints AS tc + WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='items'; + ", + &[], + ) + .unwrap(); + assert!( + foreign_keys.is_empty(), + "expected no foreign keys to exist on items table" + ); + }); + + test.after_abort(|db| { + // Ensure items can't be inserted if they don't reference valid users + let result = db.simple_query("INSERT INTO items (id, user_id) VALUES (3, 3)"); + assert!(result.is_err(), "expected insert to fail"); + + // Ensure foreign key still exists + let fk_exists = !db + .query( + " + SELECT tc.constraint_name + FROM information_schema.table_constraints AS tc + WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='items'; + ", + &[], + ) + .unwrap() + .is_empty(); + assert!(fk_exists); + }); + + test.run() +}