Refactoring the DB migration and Admin Tools functionality

main
Yasen Pramatarov 2025-11-21 20:44:37 +02:00
parent 4b330dff6c
commit c38e5ef4a6
5 changed files with 308 additions and 35 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace App\Core;
use Exception;
class MigrationException extends Exception
{
private string $migration;
public function __construct(string $migration, string $message, ?Exception $previous = null)
{
$this->migration = $migration;
parent::__construct($message, 0, $previous);
}
public function getMigration(): string
{
return $this->migration;
}
}

View File

@ -2,6 +2,9 @@
namespace App\Core; namespace App\Core;
require_once __DIR__ . '/NullLogger.php';
require_once __DIR__ . '/MigrationException.php';
use PDO; use PDO;
use Exception; use Exception;
@ -11,6 +14,8 @@ class MigrationRunner
private string $migrationsDir; private string $migrationsDir;
private string $driver; private string $driver;
private bool $isSqlite = false; private bool $isSqlite = false;
private $logger;
private array $lastResults = [];
/** /**
* @param mixed $db Either a PDO instance or the application's Database wrapper * @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->isSqlite = ($this->driver === 'sqlite');
$this->ensureMigrationsTable(); $this->ensureMigrationsTable();
$this->ensureMigrationColumns(); $this->ensureMigrationColumns();
$this->initializeLogger();
} }
private function ensureMigrationsTable(): void private function ensureMigrationsTable(): void
@ -73,6 +79,10 @@ class MigrationRunner
'content', '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->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 private function ensureColumnExists(string $column, string $alterSql): void
@ -118,7 +128,8 @@ class MigrationRunner
{ {
$all = $this->listAllMigrations(); $all = $this->listAllMigrations();
$applied = $this->listAppliedMigrations(); $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 public function hasPendingMigrations(): bool
@ -140,16 +151,27 @@ class MigrationRunner
return $this->runMigrations([reset($pending)]); 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 private function runMigrations(array $migrations): array
{ {
$appliedNow = []; $appliedNow = [];
if (empty($migrations)) { if (empty($migrations)) {
return $appliedNow; return $appliedNow;
} }
$this->lastResults = [];
try { try {
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
foreach ($migrations as $migration) { foreach ($migrations as $migration) {
try {
$path = $this->migrationsDir . '/' . $migration; $path = $this->migrationsDir . '/' . $migration;
$sql = file_get_contents($path); $sql = file_get_contents($path);
if ($sql === false) { if ($sql === false) {
@ -171,14 +193,38 @@ class MigrationRunner
} }
$this->pdo->exec($stmtSql); $this->pdo->exec($stmtSql);
} }
$this->recordMigration($migration, $trimmedSql, $hash); $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; $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->pdo->commit(); $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) { } catch (Exception $e) {
if ($this->pdo->inTransaction()) { if ($this->pdo->inTransaction()) {
$this->pdo->rollBack(); $this->pdo->rollBack();
} }
$this->logger->log('error', 'Migration run failed: ' . $e->getMessage(), ['scope' => 'system']);
throw $e; throw $e;
} }
@ -203,15 +249,106 @@ class MigrationRunner
return (bool)$stmt->fetchColumn(); 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()'; $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 = $this->pdo->prepare($sql);
$stmt->execute([ $stmt->execute([
':migration' => $name, ':migration' => $name,
':hash' => $hash, ':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;
}
} }

View File

@ -118,6 +118,32 @@ if ($action !== '') {
} else { } else {
Feedback::flash('NOTICE', 'DEFAULT', 'Applied migrations: ' . implode(', ', $applied), true); 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') { } elseif ($action === 'create_test_migration') {
$migrationsDir = __DIR__ . '/../../doc/database/migrations'; $migrationsDir = __DIR__ . '/../../doc/database/migrations';
$timestamp = date('Ymd_His'); $timestamp = date('Ymd_His');
@ -176,13 +202,39 @@ require_once __DIR__ . '/../core/MigrationRunner.php';
$migrationsDir = __DIR__ . '/../../doc/database/migrations'; $migrationsDir = __DIR__ . '/../../doc/database/migrations';
$pending = []; $pending = [];
$applied = []; $applied = [];
$next_pending = null;
$migration_contents = []; $migration_contents = [];
$test_migrations_exist = false; $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 { try {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir); $runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$pending = $runner->listPendingMigrations(); $pending = $runner->listPendingMigrations();
$applied = $runner->listAppliedMigrations(); $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 // Check if any test migrations exist
$test_migrations_exist = !empty(glob($migrationsDir . '/*_test_migration.sql')); $test_migrations_exist = !empty(glob($migrationsDir . '/*_test_migration.sql'));
@ -196,6 +248,10 @@ try {
$migration_contents[$fname] = $content; $migration_contents[$fname] = $content;
} }
} }
$record = $runner->getMigrationRecord($fname);
if ($record) {
$migration_records[$fname] = $record;
}
} }
} catch (Throwable $e) { } catch (Throwable $e) {
// show error in the page // show error in the page

View File

@ -73,7 +73,12 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div><strong>Pending</strong></div> <div>
<strong>Pending</strong>
<?php if (!empty($next_pending)): ?>
<span class="badge bg-info text-dark ms-2">Next: <?= htmlspecialchars($next_pending) ?></span>
<?php endif; ?>
</div>
<span class="badge <?= empty($pending) ? 'bg-success' : 'bg-warning text-dark' ?>"><?= count($pending) ?></span> <span class="badge <?= empty($pending) ? 'bg-success' : 'bg-warning text-dark' ?>"><?= count($pending) ?></span>
</div> </div>
<div class="mt-2 small border rounded" style="max-height: 240px; overflow: auto;"> <div class="mt-2 small border rounded" style="max-height: 240px; overflow: auto;">
@ -84,11 +89,13 @@
<?php foreach ($pending as $fname): ?> <?php foreach ($pending as $fname): ?>
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-monospace small"><?= htmlspecialchars($fname) ?></span> <span class="text-monospace small"><?= htmlspecialchars($fname) ?></span>
<div class="d-flex gap-2">
<button type="button" <button type="button"
class="btn btn-outline-primary btn-sm" class="btn btn-outline-primary btn-sm"
data-toggle="modal" data-toggle="modal"
data-target="#migrationModal<?= md5($fname) ?>">View data-target="#migrationModal<?= md5($fname) ?>">View
</button> </button>
</div>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
@ -105,7 +112,11 @@
<div class="p-2"><span class="text-muted">none</span></div> <div class="p-2"><span class="text-muted">none</span></div>
<?php else: ?> <?php else: ?>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<?php foreach ($applied as $fname): ?> <?php foreach ($applied as $fname):
if (strpos($fname, '_test_migration') !== false) {
continue;
}
?>
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-monospace small"><?= htmlspecialchars($fname) ?></span> <span class="text-monospace small"><?= htmlspecialchars($fname) ?></span>
<button type="button" <button type="button"
@ -119,16 +130,18 @@
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<form method="post" class="mt-3"> <div class="d-flex gap-2 mt-3">
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_up"> <input type="hidden" name="action" value="migrate_up">
<button type="submit" class="btn btn-primary btn-sm" <?= empty($pending) ? 'disabled' : '' ?>>Apply pending migrations</button> <button type="submit" class="btn btn-primary btn-sm" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Migration viewer modals (one per file) --> <!-- Migration viewer modals (one per file) -->
<?php if (!empty($migration_contents)): <?php if (!empty($migration_contents)):
@ -145,7 +158,24 @@
<div class="modal-body p-0"> <div class="modal-body p-0">
<pre class="mb-0" style="max-height: 60vh; overflow: auto;"><code class="p-3 d-block"><?= htmlspecialchars($content) ?></code></pre> <pre class="mb-0" style="max-height: 60vh; overflow: auto;"><code class="p-3 d-block"><?= htmlspecialchars($content) ?></code></pre>
</div> </div>
<?php
$isModalNext = (!empty($next_pending) && $next_pending === $name);
$modalResult = (!empty($migration_modal_result) && ($migration_modal_result['name'] ?? '') === $name) ? $migration_modal_result : null;
?>
<div class="modal-footer"> <div class="modal-footer">
<?php if ($isModalNext): ?>
<form method="post" class="me-auto tm-confirm" data-confirm="Apply migration <?= htmlspecialchars($name) ?>?">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_apply_one">
<input type="hidden" name="migration_name" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="btn btn-danger">Apply migration</button>
</form>
<?php endif; ?>
<?php if ($modalResult): ?>
<div class="alert alert-<?= $modalResult['status'] === 'success' ? 'success' : 'info' ?> mb-0 small">
<?= htmlspecialchars($modalResult['message']) ?>
</div>
<?php endif; ?>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div> </div>
</div> </div>
@ -153,3 +183,16 @@
</div> </div>
<?php endforeach; <?php endforeach;
endif; ?> endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('form.tm-confirm').forEach(function (form) {
form.addEventListener('submit', function (event) {
const message = form.getAttribute('data-confirm') || 'Are you sure?';
if (!confirm(message)) {
event.preventDefault();
}
});
});
});
</script>

View File

@ -17,6 +17,7 @@ function printUsage()
echo "Usage:\n"; echo "Usage:\n";
echo " php scripts/migrate.php status # Show pending and applied migrations\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 up # Apply all pending migrations\n";
echo " php scripts/migrate.php next # Apply only the next pending migration\n";
echo "\n"; echo "\n";
} }
@ -64,6 +65,21 @@ try {
$applied = $runner->applyPendingMigrations(); $applied = $runner->applyPendingMigrations();
echo "\nApplied successfully: " . count($applied) . "\n"; echo "\nApplied successfully: " . count($applied) . "\n";
exit(0); 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 { } else {
printUsage(); printUsage();
exit(1); exit(1);