Newer
Older
smart-home-server / server / SHServ / Migrations / MigrationsManager.php
<?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;
	}
}