38471-vm/includes/DatabaseInstaller.php
2026-05-02 05:05:46 +00:00

423 lines
14 KiB
PHP

<?php
declare(strict_types=1);
class DatabaseInstaller {
public static function isInstalled(): bool {
require_once __DIR__ . '/../db/config.php';
try {
return self::tableExists(db(), 'users');
} catch (PDOException $e) {
return false;
}
}
public static function install(): bool {
require_once __DIR__ . '/../db/config.php';
$pdo = db();
$schemaFile = self::getInstallSchemaFile();
$seedFile = __DIR__ . '/../db/seed.sql';
if (!file_exists($schemaFile)) {
throw new Exception("Schema file not found at $schemaFile");
}
self::executeSqlFile($schemaFile);
if (file_exists($seedFile) && self::shouldImportSeed($pdo)) {
self::executeSqlFile($seedFile);
}
self::seedInstallMigrationBaseline($pdo, $schemaFile);
self::ensureCurrentSchema();
return true;
}
private static function getInstallSchemaFile(): string {
$completeSchemaFile = __DIR__ . '/../complete_schema.sql';
if (file_exists($completeSchemaFile)) {
return $completeSchemaFile;
}
return __DIR__ . '/../db/schema.sql';
}
private static function shouldImportSeed(PDO $pdo): bool {
if (!self::tableExists($pdo, 'migrations')) {
return true;
}
$stmt = $pdo->query('SELECT COUNT(*) FROM migrations');
return (int) $stmt->fetchColumn() === 0;
}
private static function seedInstallMigrationBaseline(PDO $pdo, string $schemaFile): void {
if (!self::shouldSeedInstallMigrationBaseline($schemaFile)) {
return;
}
$baselineMigrations = self::getInstallBaselineMigrations();
if ($baselineMigrations === []) {
return;
}
self::ensureMigrationsTable($pdo);
foreach ($baselineMigrations as $migrationName) {
self::recordMigration($pdo, $migrationName);
}
}
private static function shouldSeedInstallMigrationBaseline(string $schemaFile): bool {
$completeSchemaFile = __DIR__ . '/../complete_schema.sql';
$schemaRealPath = realpath($schemaFile);
$completeSchemaRealPath = realpath($completeSchemaFile);
if ($schemaRealPath === false || $completeSchemaRealPath === false) {
return false;
}
return $schemaRealPath === $completeSchemaRealPath;
}
private static function getInstallBaselineMigrations(): array {
$baselineFile = __DIR__ . '/../db/install_baseline_migrations.php';
if (!is_file($baselineFile)) {
return [];
}
$baseline = require $baselineFile;
if (!is_array($baseline)) {
return [];
}
$normalized = [];
foreach ($baseline as $migrationName) {
if (!is_string($migrationName) || trim($migrationName) === '') {
continue;
}
$normalized[basename($migrationName)] = true;
}
return array_keys($normalized);
}
public static function ensureCurrentSchema(): void {
require_once __DIR__ . '/../db/config.php';
$pdo = db();
if (!self::tableExists($pdo, 'users')) {
return;
}
self::ensureMigrationsTable($pdo);
$executed = self::getExecutedMigrations($pdo);
foreach (self::getMigrationFiles() as $filePath) {
$migrationName = basename($filePath);
if (isset($executed[$migrationName])) {
continue;
}
$extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION));
if ($extension === 'sql') {
self::executeSqlMigration($pdo, $filePath);
} elseif ($extension === 'php') {
self::executePhpMigration($filePath);
} else {
throw new RuntimeException("Unsupported migration type: $migrationName");
}
self::recordMigration($pdo, $migrationName);
$executed[$migrationName] = true;
}
}
private static function executeSqlFile(string $filePath): void {
require_once __DIR__ . '/../db/config.php';
$host = DB_HOST;
$name = DB_NAME;
$user = DB_USER;
$pass = DB_PASS;
$passwordSegment = $pass !== '' ? ' -p' . escapeshellarg($pass) : '';
$command = sprintf(
'mysql -h %s -u %s%s %s < %s',
escapeshellarg($host),
escapeshellarg($user),
$passwordSegment,
escapeshellarg($name),
escapeshellarg($filePath)
);
$output = [];
$returnVar = 0;
exec($command . ' 2>&1', $output, $returnVar);
if ($returnVar !== 0) {
throw new Exception('Failed to execute SQL file: ' . implode("\n", $output));
}
}
private static function ensureMigrationsTable(PDO $pdo): void {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS migrations (\n"
. " id INT AUTO_INCREMENT PRIMARY KEY,\n"
. " migration VARCHAR(255) NOT NULL,\n"
. " executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n"
. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
private static function getExecutedMigrations(PDO $pdo): array {
$stmt = $pdo->query('SELECT migration FROM migrations');
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
return array_fill_keys($rows, true);
}
private static function getMigrationFiles(): array {
$files = array_merge(
glob(__DIR__ . '/../db/migrations/*.sql') ?: [],
glob(__DIR__ . '/../db/migrations/*.php') ?: []
);
usort($files, static function (string $left, string $right): int {
return strnatcasecmp(self::migrationSortKey($left), self::migrationSortKey($right));
});
return $files;
}
private static 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,
};
}
private static 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 = self::splitSqlStatements($sql);
foreach ($statements as $index => $statement) {
try {
$pdo->exec($statement);
} catch (PDOException $exception) {
if (self::isIgnorableMigrationError($pdo, $exception, $statement)) {
continue;
}
throw new RuntimeException(
'SQL migration failed in ' . basename($filePath) . ' at statement ' . ($index + 1) . ': ' . $exception->getMessage(),
0,
$exception
);
}
}
}
private static 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);
}
ob_end_clean();
if ($result === false) {
throw new RuntimeException('PHP migration returned false: ' . basename($filePath));
}
}
private static 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;
}
private static 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];
}
private static 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)]);
}
private static 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;
}
private static 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;
}
private static 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 = self::extractMissingTableName($exception);
if ($missingTable === null || !self::statementMentionsTable($statement, $missingTable)) {
return false;
}
if (self::tableExists($pdo, $missingTable) || self::schemaDefinesTable($missingTable)) {
return false;
}
return true;
}
private static 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;
}
}
return self::isLegacyMissingTableError($pdo, $exception, $statement);
}
private static function recordMigration(PDO $pdo, string $migrationName): void {
$check = $pdo->prepare('SELECT 1 FROM migrations WHERE migration = ? LIMIT 1');
$check->execute([$migrationName]);
if ($check->fetchColumn()) {
return;
}
$stmt = $pdo->prepare('INSERT INTO migrations (migration) VALUES (?)');
$stmt->execute([$migrationName]);
}
}