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