572 lines
16 KiB
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));
|
|
}
|
|
}
|