Current section
Files
Jump to
Current section
Files
src/cigogne/migration.gleam
import cigogne/internal/utils
import gleam/bool
import gleam/int
import gleam/io
import gleam/list
import gleam/order
import gleam/result
import gleam/string
import gleam/time/calendar
import gleam/time/timestamp
const max_name_length = 255
/// Migrations are often generated by reading migration files.
/// However, we allow you to create your own Migrations.
pub type Migration {
Migration(
path: String,
timestamp: timestamp.Timestamp,
name: String,
queries_up: List(String),
queries_down: List(String),
sha256: String,
)
}
/// Errors that can happen when manipulating migrations.
pub type MigrationError {
NameTooLongError(name: String)
NothingToMergeError
MigrationNotFound(fullname: String)
FileHashChanged(fullname: String)
CompoundError(errors: List(MigrationError))
}
/// Create a new migration with the given folder, name and timestamp.
/// The migration will have empty up and down queries and an empty sha256 hash.
pub fn new(
folder: String,
name: String,
timestamp: timestamp.Timestamp,
) -> Result(Migration, MigrationError) {
use name <- result.map(check_name(name))
let new_migration =
Migration(
path: "",
timestamp:,
name:,
queries_up: [],
queries_down: [],
sha256: "",
)
new_migration |> set_folder(folder)
}
/// Set the folder of a migration, updating its path accordingly.
pub fn set_folder(migration: Migration, folder: String) {
Migration(
..migration,
path: folder <> "/" <> to_fullname(migration) <> ".sql",
)
}
/// Check that the provided name is a valid migration name.
pub fn check_name(name: String) -> Result(String, MigrationError) {
case string.length(name) > max_name_length {
True -> Error(NameTooLongError(name))
False -> Ok(name)
}
}
/// Get the full name of a migration, which is its timestamp followed by its name.
pub fn to_fullname(migration: Migration) -> String {
format_timestamp(migration.timestamp) <> "-" <> migration.name
}
fn format_timestamp(timestamp: timestamp.Timestamp) -> String {
let #(date, time) = timestamp |> timestamp.to_calendar(calendar.utc_offset)
let date_str =
[date.year, date.month |> calendar.month_to_int, date.day]
|> list.map(int_to_2_chars_string)
|> string.join("")
let time_str =
[time.hours, time.minutes, time.seconds]
|> list.map(int_to_2_chars_string)
|> string.join("")
date_str <> time_str
}
fn int_to_2_chars_string(n: Int) -> String {
case n {
n if n < 10 -> "0" <> int.to_string(n)
_ -> int.to_string(n)
}
}
/// Compare two migrations.
pub fn compare(migration_a: Migration, migration_b: Migration) -> order.Order {
timestamp.compare(migration_a.timestamp, migration_b.timestamp)
|> order.break_tie(string.compare(migration_a.name, migration_b.name))
}
/// Merge multiple migrations into a single one, concatenating their up and down queries.
/// The resulting migration will have an empty path and sha256 hash.
/// The provided timestamp and name will be used for the resulting migration.
pub fn merge(
migrations: List(Migration),
timestamp: timestamp.Timestamp,
name: String,
) -> Result(Migration, MigrationError) {
use name <- result.try(check_name(name))
use <- bool.guard(list.is_empty(migrations), Error(NothingToMergeError))
let #(ups, downs) = merge_migration_contents(migrations)
let hash = ""
Ok(Migration("", timestamp, name, ups, downs, hash))
}
fn merge_migration_contents(migrations: List(Migration)) {
do_merge_contents(migrations, #([], []))
}
fn do_merge_contents(
migrations: List(Migration),
contents: #(List(String), List(String)),
) -> #(List(String), List(String)) {
case migrations {
[] -> contents
[mig, ..rest] -> {
let migration_name = to_fullname(mig)
let #(ups, downs) = contents
let mig_ups = ["\n--- " <> migration_name, ..mig.queries_up]
let ups = list.append(ups, mig_ups)
let mig_downs = ["\n--- " <> migration_name, ..mig.queries_down]
// Prepending the next migration for the down queries so that rollbacks can be done in the right order
let downs = list.append(mig_downs, downs)
do_merge_contents(rest, #(ups, downs))
}
}
}
/// Find migrations that are in `migrations` but not in `applied`.
pub fn find_unapplied(
migrations: List(Migration),
applied: List(Migration),
) -> List(Migration) {
utils.list_difference(migrations, applied, compare)
}
/// Match a list of migrations usually from the database with a list of migration usually from the filesystem
/// and ensure their hashes match. This returns migrations from the second list as they are usually more detailed.
/// This fails it a migration is not found or if a hash does not match.
pub fn match_migrations(
elements: List(Migration),
matches: List(Migration),
no_hash_check: Bool,
) -> Result(List(Migration), MigrationError) {
let #(zeroes, elements) = list.split_while(elements, is_zero_migration)
let matches = utils.find_matches(elements, matches, compare)
let results = {
use #(migration, match_res) <- list.map(matches)
case match_res {
Ok(match) -> {
case migration.sha256 == match.sha256, no_hash_check {
True, _ -> Ok(match)
False, True -> {
io.println(
"Warning: Hash of file "
<> to_fullname(migration)
<> " has changed, but hash check is disabled.",
)
Ok(match)
}
False, False -> Error(FileHashChanged(migration |> to_fullname()))
}
}
Error(_) -> Error(MigrationNotFound(migration |> to_fullname()))
}
}
utils.get_results_or_errors(results)
|> result.map_error(CompoundError)
|> result.map(fn(matches) { list.append(zeroes, matches) })
}
/// Create a "zero" migration that should be applied before the user's migrations.
pub fn create_zero_migration(
name: String,
queries_up: List(String),
queries_down: List(String),
) -> Migration {
Migration(
"",
utils.epoch(),
name,
queries_up,
queries_down,
utils.make_sha256(
queries_up |> string.join(";") <> queries_down |> string.join(";"),
),
)
}
/// Checks if a migration is a zero migration (has been created with `create_zero_migration`).
pub fn is_zero_migration(migration: Migration) -> Bool {
migration.path == "" && migration.timestamp == utils.epoch()
}
pub fn get_error_message(error: MigrationError) -> String {
case error {
CompoundError(errors:) ->
"Many migration errors happened: \n "
<> errors |> list.map(get_error_message) |> string.join(",\n ")
FileHashChanged(fullname:) ->
"Hash of file "
<> fullname
<> " has changed ! Did you modify the file after applying the migration ?"
MigrationNotFound(fullname:) ->
"Could not find a matching migration file for "
<> fullname
<> ". Check there is a file named "
<> fullname
<> ".sql in your migrations folder."
NameTooLongError(name:) ->
"Name "
<> name
<> "is too long ! Migration names shouldn't exceed 255 characters."
NothingToMergeError -> "Could not merge 0 migrations"
}
}