Skip to content

Commit

Permalink
✨ validate version order
Browse files Browse the repository at this point in the history
  • Loading branch information
Odonno committed May 13, 2023
1 parent 317ed1b commit 5b79ff0
Show file tree
Hide file tree
Showing 5 changed files with 333 additions and 8 deletions.
32 changes: 32 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ mod constants;
mod definitions;
mod models;
mod surrealdb;
mod validate_version_order;

/// The configuration used to connect to a SurrealDB instance.
pub struct SurrealdbConfiguration {
Expand Down Expand Up @@ -100,6 +101,37 @@ impl SurrealdbMigrations {
SurrealdbMigrations { db_configuration }
}

/// Validate the version order of the migrations so that you cannot run migrations if there are
/// gaps in the migrations history.
///
/// ## Examples
///
/// ```rust,no_run
/// # use anyhow::Result;
/// use surrealdb_migrations::{SurrealdbConfiguration, SurrealdbMigrations};
///
/// # #[tokio::main]
/// # async fn main() -> Result<()> {
/// let db_configuration = SurrealdbConfiguration::default();
/// let runner = SurrealdbMigrations::new(db_configuration);
///
/// runner.validate_version_order().await?;
/// runner.up().await?;
///
/// # Ok(())
/// # }
/// ```
pub async fn validate_version_order(&self) -> Result<()> {
validate_version_order::main(
self.db_configuration.url.clone(),
self.db_configuration.ns.clone(),
self.db_configuration.db.clone(),
self.db_configuration.username.clone(),
self.db_configuration.password.clone(),
)
.await
}

/// Apply schema definitions and apply all migrations.
///
/// ## Examples
Expand Down
180 changes: 180 additions & 0 deletions src/validate_version_order.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};

use anyhow::{anyhow, Context, Result};
use fs_extra::dir::{DirEntryAttr, DirEntryValue, LsResult};

use crate::{config, constants::MIGRATIONS_DIR_NAME, models::ScriptMigration, surrealdb};

pub async fn main(
url: Option<String>,
ns: Option<String>,
db: Option<String>,
username: Option<String>,
password: Option<String>,
) -> Result<()> {
let client = surrealdb::create_surrealdb_client(url, ns, db, username, password).await?;

let migrations_applied =
surrealdb::list_script_migration_ordered_by_execution_date(&client).await?;

let mut config = HashSet::new();
config.insert(DirEntryAttr::Name);
config.insert(DirEntryAttr::Path);
config.insert(DirEntryAttr::IsFile);

let folder_path = config::retrieve_folder_path();
let migrations_dir_path = concat_path(&folder_path, MIGRATIONS_DIR_NAME);

let migrations_files = fs_extra::dir::ls(migrations_dir_path, &config)?;

let migrations_not_applied = get_sorted_migrations_files(&migrations_files)
.into_iter()
.filter(|migration_file| {
is_migration_file_already_applied(migration_file, &migrations_applied).unwrap_or(false)
})
.collect::<Vec<_>>();

let last_migration_applied = migrations_applied.last();

let migrations_not_applied_before_last_applied =
if let Some(last_migration_applied) = last_migration_applied {
migrations_not_applied
.iter()
.filter(|migration_file| {
is_migration_file_before_last_applied(migration_file, &last_migration_applied)
.unwrap_or(false)
})
.collect::<Vec<_>>()
} else {
Vec::new()
};

if migrations_not_applied_before_last_applied.len() > 0 {
let migration_names = migrations_not_applied_before_last_applied
.iter()
.map(|migration_file| get_migration_file_name(migration_file).unwrap_or("".to_string()))
.collect::<Vec<_>>();

Err(anyhow!(
"The following migrations have not been applied: {}",
migration_names.join(", ")
))
} else {
Ok(())
}
}

fn concat_path(folder_path: &Option<String>, dir_name: &str) -> PathBuf {
match folder_path.to_owned() {
Some(folder_path) => Path::new(&folder_path).join(dir_name),
None => Path::new(dir_name).to_path_buf(),
}
}

fn get_sorted_migrations_files(
migrations_files: &LsResult,
) -> Vec<&HashMap<DirEntryAttr, DirEntryValue>> {
let mut sorted_migrations_files = migrations_files.items.iter().collect::<Vec<_>>();
sorted_migrations_files.sort_by(|a, b| {
let a = a.get(&DirEntryAttr::Name);
let b = b.get(&DirEntryAttr::Name);

let a = match a {
Some(DirEntryValue::String(a)) => Some(a),
_ => None,
};

let b = match b {
Some(DirEntryValue::String(b)) => Some(b),
_ => None,
};

a.cmp(&b)
});

sorted_migrations_files
}

fn is_migration_file_already_applied(
migration_file: &&std::collections::HashMap<DirEntryAttr, DirEntryValue>,
migrations_applied: &Vec<ScriptMigration>,
) -> Result<bool> {
let is_file = migration_file
.get(&DirEntryAttr::IsFile)
.context("Cannot detect if the migration file is a file or a folder")?;
let is_file = match is_file {
DirEntryValue::Boolean(is_file) => Some(is_file),
_ => None,
};
let is_file = is_file.context("Cannot detect if the migration file is a file or a folder")?;

if !is_file {
return Ok(false);
}

let name = migration_file
.get(&DirEntryAttr::Name)
.context("Cannot get name of the migration file")?;
let name = match name {
DirEntryValue::String(name) => Some(name),
_ => None,
};
let name = name.context("Cannot get name of the migration file")?;

let has_already_been_applied = migrations_applied
.iter()
.any(|migration_applied| &migration_applied.script_name == name);

if has_already_been_applied {
return Ok(false);
}

return Ok(true);
}

fn is_migration_file_before_last_applied(
migration_file: &&HashMap<DirEntryAttr, DirEntryValue>,
last_migration_applied: &ScriptMigration,
) -> Result<bool> {
let is_file = migration_file
.get(&DirEntryAttr::IsFile)
.context("Cannot detect if the migration file is a file or a folder")?;
let is_file = match is_file {
DirEntryValue::Boolean(is_file) => Some(is_file),
_ => None,
};
let is_file = is_file.context("Cannot detect if the migration file is a file or a folder")?;

if !is_file {
return Ok(false);
}

let name = migration_file
.get(&DirEntryAttr::Name)
.context("Cannot get name of the migration file")?;
let name = match name {
DirEntryValue::String(name) => Some(name),
_ => None,
};
let name = name.context("Cannot get name of the migration file")?;

Ok(name < &last_migration_applied.script_name)
}

fn get_migration_file_name(
migration_file: &&HashMap<DirEntryAttr, DirEntryValue>,
) -> Result<String> {
let name = migration_file
.get(&DirEntryAttr::Name)
.context("Cannot get name of the migration file")?;
let name = match name {
DirEntryValue::String(name) => Some(name),
_ => None,
};
let name = name.context("Cannot get name of the migration file")?;

Ok(name.to_string())
}
22 changes: 14 additions & 8 deletions tests/helpers/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ DEFINE FIELD created_at ON comment TYPE datetime VALUE $before OR time::now();";
}

pub fn get_first_migration_name() -> Result<String> {
let first_migration_file = get_first_migration_file()?;

let first_migration_name = first_migration_file
.file_stem()
.ok_or_else(|| anyhow!("Could not get file stem"))?
.to_str()
.ok_or_else(|| anyhow!("Could not convert file stem to str"))?
.to_owned();

Ok(first_migration_name)
}

pub fn get_first_migration_file() -> Result<PathBuf> {
let migrations_files_dir = std::path::Path::new("tests-files/migrations");

let mut migration_files = fs::read_dir(migrations_files_dir)?
Expand All @@ -187,14 +200,7 @@ pub fn get_first_migration_name() -> Result<String> {
.first()
.ok_or_else(|| anyhow!("No migration files found"))?;

let first_migration_name = first_migration_file
.file_stem()
.ok_or_else(|| anyhow!("Could not get file stem"))?
.to_str()
.ok_or_else(|| anyhow!("Could not convert file stem to str"))?
.to_owned();

Ok(first_migration_name)
Ok(first_migration_file.to_path_buf())
}

pub fn are_folders_equivalent(folder_one: &str, folder_two: &str) -> Result<bool> {
Expand Down
1 change: 1 addition & 0 deletions tests/library/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod list;
mod up;
mod up_to;
mod validate_version_order;
106 changes: 106 additions & 0 deletions tests/library/validate_version_order.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
use anyhow::{ensure, Result};
use regex::Regex;
use serial_test::serial;
use surrealdb_migrations::{SurrealdbConfiguration, SurrealdbMigrations};

use crate::helpers::common::*;

#[tokio::test]
#[serial]
async fn ok_if_no_migration_file() -> Result<()> {
run_with_surreal_instance_async(|| {
Box::pin(async {
clear_files_dir()?;
scaffold_empty_template()?;

let configuration = SurrealdbConfiguration::default();
let runner = SurrealdbMigrations::new(configuration);

runner.validate_version_order().await?;

Ok(())
})
})
.await
}

#[tokio::test]
#[serial]
async fn ok_if_migrations_applied_but_no_new_migration() -> Result<()> {
run_with_surreal_instance_async(|| {
Box::pin(async {
clear_files_dir()?;
scaffold_blog_template()?;

let configuration = SurrealdbConfiguration::default();
let runner = SurrealdbMigrations::new(configuration);

runner.up().await?;

runner.validate_version_order().await?;

Ok(())
})
})
.await
}

#[tokio::test]
#[serial]
async fn ok_if_migrations_applied_with_new_migration_after_last_applied() -> Result<()> {
run_with_surreal_instance_async(|| {
Box::pin(async {
clear_files_dir()?;
scaffold_blog_template()?;

let configuration = SurrealdbConfiguration::default();
let runner = SurrealdbMigrations::new(configuration);

let first_migration_name = get_first_migration_name()?;
runner.up_to(&first_migration_name).await?;

runner.validate_version_order().await?;

Ok(())
})
})
.await
}

#[tokio::test]
#[serial]
async fn fails_if_migrations_applied_with_new_migration_before_last_applied() -> Result<()> {
run_with_surreal_instance_async(|| {
Box::pin(async {
clear_files_dir()?;
scaffold_blog_template()?;

let configuration = SurrealdbConfiguration::default();
let runner = SurrealdbMigrations::new(configuration);

let first_migration_file = get_first_migration_file()?;
std::fs::remove_file(first_migration_file)?;

runner.up().await?;

clear_files_dir()?;
scaffold_blog_template()?;

let result = runner.validate_version_order().await;

ensure!(result.is_err());

let error_regex = Regex::new(
r"The following migrations have not been applied: \d+_\d+_AddAdminUser",
)?;

let error_str = result.unwrap_err().to_string();
let error_str = error_str.as_str();

ensure!(error_regex.is_match(error_str));

Ok(())
})
})
.await
}

0 comments on commit 5b79ff0

Please sign in to comment.