on main help sign in
Add new add_foreign_key action
commit814620a215727f0ea1a05a99e85852b130537536 authorfabianlindfors <fabian@flapplabs.se> dateFri 21 Jan 2022, 18:00 (5 years ago) parent9d1b7c4
7 files changed+332−19
README.md+18 src/migrations/add_foreign_key.rs+122 src/migrations/common.rs+7 src/migrations/mod.rs+4−1 src/schema.rs+1−4 tests/add_foreign_key.rs+166 tests/create_table.rs+14−14
README.md+18
@@ -21,6 +21,7 @@ Designed for Postgres 12 and later.
2121 - [Create table](#create-table)
2222 - [Rename table](#rename-table)
2323 - [Remove table](#remove-table)
24+ - [Add foreign key](#add-foreign-key)
2425 - [Columns](#columns)
2526 - [Add column](#add-column)
2627 - [Alter column](#alter-column)
@@ -243,6 +244,23 @@ type = "remove_table"
243244table = "users"
244245```
245246
247+#### Add foreign key
248+
249+The `add_foreign_key` action will add a foreign key between two existing tables. The migration will fail if the existing column values aren't valid references.
250+
251+*Example: create foreign key from `items` to `users` table*
252+
253+```toml
254+[[actions]]
255+type = "add_foreign_key"
256+table = "items"
257+
258+ [actions.foreign_key]
259+ columns = ["user_id"]
260+ referenced_table = "users"
261+ referenced_columns = ["id"]
262+```
263+
246264### Columns
247265
248266#### Add column
src/migrations/add_foreign_key.rsnew file+122
@@ -0,0 +1,122 @@
1+use super::{common::ForeignKey, Action, MigrationContext};
2+use crate::{
3+ db::{Conn, Transaction},
4+ schema::Schema,
5+};
6+use anyhow::Context;
7+use serde::{Deserialize, Serialize};
8+
9+#[derive(Serialize, Deserialize, Debug)]
10+pub struct AddForeignKey {
11+ pub table: String,
12+ foreign_key: ForeignKey,
13+}
14+
15+#[typetag::serde(name = "add_foreign_key")]
16+impl Action for AddForeignKey {
17+ fn describe(&self) -> String {
18+ format!(
19+ "Adding foreign key from table \"{}\" to \"{}\"",
20+ self.table, self.foreign_key.referenced_table
21+ )
22+ }
23+
24+ fn run(
25+ &self,
26+ ctx: &MigrationContext,
27+ db: &mut dyn Conn,
28+ schema: &Schema,
29+ ) -> anyhow::Result<()> {
30+ let table = schema.get_table(db, &self.table)?;
31+ let referenced_table = schema.get_table(db, &self.foreign_key.referenced_table)?;
32+
33+ // Add quotes around all column names
34+ let columns: Vec<String> = table
35+ .real_column_names(&self.foreign_key.columns)
36+ .map(|col| format!("\"{}\"", col))
37+ .collect();
38+ let referenced_columns: Vec<String> = referenced_table
39+ .real_column_names(&self.foreign_key.referenced_columns)
40+ .map(|col| format!("\"{}\"", col))
41+ .collect();
42+
43+ // Create foreign key but set is as NOT VALID.
44+ // This means the foreign key will be enforced for inserts and updates
45+ // but the existing data won't be checked, that would cause a long-lived lock.
46+ db.run(&format!(
47+ r#"
48+ ALTER TABLE "{table}"
49+ ADD CONSTRAINT {constraint_name}
50+ FOREIGN KEY ({columns})
51+ REFERENCES {referenced_table} ({referenced_columns})
52+ NOT VALID
53+ "#,
54+ table = table.real_name,
55+ constraint_name = self.temp_constraint_name(ctx),
56+ columns = columns.join(", "),
57+ referenced_table = referenced_table.real_name,
58+ referenced_columns = referenced_columns.join(", "),
59+ ))
60+ .context("failed to create foreign key")?;
61+
62+ db.run(&format!(
63+ r#"
64+ ALTER TABLE "{table}"
65+ VALIDATE CONSTRAINT "{constraint_name}"
66+ "#,
67+ table = table.real_name,
68+ constraint_name = self.temp_constraint_name(ctx),
69+ ))
70+ .context("failed to validate foreign key")?;
71+
72+ Ok(())
73+ }
74+
75+ fn complete<'a>(
76+ &self,
77+ ctx: &MigrationContext,
78+ db: &'a mut dyn Conn,
79+ ) -> anyhow::Result<Option<Transaction<'a>>> {
80+ db.run(&format!(
81+ r#"
82+ ALTER TABLE {table}
83+ RENAME CONSTRAINT {temp_constraint_name} TO {constraint_name}
84+ "#,
85+ table = self.table,
86+ temp_constraint_name = self.temp_constraint_name(ctx),
87+ constraint_name = self.final_constraint_name(),
88+ ))
89+ .context("failed to rename temporary constraint")?;
90+ Ok(None)
91+ }
92+
93+ fn update_schema(&self, _ctx: &MigrationContext, _schema: &mut Schema) {}
94+
95+ fn abort(&self, ctx: &MigrationContext, db: &mut dyn Conn) -> anyhow::Result<()> {
96+ db.run(&format!(
97+ r#"
98+ ALTER TABLE "{table}"
99+ DROP CONSTRAINT IF EXISTS "{constraint_name}"
100+ "#,
101+ table = self.table,
102+ constraint_name = self.temp_constraint_name(ctx),
103+ ))
104+ .context("failed to validate foreign key")?;
105+
106+ Ok(())
107+ }
108+}
109+
110+impl AddForeignKey {
111+ fn temp_constraint_name(&self, ctx: &MigrationContext) -> String {
112+ format!("{}_temp_fkey", ctx.prefix())
113+ }
114+
115+ fn final_constraint_name(&self) -> String {
116+ format!(
117+ "{table}_{columns}_fkey",
118+ table = self.table,
119+ columns = self.foreign_key.columns.join("_")
120+ )
121+ }
122+}
src/migrations/common.rs+7
@@ -19,6 +19,13 @@ fn nullable_default() -> bool {
1919 true
2020}
2121
22+#[derive(Serialize, Deserialize, Clone, Debug)]
23+pub struct ForeignKey {
24+ pub columns: Vec<String>,
25+ pub referenced_table: String,
26+ pub referenced_columns: Vec<String>,
27+}
28+
2229#[derive(Debug)]
2330struct PostgresRawValue {
2431 bytes: Vec<u8>,
src/migrations/mod.rs+4−1
@@ -10,7 +10,7 @@ mod common;
1010pub use common::Column;
1111
1212mod create_table;
13−pub use create_table::{CreateTable, ForeignKey};
13+pub use create_table::CreateTable;
1414
1515mod alter_column;
1616pub use alter_column::{AlterColumn, ColumnChanges};
@@ -39,6 +39,9 @@ pub use create_enum::CreateEnum;
3939mod remove_enum;
4040pub use remove_enum::RemoveEnum;
4141
42+mod add_foreign_key;
43+pub use add_foreign_key::AddForeignKey;
44+
4245#[derive(Serialize, Deserialize, Debug)]
4346pub struct Migration {
4447 pub name: String,
src/schema.rs+1−4
@@ -1,8 +1,5 @@
11use crate::db::Conn;
2−use std::{
3− collections::{HashMap, HashSet},
4− path::Iter,
5−};
2+use std::collections::{HashMap, HashSet};
63
74// Schema tracks changes made to tables and columns during a migration.
85// These changes are not applied until the migration is completed but
tests/add_foreign_key.rsnew file+166
@@ -0,0 +1,166 @@
1+mod common;
2+use common::Test;
3+
4+#[test]
5+fn add_foreign_key() {
6+ let mut test = Test::new("Add foreign key");
7+
8+ test.first_migration(
9+ r#"
10+ name = "create_user_table"
11+
12+ [[actions]]
13+ type = "create_table"
14+ name = "users"
15+ primary_key = ["id"]
16+
17+ [[actions.columns]]
18+ name = "id"
19+ type = "INTEGER"
20+
21+ [[actions]]
22+ type = "create_table"
23+ name = "items"
24+ primary_key = ["id"]
25+
26+ [[actions.columns]]
27+ name = "id"
28+ type = "INTEGER"
29+
30+ [[actions.columns]]
31+ name = "user_id"
32+ type = "INTEGER"
33+ "#,
34+ );
35+
36+ test.second_migration(
37+ r#"
38+ name = "add_foreign_key"
39+
40+ [[actions]]
41+ type = "add_foreign_key"
42+ table = "items"
43+
44+ [actions.foreign_key]
45+ columns = ["user_id"]
46+ referenced_table = "users"
47+ referenced_columns = ["id"]
48+ "#,
49+ );
50+
51+ test.after_first(|db| {
52+ // Insert some test users
53+ db.simple_query("INSERT INTO users (id) VALUES (1), (2)")
54+ .unwrap();
55+ });
56+
57+ test.intermediate(|db, _| {
58+ // Ensure items can be inserted if they reference valid users
59+ db.simple_query("INSERT INTO items (id, user_id) VALUES (1, 1), (2, 2)")
60+ .unwrap();
61+
62+ // Ensure items can't be inserted if they don't reference valid users
63+ let result = db.simple_query("INSERT INTO items (id, user_id) VALUES (3, 3)");
64+ assert!(result.is_err(), "expected insert to fail");
65+ });
66+
67+ test.after_completion(|db| {
68+ // Ensure items can be inserted if they reference valid users
69+ db.simple_query("INSERT INTO items (id, user_id) VALUES (3, 1), (4, 2)")
70+ .unwrap();
71+
72+ // Ensure items can't be inserted if they don't reference valid users
73+ let result = db.simple_query("INSERT INTO items (id, user_id) VALUES (5, 3)");
74+ assert!(result.is_err(), "expected insert to fail");
75+
76+ // Ensure foreign key exists with the right name
77+ let foreign_key_name: Option<String> = db
78+ .query(
79+ "
80+ SELECT tc.constraint_name
81+ FROM information_schema.table_constraints AS tc
82+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='items';
83+ ",
84+ &[],
85+ )
86+ .unwrap()
87+ .first()
88+ .map(|row| row.get(0));
89+ assert_eq!(Some("items_user_id_fkey".to_string()), foreign_key_name);
90+ });
91+
92+ test.after_abort(|db| {
93+ // Ensure foreign key doesn't exist
94+ let fk_does_not_exist = db
95+ .query(
96+ "
97+ SELECT tc.constraint_name
98+ FROM information_schema.table_constraints AS tc
99+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='items';
100+ ",
101+ &[],
102+ )
103+ .unwrap()
104+ .is_empty();
105+ assert!(fk_does_not_exist);
106+ });
107+
108+ test.run()
109+}
110+
111+#[test]
112+fn add_invalid_foreign_key() {
113+ let mut test = Test::new("Add invalid foreign key");
114+
115+ test.first_migration(
116+ r#"
117+ name = "create_user_table"
118+
119+ [[actions]]
120+ type = "create_table"
121+ name = "users"
122+ primary_key = ["id"]
123+
124+ [[actions.columns]]
125+ name = "id"
126+ type = "INTEGER"
127+
128+ [[actions]]
129+ type = "create_table"
130+ name = "items"
131+ primary_key = ["id"]
132+
133+ [[actions.columns]]
134+ name = "id"
135+ type = "INTEGER"
136+
137+ [[actions.columns]]
138+ name = "user_id"
139+ type = "INTEGER"
140+ "#,
141+ );
142+
143+ test.second_migration(
144+ r#"
145+ name = "add_foreign_key"
146+
147+ [[actions]]
148+ type = "add_foreign_key"
149+ table = "items"
150+
151+ [actions.foreign_key]
152+ columns = ["user_id"]
153+ referenced_table = "users"
154+ referenced_columns = ["id"]
155+ "#,
156+ );
157+
158+ test.after_first(|db| {
159+ // Insert some items which don't reference a valid user
160+ db.simple_query("INSERT INTO items (id, user_id) VALUES (1, 1), (2, 2)")
161+ .unwrap();
162+ });
163+
164+ test.expect_failure();
165+ test.run()
166+}
tests/create_table.rs+14−14
@@ -163,20 +163,20 @@ fn create_table_with_foreign_keys() {
163163 let foreign_key_columns: Vec<(String, String, String)> = db
164164 .query(
165165 "
166− SELECT
167− kcu.column_name,
168− ccu.table_name AS foreign_table_name,
169− ccu.column_name AS foreign_column_name
170− FROM
171− information_schema.table_constraints AS tc
172− JOIN information_schema.key_column_usage AS kcu
173− ON tc.constraint_name = kcu.constraint_name
174− AND tc.table_schema = kcu.table_schema
175− JOIN information_schema.constraint_column_usage AS ccu
176− ON ccu.constraint_name = tc.constraint_name
177− AND ccu.table_schema = tc.table_schema
178− WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='items';
179− ",
166+ SELECT
167+ kcu.column_name,
168+ ccu.table_name AS foreign_table_name,
169+ ccu.column_name AS foreign_column_name
170+ FROM
171+ information_schema.table_constraints AS tc
172+ JOIN information_schema.key_column_usage AS kcu
173+ ON tc.constraint_name = kcu.constraint_name
174+ AND tc.table_schema = kcu.table_schema
175+ JOIN information_schema.constraint_column_usage AS ccu
176+ ON ccu.constraint_name = tc.constraint_name
177+ AND ccu.table_schema = tc.table_schema
178+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name='items';
179+ ",
180180 &[],
181181 )
182182 .unwrap()