sea_orm_cli/commands/
migrate.rs

1use chrono::{Local, Utc};
2use regex::Regex;
3use std::{
4    error::Error,
5    fmt::Display,
6    fs,
7    io::Write,
8    path::{Path, PathBuf},
9    process::Command,
10};
11
12#[cfg(feature = "cli")]
13use crate::MigrateSubcommands;
14
15#[cfg(feature = "cli")]
16pub fn run_migrate_command(
17    command: Option<MigrateSubcommands>,
18    migration_dir: &str,
19    database_schema: Option<String>,
20    database_url: Option<String>,
21    verbose: bool,
22) -> Result<(), Box<dyn Error>> {
23    match command {
24        Some(MigrateSubcommands::Init) => run_migrate_init(migration_dir)?,
25        Some(MigrateSubcommands::Generate {
26            migration_name,
27            universal_time: _,
28            local_time,
29        }) => run_migrate_generate(migration_dir, &migration_name, !local_time)?,
30        _ => {
31            let (subcommand, migration_dir, steps, verbose) = match command {
32                Some(MigrateSubcommands::Fresh) => ("fresh", migration_dir, None, verbose),
33                Some(MigrateSubcommands::Refresh) => ("refresh", migration_dir, None, verbose),
34                Some(MigrateSubcommands::Reset) => ("reset", migration_dir, None, verbose),
35                Some(MigrateSubcommands::Status) => ("status", migration_dir, None, verbose),
36                Some(MigrateSubcommands::Up { num }) => ("up", migration_dir, num, verbose),
37                Some(MigrateSubcommands::Down { num }) => {
38                    ("down", migration_dir, Some(num), verbose)
39                }
40                _ => ("up", migration_dir, None, verbose),
41            };
42
43            // Construct the `--manifest-path`
44            let manifest_path = if migration_dir.ends_with('/') {
45                format!("{migration_dir}Cargo.toml")
46            } else {
47                format!("{migration_dir}/Cargo.toml")
48            };
49            // Construct the arguments that will be supplied to `cargo` command
50            let mut args = vec!["run", "--manifest-path", &manifest_path, "--", subcommand];
51            let mut envs = vec![];
52
53            let mut num: String = "".to_string();
54            if let Some(steps) = steps {
55                num = steps.to_string();
56            }
57            if !num.is_empty() {
58                args.extend(["-n", &num])
59            }
60            if let Some(database_url) = &database_url {
61                envs.push(("DATABASE_URL", database_url));
62            }
63            if let Some(database_schema) = &database_schema {
64                envs.push(("DATABASE_SCHEMA", database_schema));
65            }
66            if verbose {
67                args.push("-v");
68            }
69            // Run migrator CLI on user's behalf
70            println!("Running `cargo {}`", args.join(" "));
71            let exit_status = Command::new("cargo").args(args).envs(envs).status()?; // Get the status code
72            if !exit_status.success() {
73                // Propagate the error if any
74                return Err("Fail to run migration".into());
75            }
76        }
77    }
78
79    Ok(())
80}
81
82pub fn run_migrate_init(migration_dir: &str) -> Result<(), Box<dyn Error>> {
83    let migration_dir = match migration_dir.ends_with('/') {
84        true => migration_dir.to_string(),
85        false => format!("{migration_dir}/"),
86    };
87    println!("Initializing migration directory...");
88    macro_rules! write_file {
89        ($filename: literal) => {
90            let fn_content = |content: String| content;
91            write_file!($filename, $filename, fn_content);
92        };
93        ($filename: literal, $template: literal) => {
94            let fn_content = |content: String| content;
95            write_file!($filename, $template, fn_content);
96        };
97        ($filename: literal, $template: literal, $fn_content: expr) => {
98            let filepath = [&migration_dir, $filename].join("");
99            println!("Creating file `{}`", filepath);
100            let path = Path::new(&filepath);
101            let prefix = path.parent().unwrap();
102            fs::create_dir_all(prefix).unwrap();
103            let mut file = fs::File::create(path)?;
104            let content = include_str!(concat!("../../template/migration/", $template));
105            let content = $fn_content(content.to_string());
106            file.write_all(content.as_bytes())?;
107        };
108    }
109    write_file!("src/lib.rs");
110    write_file!("src/m20220101_000001_create_table.rs");
111    write_file!("src/main.rs");
112    write_file!("Cargo.toml", "_Cargo.toml", |content: String| {
113        let ver = format!(
114            "{}.{}.0",
115            env!("CARGO_PKG_VERSION_MAJOR"),
116            env!("CARGO_PKG_VERSION_MINOR")
117        );
118        content.replace("<sea-orm-migration-version>", &ver)
119    });
120    write_file!("README.md");
121    if glob::glob(&format!("{migration_dir}**/.git"))?.count() > 0 {
122        write_file!(".gitignore", "_gitignore");
123    }
124    println!("Done!");
125
126    Ok(())
127}
128
129pub fn run_migrate_generate(
130    migration_dir: &str,
131    migration_name: &str,
132    universal_time: bool,
133) -> Result<(), Box<dyn Error>> {
134    // Make sure the migration name doesn't contain any characters that
135    // are invalid module names in Rust.
136    if migration_name.contains('-') {
137        return Err(Box::new(MigrationCommandError::InvalidName(
138            "Hyphen `-` cannot be used in migration name".to_string(),
139        )));
140    }
141
142    println!("Generating new migration...");
143
144    // build new migration filename
145    const FMT: &str = "%Y%m%d_%H%M%S";
146    let formatted_now = if universal_time {
147        Utc::now().format(FMT)
148    } else {
149        Local::now().format(FMT)
150    };
151
152    let migration_name = migration_name.trim().replace(' ', "_");
153    let migration_name = format!("m{formatted_now}_{migration_name}");
154
155    create_new_migration(&migration_name, migration_dir)?;
156    update_migrator(&migration_name, migration_dir)?;
157
158    Ok(())
159}
160
161/// `get_full_migration_dir` looks for a `src` directory
162/// inside of `migration_dir` and appends that to the returned path if found.
163///
164/// Otherwise, `migration_dir` can point directly to a directory containing the
165/// migrations. In that case, nothing is appended.
166///
167/// This way, `src` doesn't need to be appended in the standard case where
168/// migrations are in their own crate. If the migrations are in a submodule
169/// of another crate, `migration_dir` can point directly to that module.
170fn get_full_migration_dir(migration_dir: &str) -> PathBuf {
171    let without_src = Path::new(migration_dir).to_owned();
172    let with_src = without_src.join("src");
173    match () {
174        _ if with_src.is_dir() => with_src,
175        _ => without_src,
176    }
177}
178
179fn create_new_migration(migration_name: &str, migration_dir: &str) -> Result<(), Box<dyn Error>> {
180    let migration_filepath =
181        get_full_migration_dir(migration_dir).join(format!("{}.rs", &migration_name));
182    println!("Creating migration file `{}`", migration_filepath.display());
183    // TODO: make OS agnostic
184    let migration_template =
185        include_str!("../../template/migration/src/m20220101_000001_create_table.rs");
186    let mut migration_file = fs::File::create(migration_filepath)?;
187    migration_file.write_all(migration_template.as_bytes())?;
188    Ok(())
189}
190
191/// `get_migrator_filepath` looks for a file `migration_dir/src/lib.rs`
192/// and returns that path if found.
193///
194/// If `src` is not found, it will look directly in `migration_dir` for `lib.rs`.
195///
196/// If `lib.rs` is not found, it will look for `mod.rs` instead,
197/// e.g. `migration_dir/mod.rs`.
198///
199/// This way, `src` doesn't need to be appended in the standard case where
200/// migrations are in their own crate (with a file `lib.rs`). If the
201/// migrations are in a submodule of another crate (with a file `mod.rs`),
202/// `migration_dir` can point directly to that module.
203fn get_migrator_filepath(migration_dir: &str) -> PathBuf {
204    let full_migration_dir = get_full_migration_dir(migration_dir);
205    let with_lib = full_migration_dir.join("lib.rs");
206    match () {
207        _ if with_lib.is_file() => with_lib,
208        _ => full_migration_dir.join("mod.rs"),
209    }
210}
211
212fn update_migrator(migration_name: &str, migration_dir: &str) -> Result<(), Box<dyn Error>> {
213    let migrator_filepath = get_migrator_filepath(migration_dir);
214    println!(
215        "Adding migration `{}` to `{}`",
216        migration_name,
217        migrator_filepath.display()
218    );
219    let migrator_content = fs::read_to_string(&migrator_filepath)?;
220    let mut updated_migrator_content = migrator_content.clone();
221
222    // create a backup of the migrator file in case something goes wrong
223    let migrator_backup_filepath = migrator_filepath.with_extension("rs.bak");
224    fs::copy(&migrator_filepath, &migrator_backup_filepath)?;
225    let mut migrator_file = fs::File::create(&migrator_filepath)?;
226
227    // find existing mod declarations, add new line
228    let mod_regex = Regex::new(r"mod\s+(?P<name>m\d{8}_\d{6}_\w+);")?;
229    let mods: Vec<_> = mod_regex.captures_iter(&migrator_content).collect();
230    let mods_end = if let Some(last_match) = mods.last() {
231        last_match.get(0).unwrap().end() + 1
232    } else {
233        migrator_content.len()
234    };
235    updated_migrator_content.insert_str(mods_end, format!("mod {migration_name};\n").as_str());
236
237    // build new vector from declared migration modules
238    let mut migrations: Vec<&str> = mods
239        .iter()
240        .map(|cap| cap.name("name").unwrap().as_str())
241        .collect();
242    migrations.push(migration_name);
243    let mut boxed_migrations = migrations
244        .iter()
245        .map(|migration| format!("            Box::new({migration}::Migration),"))
246        .collect::<Vec<String>>()
247        .join("\n");
248    boxed_migrations.push('\n');
249    let boxed_migrations = format!("vec![\n{boxed_migrations}        ]\n");
250    let vec_regex = Regex::new(r"vec!\[[\s\S]+\]\n")?;
251    let updated_migrator_content = vec_regex.replace(&updated_migrator_content, &boxed_migrations);
252
253    migrator_file.write_all(updated_migrator_content.as_bytes())?;
254    fs::remove_file(&migrator_backup_filepath)?;
255    Ok(())
256}
257
258#[derive(Debug)]
259enum MigrationCommandError {
260    InvalidName(String),
261}
262
263impl Display for MigrationCommandError {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        match self {
266            MigrationCommandError::InvalidName(name) => {
267                write!(f, "Invalid migration name: {name}")
268            }
269        }
270    }
271}
272
273impl Error for MigrationCommandError {}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_create_new_migration() {
281        let migration_name = "test_name";
282        let migration_dir = "/tmp/sea_orm_cli_test_new_migration/";
283        fs::create_dir_all(format!("{migration_dir}src")).unwrap();
284        create_new_migration(migration_name, migration_dir).unwrap();
285        let migration_filepath = Path::new(migration_dir)
286            .join("src")
287            .join(format!("{migration_name}.rs"));
288        assert!(migration_filepath.exists());
289        let migration_content = fs::read_to_string(migration_filepath).unwrap();
290        assert_eq!(
291            &migration_content,
292            include_str!("../../template/migration/src/m20220101_000001_create_table.rs")
293        );
294        fs::remove_dir_all("/tmp/sea_orm_cli_test_new_migration/").unwrap();
295    }
296
297    #[test]
298    fn test_update_migrator() {
299        let migration_name = "test_name";
300        let migration_dir = "/tmp/sea_orm_cli_test_update_migrator/";
301        fs::create_dir_all(format!("{migration_dir}src")).unwrap();
302        let migrator_filepath = Path::new(migration_dir).join("src").join("lib.rs");
303        fs::copy("./template/migration/src/lib.rs", &migrator_filepath).unwrap();
304        update_migrator(migration_name, migration_dir).unwrap();
305        assert!(&migrator_filepath.exists());
306        let migrator_content = fs::read_to_string(&migrator_filepath).unwrap();
307        let mod_regex = Regex::new(r"mod (?P<name>\w+);").unwrap();
308        let migrations: Vec<&str> = mod_regex
309            .captures_iter(&migrator_content)
310            .map(|cap| cap.name("name").unwrap().as_str())
311            .collect();
312        assert_eq!(migrations.len(), 2);
313        assert_eq!(
314            *migrations.first().unwrap(),
315            "m20220101_000001_create_table"
316        );
317        assert_eq!(migrations.last().unwrap(), &migration_name);
318        let boxed_regex = Regex::new(r"Box::new\((?P<name>\S+)::Migration\)").unwrap();
319        let migrations: Vec<&str> = boxed_regex
320            .captures_iter(&migrator_content)
321            .map(|cap| cap.name("name").unwrap().as_str())
322            .collect();
323        assert_eq!(migrations.len(), 2);
324        assert_eq!(
325            *migrations.first().unwrap(),
326            "m20220101_000001_create_table"
327        );
328        assert_eq!(migrations.last().unwrap(), &migration_name);
329        fs::remove_dir_all("/tmp/sea_orm_cli_test_update_migrator/").unwrap();
330    }
331}