274 lines
7.5 KiB
PHP
274 lines
7.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/db/config.php';
|
|
|
|
if (PHP_SAPI !== 'cli') {
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
header('X-Robots-Tag: noindex, nofollow');
|
|
}
|
|
|
|
@set_time_limit(0);
|
|
|
|
function migrationOutput(string $message = ''): void
|
|
{
|
|
echo $message . PHP_EOL;
|
|
}
|
|
|
|
function canRunMigrations(): bool
|
|
{
|
|
if (PHP_SAPI === 'cli') {
|
|
return true;
|
|
}
|
|
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
@session_start();
|
|
}
|
|
|
|
if (($_SESSION['user_role_name'] ?? '') === 'Administrator') {
|
|
return true;
|
|
}
|
|
|
|
$remoteAddress = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
return in_array($remoteAddress, ['127.0.0.1', '::1'], true);
|
|
}
|
|
|
|
function ensureMigrationsTable(PDO $pdo): void
|
|
{
|
|
$pdo->exec(<<<SQL
|
|
CREATE TABLE IF NOT EXISTS migrations (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
migration VARCHAR(255) NOT NULL,
|
|
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
SQL);
|
|
}
|
|
|
|
function getExecutedMigrations(PDO $pdo): array
|
|
{
|
|
$stmt = $pdo->query('SELECT migration FROM migrations');
|
|
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
|
return array_fill_keys($rows, true);
|
|
}
|
|
|
|
function getMigrationFiles(): array
|
|
{
|
|
$sqlFiles = glob(__DIR__ . '/db/migrations/*.sql') ?: [];
|
|
$phpFiles = glob(__DIR__ . '/db/migrations/*.php') ?: [];
|
|
$files = array_merge($sqlFiles, $phpFiles);
|
|
|
|
usort($files, static function (string $left, string $right): int {
|
|
return strnatcasecmp(basename($left), basename($right));
|
|
});
|
|
|
|
return $files;
|
|
}
|
|
|
|
function splitSqlStatements(string $sql): array
|
|
{
|
|
$sql = preg_replace('/^\xEF\xBB\xBF/', '', $sql) ?? $sql;
|
|
$sql = preg_replace('/\/\*.*?\*\//s', '', $sql) ?? $sql;
|
|
|
|
$lines = preg_split('/\R/', $sql) ?: [];
|
|
$filteredLines = [];
|
|
|
|
foreach ($lines as $line) {
|
|
$trimmed = ltrim($line);
|
|
if ($trimmed === '' || str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
|
|
continue;
|
|
}
|
|
$filteredLines[] = $line;
|
|
}
|
|
|
|
$cleanSql = implode("\n", $filteredLines);
|
|
$statements = [];
|
|
$buffer = '';
|
|
$inSingleQuote = false;
|
|
$inDoubleQuote = false;
|
|
$length = strlen($cleanSql);
|
|
|
|
for ($index = 0; $index < $length; $index++) {
|
|
$char = $cleanSql[$index];
|
|
$previous = $index > 0 ? $cleanSql[$index - 1] : '';
|
|
|
|
if ($char === "'" && !$inDoubleQuote && $previous !== '\\') {
|
|
$inSingleQuote = !$inSingleQuote;
|
|
} elseif ($char === '"' && !$inSingleQuote && $previous !== '\\') {
|
|
$inDoubleQuote = !$inDoubleQuote;
|
|
}
|
|
|
|
if ($char === ';' && !$inSingleQuote && !$inDoubleQuote) {
|
|
$statement = trim($buffer);
|
|
if ($statement !== '') {
|
|
$statements[] = $statement;
|
|
}
|
|
$buffer = '';
|
|
continue;
|
|
}
|
|
|
|
$buffer .= $char;
|
|
}
|
|
|
|
$tail = trim($buffer);
|
|
if ($tail !== '') {
|
|
$statements[] = $tail;
|
|
}
|
|
|
|
return $statements;
|
|
}
|
|
|
|
function isIgnorableMigrationError(PDOException $exception): bool
|
|
{
|
|
$driverCode = isset($exception->errorInfo[1]) ? (int) $exception->errorInfo[1] : null;
|
|
$message = strtolower($exception->getMessage());
|
|
$ignorableCodes = [1050, 1060, 1061, 1062, 1091, 1826];
|
|
$ignorableSnippets = [
|
|
'already exists',
|
|
'duplicate column name',
|
|
'duplicate key name',
|
|
'duplicate entry',
|
|
'duplicate foreign key constraint name',
|
|
'check that column/key exists',
|
|
];
|
|
|
|
if ($driverCode !== null && in_array($driverCode, $ignorableCodes, true)) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($ignorableSnippets as $snippet) {
|
|
if (str_contains($message, $snippet)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function executeSqlMigration(PDO $pdo, string $filePath): void
|
|
{
|
|
$sql = file_get_contents($filePath);
|
|
if ($sql === false) {
|
|
throw new RuntimeException('Unable to read SQL migration: ' . basename($filePath));
|
|
}
|
|
|
|
$statements = splitSqlStatements($sql);
|
|
if ($statements === []) {
|
|
migrationOutput(' - no executable SQL statements found');
|
|
return;
|
|
}
|
|
|
|
foreach ($statements as $number => $statement) {
|
|
try {
|
|
$pdo->exec($statement);
|
|
} catch (PDOException $exception) {
|
|
if (isIgnorableMigrationError($exception)) {
|
|
migrationOutput(' - skipped statement ' . ($number + 1) . ': ' . $exception->getMessage());
|
|
continue;
|
|
}
|
|
|
|
throw new RuntimeException(
|
|
'SQL migration failed in ' . basename($filePath) . ' at statement ' . ($number + 1) . ': ' . $exception->getMessage(),
|
|
0,
|
|
$exception
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function executePhpMigration(string $filePath): void
|
|
{
|
|
ob_start();
|
|
|
|
try {
|
|
$result = include $filePath;
|
|
} catch (Throwable $throwable) {
|
|
ob_end_clean();
|
|
throw new RuntimeException('PHP migration failed in ' . basename($filePath) . ': ' . $throwable->getMessage(), 0, $throwable);
|
|
}
|
|
|
|
$output = trim((string) ob_get_clean());
|
|
|
|
if ($result === false) {
|
|
throw new RuntimeException('PHP migration returned false: ' . basename($filePath));
|
|
}
|
|
|
|
if ($output !== '') {
|
|
foreach (preg_split('/\R/', $output) ?: [] as $line) {
|
|
migrationOutput(' ' . $line);
|
|
}
|
|
}
|
|
}
|
|
|
|
function recordMigration(PDO $pdo, string $migrationName): void
|
|
{
|
|
$stmt = $pdo->prepare('INSERT INTO migrations (migration) VALUES (:migration)');
|
|
$stmt->execute(['migration' => $migrationName]);
|
|
}
|
|
|
|
function runAllMigrations(): int
|
|
{
|
|
if (!canRunMigrations()) {
|
|
if (PHP_SAPI !== 'cli') {
|
|
http_response_code(403);
|
|
}
|
|
|
|
migrationOutput('Forbidden: run migrate.php from CLI, localhost, or while logged in as an Administrator.');
|
|
return 1;
|
|
}
|
|
|
|
try {
|
|
$pdo = db();
|
|
ensureMigrationsTable($pdo);
|
|
$executed = getExecutedMigrations($pdo);
|
|
$files = getMigrationFiles();
|
|
|
|
if ($files === []) {
|
|
migrationOutput('No migration files found in db/migrations.');
|
|
return 0;
|
|
}
|
|
|
|
$applied = 0;
|
|
$skipped = 0;
|
|
|
|
migrationOutput('Starting migration run...');
|
|
|
|
foreach ($files as $filePath) {
|
|
$migrationName = basename($filePath);
|
|
|
|
if (isset($executed[$migrationName])) {
|
|
migrationOutput('SKIP ' . $migrationName);
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
migrationOutput('RUN ' . $migrationName);
|
|
$extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION));
|
|
|
|
if ($extension === 'sql') {
|
|
executeSqlMigration($pdo, $filePath);
|
|
} elseif ($extension === 'php') {
|
|
executePhpMigration($filePath);
|
|
} else {
|
|
throw new RuntimeException('Unsupported migration type: ' . $migrationName);
|
|
}
|
|
|
|
recordMigration($pdo, $migrationName);
|
|
$executed[$migrationName] = true;
|
|
$applied++;
|
|
migrationOutput('OK ' . $migrationName);
|
|
}
|
|
|
|
migrationOutput('Done. Applied ' . $applied . ' migration(s), skipped ' . $skipped . ' already executed.');
|
|
return 0;
|
|
} catch (Throwable $throwable) {
|
|
if (PHP_SAPI !== 'cli') {
|
|
http_response_code(500);
|
|
}
|
|
|
|
migrationOutput('ERROR: ' . $throwable->getMessage());
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
exit(runAllMigrations());
|