Skip to main content

rorm_cli/migrate/
mod.rs

1use std::cmp::Ordering;
2use std::path::Path;
3
4use anyhow::{anyhow, Context};
5use rorm_db::executor::{Executor, Nothing, Optional};
6use rorm_db::sql::create_table::CreateTable;
7use rorm_db::transaction::{Transaction, TransactionError};
8use rorm_db::Database;
9use rorm_declaration::config::DatabaseConfig;
10use rorm_declaration::imr::{Annotation, DbType};
11use tracing::{error, info};
12
13use crate::migrate::apply::apply_migration;
14use crate::migrate::config::{create_db_config, deserialize_db_conf};
15pub use crate::utils::migrations::get_existing_migrations;
16
17pub mod apply;
18pub mod config;
19
20/// Options for running migrations
21pub struct MigrateOptions {
22    /// Directory, migrations exist in
23    pub migration_dir: String,
24
25    /// Path to the database configuration file
26    pub database_config: String,
27
28    /// Apply only to (inclusive) the given id, if set
29    pub apply_until: Option<u16>,
30}
31
32/// Applies migrations on the given database with a given driver
33pub async fn run_migrate_custom(
34    db_conf: DatabaseConfig,
35    migration_dir: String,
36    apply_until: Option<u16>,
37) -> anyhow::Result<()> {
38    let p = Path::new(migration_dir.as_str());
39    if !p.exists() || p.is_file() {
40        error!(
41            "Couldn't find the migration directory in {} \n\n\
42            You can specify an alternative path with --migration-dir <PATH>",
43            migration_dir.as_str()
44        );
45        return Ok(());
46    }
47
48    let existing_migrations = get_existing_migrations(migration_dir.as_str())
49        .with_context(|| "Couldn't retrieve existing migrations")?;
50
51    if existing_migrations.is_empty() {
52        info!("No migrations found.\nExiting.");
53        return Ok(());
54    }
55
56    let db = Database::connect(rorm_db::DatabaseConfiguration {
57        driver: db_conf.driver,
58        min_connections: 1,
59        max_connections: 1,
60    })
61    .await?;
62
63    let last_migration_table_name = db_conf
64        .last_migration_table_name
65        .as_ref()
66        .map_or("_rorm__last_migration", |x| x.as_str());
67
68    let mut tx = db
69        .start_transaction()
70        .await
71        .with_context(|| "Could not create transaction")?;
72
73    create_last_migration_table(&mut tx, last_migration_table_name)
74        .await
75        .with_context(|| "Couldn't create internal last migration table")?;
76
77    tx.commit()
78        .await
79        .map_err(|x| match x {
80            TransactionError::Database(x) => x,
81            TransactionError::Hook(_) => unreachable!("rorm-cli does not use hooks"),
82        })
83        .with_context(|| "Couldn't create internal last migration table")?;
84
85    let last_migration: Option<i32> = db
86        .execute::<Optional>(
87            format!(
88                "SELECT migration_id FROM {} ORDER BY id DESC LIMIT 1;",
89                &last_migration_table_name
90            ),
91            Vec::new(),
92        )
93        .await
94        .and_then(|option| option.map(|row| row.get(0)).transpose().map_err(Into::into))
95        .with_context(|| {
96            "Couldn't fetch information about successful migrations from migration table"
97        })?;
98
99    match last_migration {
100        None => {
101            // Apply all migrations
102            for migration in &existing_migrations {
103                apply_migration(&db, migration, last_migration_table_name).await?;
104                info!("Applied migration {:04}_{}", migration.id, migration.name);
105
106                if let Some(apply_until) = apply_until {
107                    if migration.id == apply_until {
108                        info!(
109                            "Applied all migrations until (inclusive) migration {apply_until:04}"
110                        );
111                        break;
112                    }
113                }
114            }
115        }
116        Some(id) => {
117            let id = id as u16;
118            // Search for last applied migration
119            if existing_migrations.iter().any(|x| x.id == id) {
120                let mut apply = false;
121                for (idx, migration) in existing_migrations.iter().enumerate() {
122                    if apply {
123                        apply_migration(&db, migration, last_migration_table_name).await?;
124                        info!("Applied migration {:04}_{}", migration.id, migration.name);
125                        continue;
126                    }
127
128                    if migration.id == id {
129                        apply = true;
130
131                        if idx == existing_migrations.len() - 1 {
132                            info!("All migration have already been applied.");
133                        }
134                    }
135
136                    if let Some(apply_until) = apply_until {
137                        match migration.id.cmp(&apply_until) {
138                            Ordering::Equal => {
139                                if apply {
140                                    info!(
141                                        "Applied all migrations until (inclusive) migration {apply_until:04}"
142                                    );
143                                } else {
144                                    info!(
145                                        "All migrations until (inclusive) migration {apply_until:04} have already been applied"
146                                    );
147                                }
148                                break;
149                            }
150                            Ordering::Greater => break,
151                            Ordering::Less => {}
152                        }
153                    }
154                }
155            } else {
156                // If last applied migration could not be found in existing migrations,
157                // panic as there's no way to determine what to do next
158                return Err(anyhow!(
159                    r#"Last applied migration {id} was not found in current migrations.
160 
161Can not proceed any further without damaging data.
162To correct, empty the {last_migration_table_name} table or reset the whole database."#,
163                ));
164            }
165        }
166    }
167
168    db.close().await;
169    Ok(())
170}
171
172/// Applies migrations on the given database
173pub async fn run_migrate(options: MigrateOptions) -> anyhow::Result<()> {
174    let db_conf_path = Path::new(options.database_config.as_str());
175
176    if !&db_conf_path.exists() {
177        error!(
178            "Couldn't find the database configuration file, created {} and exiting",
179            options.database_config.as_str()
180        );
181        create_db_config(db_conf_path)?;
182        return Ok(());
183    }
184
185    let db_conf = deserialize_db_conf(db_conf_path)?;
186
187    run_migrate_custom(db_conf, options.migration_dir, options.apply_until).await
188}
189
190/// Creates the "last_migration_table" if it doesn't exist already
191///
192/// This table is used to track which migrations have already been applied.
193///
194/// This would be its rorm shape:
195/// ```text
196/// #[derive(Model)
197/// #[rorm(name = "<table_name>")]
198/// struct LastMigrationTable {
199///     #[rorm(id)]
200///     id: i64,
201///
202///     #[rorm(auto_update_time)]
203///     updated_at: OffsetDateTime,
204///
205///     migration_id: i32,
206/// }
207/// ```
208pub async fn create_last_migration_table(
209    tx: &mut Transaction,
210    table_name: &str,
211) -> Result<(), rorm_db::Error> {
212    let db_impl = tx.dialect();
213    let statements = db_impl
214        .create_table(table_name)
215        .add_column(db_impl.create_column(
216            table_name,
217            "id",
218            DbType::Int64,
219            &[Annotation::PrimaryKey, Annotation::AutoIncrement],
220        ))
221        .add_column(db_impl.create_column(
222            table_name,
223            "updated_at",
224            DbType::DateTime,
225            &[Annotation::AutoUpdateTime],
226        ))
227        .add_column(db_impl.create_column(
228            table_name,
229            "migration_id",
230            DbType::Int32,
231            &[Annotation::NotNull],
232        ))
233        .if_not_exists()
234        .build()?;
235
236    for (query_string, bind_params) in statements {
237        tx.execute::<Nothing>(query_string, bind_params).await?;
238    }
239
240    Ok(())
241}