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