From b1725357c3395a193ca51c6e52192b362959f737 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 11 Nov 2021 05:00:11 +0800 Subject: [PATCH] feat: :sparkles: working first version --- .gitignore | 6 ++ Cargo.toml | 20 +++++ README.md | 62 ++++++++++++++- example/Cargo.toml | 10 +++ example/src/main.rs | 18 +++++ example/src/models/customer.rs | 16 ++++ example/src/models/mod.rs | 38 +++++++++ src/lib.rs | 137 +++++++++++++++++++++++++++++++++ src/migrations_table.rs | 63 +++++++++++++++ 9 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 Cargo.toml create mode 100644 example/Cargo.toml create mode 100644 example/src/main.rs create mode 100644 example/src/models/customer.rs create mode 100644 example/src/models/mod.rs create mode 100644 src/lib.rs create mode 100644 src/migrations_table.rs diff --git a/.gitignore b/.gitignore index 088ba6b..a389f76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Generated by Cargo # will have compiled files and executables /target/ +/example/target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html @@ -8,3 +9,8 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + +# SQLite Databases +test.db +test.db-shm +test.db-wal \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2eddb22 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "sea-migrations" +version = "0.0.1" +authors = [ "Oscar Beaumont "] +edition = "2021" +description = "Effortless database migrations for SeaORM!" +repository = "https://github.com/oscartbeaumont/sea-migrations" +license = "MIT" + +[dependencies] +sea-orm = { version = "0.3.2", features = [ "mock" ], default-features = false } + +[dev-dependencies] +sea-orm = { version = "0.3.2", features = [ "sqlx-sqlite", "runtime-tokio-native-tls", "macros" ], default-features = false } +tokio = { version = "1.13.0", features = [ "macros", "rt-multi-thread" ] } + +[workspace] +members = [ + "example", +] \ No newline at end of file diff --git a/README.md b/README.md index 12456fd..d1464f0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,60 @@ -# sea-migrations -Effortless database migrations for SeaORM! +

Sea Migrations

+
+ + Effortless database migrations for SeaORM! + +
+ +
+ +
+ + + Crates.io version + + + + Download + + + + docs.rs docs + +
+
+ +This crate aims to provide a simple solution to doing database migrations with [SeaORM](https://www.sea-ql.org/SeaORM/). + +Features: + - Automatically create database tables from your SeaORM entities + - Write your migration code in Rust + - Supports all SeaORM database backends + +## Beta Warning + +This project is in beta and could have major changes to API or behavior in future updates. Below are some issues the project currently has: + +Internal issues: + - Doesn't have unit tests + - Uses unsafe code to access private variables from SeaORM + - If migrations are not all run sequentially (user upgrades by 2 or more versions of the application at once) the migration version will get out of date with the migration code. + +Missing features: + - Creating join tables + - Automatically doing basic migrations such as adding a column based on the SeaORM Entity + +## Install + +Add `sea-migrations` to your dependencies: + +```toml +[dependencies] +# ... +sea-migrations= "0.0.1" +``` +## Usage + +Check out [this example application](https://github.com/oscartbeaumont/sea-migrations/tree/main/example). \ No newline at end of file diff --git a/example/Cargo.toml b/example/Cargo.toml new file mode 100644 index 0000000..e782c46 --- /dev/null +++ b/example/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "sea-migrations-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +sea-orm = { version = "^0", features = [ "sqlx-sqlite", "runtime-tokio-native-tls", "macros", "mock" ], default-features = false } +sea-migrations = { path = "../" } +tokio = { version = "1.13.0", features = [ "macros" ] } diff --git a/example/src/main.rs b/example/src/main.rs new file mode 100644 index 0000000..20bc258 --- /dev/null +++ b/example/src/main.rs @@ -0,0 +1,18 @@ +use sea_migrations::run_migrations; +use sea_orm::Database; + +mod models; + +#[tokio::main] +async fn main() -> Result<(), sea_orm::DbErr> { + let db = Database::connect("sqlite://./test.db?mode=rwc").await?; + + let migrations_result = run_migrations(&db, models::do_migrations).await; + if let Err(e) = migrations_result { + eprintln!("{}", e); + } else { + println!("Migrations successful"); + } + + Ok(()) +} diff --git a/example/src/models/customer.rs b/example/src/models/customer.rs new file mode 100644 index 0000000..e57ba1c --- /dev/null +++ b/example/src/models/customer.rs @@ -0,0 +1,16 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "customer")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + #[sea_orm(column_type = "Text", nullable)] + pub notes: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/example/src/models/mod.rs b/example/src/models/mod.rs new file mode 100644 index 0000000..ba65db1 --- /dev/null +++ b/example/src/models/mod.rs @@ -0,0 +1,38 @@ +use sea_migrations::{create_entity_table, MigrationStatus}; +use sea_orm::{ + sea_query::{Alias, ColumnDef, Table}, + ConnectionTrait, DbConn, DbErr, +}; + +pub mod customer; + +/// do_migrations is the callback for sea-migrations to run the migrations +pub async fn do_migrations( + db: &DbConn, + current_migration_version: Option, +) -> Result { + match current_migration_version { + None => { + println!("Migrating empty DB -> version 1!"); + create_entity_table(db, customer::Entity).await?; + Ok(MigrationStatus::Complete) + } + Some(1) => { + println!("Migrating from version 1 -> 2!"); + let stmt = Table::alter() + .table(customer::Entity) + .add_column( + ColumnDef::new(Alias::new("new_column")) + .integer() + .not_null() + .default(100), + ) + .to_owned(); + db.execute(db.get_database_backend().build(&stmt)).await?; + + Ok(MigrationStatus::Complete) + } + Some(2) => Ok(MigrationStatus::NotRequired), + _ => Err(DbErr::Custom(":(".into())), + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b52f33c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,137 @@ +#![deny(missing_docs)] + +//! Effortless database migrations for [SeaORM](https://www.sea-ql.org/SeaORM/)! +//! +//! Checkout an example using this package [here](https://github.com/oscartbeaumont/sea-migrations/tree/main/example). + +use std::future::Future; + +use sea_orm::{ + sea_query::{ColumnDef, Table}, + ColumnTrait, ColumnType, ConnectionTrait, DbConn, DbErr, EntityTrait, ExecResult, Iterable, + PrimaryKeyToColumn, PrimaryKeyTrait, +}; + +mod migrations_table; + +/// MigrationStatus is used to represent the status of a migration. +#[derive(PartialEq)] +pub enum MigrationStatus { + /// NotRequired is returned when no database migrations are required. If this is returned from the database migrations callback it is assumed by sea-migrations that the database is already up to date. + NotRequired, + /// Complete is returned when a migration has been run successfully. This will cause a new migration event to be added and the migration version to be incremented. + Complete, +} + +/// run_migrations will run the database migrations. It takes in callback function which will be called to do the migrations. +/// +/// ```rust +/// use sea_migrations::{run_migrations, create_entity_table, MigrationStatus}; +/// use sea_orm::{ Database, DbErr, ConnectionTrait, DbConn}; +/// use sea_orm::sea_query::{Alias, ColumnDef, Table}; +/// +/// pub async fn migrations_callback(db: &DbConn, current_migration_version: Option) -> Result { +/// match current_migration_version { +/// None => Ok(MigrationStatus::NotRequired), // Tells sea-migrations that no further migrations are required. +/// _ => Err(DbErr::Custom("Invalid migrations version number!".into())), +/// } +/// } +/// +/// #[tokio::main] +/// async fn main() -> Result<(), sea_orm::DbErr> { +/// let db = Database::connect("sqlite::memory:").await?; +/// let migrations_result = run_migrations(&db, migrations_callback).await?; +/// Ok(()) +/// } +/// +/// ``` +pub async fn run_migrations<'a, T, F>(db: &'a DbConn, handler: F) -> Result +where + T: Future>, + F: Fn(&'a DbConn, Option) -> T, +{ + migrations_table::init(db).await?; + let current_migrations_version = migrations_table::get_latest(db).await?; + let result = handler(db, current_migrations_version).await; + if result == Ok(MigrationStatus::Complete) { + migrations_table::insert_migration(db).await?; + } + + result +} + +// CustomColumnDef is a copy of the struct defined at https://github.com/SeaQL/sea-query/blob/master/src/table/column.rs#L5 with all fields set to public. +// It exists so that the unsafe transmutate operation can be applied to access private fields on the struct. +// This is a TEMPORARY solution and I will ask if these values can be directly exposed by sea_query in the future. This solution relies on internal implementation details of sea_query and unsafe code which is not good! +struct CustomColumnDef { + pub col_type: ColumnType, + pub null: bool, + pub unique: bool, + pub indexed: bool, +} + +/// create_entity_table will automatically create a database table if it does not exist for a sea_query Entity. +/// +/// ```rust +/// use sea_orm::{Database, DbErr, ConnectionTrait, DbConn}; +/// use sea_orm::entity::prelude::*; +/// use sea_migrations::create_entity_table; +/// +/// #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +/// #[sea_orm(table_name = "cake")] +/// pub struct Model { +/// #[sea_orm(primary_key)] +/// pub id: i32, +/// pub name: String, +/// } + +/// #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +/// pub enum Relation {} +/// +/// impl ActiveModelBehavior for ActiveModel {} +/// +/// #[tokio::main] +/// async fn main() -> Result<(), sea_orm::DbErr> { +/// let db = Database::connect("sqlite::memory:").await?; +/// +/// create_entity_table(&db, crate::Entity).await?; // Replace "crate" with the name of the module containing your SeaORM Model. +/// +/// Ok(()) +/// } +/// +/// ``` +pub async fn create_entity_table(db: &DbConn, entity: T) -> Result +where + T: EntityTrait, +{ + let mut stmt = Table::create(); + stmt.table(entity).if_not_exists(); + + for column in T::Column::iter() { + let column_def_prelude: CustomColumnDef = unsafe { std::mem::transmute(column.def()) }; // Note: This is used to access private fields and hence relies on internal implementation details of sea_query and unsafe code which is not good! + let column_def = + &mut ColumnDef::new_with_type(column, column_def_prelude.col_type.clone().into()); + if column_def_prelude.null { + column_def.not_null(); + } + if column_def_prelude.unique { + column_def.unique_key(); + } + if column_def_prelude.indexed { + panic!("Indexed columns are not yet able to be migrated!"); + } + + if let Some(_) = T::PrimaryKey::from_column(column) { + column_def.primary_key(); + + if T::PrimaryKey::auto_increment() && column_def_prelude.col_type == ColumnType::Integer + { + column_def.auto_increment(); + } + } + + stmt.col(column_def); + } + + db.execute(db.get_database_backend().build(&stmt)).await +} diff --git a/src/migrations_table.rs b/src/migrations_table.rs new file mode 100644 index 0000000..126e221 --- /dev/null +++ b/src/migrations_table.rs @@ -0,0 +1,63 @@ +use sea_orm::{ + sea_query::{Alias, ColumnDef, Expr, Query, Table}, + ConnectionTrait, DbConn, DbErr, Value, +}; + +// MIGRATIONS_TABLE_NAME is the name of the table created in the Database to keep track of the current state of the migrations. +const MIGRATIONS_TABLE_NAME: &str = "_sea_migrations"; + +// MIGRATIONS_TABLE_VERSION_COLUMN is the name of the column used to store the version of the migrations within the table used to track to current state of migrations. +const MIGRATIONS_TABLE_VERSION_COLUMN: &str = "version"; + +/// init will create the migrations table in the database if it does not exist. +pub async fn init(db: &DbConn) -> Result<(), DbErr> { + let stmt = Table::create() + .table(Alias::new(MIGRATIONS_TABLE_NAME)) + .if_not_exists() + .col( + ColumnDef::new(Alias::new(MIGRATIONS_TABLE_VERSION_COLUMN)) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .to_owned(); + + db.execute(db.get_database_backend().build(&stmt)).await?; + Ok(()) +} + +/// get_latest will return the version of the latest migration (or None if no migrations have previous been run). +pub async fn get_latest(db: &DbConn) -> Result, DbErr> { + let stmt = Query::select() + .expr(Expr::cust(&format!( + "MAX (`{}`) AS `{0}`", + MIGRATIONS_TABLE_VERSION_COLUMN + ))) + .from(Alias::new(MIGRATIONS_TABLE_NAME)) + .to_owned(); + + let result = db.query_one(db.get_database_backend().build(&stmt)).await?; + if let Some(result) = result { + let latest_migration_version = result.try_get("", MIGRATIONS_TABLE_VERSION_COLUMN); + if let Ok(latest_migration_version) = latest_migration_version { + Ok(Some(latest_migration_version)) + } else { + Ok(None) + } + } else { + Ok(None) + } +} + +/// insert_migration will create a new migration event in the database. +pub async fn insert_migration(db: &DbConn) -> Result { + let stmt = Query::insert() + .into_table(Alias::new(MIGRATIONS_TABLE_NAME)) + .columns(vec![Alias::new(MIGRATIONS_TABLE_VERSION_COLUMN)]) + .values_panic(vec![Value::BigInt(None)]) + .to_owned(); + + let result = db.execute(db.get_database_backend().build(&stmt)).await?; + Ok(result.last_insert_id() as u32) +}