on main help sign in
Add support for complex down function in
commit9512c80de982a74a99d004be483e891a79039653 authorfabianlindfors <fabian@flapplabs.se> dateTue 21 Nov 2023, 23:22 (3 years ago) parent21266e2
5 files changed+233−37
README.md+16 src/lib.rs+6−3 src/migrations/mod.rs+7−1 src/migrations/remove_column.rs+118−33 tests/remove_column.rs+86
README.md+16
@@ -444,6 +444,22 @@ column = "name"
444444down = "'N/A'"
445445```
446446
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+
447463### Indices
448464
449465#### Add index
src/lib.rs+6−3
@@ -210,7 +210,8 @@ fn migrate(
210210 let description = action.describe();
211211 print!(" + {} ", description);
212212
213− let ctx = MigrationContext::new(migration_index, action_index);
213+ let ctx =
214+ MigrationContext::new(migration_index, action_index, state::current_migration(db)?);
214215 result = action
215216 .run(&ctx, db, &new_schema)
216217 .with_context(|| format!("failed to {}", description));
@@ -320,7 +321,8 @@ fn complete(db: &mut DbConn, state: &mut State) -> anyhow::Result<()> {
320321 let description = action.describe();
321322 print!(" + {} ", description);
322323
323− let ctx = MigrationContext::new(migration_index, action_index);
324+ let ctx =
325+ MigrationContext::new(migration_index, action_index, state::current_migration(db)?);
324326
325327 // Update state to indicate that this action has been completed.
326328 // 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<()> {
445447 continue;
446448 }
447449
448− let ctx = MigrationContext::new(migration_index, action_index);
450+ let ctx =
451+ MigrationContext::new(migration_index, action_index, state::current_migration(db)?);
449452 action
450453 .abort(&ctx, db)
451454 .with_context(|| format!("failed to abort migration {}", migration.name))
src/migrations/mod.rs+7−1
@@ -88,13 +88,19 @@ impl Clone for Migration {
8888pub struct MigrationContext {
8989 migration_index: usize,
9090 action_index: usize,
91+ existing_schema_name: Option<String>,
9192}
9293
9394impl MigrationContext {
94− pub fn new(migration_index: usize, action_index: usize) -> Self {
95+ pub fn new(
96+ migration_index: usize,
97+ action_index: usize,
98+ existing_schema_name: Option<String>,
99+ ) -> Self {
95100 MigrationContext {
96101 migration_index,
97102 action_index,
103+ existing_schema_name,
98104 }
99105 }
100106
src/migrations/remove_column.rs+118−33
@@ -3,14 +3,25 @@ use crate::{
33 db::{Conn, Transaction},
44 schema::Schema,
55};
6−use anyhow::Context;
6+use anyhow::{bail, Context};
77use serde::{Deserialize, Serialize};
88
99#[derive(Serialize, Deserialize, Debug)]
1010pub struct RemoveColumn {
1111 pub table: String,
1212 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+ },
1425}
1526
1627impl RemoveColumn {
@@ -39,10 +50,10 @@ impl Action for RemoveColumn {
3950 db: &mut dyn Conn,
4051 schema: &Schema,
4152 ) -> anyhow::Result<()> {
53+ let table = schema.get_table(db, &self.table)?;
54+
4255 // Add down trigger
4356 if let Some(down) = &self.down {
44− let table = schema.get_table(db, &self.table)?;
45−
4657 let declarations: Vec<String> = table
4758 .columns
4859 .iter()
@@ -56,32 +67,91 @@ impl Action for RemoveColumn {
5667 })
5768 .collect();
5869
59− let query = format!(
60− r#"
61− CREATE OR REPLACE FUNCTION {trigger_name}()
62− RETURNS TRIGGER AS $$
63− BEGIN
64− IF reshape.is_new_schema() THEN
65− DECLARE
66− {declarations}
67− BEGIN
68− NEW.{column_name} = {down};
69− END;
70− END IF;
71− RETURN NEW;
72− END
73− $$ language 'plpgsql';
74−
75− 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}();
77− "#,
78− column_name = self.column,
79− trigger_name = self.trigger_name(ctx),
80− down = down,
81− table = self.table,
82− declarations = declarations.join("\n"),
83− );
84− db.run(&query).context("failed to create down trigger")?;
70+ if let Transformation::Simple(down) = down {
71+ let query = format!(
72+ r#"
73+ CREATE OR REPLACE FUNCTION {trigger_name}()
74+ RETURNS TRIGGER AS $$
75+ BEGIN
76+ IF reshape.is_new_schema() THEN
77+ DECLARE
78+ {declarations}
79+ BEGIN
80+ NEW.{column_name} = {down};
81+ END;
82+ END IF;
83+ RETURN NEW;
84+ END
85+ $$ language 'plpgsql';
86+
87+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{table}";
88+ CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{table}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
89+ "#,
90+ column_name = self.column,
91+ trigger_name = self.trigger_name(ctx),
92+ down = down,
93+ table = self.table,
94+ declarations = declarations.join("\n"),
95+ );
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+ }
85155 }
86156
87157 Ok(())
@@ -106,12 +176,20 @@ impl Action for RemoveColumn {
106176 }
107177
108178 // 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+ };
109187 let query = format!(
110188 r#"
111189 ALTER TABLE "{table}"
112190 DROP COLUMN IF EXISTS "{column}";
113191
114− DROP TRIGGER IF EXISTS "{trigger_name}" ON "{table}";
192+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{trigger_table}";
115193 DROP FUNCTION IF EXISTS "{trigger_name}";
116194 "#,
117195 table = self.table,
@@ -134,12 +212,19 @@ impl Action for RemoveColumn {
134212
135213 fn abort(&self, ctx: &MigrationContext, db: &mut dyn Conn) -> anyhow::Result<()> {
136214 // 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+ };
137223 db.run(&format!(
138224 r#"
139− DROP TRIGGER IF EXISTS "{trigger_name}" ON "{table}";
225+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{trigger_table}";
140226 DROP FUNCTION IF EXISTS "{trigger_name}";
141227 "#,
142− table = self.table,
143228 trigger_name = self.trigger_name(ctx),
144229 ))
145230 .context("failed to drop down trigger")?;
tests/remove_column.rs+86
@@ -128,4 +128,90 @@ fn remove_column_with_index() {
128128
129129 assert_eq!(0, count, "expected index to not exist");
130130 });
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();
131217}