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    pub fn new(db: &'a Database, migrations: Vec<&'a dyn Migration>) -> Self {
112        Migrator { db, migrations, table: DEFAULT_TABLE.to_string() }
113    }
114
115    /// Record applied migrations in a different table.
116    pub fn with_table(mut self, table: &str) -> Result<Self> {
117        crate::validate_identifier(table)?;
118        self.table = table.to_string();
119        Ok(self)
120    }
121
122    pub fn table(&self) -> &str {
123        &self.table
124    }
125
126    /// Create the tracking table if it is not there yet.
127    ///
128    /// The batch number is what makes `migrate:rollback` undo one deployment's
129    /// worth of migrations rather than one migration.
130    pub async fn prepare(&self) -> Result<()> {
131        // The DDL comes from the dialect: SQL Server has no `if not exists`,
132        // and the key column is spelled three different ways.
133        let sql = self.db.dialect().migrations_table_sql(&self.table);
134        self.db.run(&sql).await?;
135        Ok(())
136    }
137
138    /// Record that a migration ran.
139    async fn record(&self, name: &str, batch: i64) -> Result<()> {
140        self.db
141            .execute(
142                &format!(
143                    "insert into {} (name, batch) values ({}, {})",
144                    self.quoted_table(),
145                    self.db.dialect().placeholder(1),
146                    self.db.dialect().placeholder(2)
147                ),
148                &[Value::from(name), Value::from(batch)],
149            )
150            .await?;
151        Ok(())
152    }
153
154    /// The tracking table, quoted for the database in use.
155    fn quoted_table(&self) -> String {
156        self.db.dialect().quote(&self.table)
157    }
158
159    /// Names already applied, in the order they ran.
160    pub async fn applied(&self) -> Result<Vec<String>> {
161        let rows = self
162            .db
163            .select(&format!("select name from {} order by id", self.quoted_table()), &[])
164            .await?;
165        rows.iter().map(|row| row.get::<String>("name")).collect()
166    }
167
168    /// Migrations that have not run yet.
169    pub async fn pending(&self) -> Result<Vec<&'a dyn Migration>> {
170        let applied = self.applied().await?;
171        Ok(self
172            .migrations
173            .iter()
174            .filter(|migration| !applied.iter().any(|name| name == migration.name()))
175            .copied()
176            .collect())
177    }
178
179    async fn next_batch(&self) -> Result<i64> {
180        let highest = self
181            .db
182            .scalar::<Option<i64>>(
183                &format!("select max(batch) from {}", self.quoted_table()),
184                &[],
185            )
186            .await?
187            .flatten();
188        Ok(highest.unwrap_or(0) + 1)
189    }
190
191    /// Run every pending migration.
192    ///
193    /// A migration is **not** wrapped in a transaction, which is also what
194    /// Laravel does. Wrapping one here would be a lie: the migration's DDL and
195    /// the tracking insert are separate statements, a pooled connection is not
196    /// guaranteed to be the same one twice, and MySQL commits implicitly before
197    /// and after every DDL statement regardless — a CREATE TABLE cannot be
198    /// rolled back there at all.
199    ///
200    /// A migration that needs to be atomic should open a transaction itself
201    /// with `db.begin()`.
202    pub async fn run(&self) -> Result<MigrationReport> {
203        self.prepare().await?;
204
205        let pending = self.pending().await?;
206        let batch = self.next_batch().await?;
207        let mut report = MigrationReport {
208            skipped: self.migrations.len() - pending.len(),
209            ..MigrationReport::default()
210        };
211
212        for migration in pending {
213            let schema = Schema::new(self.db);
214
215            match migration.up(&schema).await {
216                Ok(()) => {
217                    self.record(migration.name(), batch).await?;
218                    report.applied.push(migration.name().to_string());
219                    rustlavel_core::info!("migrated: {}", migration.name());
220                }
221                Err(error) => {
222                    return Err(rustlavel_core::Error::msg(format!(
223                        "migration `{}` failed: {error}\n  \
224                         Anything it had already done is still applied. Fix the migration and \
225                         run `rustlavel migrate` again.",
226                        migration.name()
227                    )));
228                }
229            }
230        }
231
232        Ok(report)
233    }
234
235    /// Roll back the most recent batch.
236    pub async fn rollback(&self) -> Result<MigrationReport> {
237        self.prepare().await?;
238
239        let batch = self
240            .db
241            .scalar::<Option<i64>>(
242                &format!("select max(batch) from {}", self.quoted_table()),
243                &[],
244            )
245            .await?
246            .flatten();
247
248        let Some(batch) = batch else { return Ok(MigrationReport::default()) };
249
250        let rows = self
251            .db
252            .select(
253                &format!(
254                    "select name from {} where batch = {} order by id desc",
255                    self.quoted_table(),
256                    self.db.dialect().placeholder(1)
257                ),
258                &[Value::from(batch)],
259            )
260            .await?;
261
262        let mut report = MigrationReport::default();
263
264        for row in rows {
265            let name = row.get::<String>("name")?;
266            let Some(migration) = self.migrations.iter().find(|m| m.name() == name) else {
267                return Err(rustlavel_core::Error::msg(format!(
268                    "cannot roll back `{name}`: it is recorded as applied but is not in the \
269                     migration registry. Did the file get deleted?"
270                )));
271            };
272
273            let schema = Schema::new(self.db);
274            match migration.down(&schema).await {
275                Ok(()) => {
276                    self.db
277                        .execute(
278                            &format!(
279                                "delete from {} where name = {}",
280                                self.quoted_table(),
281                                self.db.dialect().placeholder(1)
282                            ),
283                            &[Value::from(name.as_str())],
284                        )
285                        .await?;
286                    report.rolled_back.push(name.clone());
287                    rustlavel_core::info!("rolled back: {name}");
288                }
289                Err(error) => {
290                    return Err(rustlavel_core::Error::msg(format!(
291                        "rolling back `{name}` failed: {error}\n  \
292                         The migration is still recorded as applied."
293                    )));
294                }
295            }
296        }
297
298        Ok(report)
299    }
300
301    /// Drop every table in the schema, then migrate from scratch.
302    ///
303    /// Refuses to run in production: this is the command that would delete a
304    /// live database, and a confirmation prompt is not available to a library.
305    pub async fn fresh(&self, environment: &str) -> Result<MigrationReport> {
306        if environment == "production" {
307            return Err(rustlavel_core::Error::msg(
308                "migrate:fresh drops every table and is refused in production. \
309                 Use migrate, or set APP_ENV to something else if this really is a scratch database."
310                    .to_string(),
311            ));
312        }
313
314        // Enumerate, then drop. Only PostgreSQL has an anonymous block to put a
315        // loop in, and this shape works identically on all three.
316        let dialect = self.db.dialect();
317
318        if let Some(sql) = dialect.disable_foreign_keys_sql() {
319            self.db.run(sql).await?;
320        }
321
322        let rows = self.db.select(dialect.list_tables_sql(), &[]).await?;
323        let tables: Vec<String> =
324            rows.iter().map(|row| row.get_at::<String>(0)).collect::<Result<_>>()?;
325
326        for table in &tables {
327            let sql = dialect.drop_table_sql(table);
328            self.db.run(&sql).await?;
329        }
330
331        if let Some(sql) = dialect.enable_foreign_keys_sql() {
332            self.db.run(sql).await?;
333        }
334
335        self.run().await
336    }
337
338    /// Which migrations have run and which have not.
339    pub async fn status(&self) -> Result<Vec<(String, bool)>> {
340        self.prepare().await?;
341        let applied = self.applied().await?;
342
343        Ok(self
344            .migrations
345            .iter()
346            .map(|migration| {
347                let name = migration.name().to_string();
348                let has_run = applied.contains(&name);
349                (name, has_run)
350            })
351            .collect())
352    }
353}
354
355/// One seeder.
356pub trait Seeder: Send + Sync {
357    fn name(&self) -> &'static str;
358
359    fn run<'a>(
360        &'a self,
361        db: &'a Database,
362    ) -> std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
363}
364
365/// Run seeders in order.
366pub async fn seed(db: &Database, seeders: &[&dyn Seeder]) -> Result<Vec<String>> {
367    let mut ran = Vec::new();
368
369    for seeder in seeders {
370        seeder.run(db).await.map_err(|e| {
371            rustlavel_core::Error::msg(format!("seeder `{}` failed: {e}", seeder.name()))
372        })?;
373        rustlavel_core::info!("seeded: {}", seeder.name());
374        ran.push(seeder.name().to_string());
375    }
376
377    Ok(ran)
378}
379
380/// Pluralize an English noun for a table name.
381///
382/// Duplicated from the CLI's naming module on purpose: the CLI is a separate
383/// binary that applications do not depend on, and a foreign key needs this at
384/// runtime.
385pub fn pluralize(word: &str) -> String {
386    let lower = word.to_lowercase();
387
388    for (singular, plural) in [
389        ("person", "people"),
390        ("child", "children"),
391        ("man", "men"),
392        ("woman", "women"),
393        ("tooth", "teeth"),
394        ("foot", "feet"),
395        ("mouse", "mice"),
396        ("goose", "geese"),
397    ] {
398        if lower.ends_with(singular) {
399            return format!("{}{plural}", &word[..word.len() - singular.len()]);
400        }
401    }
402
403    if lower.ends_with('s') && !lower.ends_with("us") && !lower.ends_with("ss") {
404        return word.to_string();
405    }
406    if let Some(stem) = lower.strip_suffix('y')
407        && !stem.ends_with(['a', 'e', 'i', 'o', 'u']) {
408            return format!("{}ies", &word[..word.len() - 1]);
409        }
410    if lower.ends_with(['s', 'x', 'z']) || lower.ends_with("ch") || lower.ends_with("sh") {
411        return format!("{word}es");
412    }
413    format!("{word}s")
414}
415
416/// A tiny deterministic generator for factories and seeders.
417///
418/// Deterministic on purpose: a seeded database that differs run to run makes a
419/// failing test impossible to reproduce.
420pub struct Faker {
421    state: u64,
422}
423
424impl Faker {
425    pub fn new(seed: u64) -> Self {
426        Faker { state: seed.max(1) }
427    }
428
429    fn next(&mut self) -> u64 {
430        // xorshift64: small, fast, and repeatable.
431        self.state ^= self.state << 13;
432        self.state ^= self.state >> 7;
433        self.state ^= self.state << 17;
434        self.state
435    }
436
437    pub fn number(&mut self, low: i64, high: i64) -> i64 {
438        if high <= low {
439            return low;
440        }
441        low + (self.next() % (high - low + 1) as u64) as i64
442    }
443
444    pub fn boolean(&mut self) -> bool {
445        self.next().is_multiple_of(2)
446    }
447
448    pub fn pick<'a, T>(&mut self, options: &'a [T]) -> &'a T {
449        &options[(self.next() % options.len() as u64) as usize]
450    }
451
452    pub fn name(&mut self) -> String {
453        const FIRST: &[&str] = &[
454            "Ada", "Grace", "Alan", "Linus", "Barbara", "Ken", "Margaret", "Dennis", "Radia", "Guido",
455        ];
456        const LAST: &[&str] = &[
457            "Lovelace", "Hopper", "Turing", "Torvalds", "Liskov", "Thompson", "Hamilton", "Ritchie",
458            "Perlman", "Rossum",
459        ];
460        format!("{} {}", self.pick(FIRST), self.pick(LAST))
461    }
462
463    pub fn email(&mut self) -> String {
464        let name = self.name().to_lowercase().replace(' ', ".");
465        format!("{name}{}@example.com", self.number(1, 9999))
466    }
467
468    pub fn sentence(&mut self) -> String {
469        const WORDS: &[&str] = &[
470            "rust", "framework", "query", "handler", "migration", "route", "template", "record",
471            "worker", "cache",
472        ];
473        let count = self.number(4, 9) as usize;
474        let mut words: Vec<String> = (0..count).map(|_| self.pick(WORDS).to_string()).collect();
475        words[0] = {
476            let first = &words[0];
477            let mut chars = first.chars();
478            match chars.next() {
479                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
480                None => String::new(),
481            }
482        };
483        format!("{}.", words.join(" "))
484    }
485
486    pub fn slug(&mut self) -> String {
487        self.sentence().trim_end_matches('.').to_lowercase().replace(' ', "-")
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn pluralizes_for_foreign_keys() {
497        assert_eq!(pluralize("user"), "users");
498        assert_eq!(pluralize("category"), "categories");
499        assert_eq!(pluralize("person"), "people");
500        assert_eq!(pluralize("status"), "statuses");
501    }
502
503    #[test]
504    fn the_faker_is_reproducible() {
505        let mut first = Faker::new(42);
506        let mut second = Faker::new(42);
507
508        assert_eq!(first.name(), second.name());
509        assert_eq!(first.email(), second.email());
510        assert_eq!(first.number(1, 100), second.number(1, 100));
511    }
512
513    #[test]
514    fn different_seeds_diverge() {
515        assert_ne!(Faker::new(1).sentence(), Faker::new(2).sentence());
516    }
517
518    #[test]
519    fn faker_numbers_stay_in_range() {
520        let mut faker = Faker::new(7);
521        for _ in 0..200 {
522            let value = faker.number(5, 10);
523            assert!((5..=10).contains(&value), "{value} out of range");
524        }
525    }
526}