2025-09-24 16:44:38 +00:00
< ? php
namespace App\Core ;
2025-11-21 18:44:37 +00:00
require_once __DIR__ . '/NullLogger.php' ;
require_once __DIR__ . '/MigrationException.php' ;
2025-09-24 16:44:38 +00:00
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-11-21 09:00:56 +00:00
private string $driver ;
private bool $isSqlite = false ;
2025-11-21 18:44:37 +00:00
private $logger ;
private array $lastResults = [];
2025-09-24 16:44:38 +00:00
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 } " );
}
2025-11-21 09:00:56 +00:00
$this -> driver = $this -> pdo -> getAttribute ( PDO :: ATTR_DRIVER_NAME );
$this -> isSqlite = ( $this -> driver === 'sqlite' );
2025-09-24 16:44:38 +00:00
$this -> ensureMigrationsTable ();
2025-11-21 09:00:56 +00:00
$this -> ensureMigrationColumns ();
2025-11-21 18:44:37 +00:00
$this -> initializeLogger ();
2025-09-24 16:44:38 +00:00
}
private function ensureMigrationsTable () : void
{
2025-11-21 09:00:56 +00:00
if ( $this -> isSqlite ) {
2025-09-24 18:29:31 +00:00
$sql = " CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
migration TEXT NOT NULL UNIQUE ,
2025-11-21 09:00:56 +00:00
applied_at TEXT NOT NULL ,
content_hash TEXT NULL ,
content TEXT NULL
2025-09-24 18:29:31 +00:00
) " ;
} else {
$sql = " CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY ,
migration VARCHAR ( 255 ) NOT NULL UNIQUE ,
2025-11-21 09:00:56 +00:00
applied_at DATETIME NOT NULL ,
content_hash CHAR ( 64 ) NULL ,
content LONGTEXT NULL
2025-09-24 18:29:31 +00:00
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 " ;
}
$this -> pdo -> exec ( $sql );
2025-09-24 16:44:38 +00:00
}
2025-11-21 09:00:56 +00:00
private function ensureMigrationColumns () : void
{
$this -> ensureColumnExists (
'content_hash' ,
$this -> isSqlite ? " ALTER TABLE migrations ADD COLUMN content_hash TEXT NULL " : " ALTER TABLE migrations ADD COLUMN content_hash CHAR(64) NULL DEFAULT NULL AFTER applied_at "
);
$this -> ensureColumnExists (
'content' ,
$this -> isSqlite ? " ALTER TABLE migrations ADD COLUMN content TEXT NULL " : " ALTER TABLE migrations ADD COLUMN content LONGTEXT NULL DEFAULT NULL AFTER content_hash "
);
2025-11-21 18:44:37 +00:00
$this -> ensureColumnExists (
'result' ,
$this -> isSqlite ? " ALTER TABLE migrations ADD COLUMN result TEXT NULL " : " ALTER TABLE migrations ADD COLUMN result LONGTEXT NULL DEFAULT NULL AFTER content "
);
2025-11-21 09:00:56 +00:00
}
private function ensureColumnExists ( string $column , string $alterSql ) : void
{
if ( $this -> columnExists ( 'migrations' , $column )) {
return ;
}
$this -> pdo -> exec ( $alterSql );
}
private function columnExists ( string $table , string $column ) : bool
{
if ( $this -> isSqlite ) {
$stmt = $this -> pdo -> query ( " PRAGMA table_info( { $table } ) " );
$columns = $stmt ? $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
foreach ( $columns as $col ) {
if (( $col [ 'name' ] ? ? '' ) === $column ) {
return true ;
}
}
return false ;
}
$stmt = $this -> pdo -> prepare ( " SHOW COLUMNS FROM { $table } LIKE :column " );
$stmt -> execute ([ ':column' => $column ]);
return ( bool ) $stmt -> fetch ( PDO :: FETCH_ASSOC );
}
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 ();
2025-11-21 18:44:37 +00:00
$pending = array_values ( array_diff ( $all , $applied ));
return $this -> sortMigrations ( $pending );
2025-09-24 16:44:38 +00:00
}
public function hasPendingMigrations () : bool
{
return count ( $this -> listPendingMigrations ()) > 0 ;
}
public function applyPendingMigrations () : array
2025-11-21 09:03:58 +00:00
{
return $this -> runMigrations ( $this -> listPendingMigrations ());
}
public function applyNextMigration () : array
2025-09-24 16:44:38 +00:00
{
$pending = $this -> listPendingMigrations ();
if ( empty ( $pending )) {
2025-11-21 09:03:58 +00:00
return [];
}
return $this -> runMigrations ([ reset ( $pending )]);
}
2025-11-21 18:44:37 +00:00
public function applyMigrationByName ( string $migration ) : array
{
$pending = $this -> listPendingMigrations ();
if ( ! in_array ( $migration , $pending , true )) {
return [];
}
return $this -> runMigrations ([ $migration ]);
}
2025-11-21 09:03:58 +00:00
private function runMigrations ( array $migrations ) : array
{
$appliedNow = [];
if ( empty ( $migrations )) {
2025-09-24 16:44:38 +00:00
return $appliedNow ;
}
2025-11-21 18:44:37 +00:00
$this -> lastResults = [];
2025-09-24 16:44:38 +00:00
try {
2025-09-24 18:29:31 +00:00
$this -> pdo -> beginTransaction ();
2025-11-21 09:03:58 +00:00
foreach ( $migrations as $migration ) {
2025-11-21 18:44:37 +00:00
try {
$path = $this -> migrationsDir . '/' . $migration ;
$sql = file_get_contents ( $path );
if ( $sql === false ) {
throw new Exception ( " Unable to read migration file: { $migration } " );
}
$trimmedSql = trim ( $sql );
$hash = hash ( 'sha256' , $trimmedSql );
2025-11-21 09:00:56 +00:00
2025-11-21 18:44:37 +00:00
if ( $this -> contentHashExists ( $hash )) {
$this -> recordMigration ( $migration , $trimmedSql , $hash );
$appliedNow [] = $migration ;
2025-11-21 09:00:56 +00:00
continue ;
}
2025-11-21 18:44:37 +00:00
$statements = $this -> splitStatements ( $trimmedSql );
foreach ( $statements as $stmtSql ) {
if ( $stmtSql === '' ) {
continue ;
}
$this -> pdo -> exec ( $stmtSql );
}
$statementCount = count ( $statements );
$resultMessage = sprintf ( 'Migration "%s" applied successfully (%d statement%s).' , $migration , $statementCount , $statementCount === 1 ? '' : 's' );
$this -> lastResults [ $migration ] = [
'content' => $trimmedSql ,
'message' => $resultMessage ,
'is_test' => $this -> isTestMigration ( $migration )
];
if ( $this -> isTestMigration ( $migration )) {
$appliedNow [] = $migration ;
$this -> logger -> log ( 'info' , $resultMessage . ' (test migration)' , [ 'scope' => 'system' , 'migration' => $migration ]);
$this -> cleanupTestMigrationFile ( $migration );
} else {
$this -> recordMigration ( $migration , $trimmedSql , $hash , $resultMessage );
$appliedNow [] = $migration ;
$this -> logger -> log ( 'info' , $resultMessage , [ 'scope' => 'system' , 'migration' => $migration ]);
}
} catch ( Exception $migrationException ) {
throw new MigrationException ( $migration , $migrationException -> getMessage (), $migrationException );
2025-09-24 16:44:38 +00:00
}
}
2025-09-24 18:29:31 +00:00
$this -> pdo -> commit ();
2025-11-21 18:44:37 +00:00
} catch ( MigrationException $e ) {
if ( $this -> pdo -> inTransaction ()) {
$this -> pdo -> rollBack ();
}
$this -> logger -> log ( 'error' , sprintf ( 'Migration "%s" failed: %s' , $e -> getMigration (), $e -> getMessage ()), [ 'scope' => 'system' , 'migration' => $e -> getMigration ()]);
throw $e ;
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
}
2025-11-21 18:44:37 +00:00
$this -> logger -> log ( 'error' , 'Migration run failed: ' . $e -> getMessage (), [ 'scope' => 'system' ]);
2025-09-24 16:44:38 +00:00
throw $e ;
}
return $appliedNow ;
}
2025-11-21 09:00:56 +00:00
private function splitStatements ( string $sql ) : array
{
if ( $sql === '' ) {
return [];
}
return array_filter ( array_map ( 'trim' , preg_split ( '/;\s*\n/' , $sql )));
}
private function contentHashExists ( string $hash ) : bool
{
if ( $hash === '' ) {
return false ;
}
$stmt = $this -> pdo -> prepare ( 'SELECT 1 FROM migrations WHERE content_hash = :hash LIMIT 1' );
$stmt -> execute ([ ':hash' => $hash ]);
return ( bool ) $stmt -> fetchColumn ();
}
2025-11-21 18:44:37 +00:00
private function recordMigration ( string $name , string $content , string $hash , ? string $result = null ) : void
2025-11-21 09:00:56 +00:00
{
$timestampExpr = $this -> isSqlite ? " datetime('now') " : 'NOW()' ;
2025-11-21 18:44:37 +00:00
$sql = " INSERT INTO migrations (migration, applied_at, content_hash, content, result) VALUES (:migration, { $timestampExpr } , :hash, :content, :result) " ;
2025-11-21 09:00:56 +00:00
$stmt = $this -> pdo -> prepare ( $sql );
$stmt -> execute ([
':migration' => $name ,
':hash' => $hash ,
2025-11-21 18:44:37 +00:00
':content' => $content === '' ? null : $content ,
':result' => $result ,
2025-11-21 09:00:56 +00:00
]);
}
2025-11-21 18:44:37 +00:00
private function sortMigrations ( array $items ) : array
{
usort ( $items , static function ( $a , $b ) {
$aTest = strpos ( $a , '_test_migration' ) !== false ;
$bTest = strpos ( $b , '_test_migration' ) !== false ;
if ( $aTest === $bTest ) {
return strcmp ( $a , $b );
}
return $aTest ? - 1 : 1 ;
});
return $items ;
}
private function isTestMigration ( string $migration ) : bool
{
return strpos ( $migration , '_test_migration' ) !== false ;
}
private function cleanupTestMigrationFile ( string $migration ) : void
{
$path = $this -> migrationsDir . '/' . $migration ;
if ( is_file ( $path )) {
@ unlink ( $path );
}
$stmt = $this -> pdo -> prepare ( 'DELETE FROM migrations WHERE migration = :migration' );
$stmt -> execute ([ ':migration' => $migration ]);
}
public function markMigrationApplied ( string $migration , ? string $note = null ) : bool
{
$path = $this -> migrationsDir . '/' . $migration ;
$content = '' ;
if ( is_file ( $path )) {
$fileContent = file_get_contents ( $path );
if ( $fileContent !== false ) {
$content = trim ( $fileContent );
}
}
$hash = $content === '' ? '' : hash ( 'sha256' , $content );
if ( $hash !== '' && $this -> contentHashExists ( $hash )) {
return true ;
}
$result = $note ? ? 'Marked as applied manually.' ;
$this -> recordMigration ( $migration , $content , $hash , $result );
return true ;
}
public function skipMigration ( string $migration ) : bool
{
$source = $this -> migrationsDir . '/' . $migration ;
if ( ! is_file ( $source )) {
return false ;
}
$skippedDir = $this -> migrationsDir . '/skipped' ;
if ( ! is_dir ( $skippedDir )) {
if ( ! mkdir ( $skippedDir , 0775 , true ) && ! is_dir ( $skippedDir )) {
throw new Exception ( 'Unable to create skipped migrations directory.' );
}
}
$destination = $skippedDir . '/' . $migration ;
if ( rename ( $source , $destination )) {
return true ;
}
return false ;
}
private function initializeLogger () : void
{
$logger = $GLOBALS [ 'logObject' ] ? ? null ;
if ( is_object ( $logger ) && method_exists ( $logger , 'log' )) {
$this -> logger = $logger ;
} else {
$this -> logger = new NullLogger ();
}
}
public function getMigrationRecord ( string $migration ) : ? array
{
$stmt = $this -> pdo -> prepare ( 'SELECT migration, applied_at, content, result FROM migrations WHERE migration = :migration LIMIT 1' );
$stmt -> execute ([ ':migration' => $migration ]);
$row = $stmt -> fetch ( PDO :: FETCH_ASSOC );
return $row ? : null ;
}
public function getLastResults () : array
{
return $this -> lastResults ;
}
2025-09-24 16:44:38 +00:00
}