From c38e5ef4a683b8995ef1eff7dffbb57a298c557a Mon Sep 17 00:00:00 2001 From: Yasen Pramatarov Date: Fri, 21 Nov 2025 20:44:37 +0200 Subject: [PATCH] Refactoring the DB migration and Admin Tools functionality --- app/core/MigrationException.php | 21 ++++ app/core/MigrationRunner.php | 183 ++++++++++++++++++++++++++++---- app/pages/admin-tools.php | 56 ++++++++++ app/templates/admin-tools.php | 67 +++++++++--- scripts/migrate.php | 16 +++ 5 files changed, 308 insertions(+), 35 deletions(-) create mode 100644 app/core/MigrationException.php diff --git a/app/core/MigrationException.php b/app/core/MigrationException.php new file mode 100644 index 0000000..1128c01 --- /dev/null +++ b/app/core/MigrationException.php @@ -0,0 +1,21 @@ +migration = $migration; + parent::__construct($message, 0, $previous); + } + + public function getMigration(): string + { + return $this->migration; + } +} diff --git a/app/core/MigrationRunner.php b/app/core/MigrationRunner.php index 201cf9b..8819367 100644 --- a/app/core/MigrationRunner.php +++ b/app/core/MigrationRunner.php @@ -2,6 +2,9 @@ namespace App\Core; +require_once __DIR__ . '/NullLogger.php'; +require_once __DIR__ . '/MigrationException.php'; + use PDO; use Exception; @@ -11,6 +14,8 @@ class MigrationRunner private string $migrationsDir; private string $driver; private bool $isSqlite = false; + private $logger; + private array $lastResults = []; /** * @param mixed $db Either a PDO instance or the application's Database wrapper @@ -39,6 +44,7 @@ class MigrationRunner $this->isSqlite = ($this->driver === 'sqlite'); $this->ensureMigrationsTable(); $this->ensureMigrationColumns(); + $this->initializeLogger(); } private function ensureMigrationsTable(): void @@ -73,6 +79,10 @@ class MigrationRunner 'content', $this->isSqlite ? "ALTER TABLE migrations ADD COLUMN content TEXT NULL" : "ALTER TABLE migrations ADD COLUMN content LONGTEXT NULL DEFAULT NULL AFTER content_hash" ); + $this->ensureColumnExists( + 'result', + $this->isSqlite ? "ALTER TABLE migrations ADD COLUMN result TEXT NULL" : "ALTER TABLE migrations ADD COLUMN result LONGTEXT NULL DEFAULT NULL AFTER content" + ); } private function ensureColumnExists(string $column, string $alterSql): void @@ -118,7 +128,8 @@ class MigrationRunner { $all = $this->listAllMigrations(); $applied = $this->listAppliedMigrations(); - return array_values(array_diff($all, $applied)); + $pending = array_values(array_diff($all, $applied)); + return $this->sortMigrations($pending); } public function hasPendingMigrations(): bool @@ -140,45 +151,80 @@ class MigrationRunner return $this->runMigrations([reset($pending)]); } + public function applyMigrationByName(string $migration): array + { + $pending = $this->listPendingMigrations(); + if (!in_array($migration, $pending, true)) { + return []; + } + return $this->runMigrations([$migration]); + } + private function runMigrations(array $migrations): array { $appliedNow = []; if (empty($migrations)) { return $appliedNow; } + $this->lastResults = []; try { $this->pdo->beginTransaction(); foreach ($migrations as $migration) { - $path = $this->migrationsDir . '/' . $migration; - $sql = file_get_contents($path); - if ($sql === false) { - throw new Exception("Unable to read migration file: {$migration}"); - } - $trimmedSql = trim($sql); - $hash = hash('sha256', $trimmedSql); + try { + $path = $this->migrationsDir . '/' . $migration; + $sql = file_get_contents($path); + if ($sql === false) { + throw new Exception("Unable to read migration file: {$migration}"); + } + $trimmedSql = trim($sql); + $hash = hash('sha256', $trimmedSql); - if ($this->contentHashExists($hash)) { - $this->recordMigration($migration, $trimmedSql, $hash); - $appliedNow[] = $migration; - continue; - } - - $statements = $this->splitStatements($trimmedSql); - foreach ($statements as $stmtSql) { - if ($stmtSql === '') { + if ($this->contentHashExists($hash)) { + $this->recordMigration($migration, $trimmedSql, $hash); + $appliedNow[] = $migration; continue; } - $this->pdo->exec($stmtSql); + + $statements = $this->splitStatements($trimmedSql); + foreach ($statements as $stmtSql) { + if ($stmtSql === '') { + continue; + } + $this->pdo->exec($stmtSql); + } + $statementCount = count($statements); + $resultMessage = sprintf('Migration "%s" applied successfully (%d statement%s).', $migration, $statementCount, $statementCount === 1 ? '' : 's'); + $this->lastResults[$migration] = [ + 'content' => $trimmedSql, + 'message' => $resultMessage, + 'is_test' => $this->isTestMigration($migration) + ]; + if ($this->isTestMigration($migration)) { + $appliedNow[] = $migration; + $this->logger->log('info', $resultMessage . ' (test migration)', ['scope' => 'system', 'migration' => $migration]); + $this->cleanupTestMigrationFile($migration); + } else { + $this->recordMigration($migration, $trimmedSql, $hash, $resultMessage); + $appliedNow[] = $migration; + $this->logger->log('info', $resultMessage, ['scope' => 'system', 'migration' => $migration]); + } + } catch (Exception $migrationException) { + throw new MigrationException($migration, $migrationException->getMessage(), $migrationException); } - $this->recordMigration($migration, $trimmedSql, $hash); - $appliedNow[] = $migration; } $this->pdo->commit(); + } catch (MigrationException $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + $this->logger->log('error', sprintf('Migration "%s" failed: %s', $e->getMigration(), $e->getMessage()), ['scope' => 'system', 'migration' => $e->getMigration()]); + throw $e; } catch (Exception $e) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } + $this->logger->log('error', 'Migration run failed: ' . $e->getMessage(), ['scope' => 'system']); throw $e; } @@ -203,15 +249,106 @@ class MigrationRunner return (bool)$stmt->fetchColumn(); } - private function recordMigration(string $name, string $content, string $hash): void + private function recordMigration(string $name, string $content, string $hash, ?string $result = null): void { $timestampExpr = $this->isSqlite ? "datetime('now')" : 'NOW()'; - $sql = "INSERT INTO migrations (migration, applied_at, content_hash, content) VALUES (:migration, {$timestampExpr}, :hash, :content)"; + $sql = "INSERT INTO migrations (migration, applied_at, content_hash, content, result) VALUES (:migration, {$timestampExpr}, :hash, :content, :result)"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ ':migration' => $name, ':hash' => $hash, - ':content' => $content === '' ? null : $content, + ':content' => $content === '' ? null : $content, + ':result' => $result, ]); } + + private function sortMigrations(array $items): array + { + usort($items, static function ($a, $b) { + $aTest = strpos($a, '_test_migration') !== false; + $bTest = strpos($b, '_test_migration') !== false; + if ($aTest === $bTest) { + return strcmp($a, $b); + } + return $aTest ? -1 : 1; + }); + return $items; + } + + private function isTestMigration(string $migration): bool + { + return strpos($migration, '_test_migration') !== false; + } + + private function cleanupTestMigrationFile(string $migration): void + { + $path = $this->migrationsDir . '/' . $migration; + if (is_file($path)) { + @unlink($path); + } + $stmt = $this->pdo->prepare('DELETE FROM migrations WHERE migration = :migration'); + $stmt->execute([':migration' => $migration]); + } + + public function markMigrationApplied(string $migration, ?string $note = null): bool + { + $path = $this->migrationsDir . '/' . $migration; + $content = ''; + if (is_file($path)) { + $fileContent = file_get_contents($path); + if ($fileContent !== false) { + $content = trim($fileContent); + } + } + $hash = $content === '' ? '' : hash('sha256', $content); + if ($hash !== '' && $this->contentHashExists($hash)) { + return true; + } + + $result = $note ?? 'Marked as applied manually.'; + $this->recordMigration($migration, $content, $hash, $result); + return true; + } + + public function skipMigration(string $migration): bool + { + $source = $this->migrationsDir . '/' . $migration; + if (!is_file($source)) { + return false; + } + $skippedDir = $this->migrationsDir . '/skipped'; + if (!is_dir($skippedDir)) { + if (!mkdir($skippedDir, 0775, true) && !is_dir($skippedDir)) { + throw new Exception('Unable to create skipped migrations directory.'); + } + } + $destination = $skippedDir . '/' . $migration; + if (rename($source, $destination)) { + return true; + } + return false; + } + + private function initializeLogger(): void + { + $logger = $GLOBALS['logObject'] ?? null; + if (is_object($logger) && method_exists($logger, 'log')) { + $this->logger = $logger; + } else { + $this->logger = new NullLogger(); + } + } + + public function getMigrationRecord(string $migration): ?array + { + $stmt = $this->pdo->prepare('SELECT migration, applied_at, content, result FROM migrations WHERE migration = :migration LIMIT 1'); + $stmt->execute([':migration' => $migration]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + public function getLastResults(): array + { + return $this->lastResults; + } } diff --git a/app/pages/admin-tools.php b/app/pages/admin-tools.php index f159235..86bbd3b 100644 --- a/app/pages/admin-tools.php +++ b/app/pages/admin-tools.php @@ -118,6 +118,32 @@ if ($action !== '') { } else { Feedback::flash('NOTICE', 'DEFAULT', 'Applied migrations: ' . implode(', ', $applied), true); } + } elseif ($action === 'migrate_apply_one') { + require_once __DIR__ . '/../core/MigrationRunner.php'; + $migrationsDir = __DIR__ . '/../../doc/database/migrations'; + $runner = new \App\Core\MigrationRunner($db, $migrationsDir); + $migrationName = trim($_POST['migration_name'] ?? ''); + $applied = $migrationName !== '' ? $runner->applyMigrationByName($migrationName) : []; + + if (empty($applied)) { + Feedback::flash('NOTICE', 'DEFAULT', 'No pending migrations.', true); + $_SESSION['migration_modal_result'] = [ + 'name' => $migrationName ?: null, + 'status' => 'info', + 'message' => 'No pending migrations to apply.' + ]; + if (!empty($migrationName)) { + $_SESSION['migration_modal_open'] = $migrationName; + } + } else { + Feedback::flash('NOTICE', 'DEFAULT', 'Applied migration: ' . implode(', ', $applied), true); + $_SESSION['migration_modal_result'] = [ + 'name' => $applied[0], + 'status' => 'success', + 'message' => 'Migration ' . $applied[0] . ' applied successfully.' + ]; + $_SESSION['migration_modal_open'] = $applied[0]; + } } elseif ($action === 'create_test_migration') { $migrationsDir = __DIR__ . '/../../doc/database/migrations'; $timestamp = date('Ymd_His'); @@ -176,13 +202,39 @@ require_once __DIR__ . '/../core/MigrationRunner.php'; $migrationsDir = __DIR__ . '/../../doc/database/migrations'; $pending = []; $applied = []; +$next_pending = null; $migration_contents = []; $test_migrations_exist = false; +$migration_modal_result = $_SESSION['migration_modal_result'] ?? null; +if (isset($_SESSION['migration_modal_result'])) { + unset($_SESSION['migration_modal_result']); +} +$modal_to_open = $_SESSION['migration_modal_open'] ?? null; +if (isset($_SESSION['migration_modal_open'])) { + unset($_SESSION['migration_modal_open']); +} +$migration_records = []; try { $runner = new \App\Core\MigrationRunner($db, $migrationsDir); $pending = $runner->listPendingMigrations(); $applied = $runner->listAppliedMigrations(); + $sortTestFirst = static function (array $items): array { + usort($items, static function ($a, $b) { + $aTest = strpos($a, '_test_migration') !== false; + $bTest = strpos($b, '_test_migration') !== false; + if ($aTest === $bTest) { + return strcmp($a, $b); + } + return $aTest ? -1 : 1; + }); + return $items; + }; + + $pending = $sortTestFirst($pending); + $applied = $sortTestFirst($applied); + $next_pending = $pending[0] ?? null; + // Check if any test migrations exist $test_migrations_exist = !empty(glob($migrationsDir . '/*_test_migration.sql')); @@ -196,6 +248,10 @@ try { $migration_contents[$fname] = $content; } } + $record = $runner->getMigrationRecord($fname); + if ($record) { + $migration_records[$fname] = $record; + } } } catch (Throwable $e) { // show error in the page diff --git a/app/templates/admin-tools.php b/app/templates/admin-tools.php index 1ace70d..ffd37d7 100644 --- a/app/templates/admin-tools.php +++ b/app/templates/admin-tools.php @@ -73,7 +73,12 @@
-
Pending
+
+ Pending + + Next: + +
@@ -84,11 +89,13 @@
  • - +
    + +
  • @@ -105,7 +112,11 @@
    none
      - +
    -
    - - - -
    +
    +
    + + + +
    +
    @@ -145,7 +158,24 @@ + @@ -153,3 +183,16 @@ + + diff --git a/scripts/migrate.php b/scripts/migrate.php index 34ee9b9..212562e 100644 --- a/scripts/migrate.php +++ b/scripts/migrate.php @@ -17,6 +17,7 @@ function printUsage() echo "Usage:\n"; echo " php scripts/migrate.php status # Show pending and applied migrations\n"; echo " php scripts/migrate.php up # Apply all pending migrations\n"; + echo " php scripts/migrate.php next # Apply only the next pending migration\n"; echo "\n"; } @@ -64,6 +65,21 @@ try { $applied = $runner->applyPendingMigrations(); echo "\nApplied successfully: " . count($applied) . "\n"; exit(0); + } elseif ($action === 'next') { + $pending = $runner->listPendingMigrations(); + if (empty($pending)) { + echo "No pending migrations.\n"; + exit(0); + } + $next = reset($pending); + echo "Applying next migration: {$next}\n"; + $applied = $runner->applyNextMigration(); + if (!empty($applied)) { + echo "Done.\n"; + } else { + echo "Nothing applied.\n"; + } + exit(0); } else { printUsage(); exit(1);