Adds initial support for DB upgrades/migrations
parent
056388be71
commit
315b68f928
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
|
@ -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;
|
|
@ -183,6 +183,25 @@ if (isset($GLOBALS['user_IP'])) {
|
||||||
$user_IP = $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
|
// CSRF middleware and run pipeline
|
||||||
$pipeline->add(function() {
|
$pipeline->add(function() {
|
||||||
// Initialize security middleware
|
// Initialize security middleware
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
Loading…
Reference in New Issue