on 9752b1e help sign in
11 files changed+91−615
since splitsnapshots
unifiedsplit
.github/workflows/lint.yaml+3−3 .github/workflows/publish-crate.yaml−20 .github/workflows/publish-docker.yaml−25 Cargo.toml+3−3 Dockerfile+1−1 README.md+20−21 src/migrations/add_column.rs+25−117 src/migrations/remove_column.rs+30−247 tests/add_column.rs+2−2 tests/complex.rs+5−174 tests/remove_column.rs+2−2
.github/workflows/lint.yaml+3−3
@@ -15,7 +15,7 @@ jobs:
1515 runs-on: ubuntu-latest
1616 steps:
1717 - name: Checkout
18− uses: actions/checkout@v4
18+ uses: actions/checkout@v2
1919 - name: Select Rust toolchain with Clippy
2020 uses: actions-rs/toolchain@v1
2121 with:
@@ -23,6 +23,6 @@ jobs:
2323 components: clippy
2424 override: true
2525 - name: Use cache for Rust dependencies
26− uses: Swatinem/rust-cache@v2
26+ uses: Swatinem/rust-cache@v1
2727 - name: Lint using Clippy
28− run: cargo clippy
28+ run: cargo clippy
.github/workflows/publish-crate.yamldeleted−20
@@ -1,20 +0,0 @@
1−name: Publish crate
2−
3−on: workflow_dispatch
4−
5−jobs:
6− publish-crate:
7− runs-on: ubuntu-latest
8− steps:
9− - name: Checkout
10− uses: actions/checkout@v2
11− - name: Select Rust toolchain
12− uses: actions-rs/toolchain@v1
13− with:
14− toolchain: stable
15− - name: Publish to crates.io
16− uses: actions-rs/cargo@v1
17− with:
18− command: publish
19− env:
20− CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
.github/workflows/publish-docker.yamldeleted−25
@@ -1,25 +0,0 @@
1−name: Publish Docker image
2−
3−on:
4− workflow_dispatch:
5− inputs:
6− version:
7− description: "Version (without 'v' prefix)"
8− required: true
9− type: string
10−
11−jobs:
12− publish-docker:
13− runs-on: ubuntu-latest
14− steps:
15− - name: Checkout
16− uses: actions/checkout@v2
17− - name: Log in to Docker
18− env:
19− DOCKER_PASSWORD: ${{ secrets.DOCKER_ACCESS_TOKEN }}
20− run: |
21− docker login -u ${{ secrets.DOCKER_USER }} -p $DOCKER_PASSWORD
22− - name: Build Docker image
23− run: docker build . --tag ${{ secrets.DOCKER_USER }}/reshape:${{ inputs.version }} --tag ${{ secrets.DOCKER_USER }}/reshape:latest
24− - name: Push Docker image
25− run: docker push --all-tags ${{ secrets.DOCKER_USER }}/reshape
Cargo.toml+3−3
@@ -1,6 +1,6 @@
11[package]
22name = "reshape"
3−version = "0.7.0"
3+version = "0.6.1"
44description = "An easy-to-use, zero-downtime schema migration tool for Postgres"
55homepage = "https://github.com/fabianlindfors/reshape"
66documentation = "https://github.com/fabianlindfors/reshape"
@@ -9,7 +9,7 @@ license = "MIT"
99keywords = ["postgres", "migrations"]
1010edition = "2021"
1111authors = ["Fabian Lindfors"]
12−rust-version = "1.70"
12+rust-version = "1.58"
1313
1414[dependencies]
1515postgres = { version = "0.19.2", features = ["with-serde_json-1"] }
@@ -23,4 +23,4 @@ version = "3.0.0"
2323colored = "2"
2424rand = "0.8"
2525dotenv = "0.15.0"
26−lexical-sort = "0.3.1"
26+lexical-sort = "0.3.1"
Dockerfile+1−1
@@ -1,4 +1,4 @@
1−FROM rust:1.70.0 AS builder
1+FROM rust:1.58.0 AS builder
22WORKDIR /usr/src/reshape
33COPY . .
44RUN cargo build --release
README.md+20−21
@@ -500,8 +500,8 @@ column = "email"
500500 # For backwards compatibility, we will write back to the removed `email` column whenever `profiles` is changed
501501 [actions.down]
502502 table = "profiles"
503− value = "profiles.email"
504− where = "users.id = profiles.user_id"
503+ value = "email"
504+ where = "id = user_id"
505505```
506506
507507### Indices
@@ -627,6 +627,17 @@ The `up` and `down` options available when creating tables, adding columns and r
627627_Example: move `email` column from `users` to `profiles` table_
628628
629629```toml
630+[[actions]]
631+type = "remove_column"
632+table = "users"
633+column = "email"
634+
635+ # When `profiles` is changed in the new schema, we write the email address back to the removed column
636+ [actions.down]
637+ table = "profiles"
638+ value = "email"
639+ where = "id = user_id"
640+
630641[[actions]]
631642type = "add_column"
632643table = "profiles"
@@ -639,19 +650,8 @@ table = "profiles"
639650 # When `users` is updated in the old schema, we write the email value to `profiles`
640651 [actions.up]
641652 table = "users"
642− value = "users.email"
643− where = "profiles.user_id = users.id"
644−
645−[[actions]]
646−type = "remove_column"
647−table = "users"
648−column = "email"
649−
650− # When `profiles` is changed in the new schema, we write the email address back to the removed column
651− [actions.down]
652− table = "profiles"
653− value = "profiles.email"
654− where = "users.id = profiles.user_id"
653+ value = "email"
654+ where = "user_id = id"
655655```
656656
657657_Example: turn a 1:N relationship between `users` and `accounts` into N:M and change the format of the associated `role`_
@@ -675,13 +675,12 @@ primary_key = ["account_id", "user_id"]
675675 [[actions.columns]]
676676 name = "role"
677677 type = "TEXT"
678− nullable = false
679678
680679 # Backfill the new table from `users` and uppercase the `role`
681680 [actions.up]
682681 table = "users"
683682 values = { user_id = "id", account_id = "account_id", role = "UPPER(account_role)" }
684− where = "user_account_connections.user_id = users.id"
683+ where = "user_id = id"
685684
686685[[actions]]
687686type = "remove_column"
@@ -691,8 +690,8 @@ column = "account_id"
691690 # When `user_account_connections` is updated, we write the `account_id` back to `users`
692691 [actions.down]
693692 table = "user_account_connections"
694− value = "user_account_connections.account_id"
695− where = "users.id = user_account_connections.user_id"
693+ value = "account_id"
694+ where = "id = user_id"
696695
697696[[actions]]
698697type = "remove_column"
@@ -702,8 +701,8 @@ column = "account_role"
702701 # When `user_account_connections` is updated, we write the lowercase role back to `users`
703702 [actions.down]
704703 table = "user_account_connections"
705− value = "LOWER(user_account_connections.role)"
706− where = "users.id = user_account_connections.user_id"
704+ value = "LOWER(role)"
705+ where = "id = user_id"
707706```
708707
709708## Commands and options
src/migrations/add_column.rs+25−117
@@ -3,7 +3,7 @@ use crate::{
33 db::{Conn, Transaction},
44 schema::Schema,
55};
6−use anyhow::{bail, Context};
6+use anyhow::Context;
77use serde::{Deserialize, Serialize};
88
99#[derive(Serialize, Deserialize, Debug)]
@@ -43,15 +43,6 @@ impl AddColumn {
4343 )
4444 }
4545
46− fn reverse_trigger_name(&self, ctx: &MigrationContext) -> String {
47− format!(
48− "{}_add_column_{}_{}_rev",
49− ctx.prefix(),
50− self.table,
51− self.column.name
52− )
53− }
54−
5546 fn not_null_constraint_name(&self, ctx: &MigrationContext) -> String {
5647 format!(
5748 "{}_add_column_not_null_{}_{}",
@@ -81,7 +72,7 @@ impl Action for AddColumn {
8172 let temp_column_name = self.temp_column_name(ctx);
8273
8374 let mut definition_parts = vec![
84− format!("\"{}\"", temp_column_name.to_string()),
75+ temp_column_name.to_string(),
8576 self.column.data_type.to_string(),
8677 ];
8778
@@ -106,21 +97,21 @@ impl Action for AddColumn {
10697 );
10798 db.run(&query).context("failed to add column")?;
10899
109− let declarations: Vec<String> = table
110− .columns
111− .iter()
112− .map(|column| {
113− format!(
114− "\"{alias}\" public.{table}.{real_name}%TYPE := NEW.{real_name};",
115− table = table.real_name,
116− alias = column.name,
117− real_name = column.real_name,
118− )
119− })
120− .collect();
121−
122100 if let Some(up) = &self.up {
123101 if let Transformation::Simple(up) = up {
102+ let declarations: Vec<String> = table
103+ .columns
104+ .iter()
105+ .map(|column| {
106+ format!(
107+ "{alias} public.{table}.{real_name}%TYPE := NEW.{real_name};",
108+ table = table.real_name,
109+ alias = column.name,
110+ real_name = column.real_name,
111+ )
112+ })
113+ .collect();
114+
124115 // Add triggers to fill in values as they are inserted/updated
125116 let query = format!(
126117 r#"
@@ -131,7 +122,7 @@ impl Action for AddColumn {
131122 DECLARE
132123 {declarations}
133124 BEGIN
134− NEW."{temp_column_name}" = {up};
125+ NEW.{temp_column_name} = {up};
135126 END;
136127 END IF;
137128 RETURN NEW;
@@ -160,20 +151,15 @@ impl Action for AddColumn {
160151 r#where,
161152 } = up
162153 {
163− let existing_schema_name = match &ctx.existing_schema_name {
164− Some(name) => name,
165− None => bail!("can't use update without previous migration"),
166− };
167−
168154 let from_table = schema.get_table(db, &from_table)?;
169155
170− let from_table_assignments: Vec<String> = from_table
156+ let declarations: Vec<String> = from_table
171157 .columns
172158 .iter()
173159 .map(|column| {
174160 format!(
175− "{table}.{alias} = NEW.{real_name};",
176− table = from_table.name,
161+ "{alias} public.{table}.{real_name}%TYPE := NEW.{real_name};",
162+ table = from_table.real_name,
177163 alias = column.name,
178164 real_name = column.real_name,
179165 )
@@ -189,18 +175,11 @@ impl Action for AddColumn {
189175 BEGIN
190176 IF NOT reshape.is_new_schema() THEN
191177 DECLARE
192− {from_table} migration_{existing_schema_name}.{from_table}%ROWTYPE;
178+ {declarations}
193179 BEGIN
194− {assignments}
195−
196− -- Don't trigger reverse trigger when making this update
197− perform set_config('reshape.disable_triggers', 'TRUE', TRUE);
198−
199180 UPDATE public."{changed_table_real}"
200181 SET "{temp_column_name}" = {value}
201182 WHERE {where};
202−
203− perform set_config('reshape.disable_triggers', '', TRUE);
204183 END;
205184 END IF;
206185 RETURN NEW;
@@ -210,81 +189,14 @@ impl Action for AddColumn {
210189 DROP TRIGGER IF EXISTS "{trigger_name}" ON "{from_table_real}";
211190 CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{from_table_real}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
212191 "#,
213− assignments = from_table_assignments.join("\n"),
214192 changed_table_real = table.real_name,
215− from_table = from_table.name,
216193 from_table_real = from_table.real_name,
217194 trigger_name = self.trigger_name(ctx),
218− // declarations = from_table_declarations.join("\n"),
195+ declarations = declarations.join("\n"),
219196 temp_column_name = temp_column_name,
220197 );
221198 db.run(&query).context("failed to create up trigger")?;
222199
223− let from_table_columns = from_table
224− .columns
225− .iter()
226− .map(|column| format!("{} as {}", column.real_name, column.name))
227− .collect::<Vec<String>>()
228− .join(", ");
229−
230− let changed_table_assignments: Vec<String> = table
231− .columns
232− .iter()
233− .map(|column| {
234− format!(
235− "{table}.{alias} := NEW.{real_name};",
236− table = table.name,
237− alias = column.name,
238− real_name = column.real_name,
239− )
240− })
241− .collect();
242−
243− // Add triggers to fill in values as they are inserted/updated
244− let query = format!(
245− r#"
246− CREATE OR REPLACE FUNCTION {trigger_name}()
247− RETURNS TRIGGER AS $$
248− #variable_conflict use_variable
249− BEGIN
250− IF NOT reshape.is_new_schema() AND NOT current_setting('reshape.disable_triggers', TRUE) = 'TRUE' THEN
251− DECLARE
252− {changed_table} migration_{existing_schema_name}.{changed_table}%ROWTYPE;
253− __temp_row migration_{existing_schema_name}.{from_table}%ROWTYPE;
254− BEGIN
255− {changed_table_assignments}
256−
257− SELECT {from_table_columns}
258− INTO __temp_row
259− FROM migration_{existing_schema_name}.{from_table} {from_table}
260− WHERE {where};
261−
262− DECLARE
263− {from_table} migration_{existing_schema_name}.{from_table}%ROWTYPE;
264− BEGIN
265− {from_table} = __temp_row;
266− NEW.{temp_column_name} = {value};
267− END;
268− END;
269− END IF;
270− RETURN NEW;
271− END
272− $$ language 'plpgsql';
273−
274− DROP TRIGGER IF EXISTS "{trigger_name}" ON "{changed_table_real}";
275− CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{changed_table_real}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
276− "#,
277− changed_table_assignments = changed_table_assignments.join("\n"),
278− changed_table_real = table.real_name,
279− changed_table = table.name,
280− from_table = from_table.name,
281− trigger_name = self.reverse_trigger_name(ctx),
282− temp_column_name = temp_column_name,
283− // declarations = declarations.join("\n"),
284− );
285− db.run(&query)
286− .context("failed to create reverse up trigger")?;
287−
288200 // Backfill values in batches by touching the from table
289201 common::batch_touch_rows(db, &from_table.real_name, None)
290202 .context("failed to batch update existing rows")?;
@@ -298,10 +210,10 @@ impl Action for AddColumn {
298210 if !self.column.nullable {
299211 let query = format!(
300212 r#"
301− ALTER TABLE "{table}"
302− ADD CONSTRAINT "{constraint_name}"
303− CHECK ("{column}" IS NOT NULL) NOT VALID
304− "#,
213+ ALTER TABLE "{table}"
214+ ADD CONSTRAINT "{constraint_name}"
215+ CHECK ("{column}" IS NOT NULL) NOT VALID
216+ "#,
305217 table = self.table,
306218 constraint_name = self.not_null_constraint_name(ctx),
307219 column = temp_column_name,
@@ -324,10 +236,8 @@ impl Action for AddColumn {
324236 let query = format!(
325237 r#"
326238 DROP FUNCTION IF EXISTS "{trigger_name}" CASCADE;
327− DROP FUNCTION IF EXISTS "{reverse_trigger_name}" CASCADE;
328239 "#,
329240 trigger_name = self.trigger_name(ctx),
330− reverse_trigger_name = self.reverse_trigger_name(ctx),
331241 );
332242 transaction
333243 .run(&query)
@@ -419,10 +329,8 @@ impl Action for AddColumn {
419329 let query = format!(
420330 r#"
421331 DROP FUNCTION IF EXISTS "{trigger_name}" CASCADE;
422− DROP FUNCTION IF EXISTS "{reverse_trigger_name}" CASCADE;
423332 "#,
424333 trigger_name = self.trigger_name(ctx),
425− reverse_trigger_name = self.reverse_trigger_name(ctx),
426334 );
427335 db.run(&query).context("failed to drop up trigger")?;
428336
src/migrations/remove_column.rs+30−247
@@ -3,7 +3,7 @@ use crate::{
33 db::{Conn, Transaction},
44 schema::Schema,
55};
6−use anyhow::{anyhow, bail, Context};
6+use anyhow::{bail, Context};
77use serde::{Deserialize, Serialize};
88
99#[derive(Serialize, Deserialize, Debug)]
@@ -33,33 +33,6 @@ impl RemoveColumn {
3333 self.column
3434 )
3535 }
36−
37− fn reverse_trigger_name(&self, ctx: &MigrationContext) -> String {
38− format!(
39− "{}_remove_column_{}_{}_rev",
40− ctx.prefix(),
41− self.table,
42− self.column
43− )
44− }
45−
46− fn not_null_constraint_trigger_name(&self, ctx: &MigrationContext) -> String {
47− format!(
48− "{}_remove_column_{}_{}_nn",
49− ctx.prefix(),
50− self.table,
51− self.column
52− )
53− }
54−
55− fn not_null_constraint_name(&self, ctx: &MigrationContext) -> String {
56− format!(
57− "{}_add_column_not_null_{}_{}",
58− ctx.prefix(),
59− self.table,
60− self.column
61− )
62− }
6336}
6437
6538#[typetag::serde(name = "remove_column")]
@@ -78,9 +51,6 @@ impl Action for RemoveColumn {
7851 schema: &Schema,
7952 ) -> anyhow::Result<()> {
8053 let table = schema.get_table(db, &self.table)?;
81− let column = table
82− .get_column(&self.column)
83− .ok_or_else(|| anyhow!("no such column {} exists", self.column))?;
8454
8555 // Add down trigger
8656 if let Some(down) = &self.down {
@@ -139,76 +109,22 @@ impl Action for RemoveColumn {
139109
140110 let from_table = schema.get_table(db, &from_table)?;
141111
142− let maybe_null_check = if !column.nullable {
143− // Replace NOT NULL constraint with a constraint trigger that only triggers on the old schema.
144− // We will add a null check to the down function on the new schema below as well to cover both cases.
145− // As we are using a complex down function, we must remove the NOT NULL check for the new schema.
146− // NOT NULL is not checked at the end of a transaction, but immediately upon update.
147− let query = format!(
148− r#"
149− CREATE OR REPLACE FUNCTION {trigger_name}()
150− RETURNS TRIGGER AS $$
151− BEGIN
152− IF NOT reshape.is_new_schema() THEN
153− IF NEW.{column} IS NULL THEN
154− RAISE EXCEPTION '{column} can not be null';
155− END IF;
156− END IF;
157− RETURN NEW;
158− END
159− $$ language 'plpgsql';
160−
161− DROP TRIGGER IF EXISTS "{trigger_name}" ON "{table}";
162−
163− CREATE CONSTRAINT TRIGGER "{trigger_name}"
164− AFTER INSERT OR UPDATE
165− ON "{table}"
166− FOR EACH ROW
167− EXECUTE PROCEDURE {trigger_name}();
168− "#,
169− table = self.table,
170− trigger_name = self.not_null_constraint_trigger_name(ctx),
171− column = self.column,
172− );
173− db.run(&query)
174− .context("failed to create null constraint trigger")?;
175−
176− db.run(&format!(
177− r#"
178− ALTER TABLE {table}
179− ALTER COLUMN {column}
180− DROP NOT NULL
181− "#,
182− table = self.table,
183− column = self.column
184− ))
185− .context("failed to remove column not null constraint")?;
186−
187− format!(
188− r#"
189− IF {value} IS NULL THEN
190− RAISE EXCEPTION '{column_name} can not be null';
191− END IF;
192− "#,
193− column_name = self.column,
194− )
195− } else {
196− "".to_string()
197− };
198−
199− let into_variables = from_table
112+ let declarations: Vec<String> = from_table
200113 .columns
201114 .iter()
202115 .map(|column| {
203116 format!(
204− "NEW.{real_name} AS {alias}",
117+ "{alias} public.{table}.{real_name}%TYPE := NEW.{real_name};",
118+ table = from_table.real_name,
205119 alias = column.name,
206120 real_name = column.real_name,
207121 )
208122 })
209− .collect::<Vec<String>>()
210− .join(", ");
123+ .collect();
211124
125+ // TODO: If column is NOT NULL, remove the constraint and perform a NULL check in some other way.
126+ // Otherwise, it's not possible to update the NOT NULL column from another table even in the same transaction.
127+ // Either make the NULL check in here or maybe use a constraint trigger: https://www.postgresql.org/docs/9.0/sql-createconstraint.html.
212128 let query = format!(
213129 r#"
214130 CREATE OR REPLACE FUNCTION {trigger_name}()
@@ -217,21 +133,11 @@ impl Action for RemoveColumn {
217133 BEGIN
218134 IF reshape.is_new_schema() THEN
219135 DECLARE
220− {from_table} record;
136+ {declarations}
221137 BEGIN
222− SELECT {into_variables}
223− INTO {from_table};
224−
225− {maybe_null_check}
226−
227− -- Don't trigger reverse trigger when making this update
228− perform set_config('reshape.disable_triggers', 'TRUE', TRUE);
229−
230138 UPDATE "migration_{existing_schema_name}"."{changed_table}" "{changed_table}"
231139 SET "{column_name}" = {value}
232140 WHERE {where};
233−
234− perform set_config('reshape.disable_triggers', '', TRUE);
235141 END;
236142 END IF;
237143 RETURN NEW;
@@ -242,80 +148,12 @@ impl Action for RemoveColumn {
242148 CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{from_table_real}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
243149 "#,
244150 changed_table = self.table,
245− from_table = from_table.name,
246151 from_table_real = from_table.real_name,
247152 column_name = self.column,
248153 trigger_name = self.trigger_name(ctx),
154+ declarations = declarations.join("\n"),
249155 );
250156 db.run(&query).context("failed to create down trigger")?;
251−
252− let changed_into_variables = table
253− .columns
254− .iter()
255− .map(|column| {
256− format!(
257− "NEW.{real_name} AS {alias}",
258− alias = column.name,
259− real_name = column.real_name,
260− )
261− })
262− .collect::<Vec<String>>()
263− .join(", ");
264−
265− let from_table_columns = from_table
266− .columns
267− .iter()
268− .map(|column| format!("{} as {}", column.real_name, column.name))
269− .collect::<Vec<String>>()
270− .join(", ");
271−
272− let query = format!(
273− r#"
274− CREATE OR REPLACE FUNCTION {trigger_name}()
275− RETURNS TRIGGER AS $$
276− #variable_conflict use_variable
277− BEGIN
278− IF reshape.is_new_schema() AND NOT current_setting('reshape.disable_triggers', TRUE) = 'TRUE' THEN
279− DECLARE
280− {changed_table} record;
281− __temp_row record;
282− BEGIN
283− SELECT {changed_into_variables}
284− INTO {changed_table};
285−
286− SELECT *
287− INTO __temp_row
288− FROM (
289− SELECT {from_table_columns}
290− FROM public.{from_table_real}
291− ) {from_table}
292− WHERE {where};
293−
294− DECLARE
295− {from_table} record;
296− BEGIN
297− {from_table} := __temp_row;
298− NEW.{column_name_real} = {value};
299− END;
300− END;
301− END IF;
302− RETURN NEW;
303− END
304− $$ language 'plpgsql';
305−
306− DROP TRIGGER IF EXISTS "{trigger_name}" ON "{changed_table_real}";
307− CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{changed_table_real}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
308− "#,
309− changed_table = table.name,
310− changed_table_real = table.real_name,
311− from_table = from_table.name,
312− from_table_real = from_table.real_name,
313− column_name_real = column.real_name,
314− trigger_name = self.reverse_trigger_name(ctx),
315− // declarations = declarations.join("\n"),
316− );
317− db.run(&query)
318− .context("failed to create reverse down trigger")?;
319157 }
320158 }
321159
@@ -341,20 +179,25 @@ impl Action for RemoveColumn {
341179 }
342180
343181 // Remove column, function and trigger
182+ let trigger_table = match &self.down {
183+ Some(Transformation::Update {
184+ table,
185+ value: _,
186+ r#where: _,
187+ }) => table,
188+ _ => &self.table,
189+ };
344190 let query = format!(
345191 r#"
346192 ALTER TABLE "{table}"
347193 DROP COLUMN IF EXISTS "{column}";
348194
349− DROP FUNCTION IF EXISTS "{trigger_name}" CASCADE;
350− DROP FUNCTION IF EXISTS "{reverse_trigger_name}" CASCADE;
351− DROP FUNCTION IF EXISTS "{null_trigger_name}" CASCADE;
195+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{trigger_table}";
196+ DROP FUNCTION IF EXISTS "{trigger_name}";
352197 "#,
353198 table = self.table,
354199 column = self.column,
355200 trigger_name = self.trigger_name(ctx),
356− reverse_trigger_name = self.reverse_trigger_name(ctx),
357− null_trigger_name = self.not_null_constraint_trigger_name(ctx),
358201 );
359202 db.run(&query)
360203 .context("failed to drop column and down trigger")?;
@@ -371,81 +214,21 @@ impl Action for RemoveColumn {
371214 }
372215
373216 fn abort(&self, ctx: &MigrationContext, db: &mut dyn Conn) -> anyhow::Result<()> {
374− // We might have temporaily removed the NOT NULL check and have to reinstate it
375− let has_not_null_function = !db
376− .query_with_params(
377− "
378− SELECT routine_name
379− FROM information_schema.routines
380− WHERE routine_schema = 'public'
381− AND routine_name = $1
382− ",
383− &[&self.not_null_constraint_trigger_name(ctx)],
384− )
385− .context("failed to get any NOT NULL function")?
386− .is_empty();
387−
388− if has_not_null_function {
389− // Make column NOT NULL again without taking any long lived locks with a temporary constraint
390− let query = format!(
391− r#"
392− ALTER TABLE "{table}"
393− ADD CONSTRAINT "{constraint_name}"
394− CHECK ("{column}" IS NOT NULL) NOT VALID
395− "#,
396− table = self.table,
397− constraint_name = self.not_null_constraint_name(ctx),
398− column = self.column,
399− );
400− db.run(&query)
401− .context("failed to add NOT NULL constraint")?;
402−
403− let query = format!(
404− r#"
405− ALTER TABLE "{table}"
406− VALIDATE CONSTRAINT "{constraint_name}"
407− "#,
408− table = self.table,
409− constraint_name = self.not_null_constraint_name(ctx),
410− );
411− db.run(&query)
412− .context("failed to validate NOT NULL constraint")?;
413−
414− // This ALTER TABLE call will not require any exclusive locks as it can use the validated constraint from above
415− db.run(&format!(
416− r#"
417− ALTER TABLE {table}
418− ALTER COLUMN {column}
419− SET NOT NULL
420− "#,
421− table = self.table,
422− column = self.column
423− ))
424− .context("failed to reinstate column NOT NULL")?;
425−
426− // Drop the temporary constraint
427− let query = format!(
428− r#"
429− ALTER TABLE "{table}"
430− DROP CONSTRAINT "{constraint_name}"
431− "#,
432− table = self.table,
433− constraint_name = self.not_null_constraint_name(ctx),
434− );
435− db.run(&query)
436− .context("failed to drop NOT NULL constraint")?;
437− }
438−
439217 // Remove function and trigger
218+ let trigger_table = match &self.down {
219+ Some(Transformation::Update {
220+ table,
221+ value: _,
222+ r#where: _,
223+ }) => table,
224+ _ => &self.table,
225+ };
440226 db.run(&format!(
441227 r#"
442− DROP FUNCTION IF EXISTS "{trigger_name}" CASCADE;
443− DROP FUNCTION IF EXISTS "{reverse_trigger_name}" CASCADE;
444− DROP FUNCTION IF EXISTS "{null_trigger_name}" CASCADE;
228+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{trigger_table}";
229+ DROP FUNCTION IF EXISTS "{trigger_name}";
445230 "#,
446231 trigger_name = self.trigger_name(ctx),
447− reverse_trigger_name = self.reverse_trigger_name(ctx),
448− null_trigger_name = self.not_null_constraint_trigger_name(ctx),
449232 ))
450233 .context("failed to drop down trigger")?;
451234
tests/add_column.rs+2−2
@@ -308,8 +308,8 @@ fn add_column_with_complex_up() {
308308
309309 [actions.up]
310310 table = "users"
311− value = "users.email"
312− where = "profiles.user_id = users.id"
311+ value = "email"
312+ where = "user_id = id"
313313 "#,
314314 );
315315
tests/complex.rs+5−174
@@ -1,165 +1,6 @@
11mod common;
22use common::Test;
33
4−#[test]
5−fn move_column_between_tables() {
6− let mut test = Test::new("Move column between tables");
7−
8− test.first_migration(
9− r#"
10− name = "create_tables"
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.columns]]
22− name = "email"
23− type = "TEXT"
24−
25− [[actions]]
26− type = "create_table"
27− name = "profiles"
28− primary_key = ["id"]
29−
30− [[actions.columns]]
31− name = "id"
32− type = "INTEGER"
33−
34− [[actions.columns]]
35− name = "user_id"
36− type = "INTEGER"
37− nullable = false
38− "#,
39− );
40−
41− test.second_migration(
42− r#"
43− name = "move_email_column"
44−
45− [[actions]]
46− type = "add_column"
47− table = "profiles"
48−
49− [actions.column]
50− name = "email"
51− type = "TEXT"
52− nullable = false
53−
54− # When `users` is updated in the old schema, we write the email value to `profiles`
55− # When `profiles` is updated in the old schema, the equivalent `users.email` will also be updated
56− [actions.up]
57− table = "users"
58− value = "users.email"
59− where = "profiles.user_id = users.id"
60−
61− [[actions]]
62− type = "remove_column"
63− table = "users"
64− column = "email"
65−
66− # When `profiles` is changed in the new schema, we write the email address back to the removed column
67− [actions.down]
68− table = "profiles"
69− value = "profiles.email"
70− where = "users.id = profiles.user_id"
71− "#,
72− );
73−
74− test.after_first(|db| {
75− db.simple_query("INSERT INTO users (id, email) VALUES (1, 'test1@test.com')")
76− .unwrap();
77− db.simple_query("INSERT INTO users (id, email) VALUES (2, 'test2@test.com')")
78− .unwrap();
79−
80− db.simple_query("INSERT INTO profiles (id, user_id) VALUES (1, 1)")
81− .unwrap();
82− db.simple_query("INSERT INTO profiles (id, user_id) VALUES (2, 2)")
83− .unwrap();
84− });
85−
86− test.intermediate(|old_db, new_db| {
87− // Ensure emails were backfilled into profiles
88− let profiles_emails: Vec<String> = new_db
89− .query(
90− r#"
91− SELECT email
92− FROM profiles
93− ORDER BY id
94− "#,
95− &[],
96− )
97− .unwrap()
98− .iter()
99− .map(|row| row.get("email"))
100− .collect();
101− assert_eq!(vec!("test1@test.com", "test2@test.com"), profiles_emails);
102−
103− // Ensure insert in old schema updates new
104− old_db
105− .simple_query("INSERT INTO users (id, email) VALUES (3, 'test3@test.com')")
106− .unwrap();
107− old_db
108− .simple_query("INSERT INTO profiles (id, user_id) VALUES (3, 3)")
109− .unwrap();
110− let new_email: String = new_db
111− .query("SELECT email FROM profiles WHERE id = 3", &[])
112− .unwrap()
113− .first()
114− .map(|row| row.get("email"))
115− .unwrap();
116− assert_eq!("test3@test.com", new_email);
117−
118− // Ensure updates in old schema updates new
119− old_db
120− .simple_query("UPDATE users SET email = 'test3+updated@test.com' WHERE id = 3")
121− .unwrap();
122− let new_email: String = new_db
123− .query("SELECT email FROM profiles WHERE id = 3", &[])
124− .unwrap()
125− .first()
126− .map(|row| row.get("email"))
127− .unwrap();
128− assert_eq!("test3+updated@test.com", new_email);
129−
130− // Ensure insert in new schema updates old
131− new_db
132− .simple_query(
133− "INSERT INTO profiles (id, user_id, email) VALUES (4, 4, 'test4@test.com')",
134− )
135− .unwrap();
136− new_db
137− .simple_query("INSERT INTO users (id) VALUES (4)")
138− .unwrap();
139− let old_email: String = old_db
140− .query("SELECT email FROM users WHERE id = 4", &[])
141− .unwrap()
142− .first()
143− .map(|row| row.get("email"))
144− .unwrap();
145− assert_eq!("test4@test.com", old_email);
146−
147− // Ensure update in new schema updates old
148− new_db
149− .simple_query("UPDATE profiles SET email = 'test4+updated@test.com' WHERE id = 4")
150− .unwrap();
151− let old_email: String = old_db
152− .query("SELECT email FROM users WHERE id = 4", &[])
153− .unwrap()
154− .first()
155− .map(|row| row.get("email"))
156− .unwrap();
157− assert_eq!("test4+updated@test.com", old_email);
158− });
159−
160− test.run();
161−}
162−
1634#[test]
1645fn extract_relation_into_new_table() {
1656 let mut test = Test::new("Extract relation into new table");
@@ -189,12 +30,10 @@ fn extract_relation_into_new_table() {
18930 [[actions.columns]]
19031 name = "account_id"
19132 type = "INTEGER"
192− nullable = false
19333
19434 [[actions.columns]]
19535 name = "account_role"
19636 type = "TEXT"
197− nullable = false
19837 "#,
19938 );
20039
@@ -218,12 +57,11 @@ fn extract_relation_into_new_table() {
21857 [[actions.columns]]
21958 name = "role"
22059 type = "TEXT"
221− nullable = false
22260
22361 [actions.up]
22462 table = "users"
22563 values = { user_id = "id", account_id = "account_id", role = "UPPER(account_role)" }
226− where = "user_account_connections.user_id = users.id"
64+ where = "user_id = id"
22765
22866 [[actions]]
22967 type = "remove_column"
@@ -232,8 +70,8 @@ fn extract_relation_into_new_table() {
23270
23371 [actions.down]
23472 table = "user_account_connections"
235− value = "user_account_connections.account_id"
236− where = "users.id = user_account_connections.user_id"
73+ value = "account_id"
74+ where = "id = user_id"
23775
23876 [[actions]]
23977 type = "remove_column"
@@ -242,8 +80,8 @@ fn extract_relation_into_new_table() {
24280
24381 [actions.down]
24482 table = "user_account_connections"
245− value = "LOWER(user_account_connections.role)"
246− where = "users.id = user_account_connections.user_id"
83+ value = "LOWER(role)"
84+ where = "id = user_id"
24785 "#,
24886 );
24987
@@ -296,13 +134,6 @@ fn extract_relation_into_new_table() {
296134 == 1
297135 );
298136
299− // Ensure NOT NULL constraint still applies to old schema
300− let result = old_db
301− .simple_query(
302− "INSERT INTO users (id, account_id, account_role) VALUES (2, NULL, 'developer')",
303− );
304− assert!(result.is_err());
305−
306137 // Ensure updated user role in old schema updates connection in new schema
307138 old_db
308139 .simple_query("UPDATE users SET account_role = 'admin' WHERE id = 2")
tests/remove_column.rs+2−2
@@ -179,8 +179,8 @@ fn remove_column_with_complex_down() {
179179
180180 [actions.down]
181181 table = "profiles"
182− value = "profiles.email"
183− where = "users.id = profiles.user_id"
182+ value = "email"
183+ where = "id = user_id"
184184 "#,
185185 );
186186