on main help sign in
Add new `custom` action

This action enables migrations to run custom SQL and is meant to be used when no other actions fit. It doesn't provide any guarantees for zero-downtime.

commit88fca45c060f88e01e452484501fd554101f87e0 authorfabianlindfors <fabian@flapplabs.se> dateThu 21 Apr 2022, 23:42 (4 years ago) parent696a6e5
5 files changed+232
README.md+28 src/migrations/custom.rs+61 src/migrations/mod.rs+3 tests/common.rs+12 tests/custom.rs+128
README.md+28
@@ -33,6 +33,7 @@ Designed for Postgres 12 and later.
3333 - [Enums](#enums)
3434 - [Create enum](#create-enum)
3535 - [Remove enum](#remove-enum)
36+ - [Custom](#custom)
3637- [Commands and options](#commands-and-options)
3738 - [`reshape migrate`](#reshape-migrate)
3839 - [`reshape complete`](#reshape-complete)
@@ -531,6 +532,33 @@ type = "remove_enum"
531532enum = "mood"
532533```
533534
535+### Custom
536+
537+The `custom` action lets you create a migration which runs custom SQL. It should be used with great care as it provides no guarantees of zero-downtime and will simply run whatever SQL is provided. Use other actions whenever possible as they are explicitly designed for zero downtime.
538+
539+There are three optional settings available which all accept SQL queries. All queries need to be idempotent, for example by using `IF NOT EXISTS` wherever available.
540+
541+- `start`: run when a migration is started using `reshape migrate`
542+- `complete`: run when a migration is completed using `reshape complete`
543+- `abort`: run when a migration is aborted using `reshape abort`
544+
545+*Example: enable PostGIS and pg_stat_statements extensions*
546+
547+```toml
548+[[actions]]
549+type = "custom"
550+
551+start = """
552+ CREATE EXTENSION IF NOT EXISTS postgis;
553+ CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
554+"""
555+
556+abort = """
557+ DROP EXTENSION IF NOT EXISTS postgis;
558+ DROP EXTENSION IF NOT EXISTS pg_stat_statements;
559+"""
560+```
561+
534562## Commands and options
535563
536564### `reshape migrate`
src/migrations/custom.rsnew file+61
@@ -0,0 +1,61 @@
1+use super::{Action, MigrationContext};
2+use crate::{
3+ db::{Conn, Transaction},
4+ schema::Schema,
5+};
6+use serde::{Deserialize, Serialize};
7+
8+#[derive(Serialize, Deserialize, Debug)]
9+pub struct Custom {
10+ #[serde(default)]
11+ pub start: Option<String>,
12+
13+ #[serde(default)]
14+ pub complete: Option<String>,
15+
16+ #[serde(default)]
17+ pub abort: Option<String>,
18+}
19+
20+#[typetag::serde(name = "custom")]
21+impl Action for Custom {
22+ fn describe(&self) -> String {
23+ "Running custom migration".to_string()
24+ }
25+
26+ fn run(
27+ &self,
28+ _ctx: &MigrationContext,
29+ db: &mut dyn Conn,
30+ _schema: &Schema,
31+ ) -> anyhow::Result<()> {
32+ if let Some(start_query) = &self.start {
33+ println!("Running query: {}", start_query);
34+ db.run(start_query)?;
35+ }
36+
37+ Ok(())
38+ }
39+
40+ fn complete<'a>(
41+ &self,
42+ _ctx: &MigrationContext,
43+ db: &'a mut dyn Conn,
44+ ) -> anyhow::Result<Option<Transaction<'a>>> {
45+ if let Some(complete_query) = &self.complete {
46+ db.run(complete_query)?;
47+ }
48+
49+ Ok(None)
50+ }
51+
52+ fn update_schema(&self, _ctx: &MigrationContext, _schema: &mut Schema) {}
53+
54+ fn abort(&self, _ctx: &MigrationContext, db: &mut dyn Conn) -> anyhow::Result<()> {
55+ if let Some(abort_query) = &self.abort {
56+ db.run(abort_query)?;
57+ }
58+
59+ Ok(())
60+ }
61+}
src/migrations/mod.rs+3
@@ -39,6 +39,9 @@ pub use create_enum::CreateEnum;
3939mod remove_enum;
4040pub use remove_enum::RemoveEnum;
4141
42+mod custom;
43+pub use custom::Custom;
44+
4245mod add_foreign_key;
4346pub use add_foreign_key::AddForeignKey;
4447
tests/common.rs+12
@@ -12,6 +12,7 @@ pub struct Test<'a> {
1212 second_migration: Option<Migration>,
1313 expect_failure: bool,
1414
15+ clear_fn: Option<fn(&mut Client) -> ()>,
1516 after_first_fn: Option<fn(&mut Client) -> ()>,
1617 intermediate_fn: Option<fn(&mut Client, &mut Client) -> ()>,
1718 after_completion_fn: Option<fn(&mut Client) -> ()>,
@@ -36,6 +37,7 @@ impl Test<'_> {
3637 first_migration: None,
3738 second_migration: None,
3839 expect_failure: false,
40+ clear_fn: None,
3941 after_first_fn: None,
4042 intermediate_fn: None,
4143 after_completion_fn: None,
@@ -54,6 +56,12 @@ impl Test<'_> {
5456 self
5557 }
5658
59+ #[allow(dead_code)]
60+ pub fn clear(&mut self, f: fn(&mut Client) -> ()) -> &mut Self {
61+ self.clear_fn = Some(f);
62+ self
63+ }
64+
5765 #[allow(dead_code)]
5866 pub fn after_first(&mut self, f: fn(&mut Client) -> ()) -> &mut Self {
5967 self.after_first_fn = Some(f);
@@ -115,6 +123,10 @@ impl Test<'_> {
115123 print_subheading("Clearing database");
116124 self.reshape.remove().unwrap();
117125
126+ if let Some(clear_fn) = self.clear_fn {
127+ clear_fn(&mut self.old_db);
128+ }
129+
118130 // Apply first migration, will automatically complete
119131 print_subheading("Applying first migration");
120132 let first_migration = self
tests/custom.rsnew file+128
@@ -0,0 +1,128 @@
1+mod common;
2+use common::Test;
3+
4+#[test]
5+fn custom_enable_extension() {
6+ let mut test = Test::new("Custom migration");
7+
8+ test.clear(|db| {
9+ db.simple_query(
10+ "
11+ DROP EXTENSION IF EXISTS bloom;
12+ DROP EXTENSION IF EXISTS btree_gin;
13+ DROP EXTENSION IF EXISTS btree_gist;
14+ ",
15+ )
16+ .unwrap();
17+ });
18+
19+ test.first_migration(
20+ r#"
21+ name = "empty_migration"
22+
23+ [[actions]]
24+ type = "custom"
25+ "#,
26+ );
27+
28+ test.second_migration(
29+ r#"
30+ name = "enable_extensions"
31+
32+ [[actions]]
33+ type = "custom"
34+
35+ start = """
36+ CREATE EXTENSION IF NOT EXISTS bloom;
37+ CREATE EXTENSION IF NOT EXISTS btree_gin;
38+ """
39+
40+ complete = "CREATE EXTENSION IF NOT EXISTS btree_gist"
41+
42+ abort = """
43+ DROP EXTENSION IF EXISTS bloom;
44+ DROP EXTENSION IF EXISTS btree_gin;
45+ """
46+ "#,
47+ );
48+
49+ test.intermediate(|db, _| {
50+ let bloom_activated = !db
51+ .query("SELECT * FROM pg_extension WHERE extname = 'bloom'", &[])
52+ .unwrap()
53+ .is_empty();
54+ assert!(bloom_activated);
55+
56+ let btree_gin_activated = !db
57+ .query(
58+ "SELECT * FROM pg_extension WHERE extname = 'btree_gin'",
59+ &[],
60+ )
61+ .unwrap()
62+ .is_empty();
63+ assert!(btree_gin_activated);
64+
65+ let btree_gist_activated = !db
66+ .query(
67+ "SELECT * FROM pg_extension WHERE extname = 'btree_gist'",
68+ &[],
69+ )
70+ .unwrap()
71+ .is_empty();
72+ assert!(!btree_gist_activated);
73+ });
74+
75+ test.after_completion(|db| {
76+ let bloom_activated = !db
77+ .query("SELECT * FROM pg_extension WHERE extname = 'bloom'", &[])
78+ .unwrap()
79+ .is_empty();
80+ assert!(bloom_activated);
81+
82+ let btree_gin_activated = !db
83+ .query(
84+ "SELECT * FROM pg_extension WHERE extname = 'btree_gin'",
85+ &[],
86+ )
87+ .unwrap()
88+ .is_empty();
89+ assert!(btree_gin_activated);
90+
91+ let btree_gist_activated = !db
92+ .query(
93+ "SELECT * FROM pg_extension WHERE extname = 'btree_gist'",
94+ &[],
95+ )
96+ .unwrap()
97+ .is_empty();
98+ assert!(btree_gist_activated);
99+ });
100+
101+ test.after_abort(|db| {
102+ let bloom_activated = !db
103+ .query("SELECT * FROM pg_extension WHERE extname = 'bloom'", &[])
104+ .unwrap()
105+ .is_empty();
106+ assert!(!bloom_activated);
107+
108+ let btree_gin_activated = !db
109+ .query(
110+ "SELECT * FROM pg_extension WHERE extname = 'btree_gin'",
111+ &[],
112+ )
113+ .unwrap()
114+ .is_empty();
115+ assert!(!btree_gin_activated);
116+
117+ let btree_gist_activated = !db
118+ .query(
119+ "SELECT * FROM pg_extension WHERE extname = 'btree_gist'",
120+ &[],
121+ )
122+ .unwrap()
123+ .is_empty();
124+ assert!(!btree_gist_activated);
125+ });
126+
127+ test.run();
128+}