Skip to main content

renox_core/db/
migrate.rs

1use std::collections::{HashMap, HashSet};
2
3use super::{Db, Dialect, now, quote, script, sql};
4use anyhow::{Context, anyhow, bail};
5
6const TABLE: &str = "renox_migrations";
7
8/// One of the framework's migrations from `crates/renox-core/migrations/DIR/`:
9/// `NAME.up.sql` (SQLite), `NAME.postgres.up.sql` and a shared `NAME.down.sql`.
10macro_rules! framework_migration {
11    ($dir:literal, $name:literal) => {
12        $crate::db::Migration::new(
13            $name,
14            include_str!(concat!(
15                env!("CARGO_MANIFEST_DIR"),
16                "/migrations/",
17                $dir,
18                "/",
19                $name,
20                ".up.sql"
21            )),
22            Some(include_str!(concat!(
23                env!("CARGO_MANIFEST_DIR"),
24                "/migrations/",
25                $dir,
26                "/",
27                $name,
28                ".down.sql"
29            ))),
30        )
31        .postgres(
32            include_str!(concat!(
33                env!("CARGO_MANIFEST_DIR"),
34                "/migrations/",
35                $dir,
36                "/",
37                $name,
38                ".postgres.up.sql"
39            )),
40            None,
41        )
42    };
43}
44pub(crate) use framework_migration;
45
46/// One migration: SQL to apply it and, optionally, SQL to undo it.
47///
48/// Usually generated from `migrations/*.up.sql` and `*.down.sql` by
49/// `renox::migrations!()`. Names start with a timestamp and run in name order.
50///
51/// When SQL differs between databases, `NAME.postgres.up.sql` (and
52/// `.postgres.down.sql`) or `NAME.sqlite.up.sql` replace the plain files on
53/// that database; the plain file may then be left out if both are given.
54///
55/// By hand (in tests, say), it's built with `const` methods:
56///
57/// ```
58/// use renox::db::Migration;
59///
60/// const NOTES: Migration = Migration::new(
61///     "20300101000000_notes",
62///     "CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT NOT NULL)",
63///     Some("DROP TABLE notes"),
64/// )
65/// .postgres(
66///     "CREATE TABLE notes (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, body TEXT NOT NULL)",
67///     None, // the plain `down` above
68/// );
69/// ```
70#[derive(Debug, Clone, Copy)]
71#[non_exhaustive]
72pub struct Migration {
73    name: &'static str,
74    /// SQL for every database without its own version (may be empty when
75    /// each database has one).
76    up: &'static str,
77    /// SQL that undoes `up` on databases without their own version; `None`: irreversible.
78    down: Option<&'static str>,
79    /// Used instead of `up`/`down` on SQLite.
80    sqlite: Option<Scripts>,
81    /// Used instead of `up`/`down` on PostgreSQL.
82    postgres: Option<Scripts>,
83}
84
85/// One database's own version of a migration.
86#[derive(Debug, Clone, Copy)]
87struct Scripts {
88    up: &'static str,
89    /// Falls back to the migration's plain `down` when `None`.
90    down: Option<&'static str>,
91}
92
93impl Migration {
94    /// A migration with the same SQL on every database (`up` may be empty
95    /// when each database gets its own with [`sqlite`](Self::sqlite) and
96    /// [`postgres`](Self::postgres)); `down: None` makes it irreversible.
97    pub const fn new(name: &'static str, up: &'static str, down: Option<&'static str>) -> Self {
98        Self {
99            name,
100            up,
101            down,
102            sqlite: None,
103            postgres: None,
104        }
105    }
106
107    /// SQLite's own SQL, used instead of the plain `up`; `down: None` falls
108    /// back to the plain `down`.
109    pub const fn sqlite(mut self, up: &'static str, down: Option<&'static str>) -> Self {
110        self.sqlite = Some(Scripts { up, down });
111        self
112    }
113
114    /// PostgreSQL's own SQL, used instead of the plain `up`; `down: None`
115    /// falls back to the plain `down`.
116    pub const fn postgres(mut self, up: &'static str, down: Option<&'static str>) -> Self {
117        self.postgres = Some(Scripts { up, down });
118        self
119    }
120
121    /// The migration's name, e.g. `20260101000000_create_products_table`; names sort the runs.
122    pub const fn name(&self) -> &'static str {
123        self.name
124    }
125
126    fn own(&self, dialect: Dialect) -> Option<&Scripts> {
127        match dialect {
128            Dialect::Sqlite => self.sqlite.as_ref(),
129            Dialect::Postgres => self.postgres.as_ref(),
130        }
131    }
132
133    /// The SQL that applies this migration on `dialect`.
134    pub fn up_for(&self, dialect: Dialect) -> &'static str {
135        self.own(dialect).map_or(self.up, |own| own.up)
136    }
137
138    /// The SQL that undoes this migration on `dialect`, if any.
139    pub fn down_for(&self, dialect: Dialect) -> Option<&'static str> {
140        self.own(dialect).and_then(|own| own.down).or(self.down)
141    }
142}
143
144/// Whether a migration has run, and in which batch.
145#[derive(Debug, Clone)]
146#[non_exhaustive]
147pub struct MigrationStatus {
148    /// The migration's name.
149    pub name: String,
150    /// The batch it ran in; `None` when it hasn't run.
151    pub batch: Option<i64>,
152    /// Applied, but its file is no longer registered.
153    pub missing: bool,
154    /// Applied, and its SQL has changed since (editing an applied migration
155    /// does nothing; add a new one instead).
156    pub changed: bool,
157}
158
159/// A line in a migration that makes it run outside a transaction.
160const NO_TRANSACTION: &str = "-- renox:no-transaction";
161
162/// Whether `sql` can't run inside the transaction migrations get: it says
163/// so, manages its own (`BEGIN` … `COMMIT`), or uses `CONCURRENTLY`, which
164/// PostgreSQL refuses in a transaction.
165fn runs_outside_transaction(sql: &str) -> bool {
166    if sql.lines().any(|line| line.trim() == NO_TRANSACTION) {
167        return true;
168    }
169    let upper = sql.to_ascii_uppercase();
170    uses_concurrently(sql)
171        || upper.split(';').any(|statement| {
172            let statement = statement.trim();
173            statement == "BEGIN"
174                || statement.starts_with("BEGIN TRANSACTION")
175                || statement.starts_with("BEGIN IMMEDIATE")
176        })
177}
178
179/// Whether `sql` has the word `CONCURRENTLY`, whatever surrounds it (a
180/// space, a newline, a tab).
181fn uses_concurrently(sql: &str) -> bool {
182    sql.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
183        .any(|word| word.eq_ignore_ascii_case("CONCURRENTLY"))
184}
185
186/// Runs `sql` outside the migration transaction. PostgreSQL runs a
187/// multi-statement script as one implicit transaction, which `CONCURRENTLY`
188/// refuses, so there such a script runs one statement at a time.
189async fn run_each(db: &Db, sql: &str) -> Result<(), super::DbError> {
190    if db.dialect() == Dialect::Postgres && uses_concurrently(sql) {
191        for statement in statements(sql) {
192            script(db, statement).await?;
193        }
194        Ok(())
195    } else {
196        script(db, sql).await.map(|_| ())
197    }
198}
199
200/// Splits SQL on `;`, except inside quotes, comments and `$tag$` bodies.
201fn statements(sql: &str) -> Vec<&str> {
202    let bytes = sql.as_bytes();
203    let mut out = Vec::new();
204    let (mut start, mut i) = (0, 0);
205    while i < bytes.len() {
206        match bytes[i] {
207            quote @ (b'\'' | b'"') => {
208                i += 1;
209                while i < bytes.len() && bytes[i] != quote {
210                    i += 1;
211                }
212            }
213            b'-' if bytes.get(i + 1) == Some(&b'-') => {
214                while i < bytes.len() && bytes[i] != b'\n' {
215                    i += 1;
216                }
217            }
218            b'/' if bytes.get(i + 1) == Some(&b'*') => {
219                i = sql[i + 2..]
220                    .find("*/")
221                    .map_or(bytes.len(), |end| i + 2 + end + 1);
222            }
223            b'$' => {
224                let tag_end = sql[i + 1..]
225                    .find(|c: char| !(c.is_alphanumeric() || c == '_'))
226                    .map(|n| i + 1 + n);
227                if let Some(end) = tag_end.filter(|&end| bytes[end] == b'$') {
228                    let tag = &sql[i..=end];
229                    i = sql[end + 1..]
230                        .find(tag)
231                        .map_or(bytes.len(), |close| end + 1 + close + tag.len() - 1);
232                }
233            }
234            b';' => {
235                out.push(&sql[start..i]);
236                start = i + 1;
237            }
238            _ => {}
239        }
240        i += 1;
241    }
242    out.push(&sql[start..]);
243    out.into_iter()
244        .filter(|statement| {
245            statement
246                .lines()
247                .any(|line| !line.trim().is_empty() && !line.trim().starts_with("--"))
248        })
249        .collect()
250}
251
252fn checksum(sql: &str) -> String {
253    crate::webhook::sha256_hex(sql)
254}
255
256/// Runs migrations in batches, like Laravel: `run` applies everything pending
257/// as one batch, `rollback` undoes whole batches, newest first. Runs don't
258/// overlap: within a process they wait for each other, and on PostgreSQL an
259/// advisory lock makes other processes (replicas deploying at once) wait too.
260pub(crate) struct Migrator {
261    migrations: Vec<Migration>,
262}
263
264/// An applied migration's batch and checksum.
265struct Applied {
266    batch: i64,
267    checksum: Option<String>,
268}
269
270/// Held while migrations run; see [`Migrator`].
271struct MigrationLock {
272    _local: tokio::sync::MutexGuard<'static, ()>,
273    #[cfg(feature = "postgres")]
274    _postgres: Option<sqlx::postgres::PgConnection>,
275}
276
277/// `pg_advisory_lock` key: "renox" + "mig" in ASCII.
278#[cfg(feature = "postgres")]
279const ADVISORY_KEY: i64 = 0x7265_6e6f_786d_6967;
280
281impl Migrator {
282    pub fn new(mut migrations: Vec<Migration>) -> anyhow::Result<Self> {
283        migrations.sort_by_key(|m| m.name);
284        for pair in migrations.windows(2) {
285            if pair[0].name == pair[1].name {
286                bail!("migration `{}` is registered twice", pair[0].name);
287            }
288        }
289        Ok(Self { migrations })
290    }
291
292    async fn lock(db: &Db) -> anyhow::Result<MigrationLock> {
293        static LOCAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
294        let local = LOCAL.lock().await;
295        #[cfg(feature = "postgres")]
296        let postgres = match db.postgres() {
297            Some(pool) => {
298                // Detached: if this future is dropped, closing the connection
299                // releases the lock.
300                let mut conn = pool.acquire().await?.detach();
301                sqlx::query("SELECT pg_advisory_lock($1)")
302                    .bind(ADVISORY_KEY)
303                    .execute(&mut conn)
304                    .await?;
305                Some(conn)
306            }
307            None => None,
308        };
309        #[cfg(not(feature = "postgres"))]
310        let _ = db;
311        Ok(MigrationLock {
312            _local: local,
313            #[cfg(feature = "postgres")]
314            _postgres: postgres,
315        })
316    }
317
318    async fn ensure_table(db: &Db) -> anyhow::Result<()> {
319        sql(format!(
320            "CREATE TABLE IF NOT EXISTS {TABLE} (
321                name TEXT PRIMARY KEY NOT NULL,
322                batch BIGINT NOT NULL,
323                applied_at TEXT NOT NULL,
324                checksum TEXT
325            )"
326        ))
327        .execute(db)
328        .await?;
329        // Tables made before checksums were kept.
330        if sql(format!("SELECT checksum FROM {TABLE} WHERE 1 = 0"))
331            .execute(db)
332            .await
333            .is_err()
334        {
335            sql(format!("ALTER TABLE {TABLE} ADD COLUMN checksum TEXT"))
336                .execute(db)
337                .await?;
338            db.schema_changed();
339        }
340        Ok(())
341    }
342
343    async fn applied(db: &Db) -> anyhow::Result<HashMap<String, Applied>> {
344        Self::ensure_table(db).await?;
345        let rows = sql(format!("SELECT name, batch, checksum FROM {TABLE}"))
346            .fetch_all(db)
347            .await?;
348        rows.iter()
349            .map(|row| {
350                Ok((
351                    row.try_get("name")?,
352                    Applied {
353                        batch: row.try_get("batch")?,
354                        checksum: row.try_get("checksum")?,
355                    },
356                ))
357            })
358            .collect()
359    }
360
361    /// Applies pending migrations as a new batch; returns their names.
362    pub async fn run(&self, db: &Db) -> anyhow::Result<Vec<&'static str>> {
363        let _lock = Self::lock(db).await?;
364        self.run_locked(db).await
365    }
366
367    async fn run_locked(&self, db: &Db) -> anyhow::Result<Vec<&'static str>> {
368        let dialect = db.dialect();
369        let applied = Self::applied(db).await?;
370        for migration in &self.migrations {
371            let changed = applied.get(migration.name).is_some_and(|a| {
372                a.checksum
373                    .as_ref()
374                    .is_some_and(|sum| *sum != checksum(migration.up_for(dialect)))
375            });
376            if changed {
377                tracing::warn!(
378                    migration = migration.name,
379                    "an applied migration was edited; the change won't run (add a new migration)"
380                );
381            }
382        }
383        let batch = applied.values().map(|a| a.batch).max().unwrap_or(0) + 1;
384        let mut done = Vec::new();
385        let result = self.run_pending(db, &applied, batch, &mut done).await;
386        // Once per batch: every change reopens the pool's connections.
387        if !done.is_empty() {
388            db.schema_changed();
389        }
390        result.map(|()| done)
391    }
392
393    async fn run_pending(
394        &self,
395        db: &Db,
396        applied: &HashMap<String, Applied>,
397        batch: i64,
398        done: &mut Vec<&'static str>,
399    ) -> anyhow::Result<()> {
400        let dialect = db.dialect();
401
402        for migration in self
403            .migrations
404            .iter()
405            .filter(|m| !applied.contains_key(m.name))
406        {
407            let up = migration.up_for(dialect);
408            let failed = || format!("migration `{}` failed", migration.name);
409            let record = sql(format!(
410                "INSERT INTO {TABLE} (name, batch, applied_at, checksum) VALUES (?, ?, ?, ?)"
411            ))
412            .bind(migration.name)
413            .bind(batch)
414            .bind(now().to_rfc3339())
415            .bind(checksum(up));
416            if runs_outside_transaction(up) {
417                run_each(db, up).await.with_context(failed)?;
418                record.execute(db).await?;
419            } else {
420                // IMMEDIATE on SQLite: another process can't slip in between
421                // the check and the insert.
422                let mut tx = db.begin_immediate().await?;
423                let already: i64 = sql(format!("SELECT COUNT(*) FROM {TABLE} WHERE name = ?"))
424                    .bind(migration.name)
425                    .scalar(&mut tx)
426                    .await?;
427                if already > 0 {
428                    continue;
429                }
430                script(&mut tx, up).await.with_context(failed)?;
431                record.execute(&mut tx).await?;
432                tx.commit().await?;
433            }
434            done.push(migration.name);
435        }
436        Ok(())
437    }
438
439    /// Undoes the last `batches` batches, newest migration first. Nothing is
440    /// undone unless every migration in them can be. A migration that is no
441    /// longer registered is forgotten, with a warning, and its changes stay.
442    pub async fn rollback(&self, db: &Db, batches: u32) -> anyhow::Result<Vec<String>> {
443        let _lock = Self::lock(db).await?;
444        let dialect = db.dialect();
445        let applied = Self::applied(db).await?;
446        let mut numbers: Vec<i64> = applied
447            .values()
448            .map(|a| a.batch)
449            .collect::<HashSet<_>>()
450            .into_iter()
451            .collect();
452        numbers.sort_unstable_by(|a, b| b.cmp(a));
453        let targets: HashSet<i64> = numbers.into_iter().take(batches as usize).collect();
454
455        let mut names: Vec<&String> = applied
456            .iter()
457            .filter(|(_, a)| targets.contains(&a.batch))
458            .map(|(name, _)| name)
459            .collect();
460        names.sort_unstable_by(|a, b| b.cmp(a));
461
462        // Check every step before taking any.
463        let mut steps = Vec::new();
464        for name in names {
465            let down = match self.migrations.iter().find(|m| m.name == name) {
466                None => None,
467                Some(migration) => Some(migration.down_for(dialect).ok_or_else(|| {
468                    anyhow!(
469                        "migration `{name}` has no .down.sql, so its batch can't be rolled back; \
470                         nothing was rolled back"
471                    )
472                })?),
473            };
474            steps.push((name, down));
475        }
476
477        let mut done = Vec::new();
478        for (name, down) in steps {
479            let forget = sql(format!("DELETE FROM {TABLE} WHERE name = ?")).bind(name);
480            match down {
481                None => {
482                    tracing::warn!(
483                        migration = %name,
484                        "not registered any more; forgotten without undoing its changes"
485                    );
486                    forget.execute(db).await?;
487                }
488                Some(down) if runs_outside_transaction(down) => {
489                    run_each(db, down)
490                        .await
491                        .with_context(|| format!("rolling back `{name}` failed"))?;
492                    forget.execute(db).await?;
493                }
494                Some(down) => {
495                    let mut tx = db.begin().await?;
496                    script(&mut tx, down)
497                        .await
498                        .with_context(|| format!("rolling back `{name}` failed"))?;
499                    forget.execute(&mut tx).await?;
500                    tx.commit().await?;
501                }
502            }
503            done.push(name.clone());
504            // Undone even if a later step fails: mark each.
505            db.schema_changed();
506        }
507        Ok(done)
508    }
509
510    /// Drops everything the migrations made, then runs all migrations.
511    pub async fn fresh(&self, db: &Db) -> anyhow::Result<Vec<&'static str>> {
512        let _lock = Self::lock(db).await?;
513        if let Some(pool) = db.sqlite() {
514            drop_all_sqlite(pool).await?;
515        }
516        #[cfg(feature = "postgres")]
517        if let Some(pool) = db.postgres() {
518            drop_all_postgres(pool).await?;
519        }
520        db.schema_changed();
521        self.run_locked(db).await
522    }
523
524    /// Every registered migration and whether it has run, then applied
525    /// migrations that are no longer registered.
526    pub async fn status(&self, db: &Db) -> anyhow::Result<Vec<MigrationStatus>> {
527        let dialect = db.dialect();
528        let applied = Self::applied(db).await?;
529        let mut status: Vec<MigrationStatus> = self
530            .migrations
531            .iter()
532            .map(|m| {
533                let found = applied.get(m.name);
534                MigrationStatus {
535                    name: m.name.to_owned(),
536                    batch: found.map(|a| a.batch),
537                    missing: false,
538                    changed: found.is_some_and(|a| {
539                        a.checksum
540                            .as_ref()
541                            .is_some_and(|sum| *sum != checksum(m.up_for(dialect)))
542                    }),
543                }
544            })
545            .collect();
546        let mut missing: Vec<MigrationStatus> = applied
547            .iter()
548            .filter(|(name, _)| !self.migrations.iter().any(|m| m.name == name.as_str()))
549            .map(|(name, a)| MigrationStatus {
550                name: name.clone(),
551                batch: Some(a.batch),
552                missing: true,
553                changed: false,
554            })
555            .collect();
556        missing.sort_by(|a, b| a.name.cmp(&b.name));
557        status.extend(missing);
558        Ok(status)
559    }
560}
561
562/// Drops every table and view on one connection, with foreign keys off so
563/// the order doesn't matter.
564async fn drop_all_sqlite(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
565    use sqlx::{AssertSqlSafe, Row};
566
567    let mut conn = pool.acquire().await?;
568    // Virtual tables (full-text search) first: dropping one drops its
569    // shadow tables, which are listed too.
570    let objects = sqlx::query(
571        "SELECT type, name FROM sqlite_master \
572         WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' \
573         ORDER BY CASE WHEN sql LIKE 'CREATE VIRTUAL TABLE%' THEN 0 ELSE 1 END, rowid",
574    )
575    .fetch_all(&mut *conn)
576    .await?;
577    sqlx::query("PRAGMA foreign_keys = OFF")
578        .execute(&mut *conn)
579        .await?;
580    for object in &objects {
581        let kind: String = object.try_get("type")?;
582        let name: String = object.try_get("name")?;
583        let sql = format!("DROP {} IF EXISTS {}", kind.to_uppercase(), quote(&name));
584        sqlx::query(AssertSqlSafe(sql)).execute(&mut *conn).await?;
585    }
586    sqlx::query("PRAGMA foreign_keys = ON")
587        .execute(&mut *conn)
588        .await?;
589    Ok(())
590}
591
592/// Drops everything in the current schema that isn't an extension's:
593/// materialized views, views, tables, sequences, functions and types.
594/// `CASCADE` takes care of what depends on what.
595#[cfg(feature = "postgres")]
596async fn drop_all_postgres(pool: &sqlx::PgPool) -> anyhow::Result<()> {
597    use sqlx::{AssertSqlSafe, Row};
598
599    // Objects created by an extension are dropped with the extension.
600    const NOT_FROM_EXTENSION: &str = "NOT EXISTS (SELECT 1 FROM pg_depend d \
601         WHERE d.objid = {oid} AND d.deptype = 'e')";
602    let owned = |oid: &str| NOT_FROM_EXTENSION.replace("{oid}", oid);
603    let queries = [
604        format!(
605            "SELECT format('DROP MATERIALIZED VIEW IF EXISTS %I CASCADE', c.relname) \
606             FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
607             WHERE n.nspname = current_schema() AND c.relkind = 'm' AND {}",
608            owned("c.oid")
609        ),
610        format!(
611            "SELECT format('DROP VIEW IF EXISTS %I CASCADE', c.relname) \
612             FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
613             WHERE n.nspname = current_schema() AND c.relkind = 'v' AND {}",
614            owned("c.oid")
615        ),
616        format!(
617            "SELECT format('DROP TABLE IF EXISTS %I CASCADE', c.relname) \
618             FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
619             WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p') AND {}",
620            owned("c.oid")
621        ),
622        format!(
623            "SELECT format('DROP SEQUENCE IF EXISTS %I CASCADE', c.relname) \
624             FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
625             WHERE n.nspname = current_schema() AND c.relkind = 'S' AND {}",
626            owned("c.oid")
627        ),
628        format!(
629            "SELECT format('DROP ROUTINE IF EXISTS %s CASCADE', p.oid::regprocedure) \
630             FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \
631             WHERE n.nspname = current_schema() AND p.prokind IN ('f', 'p') AND {}",
632            owned("p.oid")
633        ),
634        format!(
635            "SELECT format(CASE t.typtype WHEN 'd' THEN 'DROP DOMAIN IF EXISTS %I CASCADE' \
636                                          ELSE 'DROP TYPE IF EXISTS %I CASCADE' END, t.typname) \
637             FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace \
638             LEFT JOIN pg_class c ON c.oid = t.typrelid \
639             WHERE n.nspname = current_schema() AND t.typtype IN ('e', 'd', 'r', 'c') \
640             AND (t.typtype <> 'c' OR c.relkind = 'c') AND {}",
641            owned("t.oid")
642        ),
643    ];
644    for query in queries {
645        let statements = sqlx::query(AssertSqlSafe(query)).fetch_all(pool).await?;
646        for statement in &statements {
647            let statement: String = statement.try_get(0)?;
648            sqlx::query(AssertSqlSafe(statement)).execute(pool).await?;
649        }
650    }
651    Ok(())
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn splits_statements_outside_quotes_comments_and_bodies() {
660        let sql = "-- renox:no-transaction\nCREATE TABLE t (s TEXT DEFAULT ';');\n\
661                   /* a; b */ CREATE FUNCTION f() RETURNS INT AS $body$ SELECT 1; $body$ LANGUAGE SQL;\n\
662                   CREATE INDEX CONCURRENTLY i ON t (s);\n";
663        let parts = statements(sql);
664        assert_eq!(parts.len(), 3, "{parts:?}");
665        assert!(parts[1].contains("SELECT 1; $body$"));
666        assert!(parts[2].trim().starts_with("CREATE INDEX CONCURRENTLY"));
667        assert!(runs_outside_transaction(sql));
668        assert!(runs_outside_transaction(
669            "BEGIN; CREATE TABLE a (id INT); COMMIT;"
670        ));
671        assert!(!runs_outside_transaction(
672            "CREATE TABLE begin_log (id INT);"
673        ));
674    }
675
676    /// `CONCURRENTLY` followed by a newline or a tab counts too (#219).
677    #[test]
678    fn concurrently_is_found_whatever_follows_it() {
679        assert!(runs_outside_transaction(
680            "CREATE INDEX CONCURRENTLY\n    users_email ON users (email);"
681        ));
682        assert!(runs_outside_transaction(
683            "create index\tconcurrently\tx ON t (a);"
684        ));
685        assert!(!runs_outside_transaction(
686            "CREATE TABLE concurrently_log (id INT);"
687        ));
688    }
689
690    async fn tables(db: &Db) -> Vec<String> {
691        let list = match db.dialect() {
692            Dialect::Sqlite => "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
693            Dialect::Postgres => {
694                "SELECT tablename AS name FROM pg_tables \
695                 WHERE schemaname = current_schema() ORDER BY tablename"
696            }
697        };
698        sql(list)
699            .fetch_all(db)
700            .await
701            .unwrap()
702            .iter()
703            .map(|row| row.try_get::<String>("name").unwrap())
704            .collect()
705    }
706
707    /// Scripts that run outside the migration transaction, up and down,
708    /// with several statements: on PostgreSQL with `CONCURRENTLY`, so each
709    /// statement runs on its own; a migration that is only a comment.
710    #[tokio::test]
711    async fn scripts_outside_a_transaction_run_every_statement_both_ways() {
712        let db = super::super::connect(&crate::Config::default())
713            .await
714            .unwrap();
715        let outside = Migration::new(
716            "1_outside",
717            "-- renox:no-transaction\nCREATE TABLE a (id INT);\nCREATE TABLE b (id INT);",
718            Some("-- renox:no-transaction\nDROP TABLE b;\nDROP TABLE a;"),
719        )
720        .postgres(
721            "CREATE TABLE a (id INT);\nCREATE INDEX CONCURRENTLY a_id ON a (id);\nCREATE TABLE b (id INT);",
722            Some("DROP INDEX CONCURRENTLY a_id;\nDROP TABLE b;\nDROP TABLE a;"),
723        );
724        let comment = Migration::new("2_comment", "-- nothing to do yet\n", Some("-- nor here\n"));
725        let migrator = Migrator::new(vec![outside, comment]).unwrap();
726        assert_eq!(migrator.run(&db).await.unwrap(), ["1_outside", "2_comment"]);
727        let made = tables(&db).await;
728        assert!(
729            made.contains(&"a".to_owned()) && made.contains(&"b".to_owned()),
730            "{made:?}"
731        );
732        let undone = migrator.rollback(&db, 1).await.unwrap();
733        assert_eq!(undone, ["2_comment", "1_outside"]);
734        let left = tables(&db).await;
735        assert!(
736            !left.contains(&"a".to_owned()) && !left.contains(&"b".to_owned()),
737            "{left:?}"
738        );
739    }
740
741    /// A `renox_migrations` table from before checksums gets the column,
742    /// and its rows are kept.
743    #[tokio::test]
744    async fn an_old_migrations_table_gets_its_checksum_column() {
745        let db = super::super::connect(&crate::Config::default())
746            .await
747            .unwrap();
748        sql(format!(
749            "CREATE TABLE {TABLE} (name TEXT PRIMARY KEY NOT NULL, batch BIGINT NOT NULL, \
750             applied_at TEXT NOT NULL)"
751        ))
752        .execute(&db)
753        .await
754        .unwrap();
755        sql(format!(
756            "INSERT INTO {TABLE} (name, batch, applied_at) VALUES ('1_old', 1, 'then')"
757        ))
758        .execute(&db)
759        .await
760        .unwrap();
761        let old = Migration::new("1_old", "CREATE TABLE never (id INT);", None);
762        let new = Migration::new("2_new", "CREATE TABLE fresh (id INT);", None);
763        let migrator = Migrator::new(vec![old, new]).unwrap();
764        assert_eq!(migrator.run(&db).await.unwrap(), ["2_new"]);
765        let status = migrator.status(&db).await.unwrap();
766        assert_eq!(status.len(), 2);
767        assert!(!tables(&db).await.contains(&"never".to_owned()));
768    }
769
770    /// A migration recorded by someone else after the pending list was read
771    /// (another process, simulated by the migration before it) is skipped
772    /// inside its transaction instead of running twice.
773    #[tokio::test]
774    async fn a_migration_recorded_meanwhile_is_skipped() {
775        let db = super::super::connect(&crate::Config::default())
776            .await
777            .unwrap();
778        let first = Migration::new(
779            "1_first",
780            "INSERT INTO renox_migrations (name, batch, applied_at) VALUES ('2_second', 1, 'meanwhile');",
781            None,
782        );
783        let second = Migration::new("2_second", "CREATE TABLE twice (id INT);", None);
784        let done = Migrator::new(vec![first, second])
785            .unwrap()
786            .run(&db)
787            .await
788            .unwrap();
789        assert_eq!(done, ["1_first"]);
790        assert!(!tables(&db).await.contains(&"twice".to_owned()));
791    }
792
793    /// Connections that read the schema before a migration aren't reused
794    /// after it: with them, `SELECT *` on the altered table panicked inside
795    /// sqlx-sqlite (a flaky macOS CI failure in M16b).
796    #[tokio::test]
797    async fn pooled_connections_see_columns_added_by_a_migration() {
798        let dir = tempfile::tempdir().unwrap();
799        let config = crate::Config {
800            database_url: format!("sqlite://{}/app.db", dir.path().display()),
801            database_pool_size: 4,
802            ..crate::Config::default()
803        };
804        let db = super::super::connect(&config).await.unwrap();
805        let create = Migration::new(
806            "1_notes",
807            "CREATE TABLE notes (id INTEGER PRIMARY KEY, a TEXT);",
808            None,
809        );
810        Migrator::new(vec![create]).unwrap().run(&db).await.unwrap();
811        sql("INSERT INTO notes (a) VALUES ('x')")
812            .execute(&db)
813            .await
814            .unwrap();
815
816        // Every connection in the pool reads the table (and caches the query).
817        let mut open = Vec::new();
818        for _ in 0..4 {
819            let mut tx = db.begin().await.unwrap();
820            sql("SELECT * FROM notes").fetch_all(&mut tx).await.unwrap();
821            open.push(tx);
822        }
823        drop(open);
824
825        let alter = Migration::new("2_notes_b", "ALTER TABLE notes ADD COLUMN b TEXT;", None);
826        Migrator::new(vec![create, alter])
827            .unwrap()
828            .run(&db)
829            .await
830            .unwrap();
831        for _ in 0..8 {
832            let rows = sql("SELECT * FROM notes").fetch_all(&db).await.unwrap();
833            assert_eq!(rows[0].try_get::<Option<String>>("b").unwrap(), None);
834        }
835    }
836}