458 lines
16 KiB
PHP
458 lines
16 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';
|
|
|
|
$pdo = db();
|
|
$sql = file_get_contents($filePath);
|
|
if ($sql === false) {
|
|
throw new RuntimeException('Unable to read SQL file: ' . basename($filePath));
|
|
}
|
|
|
|
$statements = self::splitSqlStatements($sql);
|
|
foreach ($statements as $index => $statement) {
|
|
if (self::shouldSkipSqlFileStatement($statement, $filePath)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$pdo->exec($statement);
|
|
} catch (PDOException $exception) {
|
|
if (self::isIgnorableMigrationError($pdo, $exception, $statement)) {
|
|
continue;
|
|
}
|
|
|
|
throw new RuntimeException(
|
|
'Failed to execute SQL file ' . basename($filePath) . ' at statement ' . ($index + 1) . ': ' . $exception->getMessage(),
|
|
0,
|
|
$exception
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function shouldSkipSqlFileStatement(string $statement, string $filePath): bool {
|
|
$normalized = strtoupper(trim($statement));
|
|
if ($normalized === '') {
|
|
return true;
|
|
}
|
|
|
|
if (str_starts_with($normalized, 'LOCK TABLES ') || str_starts_with($normalized, 'UNLOCK TABLES')) {
|
|
return true;
|
|
}
|
|
|
|
if (basename($filePath) === 'seed.sql' && preg_match('/^INSERT\s+INTO\s+`?users`?/i', $statement) === 1) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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') ?: []
|
|
);
|
|
|
|
$files = array_values(array_filter($files, static function (string $filePath): bool {
|
|
return !self::isLegacyNumberedMigrationFile($filePath);
|
|
}));
|
|
|
|
usort($files, static function (string $left, string $right): int {
|
|
return strnatcasecmp(self::migrationSortKey($left), self::migrationSortKey($right));
|
|
});
|
|
|
|
return $files;
|
|
}
|
|
|
|
private static 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;
|
|
}
|
|
|
|
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('/\/\*!\d+\s*(.*?)\*\//s', '$1', $sql) ?? $sql;
|
|
$sql = preg_replace('/\/\*(?!\!)(.*?)\*\//s', '', $sql) ?? $sql;
|
|
$sql = str_replace(["\r\n", "\r"], "\n", $sql);
|
|
|
|
// Do not use \R here. In byte mode it treats 0x85 as a newline,
|
|
// which corrupts UTF-8 Arabic seed data (for example حرف م = 0xD9 0x85).
|
|
$lines = explode("\n", $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]);
|
|
}
|
|
}
|