//// 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)) }