Adds initial support for DB upgrades/migrations

main
Yasen Pramatarov 2025-09-24 19:44:38 +03:00
parent 056388be71
commit 315b68f928
5 changed files with 253 additions and 0 deletions

View File

@ -0,0 +1,94 @@
<?php
namespace App\Core;
use PDO;
use Exception;
class MigrationRunner
{
private PDO $db;
private string $migrationsDir;
public function __construct(PDO $db, string $migrationsDir)
{
$this->db = $db;
$this->migrationsDir = rtrim($migrationsDir, '/');
if (!is_dir($this->migrationsDir)) {
throw new Exception("Migrations directory not found: {$this->migrationsDir}");
}
$this->ensureMigrationsTable();
}
private function ensureMigrationsTable(): void
{
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$this->db->exec($sql);
}
public function listAllMigrations(): array
{
$files = glob($this->migrationsDir . '/*.sql');
sort($files, SORT_NATURAL);
return array_map('basename', $files);
}
public function listAppliedMigrations(): array
{
$stmt = $this->db->query('SELECT migration FROM migrations ORDER BY migration ASC');
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
public function listPendingMigrations(): array
{
$all = $this->listAllMigrations();
$applied = $this->listAppliedMigrations();
return array_values(array_diff($all, $applied));
}
public function hasPendingMigrations(): bool
{
return count($this->listPendingMigrations()) > 0;
}
public function applyPendingMigrations(): array
{
$pending = $this->listPendingMigrations();
$appliedNow = [];
if (empty($pending)) {
return $appliedNow;
}
try {
$this->db->beginTransaction();
foreach ($pending as $migration) {
$path = $this->migrationsDir . '/' . $migration;
$sql = file_get_contents($path);
if ($sql === false) {
throw new Exception("Unable to read migration file: {$migration}");
}
// Split on ; at line ends, but allow inside procedures? Keep simple for our use-cases
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
foreach ($statements as $stmtSql) {
if ($stmtSql === '') continue;
$this->db->exec($stmtSql);
}
$ins = $this->db->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())');
$ins->execute([':m' => $migration]);
$appliedNow[] = $migration;
}
$this->db->commit();
} catch (Exception $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
return $appliedNow;
}
}

View File

@ -0,0 +1,63 @@
# Database migrations
This app ships with a lightweight SQL migration system to safely upgrade a running site when code changes require database changes.
## Concepts
- Migrations live in `doc/database/migrations/` and are plain `.sql` files.
- They are named in a sortable order, e.g. `YYYYMMDD_HHMMSS_description.sql`.
- Applied migrations are tracked in a DB table called `migrations`.
- Use the CLI script `scripts/migrate.php` to inspect and apply migrations.
## Usage
1. Show current status
```bash
php scripts/migrate.php status
```
2. Apply all pending migrations
```bash
php scripts/migrate.php up
```
3. Typical deployment steps
- Pull new code from git.
- Put the site in maintenance mode (optional, recommended for sensitive changes).
- Run `php scripts/migrate.php status`.
- If there are pending migrations, run `php scripts/migrate.php up`.
- Clear opcache if applicable and resume traffic.
## Authoring new migrations
1. Create a new SQL file in `doc/database/migrations/`, e.g.:
```
doc/database/migrations/20250924_170001_add_user_meta_theme.sql
```
2. Write forward-only SQL. Avoid destructive changes unless absolutely necessary.
3. Prefer idempotent SQL. For MySQL 8.0+ you can use `ADD COLUMN IF NOT EXISTS`. For older MySQL/MariaDB versions, either:
- Check existence in PHP and conditionally run DDL, or
- Write migrations that are safe to run once and tracked by the `migrations` table.
## Notes
- The application checks for pending migrations at runtime and shows a warning banner but will not auto-apply changes.
- The `migrations` table is created automatically by the runner if missing.
- The runner executes each migration inside a single transaction (when supported by the storage engine for the statements used). If any statement fails, the migration batch is rolled back and no migration is marked as applied.
## Example migration
This repo includes an example migration that adds a per-user theme column:
```
20250924_170001_add_user_meta_theme.sql
```
It adds `user_meta.theme` used to store each user's preferred theme.

View File

@ -0,0 +1,3 @@
-- Add user theme preference column to user_meta, if it doesn't exist
ALTER TABLE user_meta
ADD COLUMN IF NOT EXISTS theme VARCHAR(64) NULL DEFAULT NULL AFTER timezone;

View File

@ -183,6 +183,25 @@ if (isset($GLOBALS['user_IP'])) {
$user_IP = $GLOBALS['user_IP'];
}
// Check for pending DB migrations (non-intrusive: warn only)
try {
$migrationsDir = __DIR__ . '/../doc/database/migrations';
if (is_dir($migrationsDir)) {
require_once __DIR__ . '/../app/core/MigrationRunner.php';
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
if ($runner->hasPendingMigrations()) {
$pending = $runner->listPendingMigrations();
$msg = 'Database schema is out of date. Pending migrations: ' . implode(', ', $pending) . '. Run: php scripts/migrate.php up';
// Log and show as a system message
$logObject->log('warning', $msg, ['scope' => 'system']);
Feedback::flash('DB', 'MIGRATIONS_PENDING', $msg, false, true);
}
}
} catch (\Throwable $e) {
// Do not break the app; log only
error_log('Migration check failed: ' . $e->getMessage());
}
// CSRF middleware and run pipeline
$pipeline->add(function() {
// Initialize security middleware

View File

@ -0,0 +1,74 @@
#!/usr/bin/env php
<?php
// Simple CLI to run DB migrations
require_once __DIR__ . '/../app/core/ConfigLoader.php';
require_once __DIR__ . '/../app/core/DatabaseConnector.php';
require_once __DIR__ . '/../app/core/MigrationRunner.php';
use App\Core\ConfigLoader;
use App\Core\DatabaseConnector;
use App\Core\MigrationRunner;
function printUsage()
{
echo "\nJilo Web - Database Migrations\n";
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 "\n";
}
$action = $argv[1] ?? 'status';
try {
// Load configuration to connect to DB
$config = ConfigLoader::loadConfig([
__DIR__ . '/../app/config/jilo-web.conf.php',
__DIR__ . '/../jilo-web.conf.php',
'/srv/jilo-web/jilo-web.conf.php',
'/opt/jilo-web/jilo-web.conf.php',
]);
$db = DatabaseConnector::connect($config);
$migrationsDir = realpath(__DIR__ . '/../doc/database/migrations');
if ($migrationsDir === false) {
fwrite(STDERR, "Migrations directory not found: doc/database/migrations\n");
exit(1);
}
$runner = new MigrationRunner($db, $migrationsDir);
if ($action === 'status') {
$all = $runner->listAllMigrations();
$applied = $runner->listAppliedMigrations();
$pending = $runner->listPendingMigrations();
echo "All migrations (" . count($all) . "):\n";
foreach ($all as $m) echo " - $m\n";
echo "\nApplied (" . count($applied) . "):\n";
foreach ($applied as $m) echo " - $m\n";
echo "\nPending (" . count($pending) . "):\n";
foreach ($pending as $m) echo " - $m\n";
echo "\n";
exit(0);
} elseif ($action === 'up') {
$pending = $runner->listPendingMigrations();
if (empty($pending)) {
echo "No pending migrations.\n";
exit(0);
}
echo "Applying " . count($pending) . " migration(s):\n";
foreach ($pending as $m) echo " - $m\n";
$applied = $runner->applyPendingMigrations();
echo "\nApplied successfully: " . count($applied) . "\n";
exit(0);
} else {
printUsage();
exit(1);
}
} catch (Throwable $e) {
fwrite(STDERR, "Migration error: " . $e->getMessage() . "\n");
exit(1);
}