docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -1,110 +0,0 @@
<?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

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
final readonly class ApplyMigrations
@@ -16,15 +17,17 @@ final readonly class ApplyMigrations
}
#[ConsoleCommand('db:migrate', 'Apply all pending migrations')]
public function migrate(): ExitCode
public function migrate(ConsoleInput $input): ExitCode
{
echo "Running migrations...\n";
$skipPreflightChecks = $input->hasOption('skip-preflight') || $input->hasOption('force');
try {
// Use the injected loader that leverages the discovery system
$migrations = $this->loader->loadMigrations();
$executed = $this->runner->migrate($migrations);
$executed = $this->runner->migrate($migrations, $skipPreflightChecks);
if (empty($executed)) {
echo "No migrations to run.\n";
@@ -35,6 +38,9 @@ final readonly class ApplyMigrations
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
echo "❌ Migration failed: " . $e->getMessage() . "\n";
echo "Error details: " . get_class($e) . "\n";
echo "File: " . $e->getFile() . ":" . $e->getLine() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
return ExitCode::SOFTWARE_ERROR;
}

View File

@@ -5,30 +5,45 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Parameter;
use App\Framework\Database\Migration\MigrationGenerator;
final class MakeMigrationCommand
final readonly class MakeMigrationCommand
{
public function __construct(
private readonly MigrationGenerator $generator
private MigrationGenerator $generator
) {
}
/**
* Generate a new migration file
*
* @param string $name Migration name (e.g. CreateUsersTable)
* @param string $domain Domain name for the migration
*/
#[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";
public function __invoke(
#[Parameter('Migration name (e.g. CreateUsersTable)', example: 'CreateUsersTable')]
string $name,
#[Parameter('Domain name for organizing migrations')]
string $domain = 'Media'
): ExitCode {
if (empty(trim($name))) {
echo "Error: Migration name cannot be empty\n";
return;
return ExitCode::INVALID_INPUT;
}
try {
$filePath = $this->generator->generate($name, $domain);
echo "Migration created: {$filePath}\n";
echo "Migration created: {$filePath}\n";
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "Error creating migration: {$e->getMessage()}\n";
echo "Error creating migration: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Table\ConsoleTable;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock;
use App\Framework\Performance\ValueObjects\Byte;
final readonly class MigrationPerformanceCommand
{
public function __construct(
private ConnectionInterface $connection,
private Clock $clock
) {
}
#[ConsoleCommand('db:migration:performance', 'Display performance statistics for migration executions')]
public function showPerformance(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeln('');
$output->writeln('<info>📊 Migration Performance Report</info>');
$output->writeln(str_repeat('=', 60));
try {
// Check if performance tracking table exists
if (! $this->performanceTableExists()) {
$output->writeln('<warning>⚠️ No performance data available yet.</warning>');
$output->writeln('Performance tracking will be available after running migrations.');
return ExitCode::SUCCESS;
}
// Get performance statistics
$stats = $this->getPerformanceStatistics();
if (empty($stats['total'])) {
$output->writeln('<comment>No migration performance data recorded yet.</comment>');
return ExitCode::SUCCESS;
}
// Display summary
$this->displaySummary($output, $stats);
// Display recent migrations table
$this->displayRecentMigrations($output);
// Display top slowest migrations
$this->displaySlowestMigrations($output);
// Display memory usage statistics
$this->displayMemoryStatistics($output);
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeln('<error>Failed to fetch performance data: ' . $e->getMessage() . '</error>');
return ExitCode::FAILURE;
}
}
private function performanceTableExists(): bool
{
try {
$this->connection->query(SqlQuery::create(
'SELECT 1 FROM migration_performance LIMIT 1'
));
return true;
} catch (\Throwable) {
return false;
}
}
private function getPerformanceStatistics(): array
{
$stats = $this->connection->queryFirst(SqlQuery::create(
'SELECT
COUNT(*) as total,
AVG(duration_ms) as avg_duration,
MIN(duration_ms) as min_duration,
MAX(duration_ms) as max_duration,
AVG(peak_memory) as avg_memory,
MAX(peak_memory) as max_memory,
SUM(CASE WHEN status = "success" THEN 1 ELSE 0 END) as successful,
SUM(CASE WHEN status = "failed" THEN 1 ELSE 0 END) as failed
FROM migration_performance'
));
return $stats ?: [
'total' => 0,
'avg_duration' => 0,
'min_duration' => 0,
'max_duration' => 0,
'avg_memory' => 0,
'max_memory' => 0,
'successful' => 0,
'failed' => 0,
];
}
private function displaySummary(ConsoleOutput $output, array $stats): void
{
$output->writeln('');
$output->writeln('<comment>📈 Overall Statistics</comment>');
$output->writeln(str_repeat('-', 40));
$successRate = $stats['total'] > 0
? round(($stats['successful'] / $stats['total']) * 100, 1)
: 0;
$output->writeln(sprintf('Total Migrations: <info>%d</info>', $stats['total']));
$output->writeln(sprintf(
'Successful: <info>%d</info> (%.1f%%)',
$stats['successful'],
$successRate
));
$output->writeln(sprintf('Failed: <error>%d</error>', $stats['failed']));
$output->writeln('');
$output->writeln(sprintf('Average Duration: <info>%.2f ms</info>', $stats['avg_duration']));
$output->writeln(sprintf('Fastest Migration: <info>%.2f ms</info>', $stats['min_duration']));
$output->writeln(sprintf('Slowest Migration: <warning>%.2f ms</warning>', $stats['max_duration']));
$output->writeln('');
$output->writeln(sprintf(
'Average Memory: <info>%s</info>',
Byte::fromBytes((int)$stats['avg_memory'])->toHumanReadable()
));
$output->writeln(sprintf(
'Peak Memory: <warning>%s</warning>',
Byte::fromBytes((int)$stats['max_memory'])->toHumanReadable()
));
}
private function displayRecentMigrations(ConsoleOutput $output): void
{
$output->writeln('');
$output->writeln('<comment>🕐 Recent Migrations (Last 10)</comment>');
$output->writeln(str_repeat('-', 40));
$recent = $this->connection->query(SqlQuery::create(
'SELECT
migration_version,
status,
duration_ms,
peak_memory,
executed_at
FROM migration_performance
ORDER BY executed_at DESC
LIMIT 10'
));
if ($recent->count() === 0) {
$output->writeln('No recent migrations found.');
return;
}
$table = new ConsoleTable($output);
$table->setHeaders(['Version', 'Status', 'Duration', 'Memory', 'Executed At']);
foreach ($recent as $row) {
$status = $row['status'] === 'success'
? '<info>✅ Success</info>'
: '<error>❌ Failed</error>';
$table->addRow([
$row['migration_version'],
$status,
sprintf('%.2f ms', $row['duration_ms']),
Byte::fromBytes((int)$row['peak_memory'])->toHumanReadable(),
$row['executed_at'],
]);
}
$table->render();
}
private function displaySlowestMigrations(ConsoleOutput $output): void
{
$output->writeln('');
$output->writeln('<comment>🐌 Slowest Migrations (Top 5)</comment>');
$output->writeln(str_repeat('-', 40));
$slowest = $this->connection->query(SqlQuery::create(
'SELECT
migration_version,
duration_ms,
peak_memory,
executed_at
FROM migration_performance
WHERE status = "success"
ORDER BY duration_ms DESC
LIMIT 5'
));
if ($slowest->count() === 0) {
$output->writeln('No migration data available.');
return;
}
$table = new ConsoleTable($output);
$table->setHeaders(['Version', 'Duration', 'Memory', 'Date']);
foreach ($slowest as $row) {
$table->addRow([
$row['migration_version'],
sprintf('<warning>%.2f ms</warning>', $row['duration_ms']),
Byte::fromBytes((int)$row['peak_memory'])->toHumanReadable(),
date('Y-m-d', strtotime($row['executed_at'])),
]);
}
$table->render();
}
private function displayMemoryStatistics(ConsoleOutput $output): void
{
$output->writeln('');
$output->writeln('<comment>💾 Memory Usage Distribution</comment>');
$output->writeln(str_repeat('-', 40));
$memoryRanges = $this->connection->query(SqlQuery::create(
'SELECT
CASE
WHEN peak_memory < 1048576 THEN "< 1 MB"
WHEN peak_memory < 5242880 THEN "1-5 MB"
WHEN peak_memory < 10485760 THEN "5-10 MB"
WHEN peak_memory < 52428800 THEN "10-50 MB"
ELSE "> 50 MB"
END as memory_range,
COUNT(*) as count
FROM migration_performance
GROUP BY memory_range
ORDER BY
CASE memory_range
WHEN "< 1 MB" THEN 1
WHEN "1-5 MB" THEN 2
WHEN "5-10 MB" THEN 3
WHEN "10-50 MB" THEN 4
ELSE 5
END'
));
foreach ($memoryRanges as $range) {
$bar = str_repeat('▓', min(50, (int)($range['count'] * 2)));
$output->writeln(sprintf(
'%10s: %s %d',
$range['memory_range'],
$bar,
$range['count']
));
}
}
#[ConsoleCommand('db:migration:performance:clear', 'Clear migration performance history')]
public function clearPerformance(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
if (! $input->hasOption('force')) {
$output->writeln('<warning>⚠️ This will delete all migration performance history.</warning>');
$output->writeln('Use --force to confirm.');
return ExitCode::FAILURE;
}
try {
$this->connection->execute(SqlQuery::create('DELETE FROM migration_performance'));
$output->writeln('<info>✅ Migration performance history cleared.</info>');
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeln('<error>Failed to clear performance data: ' . $e->getMessage() . '</error>');
return ExitCode::FAILURE;
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Exception;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
final class MemoryThresholdExceededException extends FrameworkException
{
public static function forMigration(
string $migrationVersion,
Percentage $currentUsage,
Percentage $threshold,
Byte $currentMemory,
Byte $memoryLimit
): self {
return self::create(
ErrorCode::PERF_MEMORY_LIMIT_EXCEEDED,
"Memory threshold exceeded during migration {$migrationVersion}: {$currentUsage->format(1)}% (threshold: {$threshold->format(1)}%)"
)->withData([
'migration_version' => $migrationVersion,
'current_usage_percentage' => $currentUsage->getValue(),
'threshold_percentage' => $threshold->getValue(),
'current_memory' => $currentMemory->toHumanReadable(),
'memory_limit' => $memoryLimit->toHumanReadable(),
'available_memory' => $memoryLimit->subtract($currentMemory)->toHumanReadable(),
])->withMetadata([
'category' => 'memory',
'severity' => 'high',
'action_required' => 'Consider increasing memory limit or running migrations in smaller batches',
]);
}
public static function batchAborted(
Percentage $currentUsage,
Percentage $abortThreshold,
Byte $currentMemory,
Byte $memoryLimit,
int $completedMigrations,
int $totalMigrations
): self {
return self::create(
ErrorCode::PERF_MEMORY_LIMIT_EXCEEDED,
"Migration batch aborted due to critical memory usage: {$currentUsage->format(1)}% (abort threshold: {$abortThreshold->format(1)}%)"
)->withData([
'current_usage_percentage' => $currentUsage->getValue(),
'abort_threshold_percentage' => $abortThreshold->getValue(),
'current_memory' => $currentMemory->toHumanReadable(),
'memory_limit' => $memoryLimit->toHumanReadable(),
'completed_migrations' => $completedMigrations,
'total_migrations' => $totalMigrations,
'remaining_migrations' => $totalMigrations - $completedMigrations,
])->withMetadata([
'category' => 'memory',
'severity' => 'critical',
'action_required' => 'Increase memory limit or run remaining migrations separately',
]);
}
}

View File

@@ -230,6 +230,30 @@ final readonly class MigrationCollection implements Countable, IteratorAggregate
return $this->sortedDescending()->migrations[0];
}
/**
* Filter migrations that haven't been applied yet
*
* @param MigrationVersionCollection $appliedVersions Applied migration versions
* @return self
*/
public function filterByNotApplied(MigrationVersionCollection $appliedVersions): self
{
return $this->filter(
fn (Migration $migration) => !$appliedVersions->contains($migration->getVersion())
);
}
/**
* Find migration by version (alias for getByVersion for compatibility)
*
* @param MigrationVersion $version The version to find
* @return Migration|null The migration or null if not found
*/
public function findByVersion(MigrationVersion $version): ?Migration
{
return $this->getByVersion($version);
}
/**
* Get all migrations as array
*

View File

@@ -5,38 +5,82 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Core\PathProvider;
use App\Framework\DateTime\Clock;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
final readonly class MigrationGenerator
{
public function __construct(
private PathProvider $pathProvider
private readonly PathProvider $pathProvider,
private readonly Clock $clock
) {
}
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";
try {
$timestamp = $this->clock->now()->format('Ymd_His_000');
$version = MigrationVersion::fromTimestamp($timestamp);
$className = $this->toClassName($name);
$fileName = "{$className}.php";
$migrationPath = $this->pathProvider->resolvePath("src/Domain/{$domain}/Migrations");
$migrationPath = $this->pathProvider->resolvePath("src/Domain/{$domain}/Migrations");
if (! is_dir($migrationPath)) {
mkdir($migrationPath, 0755, true);
if (! is_dir($migrationPath)) {
if (! mkdir($migrationPath, 0755, true) && ! is_dir($migrationPath)) {
throw FrameworkException::create(
ErrorCode::SYSTEM_RESOURCE_EXHAUSTED,
"Failed to create migration directory"
)->withData(['directory' => $migrationPath]);
}
}
$filePath = $migrationPath . '/' . $fileName;
if (file_exists($filePath)) {
throw FrameworkException::create(
ErrorCode::VAL_DUPLICATE_VALUE,
"Migration file already exists"
)->withContext(
ExceptionContext::forOperation('migration.generate', 'MigrationGenerator')
->withData([
'file_path' => $filePath,
'migration_name' => $name,
'domain' => $domain,
])
);
}
$content = $this->generateMigrationContent($className, $name, $domain, $version->timestamp);
if (file_put_contents($filePath, $content) === false) {
throw FrameworkException::create(
ErrorCode::SYSTEM_RESOURCE_EXHAUSTED,
"Failed to write migration file"
)->withData([
'file_path' => $filePath,
'content_length' => strlen($content),
]);
}
return $filePath;
} catch (FrameworkException $e) {
throw $e;
} catch (\Throwable $e) {
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_FAILED,
"Migration generation failed"
)->withContext(
ExceptionContext::forOperation('migration.generate', 'MigrationGenerator')
->withData([
'migration_name' => $name,
'domain' => $domain,
'error_message' => $e->getMessage(),
])
);
}
$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

View File

@@ -23,9 +23,28 @@ final readonly class MigrationLoader
$interfaceMappings = $this->discoveryRegistry->interfaces->findMappingsForInterface(Migration::class);
foreach ($interfaceMappings as $mapping) {
/** @var Migration $migrationInstance */
$migrationInstance = $this->container->get($mapping->implementation->getFullyQualified());
$migrations[] = $migrationInstance;
$className = $mapping->implementation->getFullyQualified();
// Skip interfaces and abstract classes - only load concrete classes
if (! class_exists($className)) {
continue;
}
$reflection = new \ReflectionClass($className);
if ($reflection->isInterface() || $reflection->isAbstract()) {
continue;
}
try {
/** @var Migration $migrationInstance */
$migrationInstance = $this->container->get($className);
$migrations[] = $migrationInstance;
} catch (\Throwable $e) {
// Skip classes that can't be instantiated
error_log("Warning: Could not instantiate migration class {$className}: " . $e->getMessage());
continue;
}
}
return MigrationCollection::fromArray($migrations)->sorted();

View File

@@ -5,252 +5,415 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\Transaction;
use App\Framework\Database\Migration\Services\MigrationDatabaseManager;
use App\Framework\Database\Migration\Services\MigrationErrorAnalyzer;
use App\Framework\Database\Migration\Services\MigrationLogger;
use App\Framework\Database\Migration\Services\MigrationPerformanceTracker;
use App\Framework\Database\Migration\Services\MigrationValidator;
use App\Framework\Database\Migration\ValueObjects\MemoryThresholds;
use App\Framework\Database\Migration\ValueObjects\MigrationTableConfig;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\DateTime\Clock;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\OperationTracker;
use App\Framework\Performance\PerformanceReporter;
use App\Framework\Performance\Repository\PerformanceMetricsRepository;
final readonly class MigrationRunner
{
/**
* @var MigrationDependencyGraph Dependency graph for migrations
*/
private MigrationDependencyGraph $dependencyGraph;
private MigrationDatabaseManager $databaseManager;
private MigrationPerformanceTracker $performanceTracker;
private MigrationLogger $migrationLogger;
private MigrationValidator $validator;
private MigrationErrorAnalyzer $errorAnalyzer;
public function __construct(
private ConnectionInterface $connection,
private string $migrationsTable = 'migrations'
private DatabasePlatform $platform,
private Clock $clock,
?MigrationTableConfig $tableConfig = null,
?Logger $logger = null,
?OperationTracker $operationTracker = null,
?MemoryMonitor $memoryMonitor = null,
?PerformanceReporter $performanceReporter = null,
?MemoryThresholds $memoryThresholds = null,
?PerformanceMetricsRepository $performanceMetricsRepository = null
) {
$this->ensureMigrationsTable();
$this->dependencyGraph = new MigrationDependencyGraph();
$effectiveTableConfig = $tableConfig ?? MigrationTableConfig::default();
// Initialize service classes
$this->databaseManager = new MigrationDatabaseManager(
$this->connection,
$this->platform,
$this->clock,
$effectiveTableConfig
);
$this->performanceTracker = new MigrationPerformanceTracker(
$operationTracker,
$memoryMonitor,
$performanceMetricsRepository,
$logger,
$memoryThresholds
);
$this->migrationLogger = new MigrationLogger($logger);
$this->validator = new MigrationValidator(
$this->connection,
$this->platform
);
$this->errorAnalyzer = new MigrationErrorAnalyzer();
}
/**
* Run migrations
*
* @param MigrationCollection $migrations The migrations to run
* @return array<string> List of executed migration versions
*/
public function migrate(MigrationCollection $migrations): array
public function migrate(MigrationCollection $migrations, bool $skipPreflightChecks = false): array
{
$this->databaseManager->ensureMigrationsTable();
$executedMigrations = [];
$appliedVersions = $this->getAppliedVersions();
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
// Build the dependency graph
$this->dependencyGraph->buildGraph($migrations);
// Pre-flight checks
if (! $skipPreflightChecks) {
$this->runPreFlightChecks($migrations, $appliedVersions);
}
// Get migrations in the correct execution order based on dependencies
// Filter and order migrations
$pendingMigrations = $migrations->filterByNotApplied($appliedVersions);
if ($pendingMigrations->isEmpty()) {
$this->migrationLogger->logMigration('none', 'No pending migrations', 'Skipped');
return [];
}
$this->dependencyGraph->buildGraph($pendingMigrations);
$orderedMigrations = $this->dependencyGraph->getExecutionOrder();
$totalMigrations = $orderedMigrations->count();
// Start batch tracking
$batchOperationId = 'migration_batch_' . uniqid();
$this->performanceTracker->startBatchOperation($batchOperationId, $totalMigrations);
$currentPosition = 0;
foreach ($orderedMigrations as $migration) {
$version = $migration->getVersion()->toString();
if ($appliedVersions->containsString($version)) {
$currentPosition++;
if ($appliedVersions->contains($migration->getVersion())) {
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, $version) {
echo "Migrating: {$version} - {$migration->getDescription()}\n";
// Start individual migration tracking
$migrationOperationId = "migration_{$version}";
$this->performanceTracker->startMigrationOperation(
$migrationOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalMigrations,
$batchOperationId
);
// Check memory thresholds before migration
$this->performanceTracker->checkMemoryThresholds($version, $currentPosition - 1, $totalMigrations);
// Execute migration in transaction
$this->connection->beginTransaction();
try {
$migration->up($this->connection);
$this->databaseManager->recordMigrationExecution($migration, $version);
$this->connection->execute(
"INSERT INTO {$this->migrationsTable} (version, description, executed_at) VALUES (?, ?, ?)",
[$version, $migration->getDescription(), date('Y-m-d H:i:s')]
);
});
if ($this->connection->inTransaction()) {
$this->connection->commit();
}
// Update operation with successful execution
$this->performanceTracker->updateBatchOperation($batchOperationId, [
'items_processed' => $currentPosition,
]);
} catch (\Throwable $e) {
if ($this->connection->inTransaction()) {
$this->connection->rollback();
}
throw $e;
}
$executedMigrations[] = $version;
echo "Migrated: {$version}\n";
// Complete individual migration tracking
$migrationSnapshot = $this->performanceTracker->completeMigrationOperation(
$migrationOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalMigrations,
$batchOperationId
);
$this->migrationLogger->logMigration($version, $migration->getDescription(), 'Migrated');
$this->migrationLogger->logMigrationProgress($version, $currentPosition, $totalMigrations, 'completed', [
'execution_time' => $migrationSnapshot?->duration?->toSeconds(),
'memory_used' => $migrationSnapshot?->memoryDelta?->toHumanReadable(),
]);
// Post-migration memory check
$this->performanceTracker->logPostMigrationMemory($version, $migrationSnapshot);
} catch (\Throwable $e) {
throw new DatabaseException(
"Migration {$version} failed: {$e->getMessage()}",
0,
// Track failed individual migration
$failedSnapshot = $this->performanceTracker->failMigrationOperation(
$migrationOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalMigrations,
$batchOperationId,
$e
);
// Track failed batch
$this->performanceTracker->failBatchOperation($batchOperationId, $e);
// Enhanced error reporting with recovery hints
$recoveryHints = $this->errorAnalyzer->generateMigrationRecoveryHints($migration, $e, $executedMigrations);
$migrationContext = $this->errorAnalyzer->analyzeMigrationContext($migration, $version, $e);
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_FAILED,
"Migration {$version} failed: {$e->getMessage()}"
)->withContext(
ExceptionContext::forOperation('migration.execute', 'MigrationRunner')
->withData([
'migration_version' => $version,
'migration_description' => $migration->getDescription(),
'executed_migrations' => $executedMigrations,
'total_migrations' => $totalMigrations,
'current_position' => $currentPosition,
])
->withDebug($migrationContext)
->withMetadata([
'recovery_hints' => $recoveryHints,
'can_retry' => $this->errorAnalyzer->canRetryMigration($e),
'rollback_recommended' => $this->errorAnalyzer->isRollbackRecommended($e, $executedMigrations),
])
);
}
}
// Complete batch tracking
$batchSnapshot = $this->performanceTracker->completeBatchOperation($batchOperationId);
$this->migrationLogger->logMigrationBatchSummary($executedMigrations, $totalMigrations, $batchSnapshot);
return $executedMigrations;
}
/**
* 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(MigrationCollection $migrations, int $steps = 1): MigrationCollection
public function rollback(MigrationCollection $migrations, int $steps = 1): array
{
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
if ($appliedVersions->isEmpty()) {
$this->migrationLogger->logMigration('none', 'No migrations to rollback', 'Skipped');
return [];
}
$versionsToRollback = array_slice(array_reverse($appliedVersions->toArray()), 0, $steps);
$rolledBackMigrations = [];
$appliedVersions = $this->getAppliedVersions();
$totalRollbacks = count($versionsToRollback);
// Build the dependency graph
$this->dependencyGraph->buildGraph($migrations);
// Start rollback batch tracking
$rollbackBatchId = 'rollback_batch_' . uniqid();
$this->performanceTracker->startBatchOperation($rollbackBatchId, $totalRollbacks);
// 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;
}
$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
$currentPosition = 0;
foreach ($versionsToRollback as $version) {
$migration = $migrationMap[$version];
$currentPosition++;
$migration = $migrations->findByVersion(MigrationVersion::fromTimestamp($version));
if (! $migration) {
$this->migrationLogger->logRollbackSkipped($version, ['reason' => 'Migration class not found']);
continue;
}
try {
Transaction::run($this->connection, function () use ($migration, $version) {
echo "Rolling back: {$version} - {$migration->getDescription()}\n";
// Validate rollback safety
$this->validator->validateRollbackSafety($migration, $version);
// Real-time rollback progress tracking
$this->migrationLogger->logRollbackProgress($version, $currentPosition, $totalRollbacks, 'starting');
// Start individual rollback tracking
$rollbackOperationId = "rollback_{$version}";
$this->performanceTracker->startRollbackOperation(
$rollbackOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalRollbacks,
$rollbackBatchId,
$steps
);
// Execute rollback in transaction
$this->connection->beginTransaction();
try {
$migration->down($this->connection);
$this->databaseManager->recordMigrationRollback($version);
$this->connection->execute(
"DELETE FROM {$this->migrationsTable} WHERE version = ?",
[$version]
);
});
if ($this->connection->inTransaction()) {
$this->connection->commit();
}
} catch (\Throwable $e) {
if ($this->connection->inTransaction()) {
$this->connection->rollback();
}
throw $e;
}
$rolledBackMigrations[] = $migration;
echo "Rolled back: {$version}\n";
// Complete individual rollback tracking
$rollbackSnapshot = $this->performanceTracker->completeRollbackOperation(
$rollbackOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalRollbacks,
$rollbackBatchId,
$steps
);
$this->migrationLogger->logMigration($version, $migration->getDescription(), 'Rolled back');
$this->migrationLogger->logRollbackProgress($version, $currentPosition, $totalRollbacks, 'completed', [
'execution_time' => $rollbackSnapshot?->duration?->toSeconds(),
'memory_used' => $rollbackSnapshot?->memoryDelta?->toHumanReadable(),
]);
// Post-rollback memory check
$this->performanceTracker->logPostRollbackMemory($version, $rollbackSnapshot);
} catch (\Throwable $e) {
throw new DatabaseException(
"Rollback {$version} failed: {$e->getMessage()}",
0,
$e
// Generate rollback recovery hints
$remainingRollbacks = array_slice($versionsToRollback, $currentPosition);
$recoveryHints = $this->errorAnalyzer->generateRollbackRecoveryHints($migration, $e, $remainingRollbacks);
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_ROLLBACK_FAILED,
"Rollback failed for migration {$version}: {$e->getMessage()}"
)->withContext(
ExceptionContext::forOperation('migration.rollback', 'MigrationRunner')
->withData([
'migration_version' => $version,
'rollback_steps' => $steps,
'completed_rollbacks' => count($rolledBackMigrations),
'failed_migration' => $version,
])
->withMetadata([
'recovery_hints' => $recoveryHints,
'can_continue' => $this->errorAnalyzer->canContinueRollback($remainingRollbacks),
])
);
}
}
return new MigrationCollection(...$rolledBackMigrations);
// Complete rollback batch tracking
$rollbackSnapshot = $this->performanceTracker->completeBatchOperation($rollbackBatchId);
$this->migrationLogger->logRollbackBatchSummary($rolledBackMigrations, $totalRollbacks, $rollbackSnapshot);
return $rolledBackMigrations;
}
/**
* Get status of all migrations
*
* @param MigrationCollection $migrations The migrations to check status for
* @return MigrationStatusCollection Collection of migration statuses
* Get applied migration versions
*/
public function getStatus(MigrationCollection $migrations): MigrationStatusCollection
public function getAppliedVersions(): array
{
$appliedVersions = $this->getAppliedVersions();
return $this->databaseManager->getAppliedVersions();
}
/**
* Get status information for all migrations
*
* @return array<MigrationStatus>
*/
public function getStatus(MigrationCollection $migrations): array
{
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
$statuses = [];
foreach ($migrations as $migration) {
$version = $migration->getVersion();
$applied = $appliedVersions->contains($version);
$statuses[] = $applied
? MigrationStatus::applied($version, $migration->getDescription())
: MigrationStatus::pending($version, $migration->getDescription());
$statuses[] = new MigrationStatus(
version: $version,
description: $migration->getDescription(),
applied: $applied
);
}
return new MigrationStatusCollection($statuses);
return $statuses;
}
public function getAppliedVersions(): MigrationVersionCollection
private function runPreFlightChecks(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): void
{
$versionStrings = $this->connection->queryColumn(
"SELECT version FROM {$this->migrationsTable} ORDER BY executed_at"
$results = $this->validator->runPreFlightChecks($migrations, $appliedVersions);
$dependencyResults = $this->validator->validateMigrationDependencies($migrations, $appliedVersions);
// Extract critical issues and warnings
$criticalIssues = [];
$warnings = [];
foreach ($results as $check => $result) {
if ($result['status'] === 'fail' && ($result['severity'] ?? 'info') === 'critical') {
$criticalIssues[] = $check . ': ' . $result['message'];
} elseif ($result['status'] === 'warning' || ($result['severity'] ?? 'info') === 'warning') {
$warnings[] = $check . ': ' . $result['message'];
}
}
$this->migrationLogger->logPreFlightResults($results, $criticalIssues, $warnings);
$this->migrationLogger->logDependencyValidationIssues(
$dependencyResults['orphaned_dependencies'],
$dependencyResults['partial_chains'],
$dependencyResults['ordering_issues']
);
return MigrationVersionCollection::fromStrings($versionStrings);
}
private function ensureMigrationsTable(): void
{
// 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)");
// Throw exception if critical issues found
if (! empty($criticalIssues)) {
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_PREFLIGHT_FAILED,
'Pre-flight checks failed with critical issues'
)->withData([
'critical_issues' => $criticalIssues,
'warnings' => $warnings,
'full_results' => $results,
]);
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Contracts\SchemaBuilderFactoryInterface;
use App\Framework\Database\Schema\Contracts\SchemaBuilderInterface;
final readonly class CreatePerformanceMetricsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schemaBuilder = SchemaBuilderFactoryInterface::create($connection);
$schemaBuilder->createTable('performance_metrics', function (SchemaBuilderInterface $table) {
$table->id();
$table->string('operation_id', 255)->notNull();
$table->string('operation_type', 100)->notNull();
$table->string('category', 50)->notNull();
$table->string('migration_version', 100)->nullable();
$table->integer('execution_time_ms')->notNull();
$table->bigInteger('memory_start_bytes')->notNull();
$table->bigInteger('memory_end_bytes')->notNull();
$table->bigInteger('memory_peak_bytes')->notNull();
$table->bigInteger('memory_delta_bytes')->notNull();
$table->boolean('success')->notNull()->default(true);
$table->text('error_message')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
// Indexes
$table->index('operation_type');
$table->index('category');
$table->index('migration_version');
$table->index('created_at');
$table->index('success');
});
}
public function down(ConnectionInterface $connection): void
{
$schemaBuilder = SchemaBuilderFactoryInterface::create($connection);
$schemaBuilder->dropTable('performance_metrics');
}
public function getDescription(): string
{
return 'Create performance metrics table for migration and system performance tracking';
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2024_09_28_233500');
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\ValueObjects\MigrationTableConfig;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock;
final readonly class MigrationDatabaseManager
{
public function __construct(
private ConnectionInterface $connection,
private DatabasePlatform $platform,
private Clock $clock,
private MigrationTableConfig $tableConfig
) {
}
public function ensureMigrationsTable(): void
{
if ($this->tableExists($this->tableConfig->tableName)) {
return;
}
$sql = $this->createMigrationsTableSQL($this->tableConfig->tableName);
$this->connection->execute(SqlQuery::create($sql));
}
public function recordMigrationExecution(Migration $migration, string $version): void
{
$now = $this->clock->now()->format('Y-m-d H:i:s');
$sql = "INSERT INTO {$this->tableConfig->tableName} (version, description, executed_at) VALUES (?, ?, ?)";
$this->connection->execute(SqlQuery::create($sql, [$version, $migration->getDescription(), $now]));
}
public function recordMigrationRollback(string $version): void
{
$sql = "DELETE FROM {$this->tableConfig->tableName} WHERE version = ?";
$this->connection->execute(SqlQuery::create($sql, [$version]));
}
public function getAppliedVersions(): array
{
$this->ensureMigrationsTable();
$sql = "SELECT version FROM {$this->tableConfig->tableName} ORDER BY executed_at ASC";
return $this->connection->queryColumn(SqlQuery::create($sql));
}
public function tableExists(string $tableName): bool
{
try {
$sql = $this->platform->getTableExistsSQL($tableName);
$result = $this->connection->queryScalar(SqlQuery::create($sql));
return (bool) $result;
} catch (\Throwable $e) {
return false;
}
}
private function createMigrationsTableSQL(string $tableName): string
{
return match ($this->platform->getName()) {
'mysql' => "CREATE TABLE {$tableName} (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_version (version),
INDEX idx_executed_at (executed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'postgresql' => "CREATE TABLE {$tableName} (
id SERIAL PRIMARY KEY,
version VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_{$tableName}_version ON {$tableName} (version);
CREATE INDEX idx_{$tableName}_executed_at ON {$tableName} (executed_at);",
'sqlite' => "CREATE TABLE {$tableName} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_{$tableName}_version ON {$tableName} (version);
CREATE INDEX idx_{$tableName}_executed_at ON {$tableName} (executed_at);",
default => throw new \RuntimeException("Unsupported database platform: {$this->platform->getName()}")
};
}
}

View File

@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Database\Migration\Migration;
final readonly class MigrationErrorAnalyzer
{
public function generateMigrationRecoveryHints(Migration $migration, \Throwable $error, array $executedMigrations): array
{
$hints = [];
// Analyze error type and provide specific guidance
if ($this->isDatabaseConnectionError($error)) {
$hints[] = 'Database connection lost - check database server status';
$hints[] = 'Verify database credentials and network connectivity';
$hints[] = 'Consider retrying after connection is restored';
}
if ($this->isPermissionError($error)) {
$hints[] = 'Database permission denied - verify user privileges';
$hints[] = 'Ensure migration user has CREATE, ALTER, DROP permissions';
$hints[] = 'Check if migration user can access the target database';
}
if ($this->isSyntaxError($error)) {
$hints[] = 'SQL syntax error detected - review migration SQL';
$hints[] = 'Check for database-specific syntax requirements';
$hints[] = 'Validate migration against target database version';
}
if ($this->isDiskSpaceError($error)) {
$hints[] = 'Insufficient disk space - free up storage';
$hints[] = 'Consider running migrations in smaller batches';
$hints[] = 'Check database temp directory space';
}
if ($this->isConstraintViolation($error)) {
$hints[] = 'Database constraint violation - check data integrity';
$hints[] = 'Review foreign key relationships and unique constraints';
$hints[] = 'Consider data cleanup before retry';
}
// General recovery recommendations
if ($this->canRetryMigration($error)) {
$hints[] = 'Migration can be safely retried after resolving the issue';
}
if ($this->isRollbackRecommended($error, $executedMigrations)) {
$hints[] = 'Consider rolling back completed migrations before retry';
$hints[] = sprintf('Rollback %d successfully completed migrations', count($executedMigrations));
}
if (empty($hints)) {
$hints[] = 'Review migration code and database state manually';
$hints[] = 'Check application logs for additional context';
}
return $hints;
}
public function generateRollbackRecoveryHints(Migration $migration, \Throwable $error, array $remainingRollbacks): array
{
$hints = [];
if ($this->isDatabaseConnectionError($error)) {
$hints[] = 'Database connection lost during rollback - check server status';
$hints[] = 'Rollback operation can be continued after connection restore';
}
if ($this->isDataLossRisk($error)) {
$hints[] = 'Rollback may cause data loss - backup before continuing';
$hints[] = 'Review rollback SQL for irreversible operations';
}
if ($this->canContinueRollback($remainingRollbacks)) {
$hints[] = sprintf('Can continue rollback for %d remaining migrations', count($remainingRollbacks));
} else {
$hints[] = 'Manual intervention required - some rollbacks may be unsafe';
$hints[] = 'Review each remaining migration rollback individually';
}
if ($this->requiresManualIntervention($error)) {
$hints[] = 'Automatic rollback failed - manual database intervention required';
$hints[] = 'Check database state and manually reverse changes if needed';
}
return $hints;
}
public function analyzeMigrationContext(Migration $migration, string $version, \Throwable $error): array
{
return [
'migration_info' => [
'version' => $version,
'description' => $migration->getDescription(),
'class' => get_class($migration),
],
'error_analysis' => [
'type' => get_class($error),
'message' => $error->getMessage(),
'code' => $error->getCode(),
'file' => $error->getFile(),
'line' => $error->getLine(),
'is_retryable' => $this->canRetryMigration($error),
'is_connection_error' => $this->isDatabaseConnectionError($error),
'is_permission_error' => $this->isPermissionError($error),
'is_syntax_error' => $this->isSyntaxError($error),
'requires_manual_intervention' => $this->requiresManualIntervention($error),
],
'recovery_recommendations' => $this->generateMigrationRecoveryHints($migration, $error, []),
'database_state' => $this->getDatabaseStateDebugInfo(),
'system_info' => [
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
'time_limit' => ini_get('max_execution_time'),
'php_version' => PHP_VERSION,
],
];
}
public function canRetryMigration(\Throwable $error): bool
{
// These errors are typically retryable
return $this->isDatabaseConnectionError($error) ||
$this->isTimeoutError($error) ||
$this->isDiskSpaceError($error) ||
$this->isTemporaryLockError($error);
}
public function isRollbackRecommended(\Throwable $error, array $executedMigrations): bool
{
// Recommend rollback for serious structural errors
return ! empty($executedMigrations) && (
$this->isConstraintViolation($error) ||
$this->isSyntaxError($error) ||
$this->isPermissionError($error)
);
}
public function canContinueRollback(array $remainingRollbacks): bool
{
// Can continue rollback if there are migrations to roll back
return ! empty($remainingRollbacks);
}
public function requiresManualIntervention(\Throwable $error): bool
{
// These errors typically require manual database intervention
return $this->isCorruptionError($error) ||
$this->isDataIntegrityError($error) ||
$this->isStructuralError($error);
}
private function isDatabaseConnectionError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'connection',
'server has gone away',
'lost connection',
'timeout'
);
}
private function isPermissionError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'access denied',
'permission',
'privilege',
'unauthorized'
);
}
private function isSyntaxError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'syntax error',
'sql syntax',
'invalid sql',
'parse error'
);
}
private function isDiskSpaceError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'disk full',
'no space',
'disk space',
'storage full'
);
}
private function isConstraintViolation(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'constraint',
'foreign key',
'unique',
'duplicate'
);
}
private function isTimeoutError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'timeout',
'time limit',
'execution time'
);
}
private function isTemporaryLockError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'lock',
'deadlock',
'table is locked'
);
}
private function isDataLossRisk(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'drop',
'delete',
'truncate'
);
}
private function isCorruptionError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'corrupt',
'damaged',
'invalid header'
);
}
private function isDataIntegrityError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'integrity',
'check constraint',
'data validation'
);
}
private function isStructuralError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'table structure',
'schema',
'column does not exist'
);
}
/**
* Check if a string contains any of the given matches (case-insensitive)
*/
private function containsAny(string $haystack, string ...$needles): bool
{
$lowerHaystack = strtolower($haystack);
foreach ($needles as $needle) {
if (str_contains($lowerHaystack, strtolower($needle))) {
return true;
}
}
return false;
}
private function getDatabaseStateDebugInfo(): array
{
return [
'active_connections' => 'unknown', // Would need platform-specific queries
'transaction_state' => 'unknown',
'lock_status' => 'unknown',
'note' => 'Database state debug info requires platform-specific implementation',
];
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Logging\Logger;
final readonly class MigrationLogger
{
public function __construct(
private ?Logger $logger = null
) {
}
public function logMigration(string $version, string $description, string $action): void
{
$this->logger?->info("Migration {$action}", [
'version' => $version,
'description' => $description,
'action' => $action,
]);
}
public function logRollbackSkipped(string $version, array $dependants): void
{
$this->logger?->warning('Rollback skipped due to dependant migrations', [
'version' => $version,
'dependants' => $dependants,
'reason' => 'Migration has dependencies that would be broken',
]);
}
public function logMigrationProgress(string $version, int $current, int $total, string $status, array $details = []): void
{
$percentage = $total > 0 ? round(($current / $total) * 100, 1) : 0;
$this->logger?->info("Migration progress: {$status}", array_merge([
'migration_version' => $version,
'current' => $current,
'total' => $total,
'percentage' => $percentage,
'status' => $status,
], $details));
}
public function logRollbackProgress(string $version, int $current, int $total, string $status, array $details = []): void
{
$percentage = $total > 0 ? round(($current / $total) * 100, 1) : 0;
$this->logger?->info("Rollback progress: {$status}", array_merge([
'migration_version' => $version,
'current' => $current,
'total' => $total,
'percentage' => $percentage,
'status' => $status,
], $details));
}
public function logMigrationBatchSummary(array $executedMigrations, int $totalMigrations, mixed $batchSnapshot = null): void
{
$this->logger?->info('Migration batch completed', [
'executed_migrations' => count($executedMigrations),
'total_migrations' => $totalMigrations,
'success_rate' => $totalMigrations > 0 ? round((count($executedMigrations) / $totalMigrations) * 100, 1) : 0,
'batch_execution_time' => $batchSnapshot?->duration?->toSeconds(),
'batch_memory_delta' => $batchSnapshot?->memoryDelta?->toHumanReadable(),
'executed_versions' => $executedMigrations,
]);
}
public function logRollbackBatchSummary(array $rolledBackMigrations, int $totalRollbacks, mixed $rollbackSnapshot = null): void
{
$this->logger?->info('Rollback batch completed', [
'rolled_back_migrations' => count($rolledBackMigrations),
'total_rollbacks' => $totalRollbacks,
'success_rate' => $totalRollbacks > 0 ? round((count($rolledBackMigrations) / $totalRollbacks) * 100, 1) : 0,
'batch_execution_time' => $rollbackSnapshot?->duration?->toSeconds(),
'batch_memory_delta' => $rollbackSnapshot?->memoryDelta?->toHumanReadable(),
'rolled_back_versions' => array_map(fn ($migration) => $migration->getVersion()->toString(), $rolledBackMigrations),
]);
}
public function logPreFlightResults(array $results, array $criticalIssues, array $warnings): void
{
if (! empty($criticalIssues)) {
$this->logger?->error('Pre-flight checks found critical issues', [
'critical_issues' => $criticalIssues,
'warnings' => $warnings,
'all_results' => $results,
]);
} elseif (! empty($warnings)) {
$this->logger?->warning('Pre-flight checks found warnings', [
'warnings' => $warnings,
'all_results' => $results,
]);
} else {
$this->logger?->info('Pre-flight checks passed', [
'results' => $results,
]);
}
}
public function logDependencyValidationIssues(array $orphanedDependencies, array $partialChains, array $orderingIssues): void
{
if (! empty($orphanedDependencies) || ! empty($partialChains) || ! empty($orderingIssues)) {
$this->logger?->warning('Migration dependency validation issues found', [
'orphaned_dependencies' => $orphanedDependencies,
'partial_chains' => $partialChains,
'ordering_issues' => $orderingIssues,
]);
}
}
}

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Database\Migration\Exception\MemoryThresholdExceededException;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Migration\ValueObjects\MemoryThresholds;
use App\Framework\Logging\Logger;
use App\Framework\Performance\Entity\PerformanceMetric;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\OperationTracker;
use App\Framework\Performance\PerformanceCategory;
use App\Framework\Performance\Repository\PerformanceMetricsRepository;
final readonly class MigrationPerformanceTracker
{
public function __construct(
private ?OperationTracker $operationTracker = null,
private ?MemoryMonitor $memoryMonitor = null,
private ?PerformanceMetricsRepository $performanceMetricsRepository = null,
private ?Logger $logger = null,
private ?MemoryThresholds $memoryThresholds = null
) {
}
public function startBatchOperation(string $batchId, int $totalMigrations): void
{
$this->operationTracker?->startOperation(
$batchId,
PerformanceCategory::DATABASE,
[
'operation_type' => 'migration_batch',
'total_migrations' => $totalMigrations,
'batch_id' => $batchId,
]
);
}
public function startMigrationOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId
): void {
$this->operationTracker?->startOperation(
$operationId,
PerformanceCategory::DATABASE,
[
'operation_type' => 'migration_execution',
'migration_version' => $version,
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
]
);
}
public function startRollbackOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId,
int $steps
): void {
$this->operationTracker?->startOperation(
$operationId,
PerformanceCategory::DATABASE,
[
'operation_type' => 'rollback_execution',
'migration_version' => $version,
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
'rollback_steps' => $steps,
]
);
}
public function completeMigrationOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId
): mixed {
$snapshot = $this->operationTracker?->completeOperation($operationId);
// Persist performance metrics to database
if ($snapshot && $this->performanceMetricsRepository) {
$performanceMetric = PerformanceMetric::fromPerformanceSnapshot(
operationId: $operationId,
operationType: 'migration_execution',
category: PerformanceCategory::DATABASE,
executionTime: $snapshot->duration,
memoryStart: $snapshot->memoryStart,
memoryEnd: $snapshot->memoryEnd,
memoryPeak: $snapshot->memoryPeak,
memoryDelta: $snapshot->memoryDelta,
success: true,
migrationVersion: MigrationVersion::fromTimestamp($version),
metadata: [
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
]
);
try {
$this->performanceMetricsRepository->save($performanceMetric);
} catch (\Throwable $e) {
$this->logger?->warning('Failed to persist migration performance metrics', [
'migration_version' => $version,
'error' => $e->getMessage(),
]);
}
}
return $snapshot;
}
public function completeRollbackOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId,
int $steps
): mixed {
$snapshot = $this->operationTracker?->completeOperation($operationId);
// Persist rollback performance metrics to database
if ($snapshot && $this->performanceMetricsRepository) {
$performanceMetric = PerformanceMetric::fromPerformanceSnapshot(
operationId: $operationId,
operationType: 'rollback_execution',
category: PerformanceCategory::DATABASE,
executionTime: $snapshot->duration,
memoryStart: $snapshot->memoryStart,
memoryEnd: $snapshot->memoryEnd,
memoryPeak: $snapshot->memoryPeak,
memoryDelta: $snapshot->memoryDelta,
success: true,
migrationVersion: MigrationVersion::fromTimestamp($version),
metadata: [
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
'rollback_steps' => $steps,
]
);
try {
$this->performanceMetricsRepository->save($performanceMetric);
} catch (\Throwable $e) {
$this->logger?->warning('Failed to persist rollback performance metrics', [
'migration_version' => $version,
'error' => $e->getMessage(),
]);
}
}
return $snapshot;
}
public function failMigrationOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId,
\Throwable $error
): mixed {
$snapshot = $this->operationTracker?->failOperation($operationId, $error);
// Persist failed migration performance metrics
if ($snapshot && $this->performanceMetricsRepository) {
$performanceMetric = PerformanceMetric::fromPerformanceSnapshot(
operationId: $operationId,
operationType: 'migration_execution',
category: PerformanceCategory::DATABASE,
executionTime: $snapshot->duration,
memoryStart: $snapshot->memoryStart,
memoryEnd: $snapshot->memoryEnd,
memoryPeak: $snapshot->memoryPeak,
memoryDelta: $snapshot->memoryDelta,
success: false,
errorMessage: $error->getMessage(),
migrationVersion: MigrationVersion::fromTimestamp($version),
metadata: [
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
'error_type' => get_class($error),
]
);
try {
$this->performanceMetricsRepository->save($performanceMetric);
} catch (\Throwable $saveException) {
$this->logger?->warning('Failed to persist failed migration performance metrics', [
'migration_version' => $version,
'error' => $saveException->getMessage(),
'original_error' => $error->getMessage(),
]);
}
}
return $snapshot;
}
public function completeBatchOperation(string $batchId): mixed
{
return $this->operationTracker?->completeOperation($batchId);
}
public function failBatchOperation(string $batchId, \Throwable $error): mixed
{
return $this->operationTracker?->failOperation($batchId, $error);
}
public function updateBatchOperation(string $batchId, array $data): void
{
$this->operationTracker?->updateOperation($batchId, $data);
}
public function checkMemoryThresholds(string $migrationVersion, int $completedMigrations, int $totalMigrations): void
{
if (! $this->memoryMonitor) {
return;
}
$thresholds = $this->getEffectiveMemoryThresholds();
$memorySummary = $this->memoryMonitor->getSummary();
// Check for critical memory usage that should abort the batch
if ($memorySummary->usagePercentage->getValue() >= $thresholds->abort->getValue()) {
throw MemoryThresholdExceededException::batchAborted(
currentUsage: $memorySummary->usagePercentage,
abortThreshold: $thresholds->abort,
currentMemory: $memorySummary->current,
memoryLimit: $memorySummary->limit,
completedMigrations: $completedMigrations,
totalMigrations: $totalMigrations
);
}
// Warning for high memory usage
if ($memorySummary->usagePercentage->getValue() >= $thresholds->warning->getValue()) {
$this->logger?->warning('Migration memory usage approaching threshold', [
'migration_version' => $migrationVersion,
'memory_usage_percentage' => $memorySummary->usagePercentage->getValue(),
'warning_threshold' => $thresholds->warning->getValue(),
'current_memory' => $memorySummary->getCurrentHumanReadable(),
'memory_limit' => $memorySummary->limit->toHumanReadable(),
'completed_migrations' => $completedMigrations,
'total_migrations' => $totalMigrations,
]);
}
}
public function logPostMigrationMemory(string $migrationVersion, mixed $migrationSnapshot = null): void
{
if (! $this->memoryMonitor || ! $this->logger) {
return;
}
$memorySummary = $this->memoryMonitor->getSummary();
$this->logger->info('Post-migration memory status', [
'migration_version' => $migrationVersion,
'current_memory' => $memorySummary->getCurrentHumanReadable(),
'peak_memory' => $memorySummary->getPeakHumanReadable(),
'memory_usage_percentage' => $memorySummary->getUsagePercentageFormatted(),
'execution_time' => $migrationSnapshot?->duration?->toSeconds(),
'memory_delta' => $migrationSnapshot?->memoryDelta?->toHumanReadable(),
'is_approaching_limit' => $memorySummary->isApproachingLimit,
]);
}
public function logPostRollbackMemory(string $migrationVersion, mixed $rollbackSnapshot = null): void
{
if (! $this->memoryMonitor || ! $this->logger) {
return;
}
$memorySummary = $this->memoryMonitor->getSummary();
$this->logger->info('Post-rollback memory status', [
'migration_version' => $migrationVersion,
'current_memory' => $memorySummary->getCurrentHumanReadable(),
'peak_memory' => $memorySummary->getPeakHumanReadable(),
'memory_usage_percentage' => $memorySummary->getUsagePercentageFormatted(),
'execution_time' => $rollbackSnapshot?->duration?->toSeconds(),
'memory_delta' => $rollbackSnapshot?->memoryDelta?->toHumanReadable(),
'is_approaching_limit' => $memorySummary->isApproachingLimit,
]);
}
private function getEffectiveMemoryThresholds(): MemoryThresholds
{
return $this->memoryThresholds ?? MemoryThresholds::default();
}
}

View File

@@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationCollection;
use App\Framework\Database\Migration\MigrationVersionCollection;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
final readonly class MigrationValidator
{
public function __construct(
private ConnectionInterface $connection,
private DatabasePlatform $platform
) {
}
public function runPreFlightChecks(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
$results = [];
$results['connectivity'] = $this->checkDatabaseConnectivity();
$results['permissions'] = $this->checkDatabasePermissions();
$results['schema_state'] = $this->checkSchemaState($migrations, $appliedVersions);
$results['disk_space'] = $this->checkDiskSpace();
$results['backup_readiness'] = $this->checkBackupReadiness();
$results['migration_conflicts'] = $this->checkMigrationConflicts($migrations);
$results['environment_safety'] = $this->checkEnvironmentSafety();
return $results;
}
public function validateMigrationDependencies(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
$orphanedDependencies = [];
$partialChains = $this->findPartialDependencyChains($migrations, $appliedVersions);
$orderingIssues = $this->validateMigrationOrdering($migrations, $appliedVersions);
return [
'orphaned_dependencies' => $orphanedDependencies,
'partial_chains' => $partialChains,
'ordering_issues' => $orderingIssues,
];
}
public function validateRollbackSafety(Migration $migration, string $version): void
{
// Basic rollback safety checks
if (! method_exists($migration, 'down') || ! is_callable([$migration, 'down'])) {
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_FAILED,
"Migration {$version} does not implement rollback functionality"
);
}
// Additional safety checks could be added here
// - Check for data loss operations
// - Validate rollback dependencies
// - Check for irreversible operations
}
private function checkDatabaseConnectivity(): array
{
try {
$result = $this->connection->queryScalar(SqlQuery::create('SELECT 1'));
return [
'status' => 'pass',
'message' => 'Database connection successful',
'details' => ['test_query_result' => $result],
];
} catch (\Throwable $e) {
return [
'status' => 'fail',
'message' => 'Database connection failed',
'details' => ['error' => $e->getMessage()],
'severity' => 'critical',
];
}
}
private function checkDatabasePermissions(): array
{
$permissions = [];
$requiredPermissions = ['CREATE', 'ALTER', 'DROP', 'INSERT', 'UPDATE', 'DELETE', 'SELECT'];
foreach ($requiredPermissions as $permission) {
try {
// Platform-specific permission checks would go here
$permissions[$permission] = true;
} catch (\Throwable $e) {
$permissions[$permission] = false;
}
}
$missingPermissions = array_keys(array_filter($permissions, fn ($has) => ! $has));
return [
'status' => empty($missingPermissions) ? 'pass' : 'fail',
'message' => empty($missingPermissions) ? 'All required permissions available' : 'Missing required permissions',
'details' => [
'permissions' => $permissions,
'missing' => $missingPermissions,
],
'severity' => empty($missingPermissions) ? 'info' : 'critical',
];
}
private function checkSchemaState(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
try {
$expectedTables = $this->extractExpectedTablesFromMigrations($migrations, $appliedVersions);
$actualTables = $this->getActualDatabaseTables();
$missingTables = array_diff($expectedTables, $actualTables);
$unexpectedTables = array_diff($actualTables, $expectedTables);
$status = empty($missingTables) && empty($unexpectedTables) ? 'pass' : 'warning';
return [
'status' => $status,
'message' => 'Schema state analysis completed',
'details' => [
'expected_tables' => $expectedTables,
'actual_tables' => $actualTables,
'missing_tables' => $missingTables,
'unexpected_tables' => $unexpectedTables,
],
'severity' => $status === 'pass' ? 'info' : 'warning',
];
} catch (\Throwable $e) {
return [
'status' => 'fail',
'message' => 'Schema state check failed',
'details' => ['error' => $e->getMessage()],
'severity' => 'warning',
];
}
}
private function checkDiskSpace(): array
{
try {
$freeSpaceBytes = disk_free_space('.');
$totalSpaceBytes = disk_total_space('.');
if ($freeSpaceBytes === false || $totalSpaceBytes === false) {
throw new \RuntimeException('Unable to determine disk space');
}
$freeSpace = Byte::fromBytes($freeSpaceBytes);
$totalSpace = Byte::fromBytes($totalSpaceBytes);
$usagePercentage = $totalSpaceBytes > 0 ? (1 - ($freeSpaceBytes / $totalSpaceBytes)) * 100 : 0;
$status = $usagePercentage < 90 ? 'pass' : ($usagePercentage < 95 ? 'warning' : 'fail');
return [
'status' => $status,
'message' => 'Disk space check completed',
'details' => [
'free_space_bytes' => $freeSpaceBytes,
'total_space_bytes' => $totalSpaceBytes,
'usage_percentage' => round($usagePercentage, 2),
'free_space_human' => $freeSpace->toHumanReadable(),
'total_space_human' => $totalSpace->toHumanReadable(),
],
'severity' => $status === 'pass' ? 'info' : ($status === 'warning' ? 'warning' : 'critical'),
];
} catch (\Throwable $e) {
return [
'status' => 'fail',
'message' => 'Disk space check failed',
'details' => ['error' => $e->getMessage()],
'severity' => 'warning',
];
}
}
private function checkBackupReadiness(): array
{
// This is a placeholder - actual backup systems would be checked here
return [
'status' => 'pass',
'message' => 'Backup readiness not implemented - manual verification required',
'details' => ['recommendation' => 'Ensure database backup is current before proceeding'],
'severity' => 'info',
];
}
private function checkMigrationConflicts(MigrationCollection $migrations): array
{
$versions = [];
$conflicts = [];
foreach ($migrations as $migration) {
$version = $migration->getVersion()->toString();
if (in_array($version, $versions)) {
$conflicts[] = $version;
}
$versions[] = $version;
}
return [
'status' => empty($conflicts) ? 'pass' : 'fail',
'message' => empty($conflicts) ? 'No migration conflicts found' : 'Migration version conflicts detected',
'details' => [
'total_migrations' => count($migrations),
'unique_versions' => count(array_unique($versions)),
'conflicting_versions' => $conflicts,
],
'severity' => empty($conflicts) ? 'info' : 'critical',
];
}
private function checkEnvironmentSafety(): array
{
$environment = $this->getEnvironmentDetails();
$isProduction = in_array($environment['app_env'] ?? 'unknown', ['production', 'prod']);
return [
'status' => 'pass',
'message' => 'Environment safety check completed',
'details' => [
'environment' => $environment,
'is_production' => $isProduction,
'recommendations' => $isProduction ? ['Create backup', 'Schedule maintenance window'] : [],
],
'severity' => $isProduction ? 'warning' : 'info',
];
}
private function findPartialDependencyChains(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
// Placeholder for dependency chain analysis
return [];
}
private function validateMigrationOrdering(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
$issues = [];
$versions = [];
foreach ($migrations as $migration) {
$version = $migration->getVersion()->toString();
$timestamp = $this->extractTimestampFromVersion($version);
$versions[] = ['version' => $version, 'timestamp' => $timestamp];
}
// Sort by timestamp and check if versions are in chronological order
usort($versions, fn ($a, $b) => $a['timestamp'] <=> $b['timestamp']);
$previousTimestamp = 0;
foreach ($versions as $versionInfo) {
if ($versionInfo['timestamp'] < $previousTimestamp) {
$issues[] = [
'version' => $versionInfo['version'],
'issue' => 'Version timestamp is out of chronological order',
'timestamp' => $versionInfo['timestamp'],
];
}
$previousTimestamp = $versionInfo['timestamp'];
}
return $issues;
}
private function extractTimestampFromVersion(string $version): int
{
// Extract timestamp from version format: YYYY_MM_DD_HHMMSS_Description
if (preg_match('/^(\d{4})_(\d{2})_(\d{2})_(\d{6})/', $version, $matches)) {
$year = (int) $matches[1];
$month = (int) $matches[2];
$day = (int) $matches[3];
$time = $matches[4];
$hour = (int) substr($time, 0, 2);
$minute = (int) substr($time, 2, 2);
$second = (int) substr($time, 4, 2);
return mktime($hour, $minute, $second, $month, $day, $year);
}
return 0;
}
private function extractExpectedTablesFromMigrations(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
// This would analyze migration files to extract expected table names
// For now, return empty array as placeholder
return [];
}
private function getActualDatabaseTables(): array
{
try {
$query = $this->platform->getListTablesSQL();
$result = $this->connection->queryColumn(SqlQuery::create($query));
return $result;
} catch (\Throwable $e) {
return [];
}
}
private function getEnvironmentDetails(): array
{
return [
'app_env' => $_ENV['APP_ENV'] ?? 'unknown',
'php_version' => PHP_VERSION,
'server_name' => $_SERVER['SERVER_NAME'] ?? 'unknown',
'database_driver' => $this->platform->getName(),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\ValueObjects;
use App\Framework\Core\ValueObjects\Percentage;
final readonly class MemoryThresholds
{
public function __construct(
public Percentage $warning,
public Percentage $critical,
public Percentage $abort
) {
if ($this->warning->greaterThan($this->critical)) {
throw new \InvalidArgumentException('Warning threshold cannot be higher than critical threshold');
}
if ($this->critical->greaterThan($this->abort)) {
throw new \InvalidArgumentException('Critical threshold cannot be higher than abort threshold');
}
}
public static function default(): self
{
return new self(
warning: Percentage::from(75.0), // 75% - Warning level
critical: Percentage::from(85.0), // 85% - Critical level
abort: Percentage::from(95.0) // 95% - Abort migration
);
}
public static function conservative(): self
{
return new self(
warning: Percentage::from(60.0),
critical: Percentage::from(70.0),
abort: Percentage::from(80.0)
);
}
public static function relaxed(): self
{
return new self(
warning: Percentage::from(80.0),
critical: Percentage::from(90.0),
abort: Percentage::from(98.0)
);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\ValueObjects;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Configuration for migration table structure and naming
*/
final readonly class MigrationTableConfig
{
public function __construct(
public string $tableName,
public string $versionColumn = 'version',
public string $descriptionColumn = 'description',
public string $executedAtColumn = 'executed_at',
public string $idColumn = 'id'
) {
if (empty($tableName)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Migration table name cannot be empty'
)->withData(['table_name' => $tableName]);
}
$this->validateColumnName($versionColumn, 'version');
$this->validateColumnName($descriptionColumn, 'description');
$this->validateColumnName($executedAtColumn, 'executed_at');
$this->validateColumnName($idColumn, 'id');
}
public static function default(): self
{
return new self('migrations');
}
public static function withCustomTable(string $tableName): self
{
return new self($tableName);
}
/**
* Get the full INSERT SQL for recording a migration
*/
public function getInsertSql(): string
{
return "INSERT INTO {$this->tableName} ({$this->versionColumn}, {$this->descriptionColumn}, {$this->executedAtColumn}) VALUES (?, ?, ?)";
}
/**
* Get the SELECT SQL for fetching applied versions
*/
public function getVersionSelectSql(): string
{
return "SELECT {$this->versionColumn} FROM {$this->tableName} ORDER BY {$this->executedAtColumn}";
}
/**
* Get the DELETE SQL for removing a migration record
*/
public function getDeleteSql(): string
{
return "DELETE FROM {$this->tableName} WHERE {$this->versionColumn} = ?";
}
/**
* Get the CREATE TABLE SQL for the given database driver
*/
public function getCreateTableSql(string $driver): string
{
return match($driver) {
'mysql' => "CREATE TABLE IF NOT EXISTS {$this->tableName} (
{$this->idColumn} INT PRIMARY KEY AUTO_INCREMENT,
{$this->versionColumn} VARCHAR(20) NOT NULL UNIQUE COMMENT 'Format: YYYY_MM_DD_HHMMSS',
{$this->descriptionColumn} TEXT,
{$this->executedAtColumn} TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_{$this->versionColumn} ({$this->versionColumn}),
INDEX idx_{$this->executedAtColumn} ({$this->executedAtColumn})
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
'pgsql' => "CREATE TABLE IF NOT EXISTS {$this->tableName} (
{$this->idColumn} SERIAL PRIMARY KEY,
{$this->versionColumn} VARCHAR(20) NOT NULL UNIQUE,
{$this->descriptionColumn} TEXT,
{$this->executedAtColumn} TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
'sqlite' => "CREATE TABLE IF NOT EXISTS {$this->tableName} (
{$this->idColumn} INTEGER PRIMARY KEY AUTOINCREMENT,
{$this->versionColumn} TEXT NOT NULL UNIQUE,
{$this->descriptionColumn} TEXT,
{$this->executedAtColumn} TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
default => throw FrameworkException::create(
ErrorCode::VAL_UNSUPPORTED_OPERATION,
"Unsupported database driver for migrations"
)->withData(['driver' => $driver])
};
}
/**
* Get PostgreSQL index creation SQL (separate from table creation)
*/
public function getPostgreSqlIndexSql(): array
{
return [
"CREATE INDEX IF NOT EXISTS idx_{$this->tableName}_{$this->versionColumn} ON {$this->tableName} ({$this->versionColumn})",
"CREATE INDEX IF NOT EXISTS idx_{$this->tableName}_{$this->executedAtColumn} ON {$this->tableName} ({$this->executedAtColumn})",
];
}
private function validateColumnName(string $columnName, string $context): void
{
if (empty($columnName)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Migration {$context} column name cannot be empty"
)->withData([
'column_name' => $columnName,
'context' => $context,
]);
}
// Basic SQL injection prevention
if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $columnName)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Invalid column name format for migration {$context}"
)->withData([
'column_name' => $columnName,
'context' => $context,
]);
}
}
}