Packages

Simple pure gleam database agnostic migrations library for gleam

Current section

Files

Jump to
gleager src gleager.gleam
Raw

src/gleager.gleam

//// Gleager is a database migration tool for Gleam.
////
//// It applies and rolls back versioned SQL migrations using a driver
//// abstraction. Any database library that can execute SQL statements and run
//// parameterised queries can be used by implementing the `Driver` type — see
//// the `gleager/types` module.
////
//// Migration files live in a directory and follow this format:
////
//// ```
//// 001_description.sql
//// 002_another.sql
//// ```
////
//// Each file contains annotated up and down sections:
////
//// ```sql
//// -- migrate:up
//// CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
//// -- migrate:down
//// DROP TABLE users;
//// ```
////
//// The version is extracted from the filename prefix (e.g. `001` from
//// `001_description.sql`). Migrations are applied in version order and
//// rolled back in reverse.
////
//// ## Example
////
//// ```gleam
//// import gleager
//// import gleager/types.{Driver}
//// import sqlight
////
//// pub fn main() {
//// sqlight.with_connection("dev.db", fn(conn) {
//// let driver = Driver(
//// migration_dir: "migrations",
//// exec: fn(sql, args) {
//// case args {
//// [] -> sqlight.exec(sql, on: conn)
//// _ -> {
//// let values = list.map(args, sqlight.text)
//// case sqlight.query(sql, on: conn, with: values, expecting: decode.int) {
//// Ok(_) -> Ok(Nil)
//// Error(e) -> Error(e)
//// }
//// }
//// }
//// },
//// query: fn(sql, args, decoder) {
//// let values = list.map(args, sqlight.text)
//// sqlight.query(sql, on: conn, with: values, expecting: decoder)
//// },
//// )
//// let assert Ok(Nil) = gleager.up(driver, steps: None)
//// })
//// }
//// ```
import gleager/internal/database
import gleager/internal/filesystem
import gleager/types.{type Driver, type MigrationError}
import gleam/bool
import gleam/list
import gleam/option.{type Option}
import gleam/result
/// Applies pending migrations from the migration directory in version order.
///
/// Migrations that have already been applied (tracked in a `schema_migrations`
/// table created automatically) are skipped. Each new migration runs inside a
/// transaction: either all its statements succeed and the migration is recorded,
/// or any failure rolls back the entire migration.
///
/// The `steps` parameter limits how many migrations are applied. Pass `None`
/// to apply all pending migrations, or `Some(n)` to apply at most `n`.
///
/// Returns `Ok(Nil)` when all requested migrations have been applied (or if
/// there are none). Returns `Error(MigrationError)` if any migration fails or
/// the migration directory cannot be read.
///
/// ## Examples
///
/// ```gleam
/// // Apply all pending migrations
/// let assert Ok(Nil) = gleager.up(driver, steps: None)
/// ```
///
/// ```gleam
/// // Apply only the next 2 migrations
/// let assert Ok(Nil) = gleager.up(driver, steps: Some(2))
/// ```
///
pub fn up(
driver: Driver(e),
steps steps: Option(Int),
) -> Result(Nil, MigrationError) {
use _ <- result.try(database.create_schema_table(driver.exec(_, [])))
use applied_migrations <- result.try(
database.select_migrations(fn(sql, decoder) {
driver.query(sql, [], decoder)
}),
)
use migration_files <- result.try(
filesystem.find_migrations(driver.migration_dir)
|> result.map(filesystem.make_migrations)
|> result.flatten,
)
let pending = {
use <- bool.guard(list.is_empty(applied_migrations), migration_files)
list.filter(migration_files, fn(migration) {
list.map(applied_migrations, fn(a) { a.version })
|> list.contains(migration.version)
|> bool.negate
})
}
let to_apply = case steps {
option.Some(n) -> list.take(pending, up_to: n)
option.None -> pending
}
use <- bool.guard(list.is_empty(to_apply), Ok(Nil))
to_apply |> list.try_each(database.up(_, driver.exec))
}
/// Rolls back applied migrations in reverse version order.
///
/// Each migration runs its `down` section inside a transaction: either all
/// statements succeed and the migration is removed from the tracking table,
/// or any failure rolls back the entire migration.
///
/// The `steps` parameter limits how many migrations are rolled back. Pass `None`
/// to roll back all applied migrations, or `Some(n)` to roll back at most `n`.
///
/// Returns `Ok(Nil)` when all requested migrations have been rolled back (or
/// if none are applied). Returns `Error(MigrationError)` if any migration
/// fails or the migration directory cannot be read.
///
/// ## Examples
///
/// ```gleam
/// // Roll back all applied migrations
/// let assert Ok(Nil) = gleager.down(driver, steps: None)
/// ```
///
/// ```gleam
/// // Roll back only the last migration
/// let assert Ok(Nil) = gleager.down(driver, steps: Some(1))
/// ```
///
pub fn down(
driver: Driver(e),
steps steps: Option(Int),
) -> Result(Nil, MigrationError) {
use _ <- result.try(database.create_schema_table(driver.exec(_, [])))
use applied_migrations <- result.try(
database.select_migrations(fn(sql, decoder) {
driver.query(sql, [], decoder)
}),
)
use migration_files <- result.try(
filesystem.find_migrations(driver.migration_dir)
|> result.map(filesystem.make_migrations)
|> result.flatten,
)
let pending = {
use <- bool.guard(list.is_empty(applied_migrations), [])
list.filter(migration_files, fn(migration) {
list.map(applied_migrations, fn(a) { a.version })
|> list.contains(migration.version)
})
|> list.reverse
}
let to_remove = case steps {
option.Some(n) -> list.take(pending, up_to: n)
option.None -> pending
}
use <- bool.guard(list.is_empty(to_remove), Ok(Nil))
to_remove |> list.try_each(database.down(_, driver.exec))
}