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') ?: [] ); $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('/\/\*.*?\*\//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]); } }