*/ private array $migrations = []; /** * The preparations scripts array. * @var string[] */ private array $preparations = []; /** * Migration start event. * @var callable(Migration): void|null */ public $onMigrationStart = null; /** * Migration end event. * @var callable(Migration): void|null */ public $onMigrationEnd = null; /** * Rollback start event. * @var callable(Migration): void|null */ public $onRollbackStart = null; /** * Rollback end event. * @var callable(Migration): void|null */ public $onRollbackEnd = null; /** * @param Application $application The application. * @param MigrationsConfiguration $configuration The migrations configuration. */ public function __construct(protected Application $application, protected MigrationsConfiguration $configuration) { } /** * Initial load of the migrations system. * @return $this * @throws IncompatibleTypeException * @throws MissingConditionValueException * @throws UnknownDatabaseException * @throws UndefinedNewColumnTypeException */ public function load(): static { // Get migrations database. $this->database = $this->application->databases()->db($this->configuration->getMigrationsDatabase()); // Initial load of database migrations and preparations. $this->prepareMigrationsTable(); $this->loadMigrations(); $this->loadPreparations(); return $this; } /** * Try to register a new migration file. * @param string $migrationFile The migration file name. * @param string $subdirectory Subdirectory where the migration file has been found. * @return void */ protected function registerMigration(string $migrationFile, string $subdirectory): void { if (preg_match("/^V([0-9]+)_([^.]+)\\.php$/", $migrationFile, $matches)) { // The filename matches migration name format. $version = $matches[1]; $name = $matches[2]; $this->migrations[$version] = [ "version" => $version, "name" => $name, "migrated" => false, // Already run migrations are found by using findMigrated after migration registration. "entity" => null, // Migration entity in database, when it's migrated. "package" => $subdirectory, ]; } } /** * Recursively load migrations in the given path with subdirectory. * @param string $migrationsPath The path where to find migrations. * @param string $subdirectory The subdirectory / subpackage of registered migration files. * @return void */ private function _loadMigrations(string $migrationsPath, string $subdirectory = ""): void { // Get the list of files in the migrations directory. $migrationFiles = scandir($migrationsPath); foreach ($migrationFiles as $migrationFile) { // Try to register each migration file. $migrationFileFullPath = path_join($migrationsPath, $migrationFile); if (is_dir($migrationFileFullPath) && $migrationFile != "." && $migrationFile != "..") { // If it is a directory, we try to read it recursively. $this->_loadMigrations($migrationFileFullPath, !empty($subdirectory) ? path_join($subdirectory, $migrationFile) : $migrationFile); } else { // It is a file, we try to register it as a migration file. $this->registerMigration($migrationFile, $subdirectory); } } } /** * Load all existing migrations. * @return void * @throws IncompatibleTypeException * @throws MissingConditionValueException */ protected function loadMigrations(): void { // Reset migrations list. $this->migrations = []; // Load migrations in the configured migrations path. $this->_loadMigrations($this->configuration->getMigrationsPath()); // Sort migrations by lexicographic order of version identifiers. ksort($this->migrations); // Find done migrations. $this->findMigrated(); } /** * Load all preparations scripts. * @return void */ protected function loadPreparations(): void { // Reset preparations scripts list. $this->preparations = []; if (file_exists($this->configuration->getPreparationsPath())) { // If there is a preparation folder. // Get all preparations scripts files. $this->preparations = scandir($this->configuration->getPreparationsPath()); // Filter non-SQL files. $this->preparations = array_filter($this->preparations, fn ($preparationScript) => ( // Only keep files ending with ".sql". is_file($preparationScript) && str_ends_with($preparationScript, ".sql") )); // Sort in lexicographic order. sort($this->preparations); } } /** * Prepare migrations table: create it if it doesn't exists. * @return void * @throws UndefinedNewColumnTypeException */ protected function prepareMigrationsTable(): void { $table = new Table($this->database, $this->configuration->getMigrationsTable()); $table->createIfNotExists(); $table->column("id")->type(Type::Varchar)->primary()->add(true); $table->column("name")->type(Type::Varchar)->index()->add(true); $table->column("created_at")->type(new Timestamp(true))->defaultNow()->add(true); } /** * Get a migration model for the current migrations. * @return \Nest\Database\Migrations\Model\Migration */ protected function getMigrationModel(): \Nest\Database\Migrations\Model\Migration { // Create an anonymous class for the current migrations. $migrationModel = new class extends \Nest\Database\Migrations\Model\Migration { /** * Migrations table name. * @var string */ public static string $migrationsTableName; /** * @inheritDoc */ public function definition(EntityBlueprint $blueprint): EntityBlueprint { if (!empty(static::$migrationsTableName)) // Set migrations table name. $blueprint->setTable(static::$migrationsTableName); return parent::definition($blueprint); } }; // Set migrations table name. // $migrationModel cannot be used as is: the table name has been defined after the first definition. $migrationModel::$migrationsTableName = $this->configuration->getMigrationsTable(); return $migrationModel->new(); // Return migrations model base instance. } /** * Find done migrations. * @return void * @throws IncompatibleTypeException * @throws MissingConditionValueException * @throws UnknownDatabaseException * @throws InvalidTypeException * @throws UndefinedRelationException * @throws UnhandledPropertyTypeException */ protected function findMigrated(): void { /** * Get migrated migrations. * @var \Nest\Database\Migrations\Model\Migration[] $migrations */ $migrations = $this->getMigrationModel()->query()->get(); foreach ($migrations as $migration) { // Set each migration as migrated. $this->migrations[$migration->id]["migrated"] = true; $this->migrations[$migration->id]["entity"] = $migration; } } /** * Mark the given migration as migrated. * @param string $migrationId The migration version identifier. * @return void * @throws IncompatibleTypeException * @throws MissingConditionValueException * @throws UnknownDatabaseException * @throws MissingRequiredFieldException */ public function markMigrated(string $migrationId): void { // Mark migration as migrated. $this->migrations[$migrationId]["migrated"] = true; // Create a migration and save it as migrated. $this->migrations[$migrationId]["entity"] = $this->getMigrationModel()->new(); $this->migrations[$migrationId]["entity"]->id = $migrationId; $this->migrations[$migrationId]["entity"]->name = $this->migrations[$migrationId]["name"]; $this->migrations[$migrationId]["entity"]->save(); } /** * Mark the given migration as NOT migrated. * @param string $migrationId The migration version identifier. * @return void */ public function markNotMigrated(string $migrationId): void { $this->migrations[$migrationId]["entity"]->delete(); $this->migrations[$migrationId]["migrated"] = false; } /** * Get a new version for a new database migration. * @return string The version of a new migration. */ protected function getNewVersion(): string { // Generate a new version from current date and time. return (new DateTime())->format("YmdHis"); } /** * Generate a new migration with the given name. * @param string $migrationName The migration name. * @return void */ public function newMigration(string $migrationName): void { // Database migration filename. $filename = path_join($this->configuration->getMigrationsPath(), "V".($version = $this->getNewVersion()))."_{$migrationName}.php"; // Generate new migration content. file_put_contents($filename, <<configuration->getMigrationsNamespace()}; use Nest\Database\Migrations\Migration; final class V{$version}_{$migrationName} extends Migration { /** * @inheritDoc */ #[\Override] public function getDescription(): string { return ""; } /** * @inheritDoc */ public function up(): void {} /** * @inheritDoc */ public function down(): void { throw new \Nest\Database\Exceptions\Migrations\CannotRollbackException(\$this); } } EOD ); } /** * Get migrations versions list. * @return string[] */ public function getMigrations(): array { return array_keys($this->migrations); } /** * Construct a new migration instance from its version identifier. * @param array $migrationData Migration data associative array. * @return Migration The migration instance. */ protected function newMigrationInstance(array $migrationData): Migration { // Get migration full class name. $fullClassName = "\\".$this->configuration->getMigrationsNamespace() // Add subpackage if there is one. .(!empty($migrationData["package"]) ? "\\".$migrationData["package"] : "") // Add migration class name. ."\\V{$migrationData["version"]}_{$migrationData["name"]}"; // Instantiate migration. return new $fullClassName($this->database, $migrationData["version"], $migrationData["name"]); } /** * Execute a migration. * @param string $migrationId The migration version identifier. * @return Migration The executed migration instance. * @throws IncompatibleTypeException * @throws MigrationNotFoundException * @throws MissingConditionValueException * @throws MissingRequiredFieldException * @throws UnknownDatabaseException * @throws Throwable */ public function migrateOne(string $migrationId): Migration { if (empty($this->migrations[$migrationId])) // Migration not found. throw new MigrationNotFoundException($migrationId); // Migrate in a transaction. return $this->database->transaction(function () use ($migrationId) { // Get the migration instance. $migration = $this->newMigrationInstance($this->migrations[$migrationId]); if (!empty($this->onMigrationStart)) // Fire migration start event, if it is defined. ($this->onMigrationStart)($migration); // Execute migration. $migration->up(); // Mark the migration as migrated. $this->markMigrated($migrationId); if (!empty($this->onMigrationEnd)) // Fire migration end event, if it is defined. ($this->onMigrationEnd)($migration); // Return executed migration. return $migration; }); } /** * Execute all unapplied migrations. * @return Migration[] The applied migrations list. * @throws IncompatibleTypeException * @throws MigrationNotFoundException * @throws MissingConditionValueException * @throws MissingRequiredFieldException * @throws UnknownDatabaseException * @throws Throwable */ public function migrate(): array { // Migrate in a transaction. return $this->database->transaction(function () { // Initialize the executed migrations. $migrated = []; // Clear preparations before executing migrations. $this->clearPreparations(); foreach ($this->migrations as $version => $migration) { // For each migration, if it is not executed, we execute it. if (!$migration["migrated"]) { // If the migration isn't done, execute it. $migrated[$version] = $this->migrateOne($version); } } // Do database preparations after migrations. $this->prepare(); return $migrated; // Return executed migrations list. }); } /** * Rollback a migration. * @param string $migrationId The migration version identifier. * @return Migration The rolled back migration. * @throws MigrationNotFoundException * @throws Throwable */ public function rollbackOne(string $migrationId): Migration { if (empty($this->migrations[$migrationId])) // Migration not found. throw new MigrationNotFoundException($migrationId); // Rollback in a transaction. return $this->database->transaction(function () use ($migrationId) { // Clear preparations before rolling back. $this->clearPreparations(); // Get the migration instance. $migration = $this->newMigrationInstance($this->migrations[$migrationId]); if (!empty($this->onRollbackStart)) // Fire rollback start event, if it is defined. ($this->onRollbackStart)($migration); // Rollback migration. $migration->down(); // Mark the migration as not migrated. $this->markNotMigrated($migrationId); // Do database preparations after rollback. $this->prepare(); if (!empty($this->onRollbackEnd)) // Fire rollback start event, if it is defined. ($this->onRollbackEnd)($migration); return $migration; // Return rolled back migration. }); } /** * Rollback the latest migrated migration * @return Migration|null The rolled back migration, if there is one. * @throws MigrationNotFoundException * @throws Throwable */ public function rollbackLatest(): ?Migration { // Get latest migration ID. $latestMigration = null; foreach (array_reverse($this->migrations, true) as $migrationId => $migration) { // Exploring migrations in reverse order (the most recent first). if ($migration["migrated"]) { // The first migrated migration is taken as the latest one. $latestMigration = $migrationId; break; } } if (!empty($latestMigration)) // Rollback the latest migrated migration. return $this->rollbackOne($latestMigration); else // No latest migration, do nothing and return NULL. return null; } /** * The preparations clear scripts filename regex. */ const string PREPARATIONS_CLEAR_SCRIPT_REGEX = "/^0+_(.*)$/"; /** * Clear database preparations. * Database preparations are cleared before migrations and should drop all functions and views used by tables that can * be changed by migrations. * @return void */ public function clearPreparations(): void { foreach ($this->preparations as $preparationScript) { // Execute all preparations clear scripts. if (preg_match(static::PREPARATIONS_CLEAR_SCRIPT_REGEX, $preparationScript)) // Only execute files with 00_ prefix. $this->execPreparation($preparationScript); } } /** * Do database preparations. * Database preparations are executed after migrations and can create functions and views based on tables. * @return void */ public function prepare(): void { foreach ($this->preparations as $preparationScript) { // Execute all preparations that are not clear scripts. if (!preg_match(static::PREPARATIONS_CLEAR_SCRIPT_REGEX, $preparationScript)) // Only execute files that aren't preparations clear scripts. $this->execPreparation($preparationScript); } } /** * Execute a given preparation script. * @param string $preparationScript The preparation script to execute. * @return void */ private function execPreparation(string $preparationScript): void { // Read preparation script and execute it. $this->database->execute(file_get_contents($preparationScript)); } }