38471-vm/migrate.php
2026-05-01 18:56:15 +00:00

322 lines
8.9 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 configureMigrationSession(): void
{
if (PHP_SAPI === 'cli') {
return;
}
$sessionsDir = __DIR__ . '/sessions';
if (!is_dir($sessionsDir)) {
@mkdir($sessionsDir, 0777, true);
}
if (is_writable($sessionsDir)) {
session_save_path($sessionsDir);
}
if (
(isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
) {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'None',
]);
}
}
function canRunMigrations(): bool
{
if (PHP_SAPI === 'cli') {
return true;
}
configureMigrationSession();
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
$roleName = (string) ($_SESSION['user_role_name'] ?? '');
if (strcasecmp($roleName, 'Administrator') === 0 || (int) ($_SESSION['user_id'] ?? 0) === 1) {
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 migrationSortKey(string $filePath): string
{
$basename = basename($filePath);
return match ($basename) {
'20260318_add_outlet_id_to_purchases.sql' => '20260318_10_add_outlet_id_to_purchases.sql',
'20260318_create_outlets_table.sql' => '20260318_20_create_outlets_table.sql',
'20260318_multi_outlet_schema.sql' => '20260318_30_multi_outlet_schema.sql',
'20260318_local_definitions.sql' => '20260318_40_local_definitions.sql',
'20260318_user_outlets_table.sql' => '20260318_50_user_outlets_table.sql',
default => $basename,
};
}
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(migrationSortKey($left), migrationSortKey($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',
'duplicate key on write or update',
'errno: 121',
'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());