Database/src/Migrations/Migrations.php

572 lines
16 KiB
PHP

<?php
namespace Nest\Database\Migrations;
use Nest\Application;
use Nest\Database\Database;
use Nest\Database\Migrations\Configuration\MigrationsConfiguration;
use DateTime;
use Nest\Database\Migrations\Diff\Table;
use Nest\Database\PostgreSql\Columns\Timestamp;
use Nest\Database\PostgreSql\Columns\Type;
use Nest\Database\Exceptions\Migrations\MigrationNotFoundException;
use Nest\Database\Exceptions\Migrations\UndefinedNewColumnTypeException;
use Nest\Database\Exceptions\Query\MissingConditionValueException;
use Nest\Database\Exceptions\UnknownDatabaseException;
use Nest\Exceptions\InvalidTypeException;
use Nest\Model\Exceptions\MissingRequiredFieldException;
use Nest\Model\Exceptions\UndefinedRelationException;
use Nest\Model\Exceptions\UnhandledPropertyTypeException;
use Nest\Types\Exceptions\IncompatibleTypeException;
use Nest\Model\EntityBlueprint;
use Throwable;
use function Nest\Utils\path_join;
class Migrations
{
/**
* The database on which to execute the migrations.
* @var Database
*/
protected Database $database;
/**
* The migrations array.
* Indexed by their version identifier.
* @var array<string, array>
*/
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, <<<EOD
<?php
declare(strict_types=1);
namespace {$this->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));
}
}