Current section

Files

Jump to
storch src storch.gleam
Raw

src/storch.gleam

//// Storch is a module to migrate sqlight databases
import filepath
import gleam/bool
import gleam/dynamic
import gleam/int
import gleam/io
import gleam/list
import gleam/regex
import gleam/result
import gleam/string
import simplifile
import sqlight.{type Connection}
/// Migrations with an id and a sql script
///
pub type Migration {
Migration(id: Int, up: String)
}
/// Migration error type
pub type MigrationError {
/// Folder that you gave does not exist
DirectoryNotExist(String)
/// The migration script file name is not valid
InvalidMigrationName(String)
/// The migration script file has a non-integer id
InvalidMigrationId(String)
/// Error starting or comitting the migration transaction
TransactionError(sqlight.Error)
/// Error reading/writing/creating the transactions table
MigrationsTableError(sqlight.Error)
/// Error applying the migrations script
MigrationScriptError(Int, sqlight.Error)
}
fn migration_error(error: MigrationError) -> fn(a) -> MigrationError {
fn(_) { error }
}
/// Pass in a list of migrations and a sqlight connection
///
pub fn migrate(
migrations: List(Migration),
on connection: Connection,
) -> Result(Nil, MigrationError) {
let transaction = {
use _ <- result.try(
sqlight.exec("begin transaction;", connection)
|> result.map_error(TransactionError),
)
use _ <- result.try(
sqlight.exec(
"create table if not exists storch_migrations (id integer, applied integer);",
connection,
)
|> result.map_error(MigrationsTableError),
)
let migrations_decoder = dynamic.tuple2(dynamic.int, sqlight.decode_bool)
let applications =
list.try_each(migrations, fn(migration) {
use migrated <- result.try(
sqlight.query(
"select id, applied from storch_migrations where id = ?;",
on: connection,
with: [sqlight.int(migration.id)],
expecting: migrations_decoder,
)
|> result.map_error(MigrationsTableError),
)
let already_applied = case migrated {
[] -> False
[#(_, applied)] -> applied
_ ->
panic as "Multiple migrations with the same id in the storch migrations table"
}
use <- bool.guard(when: already_applied, return: Ok(Nil))
use _ <- result.try(
sqlight.exec(migration.up, connection)
|> result.map_error(MigrationScriptError(migration.id, _)),
)
use _ <- result.try(
sqlight.query(
"insert into storch_migrations (id, applied) values (?,?) returning *;",
on: connection,
with: [sqlight.int(migration.id), sqlight.bool(True)],
expecting: migrations_decoder,
)
|> result.map_error(MigrationsTableError),
)
Ok(Nil)
})
use _ <- result.try(applications)
use _ <- result.try(
sqlight.exec("commit;", connection) |> result.map_error(TransactionError),
)
Ok(Nil)
}
case transaction {
Ok(_) -> {
Ok(Nil)
}
Error(err) -> {
io.println("error running migration")
io.debug(err)
io.println("rolling back")
let _ = sqlight.exec("rollback;", connection)
Error(err)
}
}
}
/// Get a list of migrations from a folder in the filesystem
/// migration files *must* end in .sql and start with an integer id followed by an underscore
/// example: 0000001_init.sql
///
/// you could store these in the priv directory if you like, that's probably the best way
pub fn get_migrations(
in directory: String,
) -> Result(List(Migration), MigrationError) {
use filenames <- result.try(get_migration_filenames(directory))
use raw_migrations <- result.try(read_migrations(filenames))
list.map(raw_migrations, fn(raw) { Migration(raw.0, raw.1) })
|> list.sort(fn(a, b) { int.compare(a.id, b.id) })
|> Ok
}
fn read_migrations(
scripts paths: List(String),
) -> Result(List(#(Int, String)), MigrationError) {
list.try_map(paths, fn(path) {
let filename = filepath.base_name(path)
use #(id, _) <- result.try(
string.split_once(filename, "_")
|> result.map_error(migration_error(InvalidMigrationName(filename))),
)
use id <- result.try(
int.parse(id) |> result.map_error(migration_error(InvalidMigrationId(id))),
)
let assert Ok(contents) = simplifile.read(path)
Ok(#(id, contents))
})
}
fn get_migration_filenames(
in directory: String,
) -> Result(List(String), MigrationError) {
use is_dir <- result.try(
simplifile.is_directory(directory)
|> result.map_error(migration_error(DirectoryNotExist(directory))),
)
use <- bool.guard(when: !is_dir, return: Error(DirectoryNotExist(directory)))
use filenames_raw <- result.try(
simplifile.get_files(directory)
|> result.map_error(migration_error(DirectoryNotExist(directory))),
)
list.map(filenames_raw, fn(path) {
use extension <- result.try(filepath.extension(path))
let base_path = filepath.directory_name(path)
let filename = filepath.base_name(path) |> filepath.strip_extension
use <- bool.guard(when: extension != "sql", return: Error(Nil))
use <- bool.guard(when: base_path != directory, return: Error(Nil))
use #(numbers, _) <- result.try(string.split_once(filename, "_"))
use regex <- result.try(regex.from_string("^[0-9]+$") |> result.nil_error)
use <- bool.guard(when: !regex.check(regex, numbers), return: Error(Nil))
Ok(path)
})
|> result.values
|> Ok
}