on main help sign in
Add support for complex down function in
9512c80fabianlindfors
README.md+16
@@ -444,6 +444,22 @@ column = "name"
444down = "'N/A'"
444down = "'N/A'"
445```
445```
446
446
447+_Example: remove `email` column from `users` table and use column from `profiles` table instead_
448+
449+```toml
450+[[actions]]
451+type = "remove_column"
452+name = "users"
453+column = "email"
454+
455+ # Our application will use the `profiles.email` column instead
456+ # For backwards compatibility, we will write back to the removed `email` column whenever `profiles` is changed
457+ [actions.down]
458+ table = "profiles"
459+ value = "email"
460+ where = "id = user_id"
461+```
462+
447### Indices
463### Indices
448
464
449#### Add index
465#### Add index
src/lib.rs+6−3
@@ -210,7 +210,8 @@ fn migrate(
210 let description = action.describe();
210 let description = action.describe();
211 print!(" + {} ", description);
211 print!(" + {} ", description);
212
212
213+ let ctx =
213− let ctx = MigrationContext::new(migration_index, action_index);
214+ MigrationContext::new(migration_index, action_index, state::current_migration(db)?);
214 result = action
215 result = action
215 .run(&ctx, db, &new_schema)
216 .run(&ctx, db, &new_schema)
216 .with_context(|| format!("failed to {}", description));
217 .with_context(|| format!("failed to {}", description));
@@ -320,7 +321,8 @@ fn complete(db: &mut DbConn, state: &mut State) -> anyhow::Result<()> {
320 let description = action.describe();
321 let description = action.describe();
321 print!(" + {} ", description);
322 print!(" + {} ", description);
322
323
324+ let ctx =
323− let ctx = MigrationContext::new(migration_index, action_index);
325+ MigrationContext::new(migration_index, action_index, state::current_migration(db)?);
324
326
325 // Update state to indicate that this action has been completed.
327 // Update state to indicate that this action has been completed.
326 // We won't save this new state until after the action has completed.
328 // We won't save this new state until after the action has completed.
@@ -445,7 +447,8 @@ fn abort(db: &mut DbConn, state: &mut State) -> anyhow::Result<()> {
445 continue;
447 continue;
446 }
448 }
447
449
450+ let ctx =
448− let ctx = MigrationContext::new(migration_index, action_index);
451+ MigrationContext::new(migration_index, action_index, state::current_migration(db)?);
449 action
452 action
450 .abort(&ctx, db)
453 .abort(&ctx, db)
451 .with_context(|| format!("failed to abort migration {}", migration.name))
454 .with_context(|| format!("failed to abort migration {}", migration.name))
src/migrations/mod.rs+7−1
@@ -88,13 +88,19 @@ impl Clone for Migration {
88pub struct MigrationContext {
88pub struct MigrationContext {
89 migration_index: usize,
89 migration_index: usize,
90 action_index: usize,
90 action_index: usize,
91+ existing_schema_name: Option<String>,
91}
92}
92
93
93impl MigrationContext {
94impl MigrationContext {
95+ pub fn new(
94− pub fn new(migration_index: usize, action_index: usize) -> Self {
96+ migration_index: usize,
97+ action_index: usize,
98+ existing_schema_name: Option<String>,
99+ ) -> Self {
95 MigrationContext {
100 MigrationContext {
96 migration_index,
101 migration_index,
97 action_index,
102 action_index,
103+ existing_schema_name,
98 }
104 }
99 }
105 }
100
106
src/migrations/remove_column.rs+118−33
@@ -3,14 +3,25 @@ use crate::{
3 db::{Conn, Transaction},
3 db::{Conn, Transaction},
4 schema::Schema,
4 schema::Schema,
5};
5};
6−use anyhow::Context;
6+use anyhow::{bail, Context};
7use serde::{Deserialize, Serialize};
7use serde::{Deserialize, Serialize};
8
8
9#[derive(Serialize, Deserialize, Debug)]
9#[derive(Serialize, Deserialize, Debug)]
10pub struct RemoveColumn {
10pub struct RemoveColumn {
11 pub table: String,
11 pub table: String,
12 pub column: String,
12 pub column: String,
13− pub down: Option<String>,
13+ pub down: Option<Transformation>,
14+}
15+
16+#[derive(Serialize, Deserialize, Debug)]
17+#[serde(untagged)]
18+pub enum Transformation {
19+ Simple(String),
20+ Update {
21+ table: String,
22+ value: String,
23+ r#where: String,
24+ },
14}
25}
15
26
16impl RemoveColumn {
27impl RemoveColumn {
@@ -39,10 +50,10 @@ impl Action for RemoveColumn {
39 db: &mut dyn Conn,
50 db: &mut dyn Conn,
40 schema: &Schema,
51 schema: &Schema,
41 ) -> anyhow::Result<()> {
52 ) -> anyhow::Result<()> {
53+ let table = schema.get_table(db, &self.table)?;
54+
42 // Add down trigger
55 // Add down trigger
43 if let Some(down) = &self.down {
56 if let Some(down) = &self.down {
44− let table = schema.get_table(db, &self.table)?;
45−
46 let declarations: Vec<String> = table
57 let declarations: Vec<String> = table
47 .columns
58 .columns
48 .iter()
59 .iter()
@@ -56,32 +67,91 @@ impl Action for RemoveColumn {
56 })
67 })
57 .collect();
68 .collect();
58
69
70+ if let Transformation::Simple(down) = down {
59− let query = format!(
71+ let query = format!(
60− r#"
72+ r#"
61− CREATE OR REPLACE FUNCTION {trigger_name}()
73+ CREATE OR REPLACE FUNCTION {trigger_name}()
62− RETURNS TRIGGER AS $$
74+ RETURNS TRIGGER AS $$
63− BEGIN
75+ BEGIN
64− IF reshape.is_new_schema() THEN
76+ IF reshape.is_new_schema() THEN
65− DECLARE
77+ DECLARE
66− {declarations}
78+ {declarations}
67− BEGIN
79+ BEGIN
68− NEW.{column_name} = {down};
80+ NEW.{column_name} = {down};
69− END;
81+ END;
70− END IF;
82+ END IF;
71− RETURN NEW;
83+ RETURN NEW;
72− END
84+ END
73− $$ language 'plpgsql';
85+ $$ language 'plpgsql';
74−
86+
75− DROP TRIGGER IF EXISTS "{trigger_name}" ON "{table}";
87+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{table}";
76− CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{table}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
88+ CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{table}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
77− "#,
89+ "#,
78− column_name = self.column,
90+ column_name = self.column,
79− trigger_name = self.trigger_name(ctx),
91+ trigger_name = self.trigger_name(ctx),
80− down = down,
92+ down = down,
81− table = self.table,
93+ table = self.table,
82− declarations = declarations.join("\n"),
94+ declarations = declarations.join("\n"),
83− );
95+ );
84− db.run(&query).context("failed to create down trigger")?;
96+ db.run(&query).context("failed to create down trigger")?;
97+ }
98+
99+ if let Transformation::Update {
100+ table: from_table,
101+ value,
102+ r#where,
103+ } = down
104+ {
105+ let existing_schema_name = match &ctx.existing_schema_name {
106+ Some(name) => name,
107+ None => bail!("can't use update without previous migration"),
108+ };
109+
110+ let from_table = schema.get_table(db, &from_table)?;
111+
112+ let declarations: Vec<String> = from_table
113+ .columns
114+ .iter()
115+ .map(|column| {
116+ format!(
117+ "{alias} public.{table}.{real_name}%TYPE := NEW.{real_name};",
118+ table = from_table.real_name,
119+ alias = column.name,
120+ real_name = column.real_name,
121+ )
122+ })
123+ .collect();
124+
125+ let query = format!(
126+ r#"
127+ CREATE OR REPLACE FUNCTION {trigger_name}()
128+ RETURNS TRIGGER AS $$
129+ #variable_conflict use_variable
130+ BEGIN
131+ IF reshape.is_new_schema() THEN
132+ DECLARE
133+ {declarations}
134+ BEGIN
135+ UPDATE "migration_{existing_schema_name}"."{changed_table}" "{changed_table}"
136+ SET "{column_name}" = {value}
137+ WHERE {where};
138+ END;
139+ END IF;
140+ RETURN NEW;
141+ END
142+ $$ language 'plpgsql';
143+
144+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{from_table_real}";
145+ CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{from_table_real}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
146+ "#,
147+ changed_table = self.table,
148+ from_table_real = from_table.real_name,
149+ column_name = self.column,
150+ trigger_name = self.trigger_name(ctx),
151+ declarations = declarations.join("\n"),
152+ );
153+ db.run(&query).context("failed to create down trigger")?;
154+ }
85 }
155 }
86
156
87 Ok(())
157 Ok(())
@@ -106,12 +176,20 @@ impl Action for RemoveColumn {
106 }
176 }
107
177
108 // Remove column, function and trigger
178 // Remove column, function and trigger
179+ let trigger_table = match &self.down {
180+ Some(Transformation::Update {
181+ table,
182+ value: _,
183+ r#where: _,
184+ }) => table,
185+ _ => &self.table,
186+ };
109 let query = format!(
187 let query = format!(
110 r#"
188 r#"
111 ALTER TABLE "{table}"
189 ALTER TABLE "{table}"
112 DROP COLUMN IF EXISTS "{column}";
190 DROP COLUMN IF EXISTS "{column}";
113
191
114− DROP TRIGGER IF EXISTS "{trigger_name}" ON "{table}";
192+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{trigger_table}";
115 DROP FUNCTION IF EXISTS "{trigger_name}";
193 DROP FUNCTION IF EXISTS "{trigger_name}";
116 "#,
194 "#,
117 table = self.table,
195 table = self.table,
@@ -134,12 +212,19 @@ impl Action for RemoveColumn {
134
212
135 fn abort(&self, ctx: &MigrationContext, db: &mut dyn Conn) -> anyhow::Result<()> {
213 fn abort(&self, ctx: &MigrationContext, db: &mut dyn Conn) -> anyhow::Result<()> {
136 // Remove function and trigger
214 // Remove function and trigger
215+ let trigger_table = match &self.down {
216+ Some(Transformation::Update {
217+ table,
218+ value: _,
219+ r#where: _,
220+ }) => table,
221+ _ => &self.table,
222+ };
137 db.run(&format!(
223 db.run(&format!(
138 r#"
224 r#"
139− DROP TRIGGER IF EXISTS "{trigger_name}" ON "{table}";
225+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{trigger_table}";
140 DROP FUNCTION IF EXISTS "{trigger_name}";
226 DROP FUNCTION IF EXISTS "{trigger_name}";
141 "#,
227 "#,
142− table = self.table,
143 trigger_name = self.trigger_name(ctx),
228 trigger_name = self.trigger_name(ctx),
144 ))
229 ))
145 .context("failed to drop down trigger")?;
230 .context("failed to drop down trigger")?;
tests/remove_column.rs+86
@@ -128,4 +128,90 @@ fn remove_column_with_index() {
128
128
129 assert_eq!(0, count, "expected index to not exist");
129 assert_eq!(0, count, "expected index to not exist");
130 });
130 });
131+
132+ test.run();
133+}
134+
135+#[test]
136+fn remove_column_with_complex_down() {
137+ let mut test = Test::new("Remove column complex");
138+
139+ test.first_migration(
140+ r#"
141+ name = "create_tables"
142+
143+ [[actions]]
144+ type = "create_table"
145+ name = "users"
146+ primary_key = ["id"]
147+
148+ [[actions.columns]]
149+ name = "id"
150+ type = "INTEGER"
151+
152+ [[actions.columns]]
153+ name = "email"
154+ type = "TEXT"
155+
156+ [[actions]]
157+ type = "create_table"
158+ name = "profiles"
159+ primary_key = ["user_id"]
160+
161+ [[actions.columns]]
162+ name = "user_id"
163+ type = "INTEGER"
164+
165+ [[actions.columns]]
166+ name = "email"
167+ type = "TEXT"
168+ "#,
169+ );
170+
171+ test.second_migration(
172+ r#"
173+ name = "remove_users_email_column"
174+
175+ [[actions]]
176+ type = "remove_column"
177+ table = "users"
178+ column = "email"
179+
180+ [actions.down]
181+ table = "profiles"
182+ value = "email"
183+ where = "id = user_id"
184+ "#,
185+ );
186+
187+ test.after_first(|db| {
188+ db.simple_query("INSERT INTO users (id, email) VALUES (1, 'test@example.com')")
189+ .unwrap();
190+ db.simple_query("INSERT INTO profiles (user_id, email) VALUES (1, 'test@example.com')")
191+ .unwrap();
192+ });
193+
194+ test.intermediate(|old_db, new_db| {
195+ new_db
196+ .simple_query("UPDATE profiles SET email = 'test2@example.com' WHERE user_id = 1")
197+ .unwrap();
198+
199+ // Ensure new email was propagated to users table in old schema
200+ let email: String = old_db
201+ .query(
202+ "
203+ SELECT email
204+ FROM users
205+ WHERE id = 1
206+ ",
207+ &[],
208+ )
209+ .unwrap()
210+ .first()
211+ .map(|row| row.get("email"))
212+ .unwrap();
213+ assert_eq!("test2@example.com", email);
214+ });
215+
216+ test.run();
131}
217}