From 01f13723a827b344a08f2d333cc7b38a75b08c3f Mon Sep 17 00:00:00 2001 From: yukkop Date: Thu, 18 Dec 2025 00:45:09 +0000 Subject: [PATCH] feat(`package`): `migrator`: up to latest --- package/migrator/migrator.sh | 181 +++++++++++++++++- .../test/postgresql/help-and-version.sh | 80 ++++++++ .../test/postgresql/migrate-to-latest.sh | 148 ++++++++++++++ .../migrator/test/sqlite/migrate-to-latest.sh | 72 +++++++ 4 files changed, 475 insertions(+), 6 deletions(-) create mode 100644 test/package/migrator/test/postgresql/help-and-version.sh create mode 100644 test/package/migrator/test/postgresql/migrate-to-latest.sh create mode 100644 test/package/migrator/test/sqlite/migrate-to-latest.sh diff --git a/package/migrator/migrator.sh b/package/migrator/migrator.sh index b0fb7c8..6f7a28b 100644 --- a/package/migrator/migrator.sh +++ b/package/migrator/migrator.sh @@ -241,7 +241,7 @@ BEGIN name TEXT PRIMARY KEY, version TEXT NOT NULL, installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); + )$inherits; INSERT INTO hectic.version (name, version) VALUES ('migrator', '$VERSION'); @@ -323,8 +323,139 @@ init_sql() { } help() { - # inherits: List one or more tables the migration table must inherit from - echo help + cat <<'EOF' +migrator - Database Migration Tool + +USAGE: + migrator [OPTIONS] COMMAND [ARGS...] + +DESCRIPTION: + A lightweight database migration tool supporting PostgreSQL and SQLite. + Tracks migrations in a dedicated table and supports bidirectional migrations. + +COMMANDS: + init Initialize migration tables in database + migrate Apply or revert migrations + create Create a new migration file + list List available migrations + fetch Fetch migration status from database + +GLOBAL OPTIONS: + --db-url URL, -u URL + Database connection URL (required for most commands) + PostgreSQL: postgresql://user@host/database + SQLite: sqlite:///path/to/file.db or /path/to/file.db + + --migration-dir DIR, -d DIR + Directory containing migrations (default: ./migration) + + --inherits TABLE (PostgreSQL only) Parent table for hectic.migration + Can be specified multiple times + +MIGRATE SUBCOMMANDS: + up [N] Apply next N migrations (default: 1) + up all Apply all pending migrations (same as: up latest) + down [N] Revert last N migrations (default: 1) + to MIGRATION Migrate to specific migration (forward or backward) + to latest Migrate to the latest migration (aliases: head, last) + +MIGRATE OPTIONS: + --force, -f Force migration despite tree mismatch (not implemented) + --set VAR, -v VAR Set psql variable (PostgreSQL only) + +INIT OPTIONS: + --dry-run Print initialization SQL without executing + +CREATE OPTIONS: + --name NAME, -n NAME + Name for the migration (default: random word) + +LIST OPTIONS: + --raw, -r Output raw migration names without validation + +EXAMPLES: + # Initialize migration tracking + migrator --db-url postgresql://user@localhost/mydb init + + # Create a new migration + migrator create --name add-users-table + + # Apply next migration + migrator -u postgresql://user@localhost/mydb migrate up + + # Apply next 3 migrations + migrator -u postgresql://user@localhost/mydb migrate up 3 + + # Apply all pending migrations + migrator -u postgresql://user@localhost/mydb migrate up all + # or: + migrator -u postgresql://user@localhost/mydb migrate to latest + + # Revert last migration + migrator -u postgresql://user@localhost/mydb migrate down + + # Migrate to specific version + migrator -u postgresql://user@localhost/mydb migrate to 20231201120000-add-users + + # List migrations + migrator list + + # Use SQLite + migrator --db-url sqlite:///path/to/db.sqlite migrate up + + # PostgreSQL with table inheritance + migrator --inherits audit_log --db-url $DB_URL init + +MIGRATION FILE STRUCTURE: + migration/ + └── 20231201120000-migration-name/ + ├── up.sql - Forward migration + └── down.sql - Rollback migration + +MIGRATION NAMING: + Migrations must follow the format: YYYYMMDDHHMMSS-description + Example: 20231201120000-add-users-table + +DATABASE SUPPORT: + PostgreSQL: + - Full schema support (hectic.migration) + - Domains with regex validation + - Triggers and functions + - Table inheritance (--inherits) + - Custom psql variables (--set) + + SQLite: + - Simple table names (hectic_migration, hectic_version) + - CHECK constraints for validation + - Trigger-based version control + - File-based databases + +ENVIRONMENT VARIABLES: + MIGRATION_DIR Default migration directory + DB_URL Default database URL (can be overridden with --db-url) + +EXIT CODES: + 0 Success + 1 Generic error + 2 Ambiguous arguments or unrelated migration tree + 3 Missing required argument + 4 Migration execution failed + 5 Table does not exist (for --inherits) + 9 Invalid argument or command + 13 System/database incompatibility + 127 Required tool not installed (psql or sqlite3) + +VERSION: + 0.0.1 + +AUTHOR: + Created with Nix and POSIX shell + +MORE INFO: + Migration files are executed within transactions. + Failed migrations are automatically rolled back. + Migration hashes are tracked to detect tampering. +EOF } migrate_down() { @@ -372,14 +503,20 @@ migrate_down() { migrate_up() { UP_NUMBER=1 + local apply_all=0 + while [ $# -gt 0 ]; do case $1 in --*|-*) log error "\`migrate up\` argument $WHITE$1$NC does not exists" exit 1 ;; + all|latest|head) + apply_all=1 + shift + ;; ''|*[!0-9]*) - log error "up argument not a number"; + log error "up argument not a number or 'all'"; exit 1; ;; *) @@ -389,6 +526,17 @@ migrate_up() { esac done + # If "all" specified, migrate to the last migration + if [ "$apply_all" -eq 1 ]; then + target_migration=$(printf '%s' "$fs_migrations" | tail -n1) + if [ -z "$target_migration" ]; then + log error "no migrations found" + exit 1 + fi + printf '%s' "$target_migration" + return 0 + fi + # Calculate target migration: current + UP_NUMBER if [ -z "$db_migrations" ]; then target_line=$UP_NUMBER @@ -426,7 +574,17 @@ migrate_to() { done [ "${migration_name+x}" ] || { log error "no migration name specified"; exit 1; } - printf '%s' "$migration_name" + + # Handle special keywords for latest migration + case "$migration_name" in + latest|head|last) + # Return the last migration from filesystem + printf '%s' "$fs_migrations" | tail -n1 + ;; + *) + printf '%s' "$migration_name" + ;; + esac } migration_list() { @@ -839,9 +997,20 @@ check_db_dependencies() { } if ! [ "${AS_LIBRARY+x}" ]; then + # Show help if no arguments + [ $# -eq 0 ] && { help; exit 0; } + while [ $# -gt 0 ]; do log debug "arg: $1" case $1 in + --version|-V) + printf 'migrator version %s\n' "$VERSION" + exit 0 + ;; + help|--help|-h) + help + exit 0 + ;; migrate|create|fetch|list|init) [ "${SUBCOMMAND+x}" ] && { log error "ambiguous subcommand, decide ${WHITE}$SUBCOMMAND ${NC}or ${WHITE}$1"; @@ -864,7 +1033,7 @@ if ! [ "${AS_LIBRARY+x}" ]; then done [ "${INHERITS_LIST+x}" ] && INHERITS_LIST="$(printf '%s' "$INHERITS_LIST" | sed -E 's/"/,/g; s/([^,]+)/"\1"/g')" - [ "${SUBCOMMAND+x}" ] || { log error "no subcomand specified"; exit 1; } + [ "${SUBCOMMAND+x}" ] || { log error "no subcommand specified. Use 'migrator help' for usage information."; exit 1; } log debug "subcommand: $WHITE$SUBCOMMAND" diff --git a/test/package/migrator/test/postgresql/help-and-version.sh b/test/package/migrator/test/postgresql/help-and-version.sh new file mode 100644 index 0000000..e0385f9 --- /dev/null +++ b/test/package/migrator/test/postgresql/help-and-version.sh @@ -0,0 +1,80 @@ +#!/bin/dash + +HECTIC_NAMESPACE=test-help-and-version + +### CASE 1: Help with no arguments +log notice "test case: ${WHITE}help with no arguments" + +output=$(migrator 2>&1) +if ! printf '%s' "$output" | grep -q "migrator - Database Migration Tool"; then + log error "test failed: ${WHITE}no help output when no arguments" + exit 1 +fi + +### CASE 2: Explicit help command +log notice "test case: ${WHITE}explicit help command" + +if ! migrator help | grep -q "USAGE:"; then + log error "test failed: ${WHITE}help command doesn't work" + exit 1 +fi + +### CASE 3: --help flag +log notice "test case: ${WHITE}--help flag" + +if ! migrator --help | grep -q "COMMANDS:"; then + log error "test failed: ${WHITE}--help flag doesn't work" + exit 1 +fi + +### CASE 4: -h flag +log notice "test case: ${WHITE}-h flag" + +if ! migrator -h | grep -q "EXAMPLES:"; then + log error "test failed: ${WHITE}-h flag doesn't work" + exit 1 +fi + +### CASE 5: --version flag +log notice "test case: ${WHITE}--version flag" + +version_output=$(migrator --version) +if ! printf '%s' "$version_output" | grep -q "migrator version"; then + log error "test failed: ${WHITE}--version doesn't show version" + exit 1 +fi + +### CASE 6: -V flag +log notice "test case: ${WHITE}-V flag" + +if ! migrator -V | grep -q "0.0.1"; then + log error "test failed: ${WHITE}-V flag doesn't show version" + exit 1 +fi + +### CASE 7: Help message contains database support info +log notice "test case: ${WHITE}help shows database support" + +help_output=$(migrator help) +if ! printf '%s' "$help_output" | grep -q "PostgreSQL"; then + log error "test failed: ${WHITE}help doesn't mention PostgreSQL" + exit 1 +fi + +if ! printf '%s' "$help_output" | grep -q "SQLite"; then + log error "test failed: ${WHITE}help doesn't mention SQLite" + exit 1 +fi + +### CASE 8: Help mentions key commands +log notice "test case: ${WHITE}help shows all commands" + +for cmd in init migrate create list fetch; do + if ! printf '%s' "$help_output" | grep -qi "$cmd"; then + log error "test failed: ${WHITE}help doesn't mention $cmd command" + exit 1 + fi +done + +log notice "test passed" + diff --git a/test/package/migrator/test/postgresql/migrate-to-latest.sh b/test/package/migrator/test/postgresql/migrate-to-latest.sh new file mode 100644 index 0000000..02fff3c --- /dev/null +++ b/test/package/migrator/test/postgresql/migrate-to-latest.sh @@ -0,0 +1,148 @@ +#!/bin/dash + +HECTIC_NAMESPACE=test-migrate-to-latest + +log notice "test case: ${WHITE}migrate to latest migration" + +# Create initial schema +psql "$DATABASE_URL" -c 'CREATE TABLE articles (id INTEGER PRIMARY KEY)' + +# Initialize migrator +if ! migrator --db-url "$DATABASE_URL" init; then + log error "test failed: ${WHITE}init failed" + exit 1 +fi + +# Create migrations directory with 4 migrations +mkdir -p migration +for i in 1 2 3 4; do + mig_name="2025010100000${i}-migration-${i}" + mkdir -p "migration/${mig_name}" + + echo "ALTER TABLE articles ADD COLUMN col${i} TEXT;" > "migration/${mig_name}/up.sql" + echo "ALTER TABLE articles DROP COLUMN col${i};" > "migration/${mig_name}/down.sql" +done + +### CASE 1: migrate up all +log notice "test case: ${WHITE}migrate up all" + +if ! migrator --db-url "$DATABASE_URL" migrate up all; then + log error "test failed: ${WHITE}migrate up all failed" + exit 1 +fi + +# Verify all 4 migrations were applied +applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL") +if [ "$applied_count" != "4" ]; then + log error "test failed: ${WHITE}expected 4 migrations, got $applied_count" + exit 1 +fi + +# Verify all columns exist +if ! psql -Atc "SELECT col1, col2, col3, col4 FROM articles LIMIT 0" "$DATABASE_URL" >/dev/null 2>&1; then + log error "test failed: ${WHITE}not all columns were added" + exit 1 +fi + +log info "migrate up all: success" + +# Revert all migrations for next test +migrator --db-url "$DATABASE_URL" migrate down 4 + +### CASE 2: migrate to latest +log notice "test case: ${WHITE}migrate to latest" + +if ! migrator --db-url "$DATABASE_URL" migrate to latest; then + log error "test failed: ${WHITE}migrate to latest failed" + exit 1 +fi + +# Verify all 4 migrations were applied +applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL") +if [ "$applied_count" != "4" ]; then + log error "test failed: ${WHITE}expected 4 migrations, got $applied_count" + exit 1 +fi + +log info "migrate to latest: success" + +# Revert for next test +migrator --db-url "$DATABASE_URL" migrate down 4 + +### CASE 3: migrate to head (alias) +log notice "test case: ${WHITE}migrate to head (alias)" + +if ! migrator --db-url "$DATABASE_URL" migrate to head; then + log error "test failed: ${WHITE}migrate to head failed" + exit 1 +fi + +applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL") +if [ "$applied_count" != "4" ]; then + log error "test failed: ${WHITE}expected 4 migrations, got $applied_count" + exit 1 +fi + +log info "migrate to head: success" + +# Revert for next test +migrator --db-url "$DATABASE_URL" migrate down 4 + +### CASE 4: migrate up latest (alias) +log notice "test case: ${WHITE}migrate up latest" + +if ! migrator --db-url "$DATABASE_URL" migrate up latest; then + log error "test failed: ${WHITE}migrate up latest failed" + exit 1 +fi + +applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL") +if [ "$applied_count" != "4" ]; then + log error "test failed: ${WHITE}expected 4 migrations, got $applied_count" + exit 1 +fi + +log info "migrate up latest: success" + +### CASE 5: migrate to latest when already at latest (should be no-op) +log notice "test case: ${WHITE}migrate to latest when already at latest" + +if ! migrator --db-url "$DATABASE_URL" migrate to latest; then + log error "test failed: ${WHITE}migrate to latest (no-op) failed" + exit 1 +fi + +applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL") +if [ "$applied_count" != "4" ]; then + log error "test failed: ${WHITE}expected 4 migrations, got $applied_count" + exit 1 +fi + +log info "migrate to latest (no-op): success" + +### CASE 6: Partial migration then up all +log notice "test case: ${WHITE}partial migration then up all" + +# Revert to first migration only +migrator --db-url "$DATABASE_URL" migrate down 3 + +applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL") +if [ "$applied_count" != "1" ]; then + log error "test failed: ${WHITE}expected 1 migration after down 3, got $applied_count" + exit 1 +fi + +# Now apply all remaining +if ! migrator --db-url "$DATABASE_URL" migrate up all; then + log error "test failed: ${WHITE}migrate up all from partial state failed" + exit 1 +fi + +applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL") +if [ "$applied_count" != "4" ]; then + log error "test failed: ${WHITE}expected 4 migrations after up all, got $applied_count" + exit 1 +fi + +log notice "test passed" + diff --git a/test/package/migrator/test/sqlite/migrate-to-latest.sh b/test/package/migrator/test/sqlite/migrate-to-latest.sh new file mode 100644 index 0000000..af33bc4 --- /dev/null +++ b/test/package/migrator/test/sqlite/migrate-to-latest.sh @@ -0,0 +1,72 @@ +#!/bin/dash + +HECTIC_NAMESPACE=test-sqlite-migrate-to-latest + +log notice "test case: ${WHITE}SQLite migrate to latest migration" + +# Create SQLite database +SQLITE_DB="$PWD/test.db" +export DB_URL="sqlite://$SQLITE_DB" + +# Create initial schema +sqlite3 "$SQLITE_DB" "CREATE TABLE posts (id INTEGER PRIMARY KEY)" + +# Initialize migrator +if ! migrator --db-url "$DB_URL" init; then + log error "test failed: ${WHITE}init failed" + exit 1 +fi + +# Create migrations directory with 3 migrations +mkdir -p migration +for i in 1 2 3; do + mig_name="2025010100000${i}-migration-${i}" + mkdir -p "migration/${mig_name}" + + echo "ALTER TABLE posts ADD COLUMN field${i} TEXT;" > "migration/${mig_name}/up.sql" + # Note: SQLite DROP COLUMN requires table recreation before 3.35.0 + cat > "migration/${mig_name}/down.sql" </dev/null 2>&1; then + log error "test failed: ${WHITE}not all columns were added" + exit 1 +fi + +log info "migrate up all: success" + +### CASE 2: migrate to latest when already at latest +log notice "test case: ${WHITE}migrate to latest when already at latest (SQLite)" + +if ! migrator --db-url "$DB_URL" migrate to latest; then + log error "test failed: ${WHITE}migrate to latest (no-op) failed" + exit 1 +fi + +applied_count=$(sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM hectic_migration") +if [ "$applied_count" != "3" ]; then + log error "test failed: ${WHITE}expected 3 migrations, got $applied_count" + exit 1 +fi + +log notice "test passed" +