| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  | <?php | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | namespace App\Core; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | use PDO; | 
					
						
							|  |  |  | use Exception; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | class MigrationRunner | 
					
						
							|  |  |  | { | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |     private PDO $pdo; | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |     private string $migrationsDir; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |     /** | 
					
						
							|  |  |  |      * @param mixed $db Either a PDO instance or the application's Database wrapper | 
					
						
							|  |  |  |      * @param string $migrationsDir Directory containing .sql migrations | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     public function __construct($db, string $migrationsDir) | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |     { | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |         // Normalize to PDO
 | 
					
						
							|  |  |  |         if ($db instanceof PDO) { | 
					
						
							|  |  |  |             $this->pdo = $db; | 
					
						
							|  |  |  |         } elseif (is_object($db) && method_exists($db, 'getConnection')) { | 
					
						
							|  |  |  |             $pdo = $db->getConnection(); | 
					
						
							|  |  |  |             if (!$pdo instanceof PDO) { | 
					
						
							|  |  |  |                 throw new Exception('Database wrapper did not return a PDO instance'); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |             $this->pdo = $pdo; | 
					
						
							|  |  |  |         } else { | 
					
						
							|  |  |  |             $type = is_object($db) ? get_class($db) : gettype($db); | 
					
						
							|  |  |  |             throw new Exception("Unsupported database type: {$type}"); | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |         $this->migrationsDir = rtrim($migrationsDir, '/'); | 
					
						
							|  |  |  |         if (!is_dir($this->migrationsDir)) { | 
					
						
							|  |  |  |             throw new Exception("Migrations directory not found: {$this->migrationsDir}"); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         $this->ensureMigrationsTable(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     private function ensureMigrationsTable(): void | 
					
						
							|  |  |  |     { | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |         $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); | 
					
						
							|  |  |  |         if ($driver === 'sqlite') { | 
					
						
							|  |  |  |             $sql = "CREATE TABLE IF NOT EXISTS migrations (
 | 
					
						
							|  |  |  |                 id INTEGER PRIMARY KEY AUTOINCREMENT, | 
					
						
							|  |  |  |                 migration TEXT NOT NULL UNIQUE, | 
					
						
							|  |  |  |                 applied_at TEXT NOT NULL | 
					
						
							|  |  |  |             )";
 | 
					
						
							|  |  |  |         } else { | 
					
						
							|  |  |  |             $sql = "CREATE TABLE IF NOT EXISTS migrations (
 | 
					
						
							|  |  |  |                 id INT AUTO_INCREMENT PRIMARY KEY, | 
					
						
							|  |  |  |                 migration VARCHAR(255) NOT NULL UNIQUE, | 
					
						
							|  |  |  |                 applied_at DATETIME NOT NULL | 
					
						
							|  |  |  |             ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
 | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         $this->pdo->exec($sql); | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function listAllMigrations(): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $files = glob($this->migrationsDir . '/*.sql'); | 
					
						
							|  |  |  |         sort($files, SORT_NATURAL); | 
					
						
							|  |  |  |         return array_map('basename', $files); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function listAppliedMigrations(): array | 
					
						
							|  |  |  |     { | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |         $stmt = $this->pdo->query('SELECT migration FROM migrations ORDER BY migration ASC'); | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |         return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function listPendingMigrations(): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $all = $this->listAllMigrations(); | 
					
						
							|  |  |  |         $applied = $this->listAppliedMigrations(); | 
					
						
							|  |  |  |         return array_values(array_diff($all, $applied)); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function hasPendingMigrations(): bool | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         return count($this->listPendingMigrations()) > 0; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function applyPendingMigrations(): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $pending = $this->listPendingMigrations(); | 
					
						
							|  |  |  |         $appliedNow = []; | 
					
						
							|  |  |  |         if (empty($pending)) { | 
					
						
							|  |  |  |             return $appliedNow; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |             $this->pdo->beginTransaction(); | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |             foreach ($pending as $migration) { | 
					
						
							|  |  |  |                 $path = $this->migrationsDir . '/' . $migration; | 
					
						
							|  |  |  |                 $sql = file_get_contents($path); | 
					
						
							|  |  |  |                 if ($sql === false) { | 
					
						
							|  |  |  |                     throw new Exception("Unable to read migration file: {$migration}"); | 
					
						
							|  |  |  |                 } | 
					
						
							|  |  |  |                 // Split on ; at line ends, but allow inside procedures? Keep simple for our use-cases
 | 
					
						
							|  |  |  |                 $statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql))); | 
					
						
							|  |  |  |                 foreach ($statements as $stmtSql) { | 
					
						
							|  |  |  |                     if ($stmtSql === '') continue; | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |                     $this->pdo->exec($stmtSql); | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |                 } | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |                 $ins = $this->pdo->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())'); | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |                 $ins->execute([':m' => $migration]); | 
					
						
							|  |  |  |                 $appliedNow[] = $migration; | 
					
						
							|  |  |  |             } | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |             $this->pdo->commit(); | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |         } catch (Exception $e) { | 
					
						
							| 
									
										
										
										
											2025-09-24 18:29:31 +00:00
										 |  |  |             if ($this->pdo->inTransaction()) { | 
					
						
							|  |  |  |                 $this->pdo->rollBack(); | 
					
						
							| 
									
										
										
										
											2025-09-24 16:44:38 +00:00
										 |  |  |             } | 
					
						
							|  |  |  |             throw $e; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $appliedNow; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } |