<?php 
 
declare(strict_types=1); 
 
namespace Doctrine\Migrations\Metadata\Storage; 
 
use DateTimeImmutable; 
use Doctrine\DBAL\Connection; 
use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection; 
use Doctrine\DBAL\Platforms\AbstractPlatform; 
use Doctrine\DBAL\Schema\AbstractSchemaManager; 
use Doctrine\DBAL\Schema\Comparator; 
use Doctrine\DBAL\Schema\Table; 
use Doctrine\DBAL\Schema\TableDiff; 
use Doctrine\DBAL\Types\Types; 
use Doctrine\Migrations\Exception\MetadataStorageError; 
use Doctrine\Migrations\Metadata\AvailableMigration; 
use Doctrine\Migrations\Metadata\ExecutedMigration; 
use Doctrine\Migrations\Metadata\ExecutedMigrationsList; 
use Doctrine\Migrations\MigrationsRepository; 
use Doctrine\Migrations\Version\Comparator as MigrationsComparator; 
use Doctrine\Migrations\Version\Direction; 
use Doctrine\Migrations\Version\ExecutionResult; 
use Doctrine\Migrations\Version\Version; 
use InvalidArgumentException; 
 
use function array_change_key_case; 
use function floatval; 
use function method_exists; 
use function round; 
use function sprintf; 
use function strlen; 
use function strpos; 
use function strtolower; 
use function uasort; 
 
use const CASE_LOWER; 
 
final class TableMetadataStorage implements MetadataStorage 
{ 
    /** @var bool */ 
    private $isInitialized; 
 
    /** @var bool */ 
    private $schemaUpToDate = false; 
 
    /** @var Connection */ 
    private $connection; 
 
    /** @var AbstractSchemaManager<AbstractPlatform> */ 
    private $schemaManager; 
 
    /** @var AbstractPlatform */ 
    private $platform; 
 
    /** @var TableMetadataStorageConfiguration */ 
    private $configuration; 
 
    /** @var MigrationsRepository|null */ 
    private $migrationRepository; 
 
    /** @var MigrationsComparator */ 
    private $comparator; 
 
    public function __construct( 
        Connection $connection, 
        MigrationsComparator $comparator, 
        ?MetadataStorageConfiguration $configuration = null, 
        ?MigrationsRepository $migrationRepository = null 
    ) { 
        $this->migrationRepository = $migrationRepository; 
        $this->connection          = $connection; 
        $this->schemaManager       = $connection->getSchemaManager(); 
        $this->platform            = $connection->getDatabasePlatform(); 
 
        if ($configuration !== null && ! ($configuration instanceof TableMetadataStorageConfiguration)) { 
            throw new InvalidArgumentException(sprintf('%s accepts only %s as configuration', self::class, TableMetadataStorageConfiguration::class)); 
        } 
 
        $this->configuration = $configuration ?? new TableMetadataStorageConfiguration(); 
        $this->comparator    = $comparator; 
    } 
 
    public function getExecutedMigrations(): ExecutedMigrationsList 
    { 
        if (! $this->isInitialized()) { 
            return new ExecutedMigrationsList([]); 
        } 
 
        $this->checkInitialization(); 
        $rows = $this->connection->fetchAllAssociative(sprintf('SELECT * FROM %s', $this->configuration->getTableName())); 
 
        $migrations = []; 
        foreach ($rows as $row) { 
            $row = array_change_key_case($row, CASE_LOWER); 
 
            $version = new Version($row[strtolower($this->configuration->getVersionColumnName())]); 
 
            $executedAt = $row[strtolower($this->configuration->getExecutedAtColumnName())] ?? ''; 
            $executedAt = $executedAt !== '' 
                ? DateTimeImmutable::createFromFormat($this->platform->getDateTimeFormatString(), $executedAt) 
                : null; 
 
            $executionTime = isset($row[strtolower($this->configuration->getExecutionTimeColumnName())]) 
                ? floatval($row[strtolower($this->configuration->getExecutionTimeColumnName())] / 1000) 
                : null; 
 
            $migration = new ExecutedMigration( 
                $version, 
                $executedAt instanceof DateTimeImmutable ? $executedAt : null, 
                $executionTime 
            ); 
 
            $migrations[(string) $version] = $migration; 
        } 
 
        uasort($migrations, function (ExecutedMigration $a, ExecutedMigration $b): int { 
            return $this->comparator->compare($a->getVersion(), $b->getVersion()); 
        }); 
 
        return new ExecutedMigrationsList($migrations); 
    } 
 
    public function reset(): void 
    { 
        $this->checkInitialization(); 
 
        $this->connection->executeStatement( 
            sprintf( 
                'DELETE FROM %s WHERE 1 = 1', 
                $this->platform->quoteIdentifier($this->configuration->getTableName()) 
            ) 
        ); 
    } 
 
    public function complete(ExecutionResult $result): void 
    { 
        $this->checkInitialization(); 
 
        if ($result->getDirection() === Direction::DOWN) { 
            $this->connection->delete($this->configuration->getTableName(), [ 
                $this->configuration->getVersionColumnName() => (string) $result->getVersion(), 
            ]); 
        } else { 
            $this->connection->insert($this->configuration->getTableName(), [ 
                $this->configuration->getVersionColumnName() => (string) $result->getVersion(), 
                $this->configuration->getExecutedAtColumnName() => $result->getExecutedAt(), 
                $this->configuration->getExecutionTimeColumnName() => $result->getTime() === null ? null : (int) round($result->getTime() * 1000), 
            ], [ 
                Types::STRING, 
                Types::DATETIME_MUTABLE, 
                Types::INTEGER, 
            ]); 
        } 
    } 
 
    public function ensureInitialized(): void 
    { 
        if (! $this->isInitialized()) { 
            $expectedSchemaChangelog = $this->getExpectedTable(); 
            $this->schemaManager->createTable($expectedSchemaChangelog); 
            $this->schemaUpToDate = true; 
            $this->isInitialized  = true; 
 
            return; 
        } 
 
        $this->isInitialized     = true; 
        $expectedSchemaChangelog = $this->getExpectedTable(); 
        $diff                    = $this->needsUpdate($expectedSchemaChangelog); 
        if ($diff === null) { 
            $this->schemaUpToDate = true; 
 
            return; 
        } 
 
        $this->schemaUpToDate = true; 
        $this->schemaManager->alterTable($diff); 
        $this->updateMigratedVersionsFromV1orV2toV3(); 
    } 
 
    private function needsUpdate(Table $expectedTable): ?TableDiff 
    { 
        if ($this->schemaUpToDate) { 
            return null; 
        } 
 
        $comparator   = method_exists($this->schemaManager, 'createComparator') ? 
            $this->schemaManager->createComparator() : 
            new Comparator(); 
        $currentTable = $this->schemaManager->listTableDetails($this->configuration->getTableName()); 
        $diff         = $comparator->diffTable($currentTable, $expectedTable); 
 
        return $diff instanceof TableDiff ? $diff : null; 
    } 
 
    private function isInitialized(): bool 
    { 
        if ($this->isInitialized) { 
            return $this->isInitialized; 
        } 
 
        if ($this->connection instanceof PrimaryReadReplicaConnection) { 
            $this->connection->ensureConnectedToPrimary(); 
        } 
 
        return $this->schemaManager->tablesExist([$this->configuration->getTableName()]); 
    } 
 
    private function checkInitialization(): void 
    { 
        if (! $this->isInitialized()) { 
            throw MetadataStorageError::notInitialized(); 
        } 
 
        $expectedTable = $this->getExpectedTable(); 
 
        if ($this->needsUpdate($expectedTable) !== null) { 
            throw MetadataStorageError::notUpToDate(); 
        } 
    } 
 
    private function getExpectedTable(): Table 
    { 
        $schemaChangelog = new Table($this->configuration->getTableName()); 
 
        $schemaChangelog->addColumn( 
            $this->configuration->getVersionColumnName(), 
            'string', 
            ['notnull' => true, 'length' => $this->configuration->getVersionColumnLength()] 
        ); 
        $schemaChangelog->addColumn($this->configuration->getExecutedAtColumnName(), 'datetime', ['notnull' => false]); 
        $schemaChangelog->addColumn($this->configuration->getExecutionTimeColumnName(), 'integer', ['notnull' => false]); 
 
        $schemaChangelog->setPrimaryKey([$this->configuration->getVersionColumnName()]); 
 
        return $schemaChangelog; 
    } 
 
    private function updateMigratedVersionsFromV1orV2toV3(): void 
    { 
        if ($this->migrationRepository === null) { 
            return; 
        } 
 
        $availableMigrations = $this->migrationRepository->getMigrations()->getItems(); 
        $executedMigrations  = $this->getExecutedMigrations()->getItems(); 
 
        foreach ($availableMigrations as $availableMigration) { 
            foreach ($executedMigrations as $k => $executedMigration) { 
                if ($this->isAlreadyV3Format($availableMigration, $executedMigration)) { 
                    continue; 
                } 
 
                $this->connection->update( 
                    $this->configuration->getTableName(), 
                    [ 
                        $this->configuration->getVersionColumnName() => (string) $availableMigration->getVersion(), 
                    ], 
                    [ 
                        $this->configuration->getVersionColumnName() => (string) $executedMigration->getVersion(), 
                    ] 
                ); 
                unset($executedMigrations[$k]); 
            } 
        } 
    } 
 
    private function isAlreadyV3Format(AvailableMigration $availableMigration, ExecutedMigration $executedMigration): bool 
    { 
        return strpos( 
            (string) $availableMigration->getVersion(), 
            (string) $executedMigration->getVersion() 
        ) !== strlen((string) $availableMigration->getVersion()) - 
                strlen((string) $executedMigration->getVersion()); 
    } 
}