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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}
}

View File

@@ -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)));
}
}

View 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;
}

View File

@@ -12,7 +12,7 @@ interface Migration
public function down(ConnectionInterface $connection): void;
public function getVersion(): string;
public function getVersion(): MigrationVersion;
public function getDescription(): string;
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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();
}
}

View File

@@ -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)");
}
}
}

View 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,
];
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}