on de44fdd help sign in
11 files changed+120−545
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+59−187 tests/add_column.rs+2−2 tests/complex.rs+5−164 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+59−187
@@ -34,16 +34,7 @@ impl RemoveColumn {
3434 )
3535 }
3636
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 {
37+ fn null_constraint_trigger_name(&self, ctx: &MigrationContext) -> String {
4738 format!(
4839 "{}_remove_column_{}_{}_nn",
4940 ctx.prefix(),
@@ -51,15 +42,6 @@ impl RemoveColumn {
5142 self.column
5243 )
5344 }
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− }
6345}
6446
6547#[typetag::serde(name = "remove_column")]
@@ -139,9 +121,21 @@ impl Action for RemoveColumn {
139121
140122 let from_table = schema.get_table(db, &from_table)?;
141123
124+ let declarations: Vec<String> = from_table
125+ .columns
126+ .iter()
127+ .map(|column| {
128+ format!(
129+ "{alias} public.{table}.{real_name}%TYPE := NEW.{real_name};",
130+ table = from_table.real_name,
131+ alias = column.name,
132+ real_name = column.real_name,
133+ )
134+ })
135+ .collect();
136+
142137 let maybe_null_check = if !column.nullable {
143138 // 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.
145139 // As we are using a complex down function, we must remove the NOT NULL check for the new schema.
146140 // NOT NULL is not checked at the end of a transaction, but immediately upon update.
147141 let query = format!(
@@ -167,7 +161,7 @@ impl Action for RemoveColumn {
167161 EXECUTE PROCEDURE {trigger_name}();
168162 "#,
169163 table = self.table,
170− trigger_name = self.not_null_constraint_trigger_name(ctx),
164+ trigger_name = self.null_constraint_trigger_name(ctx),
171165 column = self.column,
172166 );
173167 db.run(&query)
@@ -196,19 +190,6 @@ impl Action for RemoveColumn {
196190 "".to_string()
197191 };
198192
199− let into_variables = from_table
200− .columns
201− .iter()
202− .map(|column| {
203− format!(
204− "NEW.{real_name} AS {alias}",
205− alias = column.name,
206− real_name = column.real_name,
207− )
208− })
209− .collect::<Vec<String>>()
210− .join(", ");
211−
212193 let query = format!(
213194 r#"
214195 CREATE OR REPLACE FUNCTION {trigger_name}()
@@ -217,21 +198,12 @@ impl Action for RemoveColumn {
217198 BEGIN
218199 IF reshape.is_new_schema() THEN
219200 DECLARE
220− {from_table} record;
201+ {declarations}
221202 BEGIN
222− SELECT {into_variables}
223− INTO {from_table};
224−
225203 {maybe_null_check}
226−
227− -- Don't trigger reverse trigger when making this update
228− perform set_config('reshape.disable_triggers', 'TRUE', TRUE);
229−
230204 UPDATE "migration_{existing_schema_name}"."{changed_table}" "{changed_table}"
231205 SET "{column_name}" = {value}
232206 WHERE {where};
233−
234− perform set_config('reshape.disable_triggers', '', TRUE);
235207 END;
236208 END IF;
237209 RETURN NEW;
@@ -242,83 +214,17 @@ impl Action for RemoveColumn {
242214 CREATE TRIGGER "{trigger_name}" BEFORE UPDATE OR INSERT ON "{from_table_real}" FOR EACH ROW EXECUTE PROCEDURE {trigger_name}();
243215 "#,
244216 changed_table = self.table,
245− from_table = from_table.name,
246217 from_table_real = from_table.real_name,
247218 column_name = self.column,
248219 trigger_name = self.trigger_name(ctx),
220+ declarations = declarations.join("\n"),
249221 );
250222 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")?;
319223 }
320224 }
321225
226+ if !column.nullable {}
227+
322228 Ok(())
323229 }
324230
@@ -341,20 +247,29 @@ impl Action for RemoveColumn {
341247 }
342248
343249 // Remove column, function and trigger
250+ let trigger_table = match &self.down {
251+ Some(Transformation::Update {
252+ table,
253+ value: _,
254+ r#where: _,
255+ }) => table,
256+ _ => &self.table,
257+ };
344258 let query = format!(
345259 r#"
346260 ALTER TABLE "{table}"
347261 DROP COLUMN IF EXISTS "{column}";
348262
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;
263+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{trigger_table}";
264+ DROP FUNCTION IF EXISTS "{trigger_name}";
265+
266+ DROP TRIGGER IF EXISTS "{null_trigger_name}" ON "{table}";
267+ DROP FUNCTION IF EXISTS "{null_trigger_name}";
352268 "#,
353269 table = self.table,
354270 column = self.column,
355271 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),
272+ null_trigger_name = self.null_constraint_trigger_name(ctx),
358273 );
359274 db.run(&query)
360275 .context("failed to drop column and down trigger")?;
@@ -371,81 +286,38 @@ impl Action for RemoveColumn {
371286 }
372287
373288 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− }
289+ // We might have removed the NOT NULL check so we reinstate it
290+ db.run(&format!(
291+ r#"
292+ ALTER TABLE {table}
293+ ALTER COLUMN {column}
294+ SET NOT NULL
295+ "#,
296+ table = self.table,
297+ column = self.column
298+ ))
299+ .context("failed to reinstate column not null constraint")?;
438300
439301 // Remove function and trigger
302+ let trigger_table = match &self.down {
303+ Some(Transformation::Update {
304+ table,
305+ value: _,
306+ r#where: _,
307+ }) => table,
308+ _ => &self.table,
309+ };
440310 db.run(&format!(
441311 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;
312+ DROP TRIGGER IF EXISTS "{trigger_name}" ON "{trigger_table}";
313+ DROP FUNCTION IF EXISTS "{trigger_name}";
314+
315+ DROP TRIGGER IF EXISTS "{null_trigger_name}" ON "{table}";
316+ DROP FUNCTION IF EXISTS "{null_trigger_name}";
445317 "#,
318+ table = self.table,
446319 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),
320+ null_trigger_name = self.null_constraint_trigger_name(ctx),
449321 ))
450322 .context("failed to drop down trigger")?;
451323
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−164
@@ -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");
@@ -223,7 +64,7 @@ fn extract_relation_into_new_table() {
22364 [actions.up]
22465 table = "users"
22566 values = { user_id = "id", account_id = "account_id", role = "UPPER(account_role)" }
226− where = "user_account_connections.user_id = users.id"
67+ where = "user_id = id"
22768
22869 [[actions]]
22970 type = "remove_column"
@@ -232,8 +73,8 @@ fn extract_relation_into_new_table() {
23273
23374 [actions.down]
23475 table = "user_account_connections"
235− value = "user_account_connections.account_id"
236− where = "users.id = user_account_connections.user_id"
76+ value = "account_id"
77+ where = "id = user_id"
23778
23879 [[actions]]
23980 type = "remove_column"
@@ -242,8 +83,8 @@ fn extract_relation_into_new_table() {
24283
24384 [actions.down]
24485 table = "user_account_connections"
245− value = "LOWER(user_account_connections.role)"
246− where = "users.id = user_account_connections.user_id"
86+ value = "LOWER(role)"
87+ where = "id = user_id"
24788 "#,
24889 );
24990
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