Skip to content
This repository has been archived by the owner on Sep 12, 2022. It is now read-only.

Commit

Permalink
feat: ✨ working first version
Browse files Browse the repository at this point in the history
  • Loading branch information
oscartbeaumont committed Nov 10, 2021
1 parent e868350 commit b172535
Show file tree
Hide file tree
Showing 9 changed files with 368 additions and 2 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# 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
Cargo.lock

# These are backup files generated by rustfmt
**/*.rs.bk

# SQLite Databases
test.db
test.db-shm
test.db-wal
20 changes: 20 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "sea-migrations"
version = "0.0.1"
authors = [ "Oscar Beaumont <oscar@otbeaumont.me>"]
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",
]
62 changes: 60 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,60 @@
# sea-migrations
Effortless database migrations for SeaORM!
<h1 align="center">Sea Migrations</h1>
<div align="center">
<strong>
Effortless database migrations for <a href="https://www.sea-ql.org/SeaORM/">SeaORM</a>!
</strong>
</div>

<br />

<div align="center">
<!-- Crates version -->
<a href="https://crates.io/crates/sea-migrations">
<img src="https://img.shields.io/crates/v/sea-migrations.svg?style=flat-square"
alt="Crates.io version" />
</a>
<!-- Downloads -->
<a href="https://crates.io/crates/sea-migrations">
<img src="https://img.shields.io/crates/d/sea-migrations.svg?style=flat-square"
alt="Download" />
</a>
<!-- docs.rs docs -->
<a href="https://docs.rs/sea-migrations">
<img src="https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square"
alt="docs.rs docs" />
</a>
</div>
<br/>

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).
10 changes: 10 additions & 0 deletions example/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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" ] }
18 changes: 18 additions & 0 deletions example/src/main.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
16 changes: 16 additions & 0 deletions example/src/models/customer.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
38 changes: 38 additions & 0 deletions example/src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -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<u32>,
) -> Result<MigrationStatus, DbErr> {
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())),
}
}
137 changes: 137 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<u32>) -> Result<MigrationStatus, DbErr> {
/// 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<MigrationStatus, DbErr>
where
T: Future<Output = Result<MigrationStatus, DbErr>>,
F: Fn(&'a DbConn, Option<u32>) -> 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<T: 'static>(db: &DbConn, entity: T) -> Result<ExecResult, DbErr>
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
}
63 changes: 63 additions & 0 deletions src/migrations_table.rs
Original file line number Diff line number Diff line change
@@ -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<Option<u32>, 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<u32, DbErr> {
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)
}

0 comments on commit b172535

Please sign in to comment.