Skip to main content

doido_generators/generators/
migration.rs

1use crate::generator::{GeneratedFile, Generator};
2use crate::generators::field::Field;
3use crate::generators::migration_support::{
4    create_table_imports, create_table_up, drop_table_down, register_migration,
5    render_migration_file, MIGRATION_LIB_BASE, MIGRATION_SRC_DIR,
6};
7use crate::generators::to_snake;
8use chrono::Utc;
9use doido_core::Result;
10
11pub struct MigrationGenerator;
12
13impl Generator for MigrationGenerator {
14    fn name(&self) -> &str {
15        "migration"
16    }
17
18    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
19        let name = args.first().copied().ok_or_else(|| {
20            doido_core::anyhow::anyhow!("migration generator requires a name argument")
21        })?;
22        let snake = to_snake(name);
23        // Remaining args are `name:type[:modifier...]` column specs.
24        let fields = Field::parse_all(&args[1..])?;
25
26        // Infer the operation from the name (Rails-style) and render the file
27        // using the `doido_model::migration` builders.
28        let (imports, up_body, down_body) = MigrationOp::parse(&snake).render(&fields);
29        let migration = render_migration_file(&imports, &up_body, &down_body);
30
31        // Same convention as the model generator: the module name doubles as the
32        // migration id (SeaORM's `DeriveMigrationName` derives it from the path).
33        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
34        let migration_module = format!("m{timestamp}_{snake}");
35
36        // Register the migration in db/migration/src/lib.rs, preserving any
37        // migrations already registered there.
38        let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
39        let existing =
40            std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
41        let lib = register_migration(&existing, &migration_module);
42
43        let test = crate::templates::get("migration/migration_test.rs.template")
44            .replace("{snake}", &snake);
45
46        Ok(vec![
47            GeneratedFile {
48                path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
49                content: migration,
50            },
51            GeneratedFile {
52                path: lib_path,
53                content: lib,
54            },
55            GeneratedFile {
56                path: format!("tests/{snake}_migration_test.rs"),
57                content: test,
58            },
59        ])
60    }
61}
62
63/// The database operation inferred from a Rails-style migration name.
64enum MigrationOp {
65    /// `create_<table>` — create the table from the field specs.
66    CreateTable { table: String },
67    /// `drop_<table>` — drop the table (down re-creates it from the fields).
68    DropTable { table: String },
69    /// `add_<cols>_to_<table>` — add the field columns to an existing table.
70    AddColumns { table: String },
71    /// `remove_<cols>_from_<table>` — drop the field columns from a table.
72    RemoveColumns { table: String },
73    /// Unrecognised name — a `create_table` skeleton for the user to fill in.
74    Generic { name: String },
75}
76
77impl MigrationOp {
78    /// Infer the operation from a snake_cased migration name.
79    fn parse(snake: &str) -> Self {
80        if let Some(table) = snake.strip_prefix("create_") {
81            return Self::CreateTable {
82                table: table.to_string(),
83            };
84        }
85        if let Some(rest) = snake.strip_prefix("add_") {
86            if let Some((_, table)) = rest.rsplit_once("_to_") {
87                return Self::AddColumns {
88                    table: table.to_string(),
89                };
90            }
91        }
92        for prefix in ["remove_", "delete_", "drop_"] {
93            if let Some(rest) = snake.strip_prefix(prefix) {
94                if let Some((_, table)) = rest.rsplit_once("_from_") {
95                    return Self::RemoveColumns {
96                        table: table.to_string(),
97                    };
98                }
99            }
100        }
101        if let Some(table) = snake.strip_prefix("drop_") {
102            return Self::DropTable {
103                table: table.to_string(),
104            };
105        }
106        Self::Generic {
107            name: snake.to_string(),
108        }
109    }
110
111    /// Render `(imports, up_body, down_body)` for this operation.
112    fn render(&self, fields: &[Field]) -> (String, String, String) {
113        match self {
114            Self::CreateTable { table } | Self::Generic { name: table } => (
115                create_table_imports(fields),
116                create_table_up(table, fields),
117                drop_table_down(table),
118            ),
119            Self::DropTable { table } => {
120                let up = format!("        drop_table(manager, \"{table}\").await\n");
121                if fields.is_empty() {
122                    let down = format!(
123                        "        // TODO: re-create the `{table}` table to make this reversible.\n\
124                         \x20       let _ = manager;\n        Ok(())\n"
125                    );
126                    (
127                        "use doido_model::migration::drop_table;".to_string(),
128                        up,
129                        down,
130                    )
131                } else {
132                    // `down` re-creates the table, so it needs the create imports.
133                    (
134                        create_table_imports(fields),
135                        up,
136                        create_table_up(table, fields),
137                    )
138                }
139            }
140            Self::AddColumns { table } => (
141                "use doido_model::migration::alter_table;".to_string(),
142                alter_body(table, &add_lines(fields)),
143                alter_body(table, &drop_lines(fields)),
144            ),
145            Self::RemoveColumns { table } => (
146                "use doido_model::migration::alter_table;".to_string(),
147                alter_body(table, &drop_lines(fields)),
148                alter_body(table, &add_lines(fields)),
149            ),
150        }
151    }
152}
153
154fn add_lines(fields: &[Field]) -> Vec<String> {
155    fields.iter().map(Field::alter_add_line).collect()
156}
157
158fn drop_lines(fields: &[Field]) -> Vec<String> {
159    fields.iter().map(Field::alter_drop_line).collect()
160}
161
162/// Renders an `alter_table(manager, "<table>", |t| { … }).await` body from the
163/// given builder statements. With no statements it leaves a TODO skeleton.
164fn alter_body(table: &str, lines: &[String]) -> String {
165    if lines.is_empty() {
166        return format!(
167            "        // TODO: describe the column changes, e.g.\n\
168             \x20       // `t.add_column(\"name\", |c| {{ c.string(); }});`.\n\
169             \x20       alter_table(manager, \"{table}\", |_t| {{}}).await\n"
170        );
171    }
172    let body: String = lines.iter().map(|l| format!("            {l}\n")).collect();
173    format!("        alter_table(manager, \"{table}\", |t| {{\n{body}        }})\n        .await\n")
174}