Some checks failed
Deploy Application / deploy (push) Has been cancelled
407 lines
11 KiB
PHP
407 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Config\AppConfig;
|
|
use App\Framework\Database\ConnectionInterface;
|
|
use App\Framework\Database\Exception\DatabaseException;
|
|
use App\Framework\Database\Migration\Migration;
|
|
use App\Framework\Database\Migration\MigrationCollection;
|
|
use App\Framework\Database\Migration\MigrationDependencyGraph;
|
|
use App\Framework\Database\Migration\MigrationRunner;
|
|
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\MigrationTableConfig;
|
|
use App\Framework\Database\Platform\MySqlPlatform;
|
|
use App\Framework\Database\ResultInterface;
|
|
use App\Framework\Database\ValueObjects\SqlQuery;
|
|
use App\Framework\DateTime\SystemClock;
|
|
use App\Framework\Id\Ulid\UlidGenerator;
|
|
use App\Framework\Performance\MemoryMonitor;
|
|
use App\Framework\Performance\OperationTracker;
|
|
|
|
beforeEach(function () {
|
|
$this->connection = new TestConnection();
|
|
$this->platform = new MySqlPlatform();
|
|
$this->clock = new SystemClock();
|
|
$this->memoryMonitor = new MemoryMonitor();
|
|
$this->operationTracker = new OperationTracker();
|
|
$this->ulidGenerator = new class implements UlidGenerator {
|
|
public function generate(): string
|
|
{
|
|
return '01ARZ3NDEKTSV4RRFFQ69G5FAV';
|
|
}
|
|
};
|
|
$this->appConfig = new AppConfig(\App\Framework\Config\EnvironmentType::DEVELOPMENT);
|
|
|
|
$tableConfig = MigrationTableConfig::withCustomTable('test_migrations');
|
|
$dependencyGraph = new MigrationDependencyGraph();
|
|
$databaseManager = new MigrationDatabaseManager(
|
|
$this->connection,
|
|
$this->platform,
|
|
$this->clock,
|
|
$tableConfig
|
|
);
|
|
$performanceTracker = new MigrationPerformanceTracker(
|
|
$this->operationTracker,
|
|
$this->memoryMonitor,
|
|
null,
|
|
null,
|
|
null
|
|
);
|
|
$migrationLogger = new MigrationLogger(null);
|
|
$validator = new MigrationValidator(
|
|
$this->connection,
|
|
$this->platform,
|
|
$this->appConfig
|
|
);
|
|
$errorAnalyzer = new MigrationErrorAnalyzer();
|
|
|
|
$this->migrationRunner = new MigrationRunner(
|
|
$this->connection,
|
|
$this->platform,
|
|
$this->clock,
|
|
$this->ulidGenerator,
|
|
$this->appConfig,
|
|
$dependencyGraph,
|
|
$databaseManager,
|
|
$performanceTracker,
|
|
$migrationLogger,
|
|
$validator,
|
|
$errorAnalyzer
|
|
);
|
|
});
|
|
|
|
test('constructor creates migrations table', function () {
|
|
// Constructor should have already been called in beforeEach
|
|
$queries = $this->connection->getQueries();
|
|
|
|
expect($queries)->toHaveCount(1)
|
|
->and($queries[0]['type'])->toBe('execute')
|
|
->and($queries[0]['sql'])->toContain('CREATE TABLE')
|
|
->and($queries[0]['sql'])->toContain('test_migrations');
|
|
});
|
|
|
|
test('migrate runs pending migrations', function () {
|
|
// Mock migration
|
|
$migration = new TestMigration();
|
|
$migrations = new MigrationCollection($migration);
|
|
|
|
// Set no applied migrations initially
|
|
$this->connection->setAppliedMigrations([]);
|
|
|
|
$result = $this->migrationRunner->migrate($migrations);
|
|
|
|
expect($result)->toContain('2024_01_01_000000');
|
|
expect($migration->wasExecuted())->toBeTrue();
|
|
|
|
// Verify the migration was recorded
|
|
$queries = $this->connection->getQueries();
|
|
$insertQueries = array_filter(
|
|
$queries,
|
|
fn ($q) =>
|
|
$q['type'] === 'execute' && str_contains($q['sql'], 'INSERT INTO')
|
|
);
|
|
expect($insertQueries)->toHaveCount(1);
|
|
});
|
|
|
|
test('migrate skips already applied migrations', function () {
|
|
$migration = new TestMigration();
|
|
$migrations = new MigrationCollection($migration);
|
|
|
|
// Set migration as already applied
|
|
$this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]);
|
|
|
|
$result = $this->migrationRunner->migrate($migrations);
|
|
|
|
expect($result)->toBeEmpty();
|
|
expect($migration->wasExecuted())->toBeFalse();
|
|
});
|
|
|
|
test('migrate rolls back on failure', function () {
|
|
// Mock failing migration
|
|
$failingMigration = new FailingTestMigration();
|
|
$migrations = new MigrationCollection($failingMigration);
|
|
|
|
// Set no applied migrations initially
|
|
$this->connection->setAppliedMigrations([]);
|
|
$this->connection->setShouldFail(true);
|
|
|
|
expect(fn () => $this->migrationRunner->migrate($migrations))
|
|
->toThrow(DatabaseException::class);
|
|
|
|
// Verify transaction was used (inTransaction was called)
|
|
expect($this->connection->inTransaction())->toBeFalse(); // Should be rolled back
|
|
});
|
|
|
|
test('rollback reverts applied migration', function () {
|
|
$migration = new TestMigration();
|
|
$migrations = new MigrationCollection($migration);
|
|
|
|
// Set migration as already applied
|
|
$this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]);
|
|
|
|
$result = $this->migrationRunner->rollback($migrations, 1);
|
|
|
|
expect($result)->toHaveCount(1);
|
|
expect($migration->wasRolledBack())->toBeTrue();
|
|
|
|
// Verify the migration record was deleted
|
|
$queries = $this->connection->getQueries();
|
|
$deleteQueries = array_filter(
|
|
$queries,
|
|
fn ($q) =>
|
|
$q['type'] === 'execute' && str_contains($q['sql'], 'DELETE FROM')
|
|
);
|
|
expect($deleteQueries)->toHaveCount(1);
|
|
});
|
|
|
|
test('get status returns migration status', function () {
|
|
$migration1 = new TestMigration();
|
|
$migration2 = new class implements Migration {
|
|
public function up(ConnectionInterface $connection): void {}
|
|
public function down(ConnectionInterface $connection): void {}
|
|
public function getDescription(): string { return 'Pending Migration'; }
|
|
public function getVersion(): \App\Framework\Database\Migration\MigrationVersion {
|
|
return \App\Framework\Database\Migration\MigrationVersion::fromTimestamp('2024_01_02_000000');
|
|
}
|
|
};
|
|
$migrations = new MigrationCollection($migration1, $migration2);
|
|
|
|
// Set only first migration as applied
|
|
$this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]);
|
|
|
|
$status = $this->migrationRunner->getStatus($migrations);
|
|
|
|
expect($status)->toHaveCount(2)
|
|
->and($status[0]->applied)->toBeTrue()
|
|
->and($status[1]->applied)->toBeFalse();
|
|
});
|
|
|
|
// Test fixtures
|
|
class TestMigration implements Migration
|
|
{
|
|
private bool $executed = false;
|
|
|
|
private bool $rolledBack = false;
|
|
|
|
public function up(ConnectionInterface $connection): void
|
|
{
|
|
$this->executed = true;
|
|
// Simulate migration execution
|
|
$connection->execute(SqlQuery::create('CREATE TABLE test_table (id INT)'));
|
|
}
|
|
|
|
public function down(ConnectionInterface $connection): void
|
|
{
|
|
$this->rolledBack = true;
|
|
// Simulate migration rollback
|
|
$connection->execute(SqlQuery::create('DROP TABLE test_table'));
|
|
}
|
|
|
|
public function getDescription(): string
|
|
{
|
|
return 'Test Migration';
|
|
}
|
|
|
|
public function getVersion(): \App\Framework\Database\Migration\MigrationVersion
|
|
{
|
|
return \App\Framework\Database\Migration\MigrationVersion::fromTimestamp('2024_01_01_000000');
|
|
}
|
|
|
|
public function wasExecuted(): bool
|
|
{
|
|
return $this->executed;
|
|
}
|
|
|
|
public function wasRolledBack(): bool
|
|
{
|
|
return $this->rolledBack;
|
|
}
|
|
}
|
|
|
|
class FailingTestMigration implements Migration
|
|
{
|
|
public function up(ConnectionInterface $connection): void
|
|
{
|
|
throw new \Exception('Migration failed');
|
|
}
|
|
|
|
public function down(ConnectionInterface $connection): void
|
|
{
|
|
// No-op
|
|
}
|
|
|
|
public function getDescription(): string
|
|
{
|
|
return 'Failing Migration';
|
|
}
|
|
|
|
public function getVersion(): \App\Framework\Database\Migration\MigrationVersion
|
|
{
|
|
return \App\Framework\Database\Migration\MigrationVersion::fromTimestamp('2024_01_01_000000');
|
|
}
|
|
}
|
|
|
|
class TestConnection implements ConnectionInterface
|
|
{
|
|
private array $queries = [];
|
|
|
|
private array $appliedMigrations = [];
|
|
|
|
private bool $inTransaction = false;
|
|
|
|
private bool $shouldFail = false;
|
|
|
|
public function setAppliedMigrations(array $migrations): void
|
|
{
|
|
$this->appliedMigrations = $migrations;
|
|
}
|
|
|
|
public function setShouldFail(bool $fail): void
|
|
{
|
|
$this->shouldFail = $fail;
|
|
}
|
|
|
|
public function execute(SqlQuery $query): int
|
|
{
|
|
$this->queries[] = ['type' => 'execute', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
|
|
|
|
if ($this->shouldFail && strpos($query->sql, 'INSERT INTO') !== false) {
|
|
throw new DatabaseException('Simulated database failure');
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
public function query(SqlQuery $query): ResultInterface
|
|
{
|
|
$this->queries[] = ['type' => 'query', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
|
|
|
|
return new TestResult($this->appliedMigrations);
|
|
}
|
|
|
|
public function queryOne(SqlQuery $query): ?array
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function queryColumn(SqlQuery $query): array
|
|
{
|
|
$this->queries[] = ['type' => 'queryColumn', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
|
|
|
|
// Return the versions from applied migrations
|
|
return array_column($this->appliedMigrations, 'version');
|
|
}
|
|
|
|
public function queryScalar(SqlQuery $query): mixed
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function beginTransaction(): void
|
|
{
|
|
$this->inTransaction = true;
|
|
}
|
|
|
|
public function commit(): void
|
|
{
|
|
$this->inTransaction = false;
|
|
}
|
|
|
|
public function rollback(): void
|
|
{
|
|
$this->inTransaction = false;
|
|
}
|
|
|
|
public function inTransaction(): bool
|
|
{
|
|
return $this->inTransaction;
|
|
}
|
|
|
|
public function lastInsertId(): string
|
|
{
|
|
return '1';
|
|
}
|
|
|
|
public function getPdo(): \PDO
|
|
{
|
|
return new class () extends \PDO {
|
|
public function __construct()
|
|
{
|
|
// Skip parent constructor to avoid actual DB connection
|
|
}
|
|
|
|
public function getAttribute(int $attribute): mixed
|
|
{
|
|
if ($attribute === \PDO::ATTR_DRIVER_NAME) {
|
|
return 'sqlite';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
|
|
public function getQueries(): array
|
|
{
|
|
return $this->queries;
|
|
}
|
|
}
|
|
|
|
class TestResult implements ResultInterface
|
|
{
|
|
private array $data;
|
|
|
|
private int $position = 0;
|
|
|
|
public function __construct(array $data = [])
|
|
{
|
|
$this->data = $data;
|
|
}
|
|
|
|
public function fetch(): ?array
|
|
{
|
|
if ($this->position >= count($this->data)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->data[$this->position++];
|
|
}
|
|
|
|
public function fetchAll(): array
|
|
{
|
|
return $this->data;
|
|
}
|
|
|
|
public function fetchColumn(int $column = 0): array
|
|
{
|
|
return array_column($this->data, $column);
|
|
}
|
|
|
|
public function fetchScalar(): mixed
|
|
{
|
|
$row = $this->fetch();
|
|
|
|
return $row ? reset($row) : null;
|
|
}
|
|
|
|
public function rowCount(): int
|
|
{
|
|
return count($this->data);
|
|
}
|
|
|
|
public function getIterator(): \Iterator
|
|
{
|
|
return new \ArrayIterator($this->data);
|
|
}
|
|
|
|
public function count(): int
|
|
{
|
|
return count($this->data);
|
|
}
|
|
}
|