<?php
namespace SHServ\Migrations;
/**
* Manages migration execution and tracking.
*
* Migrations are stored in `server/migrations/` as PHP files named:
* YYYYmmdd_HHMMSS_migration_name.php
*
* Each file must contain a class extending Migration with up() and down().
*
* Applied migrations are tracked in the `migrations` table.
*/
class MigrationsManager {
protected string $migrations_dir;
protected \Fury\Modules\ThinBuilder\ThinBuilder $tb;
protected string $table_name = 'migrations';
public function __construct() {
$this -> migrations_dir = dirname(__DIR__, 2) . '/migrations';
$this -> tb = app() -> thin_builder;
$this -> ensure_migrations_table();
}
/**
* Create the migrations tracking table if it doesn't exist.
*/
protected function ensure_migrations_table(): void {
if ($this -> table_exists($this -> table_name)) {
return;
}
$this -> tb -> create_table($this -> table_name, [
'id' => [
'type' => 'INT',
'length' => 11,
'auto_increment' => true,
'can_be_null' => false,
],
'migration' => [
'type' => 'VARCHAR',
'length' => 255,
'can_be_null' => false,
],
'batch' => [
'type' => 'INT',
'length' => 11,
'default' => 0,
'can_be_null' => false,
],
'applied_at' => [
'type' => 'TIMESTAMP',
'default' => 'CURRENT_TIMESTAMP',
'can_be_null' => false,
],
], 'id', 'InnoDB');
}
/**
* Run all pending migrations.
*
* @return array Applied migration filenames
*/
public function migrate(): array {
$pending = $this -> get_pending_migrations();
if (empty($pending)) {
return [];
}
$batch = $this -> get_next_batch_number();
$applied = [];
foreach ($pending as $file) {
$migration = $this -> load_migration($file);
$migration -> up();
$this -> tb -> insert($this -> table_name, [
'migration' => $file,
'batch' => $batch,
'applied_at' => date('Y-m-d H:i:s'),
]);
$applied[] = $file;
}
return $applied;
}
/**
* Rollback the last batch of migrations.
*
* @param int $steps Number of batches to rollback (default 1)
* @return array Rolled back migration filenames
*/
public function rollback(int $steps = 1): array {
$batches = $this -> tb -> select(
$this -> table_name,
['batch'],
[],
['batch'],
'DESC',
[0, $steps]
);
if (empty($batches)) {
return [];
}
$batch_numbers = array_map(fn($b) => (int) $b['batch'], $batches);
$batch_numbers = array_unique($batch_numbers);
$migrations_to_rollback = $this -> tb -> select(
$this -> table_name,
[],
['batch', 'IN', $batch_numbers],
['id'],
'DESC',
[]
);
$rolled_back = [];
foreach ($migrations_to_rollback as $row) {
$file = $row['migration'];
$migration = $this -> load_migration($file);
$migration -> down();
$this -> tb -> delete($this -> table_name, ['id', '=', $row['id']]);
$rolled_back[] = $file;
}
return $rolled_back;
}
/**
* Show migration status: applied and pending.
*
* @return array ['applied' => [...], 'pending' => [...]]
*/
public function status(): array {
$all_files = $this -> get_migration_files();
$applied_rows = $this -> tb -> select($this -> table_name, [], [], ['id'], 'ASC', []);
$applied_map = array_column($applied_rows, 'batch', 'migration');
$applied = [];
$pending = [];
foreach ($all_files as $file) {
if (isset($applied_map[$file])) {
$applied[] = ['file' => $file, 'batch' => $applied_map[$file]];
} else {
$pending[] = $file;
}
}
return compact('applied', 'pending');
}
/**
* Generate a new migration file template.
*
* @param string $name Snake_case migration name
* @return string Generated filename
*/
public function create(string $name): string {
$timestamp = date('Ymd_His');
$filename = "{$timestamp}_{$name}.php";
$filepath = $this -> migrations_dir . '/' . $filename;
$class_name = $this -> migration_class_name($name);
$template = <<<PHP
<?php
use SHServ\Migrations\Migration;
/**
* Migration: {$name}
*/
class {$class_name} extends Migration {
public function up(): void {
// TODO: implement migration
}
public function down(): void {
// TODO: implement rollback
}
}
PHP;
file_put_contents($filepath, $template);
return $filename;
}
/**
* Get list of migration files sorted by name (chronological).
*
* @return array
*/
protected function get_migration_files(): array {
if (!is_dir($this -> migrations_dir)) {
return [];
}
$files = array_filter(scandir($this -> migrations_dir), function ($f) {
return substr($f, -4) === '.php';
});
sort($files);
return array_values($files);
}
/**
* Get migrations that have not been applied yet.
*
* @return array
*/
protected function get_pending_migrations(): array {
$all = $this -> get_migration_files();
$applied = $this -> tb -> select($this -> table_name, ['migration'], [], ['id'], 'ASC', []);
$applied_names = array_column($applied, 'migration');
return array_values(array_diff($all, $applied_names));
}
/**
* Determine the next batch number.
*
* @return int
*/
protected function get_next_batch_number(): int {
$result = $this -> tb -> select(
$this -> table_name,
['batch'],
[],
['batch'],
'DESC',
[0, 1]
);
if (empty($result)) {
return 1;
}
return (int) $result[0]['batch'] + 1;
}
/**
* Load and instantiate a migration class from file.
*
* @param string $filename
* @return Migration
*/
protected function load_migration(string $filename): Migration {
$filepath = $this -> migrations_dir . '/' . $filename;
if (!file_exists($filepath)) {
throw new \Exception("Migration file not found: {$filepath}");
}
require_once $filepath;
// Extract class name from filename: Ymd_His_name.php => MigrationName
$base = substr($filename, 0, -4);
$parts = explode('_', $base, 3);
if (count($parts) < 3) {
throw new \Exception("Invalid migration filename format: {$filename}");
}
$class_name = $this -> migration_class_name($parts[2]);
if (!class_exists($class_name)) {
throw new \Exception("Migration class {$class_name} not found in {$filename}");
}
$instance = new $class_name();
if (!($instance instanceof Migration)) {
throw new \Exception("Class {$class_name} must extend SHServ\\Migrations\\Migration");
}
return $instance;
}
/**
* Convert snake_case migration name to CamelCase class name.
*
* @param string $name
* @return string
*/
protected function migration_class_name(string $name): string {
return str_replace(' ', '', ucwords(str_replace('_', ' ', $name)));
}
/**
* Check if a table exists.
*
* @param string $tablename
* @return bool
*/
protected function table_exists(string $tablename): bool {
$result = $this -> tb -> query(
"SHOW TABLES LIKE '{$tablename}'",
'fetch',
\PDO::FETCH_NUM
);
return (bool) $result;
}
}