Skip to main content

rustlavel_db/
migration.rs

1//! Migrations and seeding.
2//!
3//! Laravel finds migrations by scanning a directory at runtime. A compiled
4//! language cannot, so the CLI generates a registry that lists them and the
5//! application passes it in — the developer never edits that file by hand, and
6//! the experience is the same.
7
8use crate::schema::Schema;
9use crate::{Database, Value};
10use rustlavel_core::Result;
11
12/// Where applied migrations are recorded, unless a `Migrator` says otherwise.
13pub const DEFAULT_TABLE: &str = "rustlavel_migrations";
14
15/// One migration.
16///
17/// `name` must be unique and sortable — the generator produces
18/// `2026_08_29_000001_create_users_table`, so lexical order is time order.
19pub trait Migration: Send + Sync {
20    fn name(&self) -> &'static str;
21
22    /// Apply the change.
23    fn up<'a>(
24        &'a self,
25        schema: &'a Schema<'a>,
26    ) -> std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
27
28    /// Undo it. A migration that cannot be undone should say so by returning an
29    /// error, rather than silently doing nothing.
30    fn down<'a>(
31        &'a self,
32        schema: &'a Schema<'a>,
33    ) -> std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
34}
35
36/// Define a migration without writing the pinned-future boilerplate.
37///
38/// ```ignore
39/// migration!(
40///     CreateUsersTable,
41///     "2026_08_29_000001_create_users_table",
42///     up: |schema| {
43///         schema.create("users", |t| { t.id(); t.string("name"); }).await
44///     },
45///     down: |schema| { schema.drop("users").await },
46/// );
47/// ```
48///
49/// The bodies are blocks rather than closures on purpose: a closure returning
50/// a future that borrows its argument cannot express the lifetime that
51/// relationship needs, and the error it produces is unreadable.
52#[macro_export]
53macro_rules! migration {
54    (
55        $type:ident,
56        $name:literal,
57        up: |$up_schema:ident| $up:block,
58        down: |$down_schema:ident| $down:block $(,)?
59    ) => {
60        pub struct $type;
61
62        impl $crate::migration::Migration for $type {
63            fn name(&self) -> &'static str {
64                $name
65            }
66
67            fn up<'a>(
68                &'a self,
69                schema: &'a $crate::schema::Schema<'a>,
70            ) -> ::std::pin::Pin<
71                ::std::boxed::Box<dyn ::std::future::Future<Output = $crate::Result<()>> + Send + 'a>,
72            > {
73                let $up_schema = schema;
74                ::std::boxed::Box::pin(async move { $up })
75            }
76
77            fn down<'a>(
78                &'a self,
79                schema: &'a $crate::schema::Schema<'a>,
80            ) -> ::std::pin::Pin<
81                ::std::boxed::Box<dyn ::std::future::Future<Output = $crate::Result<()>> + Send + 'a>,
82            > {
83                let $down_schema = schema;
84                ::std::boxed::Box::pin(async move { $down })
85            }
86        }
87    };
88}
89
90/// What a migration run did, so the CLI can report it.
91#[derive(Debug, Default, PartialEq)]
92pub struct MigrationReport {
93    pub applied: Vec<String>,
94    pub rolled_back: Vec<String>,
95    pub skipped: usize,
96}
97
98/// Applies and rolls back migrations, tracking which have run.
99pub struct Migrator<'a> {
100    db: &'a Database,
101    migrations: Vec<&'a dyn Migration>,
102    /// Where applied migrations are recorded.
103    ///
104    /// Configurable so two suites can share one database without rolling back
105    /// each other's batches — which is exactly what happened the first time the
106    /// framework's own tests ran side by side.
107    table: String,
108}
109
110impl<'a> Migrator<'a> {
111    /// A migrator over any database, with any list of migrations.
112    ///
113    /// Both halves are deliberate, and together they are how an application
114    /// provisions a tenant from its own admin screen rather than from the CLI:
115    /// create the database, then run into it only the migrations belonging to
116    /// the modules that tenant enabled.
117    ///
118    /// ```no_run
119    /// # use rustlavel_db::{Database, Migration, Migrator};
120    /// # async fn provision(url: &str, wanted: Vec<&'static dyn Migration>) -> rustlavel_db::Result<()> {
121    /// let tenant = Database::connect(url).await?;
122    ///
123    /// let migrator = Migrator::new(&tenant, wanted);
124    /// migrator.prepare().await?;
125    /// let report = migrator.run().await?;
126    /// println!("{} migrations applied", report.applied.len());
127    /// # Ok(())
128    /// # }
129    /// ```
130    ///
131    /// `App::migrations(...)` is boot-time wiring and `rustlavel migrate`
132    /// targets `DATABASE_URL`; neither can do this, which is why it is worth
133    /// saying that this can.
134    pub fn new(db: &'a Database, migrations: Vec<&'a dyn Migration>) -> Self {
135        Migrator { db, migrations, table: DEFAULT_TABLE.to_string() }
136    }
137
138    /// Record applied migrations in a different table.
139    pub fn with_table(mut self, table: &str) -> Result<Self> {
140        crate::validate_identifier(table)?;
141        self.table = table.to_string();
142        Ok(self)
143    }
144
145    pub fn table(&self) -> &str {
146        &self.table
147    }
148
149    /// Create the tracking table if it is not there yet.
150    ///
151    /// The batch number is what makes `migrate:rollback` undo one deployment's
152    /// worth of migrations rather than one migration.
153    pub async fn prepare(&self) -> Result<()> {
154        // The DDL comes from the dialect: SQL Server has no `if not exists`,
155        // and the key column is spelled three different ways.
156        let sql = self.db.dialect().migrations_table_sql(&self.table);
157        self.db.run(&sql).await?;
158        Ok(())
159    }
160
161    /// Record that a migration ran.
162    async fn record(&self, name: &str, batch: i64) -> Result<()> {
163        self.db
164            .execute(
165                &format!(
166                    "insert into {} (name, batch) values ({}, {})",
167                    self.quoted_table(),
168                    self.db.dialect().placeholder(1),
169                    self.db.dialect().placeholder(2)
170                ),
171                &[Value::from(name), Value::from(batch)],
172            )
173            .await?;
174        Ok(())
175    }
176
177    /// The tracking table, quoted for the database in use.
178    fn quoted_table(&self) -> String {
179        self.db.dialect().quote(&self.table)
180    }
181
182    /// Names already applied, in the order they ran.
183    pub async fn applied(&self) -> Result<Vec<String>> {
184        let rows = self
185            .db
186            .select(&format!("select name from {} order by id", self.quoted_table()), &[])
187            .await?;
188        rows.iter().map(|row| row.get::<String>("name")).collect()
189    }
190
191    /// Migrations that have not run yet.
192    pub async fn pending(&self) -> Result<Vec<&'a dyn Migration>> {
193        let applied = self.applied().await?;
194        Ok(self
195            .migrations
196            .iter()
197            .filter(|migration| !applied.iter().any(|name| name == migration.name()))
198            .copied()
199            .collect())
200    }
201
202    async fn next_batch(&self) -> Result<i64> {
203        let highest = self
204            .db
205            .scalar::<Option<i64>>(
206                &format!("select max(batch) from {}", self.quoted_table()),
207                &[],
208            )
209            .await?
210            .flatten();
211        Ok(highest.unwrap_or(0) + 1)
212    }
213
214    /// Run every pending migration.
215    ///
216    /// A migration is **not** wrapped in a transaction, which is also what
217    /// Laravel does. Wrapping one here would be a lie: the migration's DDL and
218    /// the tracking insert are separate statements, a pooled connection is not
219    /// guaranteed to be the same one twice, and MySQL commits implicitly before
220    /// and after every DDL statement regardless — a CREATE TABLE cannot be
221    /// rolled back there at all.
222    ///
223    /// A migration that needs to be atomic should open a transaction itself
224    /// with `db.begin()`.
225    pub async fn run(&self) -> Result<MigrationReport> {
226        self.prepare().await?;
227
228        let pending = self.pending().await?;
229        let batch = self.next_batch().await?;
230        let mut report = MigrationReport {
231            skipped: self.migrations.len() - pending.len(),
232            ..MigrationReport::default()
233        };
234
235        for migration in pending {
236            let schema = Schema::new(self.db);
237
238            match migration.up(&schema).await {
239                Ok(()) => {
240                    self.record(migration.name(), batch).await?;
241                    report.applied.push(migration.name().to_string());
242                    rustlavel_core::info!("migrated: {}", migration.name());
243                }
244                Err(error) => {
245                    return Err(rustlavel_core::Error::msg(format!(
246                        "migration `{}` failed: {error}\n  \
247                         Anything it had already done is still applied. Fix the migration and \
248                         run `rustlavel migrate` again.",
249                        migration.name()
250                    )));
251                }
252            }
253        }
254
255        Ok(report)
256    }
257
258    /// Roll back the most recent batch.
259    pub async fn rollback(&self) -> Result<MigrationReport> {
260        self.prepare().await?;
261
262        let batch = self
263            .db
264            .scalar::<Option<i64>>(
265                &format!("select max(batch) from {}", self.quoted_table()),
266                &[],
267            )
268            .await?
269            .flatten();
270
271        let Some(batch) = batch else { return Ok(MigrationReport::default()) };
272
273        let rows = self
274            .db
275            .select(
276                &format!(
277                    "select name from {} where batch = {} order by id desc",
278                    self.quoted_table(),
279                    self.db.dialect().placeholder(1)
280                ),
281                &[Value::from(batch)],
282            )
283            .await?;
284
285        let mut report = MigrationReport::default();
286
287        for row in rows {
288            let name = row.get::<String>("name")?;
289            let Some(migration) = self.migrations.iter().find(|m| m.name() == name) else {
290                return Err(rustlavel_core::Error::msg(format!(
291                    "cannot roll back `{name}`: it is recorded as applied but is not in the \
292                     migration registry. Did the file get deleted?"
293                )));
294            };
295
296            let schema = Schema::new(self.db);
297            match migration.down(&schema).await {
298                Ok(()) => {
299                    self.db
300                        .execute(
301                            &format!(
302                                "delete from {} where name = {}",
303                                self.quoted_table(),
304                                self.db.dialect().placeholder(1)
305                            ),
306                            &[Value::from(name.as_str())],
307                        )
308                        .await?;
309                    report.rolled_back.push(name.clone());
310                    rustlavel_core::info!("rolled back: {name}");
311                }
312                Err(error) => {
313                    return Err(rustlavel_core::Error::msg(format!(
314                        "rolling back `{name}` failed: {error}\n  \
315                         The migration is still recorded as applied."
316                    )));
317                }
318            }
319        }
320
321        Ok(report)
322    }
323
324    /// Drop every table in the schema, then migrate from scratch.
325    ///
326    /// Refuses to run in production: this is the command that would delete a
327    /// live database, and a confirmation prompt is not available to a library.
328    pub async fn fresh(&self, environment: &str) -> Result<MigrationReport> {
329        if environment == "production" {
330            return Err(rustlavel_core::Error::msg(
331                "migrate:fresh drops every table and is refused in production. \
332                 Use migrate, or set APP_ENV to something else if this really is a scratch database."
333                    .to_string(),
334            ));
335        }
336
337        // Enumerate, then drop. Only PostgreSQL has an anonymous block to put a
338        // loop in, and this shape works identically on all three.
339        let dialect = self.db.dialect();
340
341        if let Some(sql) = dialect.disable_foreign_keys_sql() {
342            self.db.run(sql).await?;
343        }
344
345        let rows = self.db.select(dialect.list_tables_sql(), &[]).await?;
346        let tables: Vec<String> =
347            rows.iter().map(|row| row.get_at::<String>(0)).collect::<Result<_>>()?;
348
349        for table in &tables {
350            let sql = dialect.drop_table_sql(table);
351            self.db.run(&sql).await?;
352        }
353
354        if let Some(sql) = dialect.enable_foreign_keys_sql() {
355            self.db.run(sql).await?;
356        }
357
358        self.run().await
359    }
360
361    /// Which migrations have run and which have not.
362    pub async fn status(&self) -> Result<Vec<(String, bool)>> {
363        self.prepare().await?;
364        let applied = self.applied().await?;
365
366        Ok(self
367            .migrations
368            .iter()
369            .map(|migration| {
370                let name = migration.name().to_string();
371                let has_run = applied.contains(&name);
372                (name, has_run)
373            })
374            .collect())
375    }
376}
377
378/// One seeder.
379pub trait Seeder: Send + Sync {
380    fn name(&self) -> &'static str;
381
382    fn run<'a>(
383        &'a self,
384        db: &'a Database,
385    ) -> std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
386}
387
388/// Run seeders in order.
389pub async fn seed(db: &Database, seeders: &[&dyn Seeder]) -> Result<Vec<String>> {
390    let mut ran = Vec::new();
391
392    for seeder in seeders {
393        seeder.run(db).await.map_err(|e| {
394            rustlavel_core::Error::msg(format!("seeder `{}` failed: {e}", seeder.name()))
395        })?;
396        rustlavel_core::info!("seeded: {}", seeder.name());
397        ran.push(seeder.name().to_string());
398    }
399
400    Ok(ran)
401}
402
403/// Pluralize an English noun for a table name.
404///
405/// Duplicated from the CLI's naming module on purpose: the CLI is a separate
406/// binary that applications do not depend on, and a foreign key needs this at
407/// runtime.
408pub fn pluralize(word: &str) -> String {
409    let lower = word.to_lowercase();
410
411    for (singular, plural) in [
412        ("person", "people"),
413        ("child", "children"),
414        ("man", "men"),
415        ("woman", "women"),
416        ("tooth", "teeth"),
417        ("foot", "feet"),
418        ("mouse", "mice"),
419        ("goose", "geese"),
420    ] {
421        if lower.ends_with(singular) {
422            return format!("{}{plural}", &word[..word.len() - singular.len()]);
423        }
424    }
425
426    if lower.ends_with('s') && !lower.ends_with("us") && !lower.ends_with("ss") {
427        return word.to_string();
428    }
429    if let Some(stem) = lower.strip_suffix('y')
430        && !stem.ends_with(['a', 'e', 'i', 'o', 'u']) {
431            return format!("{}ies", &word[..word.len() - 1]);
432        }
433    if lower.ends_with(['s', 'x', 'z']) || lower.ends_with("ch") || lower.ends_with("sh") {
434        return format!("{word}es");
435    }
436    format!("{word}s")
437}
438
439/// A tiny deterministic generator for factories and seeders.
440///
441/// Deterministic on purpose: a seeded database that differs run to run makes a
442/// failing test impossible to reproduce.
443pub struct Faker {
444    state: u64,
445}
446
447impl Faker {
448    pub fn new(seed: u64) -> Self {
449        Faker { state: seed.max(1) }
450    }
451
452    fn next(&mut self) -> u64 {
453        // xorshift64: small, fast, and repeatable.
454        self.state ^= self.state << 13;
455        self.state ^= self.state >> 7;
456        self.state ^= self.state << 17;
457        self.state
458    }
459
460    pub fn number(&mut self, low: i64, high: i64) -> i64 {
461        if high <= low {
462            return low;
463        }
464        low + (self.next() % (high - low + 1) as u64) as i64
465    }
466
467    pub fn boolean(&mut self) -> bool {
468        self.next().is_multiple_of(2)
469    }
470
471    pub fn pick<'a, T>(&mut self, options: &'a [T]) -> &'a T {
472        &options[(self.next() % options.len() as u64) as usize]
473    }
474
475    pub fn name(&mut self) -> String {
476        const FIRST: &[&str] = &[
477            "Ada", "Grace", "Alan", "Linus", "Barbara", "Ken", "Margaret", "Dennis", "Radia", "Guido",
478        ];
479        const LAST: &[&str] = &[
480            "Lovelace", "Hopper", "Turing", "Torvalds", "Liskov", "Thompson", "Hamilton", "Ritchie",
481            "Perlman", "Rossum",
482        ];
483        format!("{} {}", self.pick(FIRST), self.pick(LAST))
484    }
485
486    pub fn email(&mut self) -> String {
487        let name = self.name().to_lowercase().replace(' ', ".");
488        format!("{name}{}@example.com", self.number(1, 9999))
489    }
490
491    pub fn sentence(&mut self) -> String {
492        const WORDS: &[&str] = &[
493            "rust", "framework", "query", "handler", "migration", "route", "template", "record",
494            "worker", "cache",
495        ];
496        let count = self.number(4, 9) as usize;
497        let mut words: Vec<String> = (0..count).map(|_| self.pick(WORDS).to_string()).collect();
498        words[0] = {
499            let first = &words[0];
500            let mut chars = first.chars();
501            match chars.next() {
502                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
503                None => String::new(),
504            }
505        };
506        format!("{}.", words.join(" "))
507    }
508
509    pub fn slug(&mut self) -> String {
510        self.sentence().trim_end_matches('.').to_lowercase().replace(' ', "-")
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn pluralizes_for_foreign_keys() {
520        assert_eq!(pluralize("user"), "users");
521        assert_eq!(pluralize("category"), "categories");
522        assert_eq!(pluralize("person"), "people");
523        assert_eq!(pluralize("status"), "statuses");
524    }
525
526    #[test]
527    fn the_faker_is_reproducible() {
528        let mut first = Faker::new(42);
529        let mut second = Faker::new(42);
530
531        assert_eq!(first.name(), second.name());
532        assert_eq!(first.email(), second.email());
533        assert_eq!(first.number(1, 100), second.number(1, 100));
534    }
535
536    #[test]
537    fn different_seeds_diverge() {
538        assert_ne!(Faker::new(1).sentence(), Faker::new(2).sentence());
539    }
540
541    #[test]
542    fn faker_numbers_stay_in_range() {
543        let mut faker = Faker::new(7);
544        for _ in 0..200 {
545            let value = faker.number(5, 10);
546            assert!((5..=10).contains(&value), "{value} out of range");
547        }
548    }
549}