Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
110
src/Framework/Database/Migration/AbstractDependentMigration.php
Normal file
110
src/Framework/Database/Migration/AbstractDependentMigration.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
|
||||
/**
|
||||
* Abstract base class for migrations that have dependencies on other migrations.
|
||||
*
|
||||
* This class implements the DependentMigration interface and provides common
|
||||
* functionality for dependency tracking. Concrete migrations can extend this
|
||||
* class to easily add dependency support.
|
||||
*/
|
||||
abstract class AbstractDependentMigration implements DependentMigration
|
||||
{
|
||||
/**
|
||||
* @var MigrationVersionCollection Collection of migration versions that this migration depends on
|
||||
*/
|
||||
private MigrationVersionCollection $dependencies;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->dependencies = new MigrationVersionCollection([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the migration
|
||||
*/
|
||||
abstract public function up(ConnectionInterface $connection): void;
|
||||
|
||||
/**
|
||||
* Revert the migration
|
||||
*/
|
||||
abstract public function down(ConnectionInterface $connection): void;
|
||||
|
||||
/**
|
||||
* Get the version of this migration
|
||||
*/
|
||||
abstract public function getVersion(): MigrationVersion;
|
||||
|
||||
/**
|
||||
* Get the description of this migration
|
||||
*/
|
||||
abstract public function getDescription(): string;
|
||||
|
||||
/**
|
||||
* Get the versions of migrations that this migration depends on
|
||||
*/
|
||||
public function getDependencies(): MigrationVersionCollection
|
||||
{
|
||||
return $this->dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this migration depends on another migration
|
||||
*/
|
||||
public function dependsOn(MigrationVersion $version): bool
|
||||
{
|
||||
return $this->dependencies->contains($version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a dependency to this migration
|
||||
*/
|
||||
public function addDependency(MigrationVersion $version): self
|
||||
{
|
||||
if (! $this->dependsOn($version)) {
|
||||
$this->dependencies = $this->dependencies->add($version);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple dependencies to this migration
|
||||
*
|
||||
* @param MigrationVersionCollection|array<MigrationVersion> $versions The versions to depend on
|
||||
* @return self
|
||||
*/
|
||||
public function addDependencies(MigrationVersionCollection|array $versions): self
|
||||
{
|
||||
if (is_array($versions)) {
|
||||
$versions = new MigrationVersionCollection($versions);
|
||||
}
|
||||
|
||||
foreach ($versions as $version) {
|
||||
$this->addDependency($version);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a dependency by version string
|
||||
*
|
||||
* @param string $versionString The version string (e.g., "2023_01_01_120000")
|
||||
* @return self
|
||||
*/
|
||||
public function addDependencyByVersion(string $versionString): self
|
||||
{
|
||||
$version = MigrationVersion::fromString($versionString);
|
||||
|
||||
return $this->addDependency($version);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\Database\DatabaseManager;
|
||||
use App\Framework\Console\ExitCode;
|
||||
|
||||
final readonly class ApplyMigrations
|
||||
{
|
||||
public function __construct(
|
||||
private PathProvider $pathProvider,
|
||||
private DatabaseManager $db
|
||||
) {}
|
||||
|
||||
#[ConsoleCommand('db:migrate', 'Apply all migrations')]
|
||||
public function __invoke(): void
|
||||
{
|
||||
$path = $this->pathProvider->resolvePath('src/Domain/Media/Migrations');
|
||||
|
||||
$this->db->migrate($path);
|
||||
private MigrationRunner $runner,
|
||||
private MigrationLoader $loader
|
||||
) {
|
||||
}
|
||||
|
||||
#[ConsoleCommand('db:rollback', 'Apply a single migration')]
|
||||
public function up(): void
|
||||
#[ConsoleCommand('db:migrate', 'Apply all pending migrations')]
|
||||
public function migrate(): ExitCode
|
||||
{
|
||||
$path = $this->pathProvider->resolvePath('src/Domain/Media/Migrations');
|
||||
echo "Running migrations...\n";
|
||||
|
||||
$this->db->rollback($path);
|
||||
try {
|
||||
// Use the injected loader that leverages the discovery system
|
||||
$migrations = $this->loader->loadMigrations();
|
||||
|
||||
$executed = $this->runner->migrate($migrations);
|
||||
|
||||
if (empty($executed)) {
|
||||
echo "No migrations to run.\n";
|
||||
} else {
|
||||
echo sprintf("Executed %d migrations.\n", count($executed));
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
echo "❌ Migration failed: " . $e->getMessage() . "\n";
|
||||
|
||||
return ExitCode::SOFTWARE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
#[ConsoleCommand('db:rollback', 'Rollback the last migration batch')]
|
||||
public function rollback(int $steps = 1): ExitCode
|
||||
{
|
||||
echo "Rolling back migrations...\n";
|
||||
|
||||
// Use the injected loader that leverages the discovery system
|
||||
$migrations = $this->loader->loadMigrations();
|
||||
|
||||
$rolledBack = $this->runner->rollback($migrations, $steps);
|
||||
|
||||
if ($rolledBack->isEmpty()) {
|
||||
echo "No migrations to roll back.\n";
|
||||
} else {
|
||||
echo sprintf("Rolled back %d migrations.\n", $rolledBack->count());
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
#[ConsoleCommand('db:status', 'Show migration status')]
|
||||
public function status(): ExitCode
|
||||
{
|
||||
echo "Migration Status:\n";
|
||||
echo str_repeat('-', 80) . "\n";
|
||||
|
||||
// Use the injected loader that leverages the discovery system
|
||||
$migrations = $this->loader->loadMigrations();
|
||||
$statusCollection = $this->runner->getStatus($migrations);
|
||||
|
||||
foreach ($statusCollection as $migrationStatus) {
|
||||
echo sprintf(
|
||||
" %s %s - %s\n",
|
||||
$migrationStatus->getStatusIcon(),
|
||||
$migrationStatus->version,
|
||||
$migrationStatus->description
|
||||
);
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration\Commands;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Database\Migration\MigrationGenerator;
|
||||
|
||||
final class MakeMigrationCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MigrationGenerator $generator
|
||||
) {
|
||||
}
|
||||
|
||||
#[ConsoleCommand('make:migration', 'Generate a new migration file')]
|
||||
public function __invoke(string $name, string $domain = 'Media'): void
|
||||
{
|
||||
if (empty($name)) {
|
||||
echo "Error: Migration name is required\n";
|
||||
echo "Usage: make:migration CreateUsersTable [Domain]\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$filePath = $this->generator->generate($name, $domain);
|
||||
echo "Migration created: {$filePath}\n";
|
||||
} catch (\Exception $e) {
|
||||
echo "Error creating migration: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration\Commands;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\Database\DatabaseManager;
|
||||
use App\Framework\Database\Migration\MigrationVersion;
|
||||
use App\Framework\Database\Schema\Comparison\MigrationGenerator;
|
||||
use App\Framework\Database\Schema\Comparison\SchemaComparator;
|
||||
|
||||
/**
|
||||
* Command to generate a migration from schema differences between two databases
|
||||
*/
|
||||
final readonly class MakeMigrationFromDiffCommand
|
||||
{
|
||||
public function __construct(
|
||||
private DatabaseManager $databaseManager,
|
||||
private MigrationGenerator $migrationGenerator,
|
||||
private PathProvider $pathProvider
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a migration from schema differences
|
||||
*
|
||||
* @param string $name The name of the migration
|
||||
* @param string $sourceConnection The name of the source connection (default: default)
|
||||
* @param string $targetConnection The name of the target connection
|
||||
* @param string|null $sourceSchema The name of the source schema (default: public for PostgreSQL, database name for MySQL)
|
||||
* @param string|null $targetSchema The name of the target schema (default: public for PostgreSQL, database name for MySQL)
|
||||
* @param string $domain The domain for the migration (default: Media)
|
||||
* @return ExitCode
|
||||
*/
|
||||
#[ConsoleCommand('db:migrate:diff', 'Generate a migration from schema differences')]
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $sourceConnection = 'default',
|
||||
string $targetConnection = 'default',
|
||||
?string $sourceSchema = null,
|
||||
?string $targetSchema = null,
|
||||
string $domain = 'Media'
|
||||
): ExitCode {
|
||||
if (empty($name)) {
|
||||
echo "Error: Migration name is required\n";
|
||||
echo "Usage: db:migrate:diff UpdateUserTable [sourceConnection] [targetConnection] [sourceSchema] [targetSchema] [domain]\n";
|
||||
|
||||
return ExitCode::INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the connections
|
||||
$sourceConn = $this->databaseManager->getConnection($sourceConnection);
|
||||
$targetConn = $this->databaseManager->getConnection($targetConnection);
|
||||
|
||||
// Compare the schemas
|
||||
$comparator = new SchemaComparator($sourceConn, $targetConn);
|
||||
$difference = $comparator->compare($sourceSchema, $targetSchema);
|
||||
|
||||
if (! $difference->hasDifferences()) {
|
||||
echo "No schema differences found.\n";
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// Generate the migration
|
||||
$className = $this->toClassName($name);
|
||||
$version = MigrationVersion::fromDateTime(new \DateTimeImmutable());
|
||||
$description = $name;
|
||||
|
||||
$migrationCode = $this->migrationGenerator->generateMigration(
|
||||
$difference,
|
||||
$className,
|
||||
$description,
|
||||
$version
|
||||
);
|
||||
|
||||
// Save the migration to a file
|
||||
$migrationPath = $this->pathProvider->resolvePath("src/Domain/{$domain}/Migrations");
|
||||
|
||||
if (! is_dir($migrationPath)) {
|
||||
mkdir($migrationPath, 0755, true);
|
||||
}
|
||||
|
||||
$fileName = "{$className}.php";
|
||||
$filePath = $migrationPath . '/' . $fileName;
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
echo "Error: Migration file already exists: {$filePath}\n";
|
||||
|
||||
return ExitCode::SOFTWARE_ERROR;
|
||||
}
|
||||
|
||||
file_put_contents($filePath, $migrationCode);
|
||||
echo "Migration created: {$filePath}\n";
|
||||
|
||||
// Display a summary of the differences
|
||||
echo "\nSchema Difference Summary:\n";
|
||||
echo str_repeat('-', 80) . "\n";
|
||||
|
||||
$summary = $difference->getSummary();
|
||||
echo "Missing tables: {$summary['missing_tables']}\n";
|
||||
echo "Extra tables: {$summary['extra_tables']}\n";
|
||||
echo "Modified tables: " . count($difference->tableDifferences) . "\n";
|
||||
|
||||
echo "\nFor detailed differences, use the db:schema:diff command.\n";
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error creating migration: {$e->getMessage()}\n";
|
||||
if (isset($_ENV['APP_DEBUG']) && $_ENV['APP_DEBUG']) {
|
||||
echo $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
return ExitCode::SOFTWARE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to a class name
|
||||
*/
|
||||
private function toClassName(string $name): string
|
||||
{
|
||||
// Convert "Create Users Table" to "CreateUsersTable"
|
||||
return str_replace(' ', '', ucwords(str_replace('_', ' ', $name)));
|
||||
}
|
||||
}
|
||||
40
src/Framework/Database/Migration/DependentMigration.php
Normal file
40
src/Framework/Database/Migration/DependentMigration.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
/**
|
||||
* Interface for migrations that have dependencies on other migrations.
|
||||
*
|
||||
* This extends the base Migration interface to add dependency tracking.
|
||||
* Migrations that implement this interface can specify other migrations
|
||||
* that must be applied before this one.
|
||||
*/
|
||||
interface DependentMigration extends Migration
|
||||
{
|
||||
/**
|
||||
* Get the versions of migrations that this migration depends on.
|
||||
*
|
||||
* These migrations must be applied before this one.
|
||||
*
|
||||
* @return MigrationVersionCollection Collection of migration versions that this migration depends on
|
||||
*/
|
||||
public function getDependencies(): MigrationVersionCollection;
|
||||
|
||||
/**
|
||||
* Check if this migration depends on another migration.
|
||||
*
|
||||
* @param MigrationVersion $version The version to check
|
||||
* @return bool True if this migration depends on the specified version
|
||||
*/
|
||||
public function dependsOn(MigrationVersion $version): bool;
|
||||
|
||||
/**
|
||||
* Add a dependency to this migration.
|
||||
*
|
||||
* @param MigrationVersion $version The version to depend on
|
||||
* @return self
|
||||
*/
|
||||
public function addDependency(MigrationVersion $version): self;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ interface Migration
|
||||
|
||||
public function down(ConnectionInterface $connection): void;
|
||||
|
||||
public function getVersion(): string;
|
||||
public function getVersion(): MigrationVersion;
|
||||
|
||||
public function getDescription(): string;
|
||||
}
|
||||
|
||||
242
src/Framework/Database/Migration/MigrationCollection.php
Normal file
242
src/Framework/Database/Migration/MigrationCollection.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
use ArrayIterator;
|
||||
use Countable;
|
||||
use IteratorAggregate;
|
||||
use Traversable;
|
||||
|
||||
/**
|
||||
* Collection of Migration instances with type-safe operations
|
||||
*/
|
||||
final readonly class MigrationCollection implements Countable, IteratorAggregate
|
||||
{
|
||||
/**
|
||||
* @var Migration[]
|
||||
*/
|
||||
private array $migrations;
|
||||
|
||||
/**
|
||||
* @param Migration ...$migrations Migration instances
|
||||
*/
|
||||
public function __construct(Migration ...$migrations)
|
||||
{
|
||||
$this->migrations = $migrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create collection from array of Migration instances
|
||||
*
|
||||
* @param Migration[] $migrations Array of migration instances
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $migrations): self
|
||||
{
|
||||
return new self(...$migrations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get iterator for foreach loops
|
||||
*
|
||||
* @return Traversable<Migration>
|
||||
*/
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return new ArrayIterator($this->migrations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count migrations in collection
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->migrations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if collection is empty
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->migrations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get migration by version
|
||||
*
|
||||
* @param MigrationVersion $version The version to find
|
||||
* @return Migration|null The migration or null if not found
|
||||
*/
|
||||
public function getByVersion(MigrationVersion $version): ?Migration
|
||||
{
|
||||
foreach ($this->migrations as $migration) {
|
||||
if ($migration->getVersion()->equals($version)) {
|
||||
return $migration;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get migration by version string
|
||||
*
|
||||
* @param string $versionString The version string to find
|
||||
* @return Migration|null The migration or null if not found
|
||||
*/
|
||||
public function getByVersionString(string $versionString): ?Migration
|
||||
{
|
||||
foreach ($this->migrations as $migration) {
|
||||
if ((string) $migration->getVersion() === $versionString) {
|
||||
return $migration;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if collection contains a migration with specific version
|
||||
*
|
||||
* @param MigrationVersion $version The version to check
|
||||
* @return bool
|
||||
*/
|
||||
public function hasVersion(MigrationVersion $version): bool
|
||||
{
|
||||
return $this->getByVersion($version) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if collection contains a migration with specific version string
|
||||
*
|
||||
* @param string $versionString The version string to check
|
||||
* @return bool
|
||||
*/
|
||||
public function hasVersionString(string $versionString): bool
|
||||
{
|
||||
return $this->getByVersionString($versionString) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versions in the collection
|
||||
*
|
||||
* @return MigrationVersionCollection
|
||||
*/
|
||||
public function getVersions(): MigrationVersionCollection
|
||||
{
|
||||
$versions = array_map(
|
||||
fn (Migration $migration) => $migration->getVersion(),
|
||||
$this->migrations
|
||||
);
|
||||
|
||||
return MigrationVersionCollection::fromVersions($versions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorted collection (by version ascending)
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function sorted(): self
|
||||
{
|
||||
$sorted = $this->migrations;
|
||||
usort($sorted, fn (Migration $a, Migration $b) => $a->getVersion()->compare($b->getVersion()));
|
||||
|
||||
return new self(...$sorted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection sorted in reverse order (by version descending)
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function sortedDescending(): self
|
||||
{
|
||||
$sorted = $this->migrations;
|
||||
usort($sorted, fn (Migration $a, Migration $b) => $b->getVersion()->compare($a->getVersion()));
|
||||
|
||||
return new self(...$sorted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter migrations by a predicate
|
||||
*
|
||||
* @param callable(Migration): bool $predicate Filter function
|
||||
* @return self
|
||||
*/
|
||||
public function filter(callable $predicate): self
|
||||
{
|
||||
return new self(...array_filter($this->migrations, $predicate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get migrations after a specific version
|
||||
*
|
||||
* @param MigrationVersion $version The version to compare against
|
||||
* @return self
|
||||
*/
|
||||
public function after(MigrationVersion $version): self
|
||||
{
|
||||
return $this->filter(
|
||||
fn (Migration $migration) => $migration->getVersion()->compare($version) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get migrations before or equal to a specific version
|
||||
*
|
||||
* @param MigrationVersion $version The version to compare against
|
||||
* @return self
|
||||
*/
|
||||
public function upTo(MigrationVersion $version): self
|
||||
{
|
||||
return $this->filter(
|
||||
fn (Migration $migration) => $migration->getVersion()->compare($version) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first migration (lowest version)
|
||||
*
|
||||
* @return Migration|null
|
||||
*/
|
||||
public function first(): ?Migration
|
||||
{
|
||||
if (empty($this->migrations)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->sorted()->migrations[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last migration (highest version)
|
||||
*
|
||||
* @return Migration|null
|
||||
*/
|
||||
public function last(): ?Migration
|
||||
{
|
||||
if (empty($this->migrations)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->sortedDescending()->migrations[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all migrations as array
|
||||
*
|
||||
* @return Migration[]
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->migrations;
|
||||
}
|
||||
}
|
||||
196
src/Framework/Database/Migration/MigrationDependencyGraph.php
Normal file
196
src/Framework/Database/Migration/MigrationDependencyGraph.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
use App\Framework\Database\Exception\DatabaseException;
|
||||
|
||||
/**
|
||||
* Manages dependencies between migrations and determines the correct execution order.
|
||||
*/
|
||||
final class MigrationDependencyGraph
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string>> Adjacency list representation of the dependency graph
|
||||
*/
|
||||
private array $dependencies = [];
|
||||
|
||||
/**
|
||||
* @var array<string, Migration> Map of version strings to migration instances
|
||||
*/
|
||||
private array $migrations = [];
|
||||
|
||||
/**
|
||||
* Build a dependency graph from a collection of migrations
|
||||
*/
|
||||
public function buildGraph(MigrationCollection $migrations): void
|
||||
{
|
||||
$this->dependencies = [];
|
||||
$this->migrations = [];
|
||||
|
||||
// First, add all migrations to the graph
|
||||
foreach ($migrations as $migration) {
|
||||
$version = $migration->getVersion()->toString();
|
||||
$this->migrations[$version] = $migration;
|
||||
$this->dependencies[$version] = [];
|
||||
}
|
||||
|
||||
// Then, add dependencies for migrations that implement DependentMigration
|
||||
foreach ($migrations as $migration) {
|
||||
if ($migration instanceof DependentMigration) {
|
||||
$version = $migration->getVersion()->toString();
|
||||
|
||||
foreach ($migration->getDependencies() as $dependencyVersion) {
|
||||
$dependencyVersionString = $dependencyVersion->toString();
|
||||
|
||||
// Check if the dependency exists
|
||||
if (! isset($this->migrations[$dependencyVersionString])) {
|
||||
throw new DatabaseException(
|
||||
"Migration {$version} depends on {$dependencyVersionString}, but that migration does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
// Add the dependency to the graph
|
||||
$this->dependencies[$version][] = $dependencyVersionString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies
|
||||
$this->detectCircularDependencies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migrations in the correct execution order based on dependencies
|
||||
*/
|
||||
public function getExecutionOrder(): MigrationCollection
|
||||
{
|
||||
$visited = [];
|
||||
$order = [];
|
||||
|
||||
// Perform a topological sort using depth-first search
|
||||
foreach (array_keys($this->migrations) as $version) {
|
||||
if (! isset($visited[$version])) {
|
||||
$this->topologicalSort($version, $visited, $order);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the ordered version strings back to Migration instances
|
||||
$orderedMigrations = [];
|
||||
foreach ($order as $version) {
|
||||
$orderedMigrations[] = $this->migrations[$version];
|
||||
}
|
||||
|
||||
return new MigrationCollection(...$orderedMigrations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dependencies for a specific migration
|
||||
*
|
||||
* @param string $version The migration version
|
||||
* @return array<string> The versions this migration depends on
|
||||
*/
|
||||
public function getDependencies(string $version): array
|
||||
{
|
||||
return $this->dependencies[$version] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a migration has dependencies
|
||||
*/
|
||||
public function hasDependencies(string $version): bool
|
||||
{
|
||||
return ! empty($this->dependencies[$version] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a migration is a dependency of another migration
|
||||
*/
|
||||
public function isDependencyOf(string $version, string $potentialDependant): bool
|
||||
{
|
||||
return in_array($version, $this->dependencies[$potentialDependant] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all migrations that depend on a specific migration
|
||||
*
|
||||
* @param string $version The migration version
|
||||
* @return array<string> The versions that depend on this migration
|
||||
*/
|
||||
public function getDependants(string $version): array
|
||||
{
|
||||
$dependants = [];
|
||||
|
||||
foreach ($this->dependencies as $migrationVersion => $dependencies) {
|
||||
if (in_array($version, $dependencies)) {
|
||||
$dependants[] = $migrationVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return $dependants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect circular dependencies in the graph
|
||||
*
|
||||
* @throws DatabaseException If a circular dependency is detected
|
||||
*/
|
||||
private function detectCircularDependencies(): void
|
||||
{
|
||||
$visited = [];
|
||||
$recursionStack = [];
|
||||
|
||||
foreach (array_keys($this->migrations) as $version) {
|
||||
if ($this->detectCircularDependenciesUtil($version, $visited, $recursionStack)) {
|
||||
$cycle = implode(' -> ', array_keys(array_filter($recursionStack)));
|
||||
|
||||
throw new DatabaseException("Circular dependency detected in migrations: {$cycle}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for circular dependency detection using DFS
|
||||
*/
|
||||
private function detectCircularDependenciesUtil(string $version, array &$visited, array &$recursionStack): bool
|
||||
{
|
||||
// If the node is not visited yet, mark it as visited and add to recursion stack
|
||||
if (! isset($visited[$version])) {
|
||||
$visited[$version] = true;
|
||||
$recursionStack[$version] = true;
|
||||
|
||||
// Visit all dependencies
|
||||
foreach ($this->dependencies[$version] as $dependency) {
|
||||
if (! isset($visited[$dependency]) && $this->detectCircularDependenciesUtil($dependency, $visited, $recursionStack)) {
|
||||
return true;
|
||||
} elseif (isset($recursionStack[$dependency])) {
|
||||
return true; // Circular dependency found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the node from recursion stack
|
||||
unset($recursionStack[$version]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a topological sort using depth-first search
|
||||
*/
|
||||
private function topologicalSort(string $version, array &$visited, array &$order): void
|
||||
{
|
||||
$visited[$version] = true;
|
||||
|
||||
// Visit all dependencies first
|
||||
foreach ($this->dependencies[$version] as $dependency) {
|
||||
if (! isset($visited[$dependency])) {
|
||||
$this->topologicalSort($dependency, $visited, $order);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the current node to the order
|
||||
$order[] = $version;
|
||||
}
|
||||
}
|
||||
105
src/Framework/Database/Migration/MigrationGenerator.php
Normal file
105
src/Framework/Database/Migration/MigrationGenerator.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
use App\Framework\Core\PathProvider;
|
||||
|
||||
final readonly class MigrationGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private PathProvider $pathProvider
|
||||
) {
|
||||
}
|
||||
|
||||
public function generate(string $name, string $domain = 'Media'): string
|
||||
{
|
||||
$timestamp = date('Ymd_His_000');
|
||||
$version = MigrationVersion::fromTimestamp($timestamp);
|
||||
$className = $this->toClassName($name);
|
||||
$fileName = "{$className}.php";
|
||||
|
||||
$migrationPath = $this->pathProvider->resolvePath("src/Domain/{$domain}/Migrations");
|
||||
|
||||
if (! is_dir($migrationPath)) {
|
||||
mkdir($migrationPath, 0755, true);
|
||||
}
|
||||
|
||||
$filePath = $migrationPath . '/' . $fileName;
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
throw new \RuntimeException("Migration file already exists: {$filePath}");
|
||||
}
|
||||
|
||||
$content = $this->generateMigrationContent($className, $name, $domain, $version->timestamp);
|
||||
|
||||
file_put_contents($filePath, $content);
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
private function toClassName(string $name): string
|
||||
{
|
||||
// Convert "Create Users Table" to "CreateUsersTable"
|
||||
return str_replace(' ', '', ucwords(str_replace('_', ' ', $name)));
|
||||
}
|
||||
|
||||
private function generateMigrationContent(string $className, string $name, string $domain, string $timestamp): string
|
||||
{
|
||||
$namespace = "App\\Domain\\{$domain}\\Migrations";
|
||||
|
||||
return <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {$namespace};
|
||||
|
||||
use App\\Framework\\Database\\ConnectionInterface;
|
||||
use App\\Framework\\Database\\Migration\\Migration;
|
||||
use App\\Framework\\Database\\Migration\\MigrationVersion;
|
||||
use App\\Framework\\Database\\Schema\\Schema;
|
||||
|
||||
final class {$className} implements Migration
|
||||
{
|
||||
public function up(ConnectionInterface \$connection): void
|
||||
{
|
||||
\$schema = new Schema(\$connection);
|
||||
|
||||
// TODO: Implement your migration here
|
||||
// Example:
|
||||
// \$schema->create('table_name', function(\$table) {
|
||||
// \$table->ulid('ulid')->primary();
|
||||
// \$table->string('name');
|
||||
// \$table->timestamps();
|
||||
// });
|
||||
|
||||
\$schema->execute();
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface \$connection): void
|
||||
{
|
||||
\$schema = new Schema(\$connection);
|
||||
|
||||
// TODO: Implement rollback
|
||||
// Example:
|
||||
// \$schema->dropIfExists('table_name');
|
||||
|
||||
\$schema->execute();
|
||||
}
|
||||
|
||||
public function getVersion(): MigrationVersion
|
||||
{
|
||||
return MigrationVersion::fromTimestamp('{$timestamp}');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '{$name}';
|
||||
}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
}
|
||||
@@ -4,70 +4,30 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
use App\Framework\Database\Exception\DatabaseException;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
|
||||
final class MigrationLoader
|
||||
final readonly class MigrationLoader
|
||||
{
|
||||
private string $migrationsPath;
|
||||
|
||||
public function __construct(string $migrationsPath)
|
||||
{
|
||||
$this->migrationsPath = $migrationsPath;
|
||||
public function __construct(
|
||||
private DiscoveryRegistry $discoveryRegistry,
|
||||
private Container $container
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Migration[]
|
||||
*/
|
||||
public function loadMigrations(): array
|
||||
public function loadMigrations(): MigrationCollection
|
||||
{
|
||||
if (!is_dir($this->migrationsPath)) {
|
||||
throw new DatabaseException("Migrations directory does not exist: {$this->migrationsPath}");
|
||||
}
|
||||
|
||||
$migrations = [];
|
||||
$files = glob($this->migrationsPath . '/*.php');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$className = $this->getClassNameFromFile($file);
|
||||
// Get all interface mappings for the Migration interface
|
||||
$interfaceMappings = $this->discoveryRegistry->interfaces->findMappingsForInterface(Migration::class);
|
||||
|
||||
if (!$className) {
|
||||
continue;
|
||||
}
|
||||
|
||||
require_once $file;
|
||||
|
||||
if (!class_exists($className)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($className);
|
||||
|
||||
if (!$reflection->implementsInterface(Migration::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$migration = $reflection->newInstance();
|
||||
$migrations[] = $migration;
|
||||
foreach ($interfaceMappings as $mapping) {
|
||||
/** @var Migration $migrationInstance */
|
||||
$migrationInstance = $this->container->get($mapping->implementation->getFullyQualified());
|
||||
$migrations[] = $migrationInstance;
|
||||
}
|
||||
|
||||
usort($migrations, fn($a, $b) => $a->getVersion() <=> $b->getVersion());
|
||||
|
||||
return $migrations;
|
||||
}
|
||||
|
||||
private function getClassNameFromFile(string $file): ?string
|
||||
{
|
||||
$content = file_get_contents($file);
|
||||
|
||||
$namespace = '';
|
||||
if (preg_match('/namespace\s+([^;]+);/i', $content, $matches)) {
|
||||
$namespace = $matches[1] . '\\';
|
||||
}
|
||||
|
||||
if (preg_match('/class\s+([^\s{]+)/i', $content, $matches)) {
|
||||
return $namespace . $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
return MigrationCollection::fromArray($migrations)->sorted();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,48 +8,81 @@ use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Exception\DatabaseException;
|
||||
use App\Framework\Database\Transaction;
|
||||
|
||||
final class MigrationRunner
|
||||
final readonly class MigrationRunner
|
||||
{
|
||||
private ConnectionInterface $connection;
|
||||
private string $migrationsTable;
|
||||
/**
|
||||
* @var MigrationDependencyGraph Dependency graph for migrations
|
||||
*/
|
||||
private MigrationDependencyGraph $dependencyGraph;
|
||||
|
||||
public function __construct(ConnectionInterface $connection, string $migrationsTable = 'migrations')
|
||||
{
|
||||
$this->connection = $connection;
|
||||
$this->migrationsTable = $migrationsTable;
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection,
|
||||
private string $migrationsTable = 'migrations'
|
||||
) {
|
||||
$this->ensureMigrationsTable();
|
||||
$this->dependencyGraph = new MigrationDependencyGraph();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Migration[] $migrations
|
||||
* Run migrations
|
||||
*
|
||||
* @param MigrationCollection $migrations The migrations to run
|
||||
* @return array<string> List of executed migration versions
|
||||
*/
|
||||
public function migrate(array $migrations): array
|
||||
public function migrate(MigrationCollection $migrations): array
|
||||
{
|
||||
$executedMigrations = [];
|
||||
$appliedVersions = $this->getAppliedVersions();
|
||||
|
||||
foreach ($migrations as $migration) {
|
||||
if (in_array($migration->getVersion(), $appliedVersions, true)) {
|
||||
// Build the dependency graph
|
||||
$this->dependencyGraph->buildGraph($migrations);
|
||||
|
||||
// Get migrations in the correct execution order based on dependencies
|
||||
$orderedMigrations = $this->dependencyGraph->getExecutionOrder();
|
||||
|
||||
foreach ($orderedMigrations as $migration) {
|
||||
$version = $migration->getVersion()->toString();
|
||||
if ($appliedVersions->containsString($version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if all dependencies are applied
|
||||
if ($migration instanceof DependentMigration) {
|
||||
$missingDependencies = [];
|
||||
|
||||
foreach ($migration->getDependencies() as $dependencyVersion) {
|
||||
$dependencyVersionString = $dependencyVersion->toString();
|
||||
if (! $appliedVersions->containsString($dependencyVersionString) &&
|
||||
! in_array($dependencyVersionString, $executedMigrations)) {
|
||||
$missingDependencies[] = $dependencyVersionString;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($missingDependencies)) {
|
||||
throw new DatabaseException(
|
||||
"Cannot apply migration {$version} because it depends on migrations that have not been applied: " .
|
||||
implode(', ', $missingDependencies)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Transaction::run($this->connection, function() use ($migration) {
|
||||
echo "Migrating: {$migration->getVersion()} - {$migration->getDescription()}\n";
|
||||
Transaction::run($this->connection, function () use ($migration, $version) {
|
||||
echo "Migrating: {$version} - {$migration->getDescription()}\n";
|
||||
|
||||
$migration->up($this->connection);
|
||||
|
||||
$this->connection->execute(
|
||||
"INSERT INTO {$this->migrationsTable} (version, description, executed_at) VALUES (?, ?, ?)",
|
||||
[$migration->getVersion(), $migration->getDescription(), date('Y-m-d H:i:s')]
|
||||
[$version, $migration->getDescription(), date('Y-m-d H:i:s')]
|
||||
);
|
||||
});
|
||||
|
||||
$executedMigrations[] = $migration->getVersion();
|
||||
echo "Migrated: {$migration->getVersion()}\n";
|
||||
$executedMigrations[] = $version;
|
||||
echo "Migrated: {$version}\n";
|
||||
} catch (\Throwable $e) {
|
||||
throw new DatabaseException(
|
||||
"Migration {$migration->getVersion()} failed: {$e->getMessage()}",
|
||||
"Migration {$version} failed: {$e->getMessage()}",
|
||||
0,
|
||||
$e
|
||||
);
|
||||
@@ -60,86 +93,164 @@ final class MigrationRunner
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Migration[] $migrations
|
||||
* Rollback migrations
|
||||
*
|
||||
* @param MigrationCollection $migrations The migrations to rollback from
|
||||
* @param int $steps Number of migrations to rollback
|
||||
* @return MigrationCollection List of rolled back migration versions
|
||||
*/
|
||||
public function rollback(array $migrations, int $steps = 1): array
|
||||
public function rollback(MigrationCollection $migrations, int $steps = 1): MigrationCollection
|
||||
{
|
||||
$rolledBackMigrations = [];
|
||||
$appliedVersions = $this->getAppliedVersions();
|
||||
|
||||
$sortedMigrations = $migrations;
|
||||
usort($sortedMigrations, fn($a, $b) => $b->getVersion() <=> $a->getVersion());
|
||||
// Build the dependency graph
|
||||
$this->dependencyGraph->buildGraph($migrations);
|
||||
|
||||
// Use the collection's sortedDescending method to get migrations in reverse order
|
||||
$sortedMigrations = $migrations->sortedDescending();
|
||||
|
||||
// Create a map of version strings to migrations for easy lookup
|
||||
$migrationMap = [];
|
||||
foreach ($migrations as $migration) {
|
||||
$migrationMap[$migration->getVersion()->toString()] = $migration;
|
||||
}
|
||||
|
||||
// Create a list of versions that can be rolled back
|
||||
$versionsToRollback = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($sortedMigrations as $migration) {
|
||||
if ($count >= $steps) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!in_array($migration->getVersion(), $appliedVersions, true)) {
|
||||
$version = $migration->getVersion()->toString();
|
||||
if (! $appliedVersions->containsString($version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any applied migrations depend on this one
|
||||
$dependants = $this->dependencyGraph->getDependants($version);
|
||||
$appliedDependants = [];
|
||||
|
||||
foreach ($dependants as $dependant) {
|
||||
if ($appliedVersions->containsString($dependant) && ! in_array($dependant, $versionsToRollback)) {
|
||||
$appliedDependants[] = $dependant;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are applied dependants, we can't roll back this migration
|
||||
if (! empty($appliedDependants)) {
|
||||
echo "Cannot roll back {$version} because the following migrations depend on it: " . implode(', ', $appliedDependants) . "\n";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$versionsToRollback[] = $version;
|
||||
$count++;
|
||||
}
|
||||
|
||||
// Roll back the migrations in the correct order
|
||||
foreach ($versionsToRollback as $version) {
|
||||
$migration = $migrationMap[$version];
|
||||
|
||||
try {
|
||||
Transaction::run($this->connection, function() use ($migration) {
|
||||
echo "Rolling back: {$migration->getVersion()} - {$migration->getDescription()}\n";
|
||||
Transaction::run($this->connection, function () use ($migration, $version) {
|
||||
echo "Rolling back: {$version} - {$migration->getDescription()}\n";
|
||||
|
||||
$migration->down($this->connection);
|
||||
|
||||
$this->connection->execute(
|
||||
"DELETE FROM {$this->migrationsTable} WHERE version = ?",
|
||||
[$migration->getVersion()]
|
||||
[$version]
|
||||
);
|
||||
});
|
||||
|
||||
$rolledBackMigrations[] = $migration->getVersion();
|
||||
$count++;
|
||||
echo "Rolled back: {$migration->getVersion()}\n";
|
||||
$rolledBackMigrations[] = $migration;
|
||||
echo "Rolled back: {$version}\n";
|
||||
} catch (\Throwable $e) {
|
||||
throw new DatabaseException(
|
||||
"Rollback {$migration->getVersion()} failed: {$e->getMessage()}",
|
||||
"Rollback {$version} failed: {$e->getMessage()}",
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $rolledBackMigrations;
|
||||
return new MigrationCollection(...$rolledBackMigrations);
|
||||
}
|
||||
|
||||
public function getStatus(array $migrations): array
|
||||
/**
|
||||
* Get status of all migrations
|
||||
*
|
||||
* @param MigrationCollection $migrations The migrations to check status for
|
||||
* @return MigrationStatusCollection Collection of migration statuses
|
||||
*/
|
||||
public function getStatus(MigrationCollection $migrations): MigrationStatusCollection
|
||||
{
|
||||
$appliedVersions = $this->getAppliedVersions();
|
||||
$status = [];
|
||||
$statuses = [];
|
||||
|
||||
foreach ($migrations as $migration) {
|
||||
$status[] = [
|
||||
'version' => $migration->getVersion(),
|
||||
'description' => $migration->getDescription(),
|
||||
'applied' => in_array($migration->getVersion(), $appliedVersions, true),
|
||||
];
|
||||
$version = $migration->getVersion();
|
||||
$applied = $appliedVersions->contains($version);
|
||||
|
||||
$statuses[] = $applied
|
||||
? MigrationStatus::applied($version, $migration->getDescription())
|
||||
: MigrationStatus::pending($version, $migration->getDescription());
|
||||
}
|
||||
|
||||
return $status;
|
||||
return new MigrationStatusCollection($statuses);
|
||||
}
|
||||
|
||||
private function getAppliedVersions(): array
|
||||
public function getAppliedVersions(): MigrationVersionCollection
|
||||
{
|
||||
return $this->connection->queryColumn(
|
||||
$versionStrings = $this->connection->queryColumn(
|
||||
"SELECT version FROM {$this->migrationsTable} ORDER BY executed_at"
|
||||
);
|
||||
|
||||
return MigrationVersionCollection::fromStrings($versionStrings);
|
||||
}
|
||||
|
||||
private function ensureMigrationsTable(): void
|
||||
{
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
version VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
executed_at DATETIME NOT NULL,
|
||||
INDEX idx_version (version)
|
||||
)";
|
||||
// Use database-agnostic approach
|
||||
$driver = $this->connection->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
|
||||
$sql = match($driver) {
|
||||
'mysql' => "CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
version VARCHAR(20) NOT NULL UNIQUE COMMENT 'Format: YYYY_MM_DD_HHMMSS',
|
||||
description TEXT,
|
||||
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_version (version),
|
||||
INDEX idx_executed_at (executed_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
|
||||
'pgsql' => "CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
version VARCHAR(20) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
|
||||
'sqlite' => "CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
|
||||
default => throw new \RuntimeException("Unsupported database driver: {$driver}")
|
||||
};
|
||||
|
||||
$this->connection->execute($sql);
|
||||
|
||||
// Create indexes for PostgreSQL separately
|
||||
if ($driver === 'pgsql') {
|
||||
$this->connection->execute("CREATE INDEX IF NOT EXISTS idx_{$this->migrationsTable}_version ON {$this->migrationsTable} (version)");
|
||||
$this->connection->execute("CREATE INDEX IF NOT EXISTS idx_{$this->migrationsTable}_executed_at ON {$this->migrationsTable} (executed_at)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
src/Framework/Database/Migration/MigrationStatus.php
Normal file
60
src/Framework/Database/Migration/MigrationStatus.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
/**
|
||||
* Value Object representing the status of a single migration
|
||||
*/
|
||||
final readonly class MigrationStatus
|
||||
{
|
||||
public function __construct(
|
||||
public MigrationVersion $version,
|
||||
public string $description,
|
||||
public bool $applied
|
||||
) {
|
||||
}
|
||||
|
||||
public static function applied(MigrationVersion $version, string $description): self
|
||||
{
|
||||
return new self($version, $description, true);
|
||||
}
|
||||
|
||||
public static function pending(MigrationVersion $version, string $description): self
|
||||
{
|
||||
return new self($version, $description, false);
|
||||
}
|
||||
|
||||
public function isApplied(): bool
|
||||
{
|
||||
return $this->applied;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return ! $this->applied;
|
||||
}
|
||||
|
||||
public function getStatusIcon(): string
|
||||
{
|
||||
return $this->applied ? '✓' : '✗';
|
||||
}
|
||||
|
||||
public function getStatusText(): string
|
||||
{
|
||||
return $this->applied ? 'Applied' : 'Pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for backward compatibility
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'version' => (string) $this->version,
|
||||
'description' => $this->description,
|
||||
'applied' => $this->applied,
|
||||
];
|
||||
}
|
||||
}
|
||||
101
src/Framework/Database/Migration/MigrationStatusCollection.php
Normal file
101
src/Framework/Database/Migration/MigrationStatusCollection.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
use ArrayIterator;
|
||||
use Countable;
|
||||
use IteratorAggregate;
|
||||
use Traversable;
|
||||
|
||||
/**
|
||||
* Collection of MigrationStatus objects
|
||||
*/
|
||||
final readonly class MigrationStatusCollection implements Countable, IteratorAggregate
|
||||
{
|
||||
/** @var MigrationStatus[] */
|
||||
private array $statuses;
|
||||
|
||||
/**
|
||||
* @param MigrationStatus[] $statuses
|
||||
*/
|
||||
public function __construct(array $statuses = [])
|
||||
{
|
||||
$this->statuses = array_values($statuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MigrationStatus[] $statuses
|
||||
*/
|
||||
public static function fromArray(array $statuses): self
|
||||
{
|
||||
return new self($statuses);
|
||||
}
|
||||
|
||||
public function add(MigrationStatus $status): self
|
||||
{
|
||||
$statuses = $this->statuses;
|
||||
$statuses[] = $status;
|
||||
|
||||
return new self($statuses);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->statuses);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return count($this->statuses) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only applied migrations
|
||||
*/
|
||||
public function getApplied(): self
|
||||
{
|
||||
return new self(array_filter($this->statuses, fn (MigrationStatus $status) => $status->isApplied()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only pending migrations
|
||||
*/
|
||||
public function getPending(): self
|
||||
{
|
||||
return new self(array_filter($this->statuses, fn (MigrationStatus $status) => $status->isPending()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count applied migrations
|
||||
*/
|
||||
public function countApplied(): int
|
||||
{
|
||||
return count(array_filter($this->statuses, fn (MigrationStatus $status) => $status->isApplied()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count pending migrations
|
||||
*/
|
||||
public function countPending(): int
|
||||
{
|
||||
return count(array_filter($this->statuses, fn (MigrationStatus $status) => $status->isPending()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for backward compatibility
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_map(fn (MigrationStatus $status) => $status->toArray(), $this->statuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Traversable<int, MigrationStatus>
|
||||
*/
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return new ArrayIterator($this->statuses);
|
||||
}
|
||||
}
|
||||
44
src/Framework/Database/Migration/MigrationVersion.php
Normal file
44
src/Framework/Database/Migration/MigrationVersion.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
final readonly class MigrationVersion
|
||||
{
|
||||
public function __construct(
|
||||
public string $timestamp
|
||||
) {
|
||||
if (! preg_match('/^\d{4}_\d{2}_\d{2}_\d{6}$/', $timestamp)) {
|
||||
throw new \InvalidArgumentException("Invalid migration timestamp format. Expected: YYYY_MM_DD_HHMMSS, got: {$timestamp}");
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromTimestamp(string $timestamp): self
|
||||
{
|
||||
return new self($timestamp);
|
||||
}
|
||||
|
||||
public static function fromSequence(string $domain, int $sequence): self
|
||||
{
|
||||
// Convert to timestamp-like format for consistent sorting
|
||||
$timestamp = sprintf('%s_%03d_000000', $domain, $sequence);
|
||||
|
||||
return new self($timestamp);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
public function compare(MigrationVersion $other): int
|
||||
{
|
||||
return $this->timestamp <=> $other->timestamp;
|
||||
}
|
||||
|
||||
public function equals(MigrationVersion $other): bool
|
||||
{
|
||||
return $this->timestamp === $other->timestamp;
|
||||
}
|
||||
}
|
||||
139
src/Framework/Database/Migration/MigrationVersionCollection.php
Normal file
139
src/Framework/Database/Migration/MigrationVersionCollection.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Migration;
|
||||
|
||||
use ArrayIterator;
|
||||
use Countable;
|
||||
use IteratorAggregate;
|
||||
use Traversable;
|
||||
|
||||
/**
|
||||
* Collection of MigrationVersion objects
|
||||
*/
|
||||
final readonly class MigrationVersionCollection implements Countable, IteratorAggregate
|
||||
{
|
||||
/** @var MigrationVersion[] */
|
||||
private array $versions;
|
||||
|
||||
/**
|
||||
* @param MigrationVersion[] $versions
|
||||
*/
|
||||
public function __construct(array $versions = [])
|
||||
{
|
||||
$this->versions = array_values($versions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array of version strings
|
||||
* @param string[] $versionStrings
|
||||
*/
|
||||
public static function fromStrings(array $versionStrings): self
|
||||
{
|
||||
$versions = array_map(
|
||||
fn (string $versionString) => MigrationVersion::fromTimestamp($versionString),
|
||||
$versionStrings
|
||||
);
|
||||
|
||||
return new self($versions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MigrationVersion[] $versions
|
||||
*/
|
||||
public static function fromVersions(array $versions): self
|
||||
{
|
||||
return new self($versions);
|
||||
}
|
||||
|
||||
public function add(MigrationVersion $version): self
|
||||
{
|
||||
$versions = $this->versions;
|
||||
$versions[] = $version;
|
||||
|
||||
return new self($versions);
|
||||
}
|
||||
|
||||
public function contains(MigrationVersion $version): bool
|
||||
{
|
||||
foreach ($this->versions as $existingVersion) {
|
||||
if ($existingVersion->equals($version)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function containsString(string $versionString): bool
|
||||
{
|
||||
foreach ($this->versions as $version) {
|
||||
if ($version->timestamp === $versionString) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->versions);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return count($this->versions) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorted versions (oldest first)
|
||||
*/
|
||||
public function sorted(): self
|
||||
{
|
||||
$versions = $this->versions;
|
||||
usort($versions, fn (MigrationVersion $a, MigrationVersion $b) => $a->compare($b));
|
||||
|
||||
return new self($versions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest (most recent) version
|
||||
*/
|
||||
public function getLatest(): ?MigrationVersion
|
||||
{
|
||||
if ($this->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sortedVersions = $this->sorted()->versions;
|
||||
|
||||
return end($sortedVersions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array of version strings for backward compatibility
|
||||
* @return string[]
|
||||
*/
|
||||
public function toStringArray(): array
|
||||
{
|
||||
return array_map(fn (MigrationVersion $version) => (string) $version, $this->versions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MigrationVersion[]
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->versions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Traversable<int, MigrationVersion>
|
||||
*/
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return new ArrayIterator($this->versions);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user