Add remove_foreign_key action

This commit is contained in:
fabianlindfors 2022-01-27 23:33:23 +01:00
parent 5984c1c507
commit 4e93a39be0
4 changed files with 232 additions and 0 deletions

View File

@ -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

View File

@ -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,

View File

@ -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<Option<Transaction<'a>>> {
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(())
}
}

124
tests/remove_foreign_key.rs Normal file
View File

@ -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()
}