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(<<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());