425 lines
12 KiB
PHP
425 lines
12 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("0;0660;" . $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);
|
|
$files = array_values(array_filter($files, static function (string $filePath): bool {
|
|
return !isLegacyNumberedMigrationFile($filePath);
|
|
}));
|
|
|
|
usort($files, static function (string $left, string $right): int {
|
|
return strnatcasecmp(migrationSortKey($left), migrationSortKey($right));
|
|
});
|
|
|
|
return $files;
|
|
}
|
|
|
|
function isLegacyNumberedMigrationFile(string $filePath): bool
|
|
{
|
|
// Old packaged builds sometimes carried numeric migrations like 001_*.sql / 002_*.sql
|
|
// from a legacy orders-based schema. This project now uses date-based migrations instead.
|
|
return preg_match('/^\d{1,7}_/', basename($filePath)) === 1;
|
|
}
|
|
|
|
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 tableExists(PDO $pdo, string $tableName): bool
|
|
{
|
|
static $cache = [];
|
|
|
|
$normalized = strtolower($tableName);
|
|
if (array_key_exists($normalized, $cache)) {
|
|
return $cache[$normalized];
|
|
}
|
|
|
|
$stmt = $pdo->prepare('SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table LIMIT 1');
|
|
$stmt->execute(['table' => $tableName]);
|
|
|
|
$cache[$normalized] = (bool) $stmt->fetchColumn();
|
|
return $cache[$normalized];
|
|
}
|
|
|
|
function schemaDefinesTable(string $tableName): bool
|
|
{
|
|
static $tables = null;
|
|
|
|
if ($tables === null) {
|
|
$tables = [];
|
|
$schemaFiles = [
|
|
__DIR__ . '/db/schema.sql',
|
|
__DIR__ . '/complete_schema.sql',
|
|
];
|
|
|
|
foreach ($schemaFiles as $schemaFile) {
|
|
if (!is_file($schemaFile)) {
|
|
continue;
|
|
}
|
|
|
|
$sql = file_get_contents($schemaFile);
|
|
if ($sql === false) {
|
|
continue;
|
|
}
|
|
|
|
if (preg_match_all('/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([a-zA-Z0-9_]+)`?/i', $sql, $matches)) {
|
|
foreach ($matches[1] as $name) {
|
|
$tables[strtolower($name)] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return isset($tables[strtolower($tableName)]);
|
|
}
|
|
|
|
function extractMissingTableName(PDOException $exception): ?string
|
|
{
|
|
$message = $exception->getMessage();
|
|
|
|
if (preg_match("/Table '([^']+)' doesn't exist/i", $message, $matches)) {
|
|
$qualifiedName = str_replace('`', '', $matches[1]);
|
|
$parts = explode('.', $qualifiedName);
|
|
$tableName = trim((string) end($parts));
|
|
return $tableName !== '' ? $tableName : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function statementMentionsTable(string $statement, string $tableName): bool
|
|
{
|
|
$pattern = '/(^|[^a-zA-Z0-9_])`?' . preg_quote($tableName, '/') . '`?([^a-zA-Z0-9_]|$)/i';
|
|
return preg_match($pattern, $statement) === 1;
|
|
}
|
|
|
|
function isLegacyMissingTableError(PDO $pdo, PDOException $exception, string $statement): bool
|
|
{
|
|
$driverCode = isset($exception->errorInfo[1]) ? (int) $exception->errorInfo[1] : null;
|
|
$message = strtolower($exception->getMessage());
|
|
|
|
if ($driverCode !== 1146 && !str_contains($message, 'base table or view not found')) {
|
|
return false;
|
|
}
|
|
|
|
$missingTable = extractMissingTableName($exception);
|
|
if ($missingTable === null || !statementMentionsTable($statement, $missingTable)) {
|
|
return false;
|
|
}
|
|
|
|
if (tableExists($pdo, $missingTable) || schemaDefinesTable($missingTable)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isIgnorableMigrationError(PDO $pdo, PDOException $exception, string $statement): 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;
|
|
}
|
|
}
|
|
|
|
if (isLegacyMissingTableError($pdo, $exception, $statement)) {
|
|
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($pdo, $exception, $statement)) {
|
|
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());
|