$config From .runway-config.json */ public function __construct(array $config) { parent::__construct('migrate', 'Apply pending SQL migrations from migrations', $config); } public function execute(): void { $io = $this->io(); $projectRoot = getcwd(); if ($projectRoot === false) { $io->error('Unable to determine project root (getcwd failed).', true); return; } try { $appConfig = $this->loadAppConfig($projectRoot); $db = DBFactory::create($appConfig); } catch (\Throwable $e) { $io->error($e->getMessage(), true); return; } $migrationsDir = $projectRoot . DIRECTORY_SEPARATOR . 'migrations'; if (!is_dir($migrationsDir)) { $io->error('migrations/ directory not found.', true); return; } $driver = strtolower((string) $appConfig->get('database.driver', 'sqlite')); $this->ensureMigrationsTable($db, $driver); $applied = $this->getAppliedNames($db); $files = $this->listMigrationFiles($migrationsDir, $driver); $pending = 0; foreach ($files as $file) { $name = basename($file); if (isset($applied[$name])) { continue; } $pending++; $sql = file_get_contents($file); if ($sql === false || trim($sql) === '') { $io->error("Skipping empty or unreadable migration: {$name}", true); continue; } $io->info("Applying {$name}...", true); try { $this->applyMigration($db, $name, $sql); $io->ok(" applied {$name}", true); } catch (PDOException $e) { $io->error(" failed {$name}: " . $e->getMessage(), true); return; } } if ($pending === 0) { $io->info('No pending migrations.', true); return; } $io->ok("Done. Applied {$pending} migration(s).", true); } /** * Pick migration files for the active driver. * * - sqlite (default): *.sql excluding *.mysql.sql * - mysql: *.mysql.sql only * * @return array Absolute paths, sorted */ public static function listMigrationFiles(string $migrationsDir, string $driver): array { $driver = strtolower($driver); $all = glob($migrationsDir . DIRECTORY_SEPARATOR . '*.sql'); if ($all === false) { return []; } $files = []; foreach ($all as $file) { $name = basename($file); $isMysql = self::isMysqlMigrationName($name); if ($driver === 'mysql') { if ($isMysql) { $files[] = $file; } continue; } // sqlite and any other non-mysql default: plain .sql only if (!$isMysql) { $files[] = $file; } } sort($files, SORT_STRING); return $files; } public static function isMysqlMigrationName(string $basename): bool { return (bool) preg_match('/\.mysql\.sql$/', $basename); } private function loadAppConfig(string $projectRoot): Config { Env::load($projectRoot . DIRECTORY_SEPARATOR . '.env'); $configFile = $projectRoot . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.php'; if (!is_file($configFile)) { throw new \RuntimeException( 'app/config/config.php not found. Copy config_sample.php first.' ); } $fileConfig = require $configFile; if (!is_array($fileConfig)) { throw new \RuntimeException('config.php must return an array.'); } $merged = Config::mergeEnv($fileConfig, $_ENV); return new Config($merged); } private function ensureMigrationsTable(SimplePdo $db, string $driver): void { if ($driver === 'mysql') { $db->exec( 'CREATE TABLE IF NOT EXISTS `_migrations` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(255) NOT NULL, `applied_at` VARCHAR(64) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_migrations_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); return; } $db->exec( 'CREATE TABLE IF NOT EXISTS _migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, applied_at TEXT NOT NULL )' ); } /** * @return array */ private function getAppliedNames(SimplePdo $db): array { $rows = $db->fetchAll('SELECT name FROM _migrations ORDER BY name ASC'); $map = []; foreach ($rows as $row) { // SimplePdo::fetchAll returns Collection (ArrayAccess) or array rows $name = $row['name'] ?? null; if ($name !== null && $name !== '') { $map[(string) $name] = true; } } return $map; } private function applyMigration(SimplePdo $db, string $name, string $sql): void { $driver = $db->getAttribute(\PDO::ATTR_DRIVER_NAME); $useTransaction = in_array($driver, ['sqlite', 'mysql', 'pgsql'], true); if ($useTransaction) { $db->beginTransaction(); } try { // Split on semicolons at end of lines for multi-statement files $statements = $this->splitSql($sql); foreach ($statements as $statement) { $db->exec($statement); } $stmt = $db->prepare( 'INSERT INTO _migrations (name, applied_at) VALUES (?, ?)' ); $stmt->execute([$name, date('c')]); if ($useTransaction && $db->inTransaction()) { $db->commit(); } } catch (\Throwable $e) { if ($useTransaction && $db->inTransaction()) { $db->rollBack(); } throw $e; } } /** * @return array */ private function splitSql(string $sql): array { // Strip line comments $lines = preg_split('/\R/', $sql); $cleaned = []; if ($lines !== false) { foreach ($lines as $line) { $trim = ltrim($line); if (strpos($trim, '--') === 0) { continue; } $cleaned[] = $line; } } $body = implode("\n", $cleaned); $parts = preg_split('/;\s*[\r\n]+/', $body); $out = []; if ($parts !== false) { foreach ($parts as $part) { $part = trim($part); if ($part !== '') { $out[] = $part; } } } return $out; } }