vendor/doctrine/migrations/src/Metadata/Storage/TableMetadataStorage.php line 64

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace Doctrine\Migrations\Metadata\Storage;
  4. use DateTimeImmutable;
  5. use Doctrine\DBAL\Connection;
  6. use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection;
  7. use Doctrine\DBAL\Platforms\AbstractPlatform;
  8. use Doctrine\DBAL\Schema\AbstractSchemaManager;
  9. use Doctrine\DBAL\Schema\ComparatorConfig;
  10. use Doctrine\DBAL\Schema\Name\UnqualifiedName;
  11. use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
  12. use Doctrine\DBAL\Schema\Table;
  13. use Doctrine\DBAL\Schema\TableDiff;
  14. use Doctrine\DBAL\Types\Types;
  15. use Doctrine\Migrations\Exception\MetadataStorageError;
  16. use Doctrine\Migrations\Metadata\AvailableMigration;
  17. use Doctrine\Migrations\Metadata\ExecutedMigration;
  18. use Doctrine\Migrations\Metadata\ExecutedMigrationsList;
  19. use Doctrine\Migrations\MigrationsRepository;
  20. use Doctrine\Migrations\Query\Query;
  21. use Doctrine\Migrations\Version\Comparator as MigrationsComparator;
  22. use Doctrine\Migrations\Version\Direction;
  23. use Doctrine\Migrations\Version\ExecutionResult;
  24. use Doctrine\Migrations\Version\Version;
  25. use InvalidArgumentException;
  26. use function array_change_key_case;
  27. use function assert;
  28. use function class_exists;
  29. use function explode;
  30. use function floatval;
  31. use function method_exists;
  32. use function round;
  33. use function sprintf;
  34. use function str_contains;
  35. use function strlen;
  36. use function strpos;
  37. use function strtolower;
  38. use function uasort;
  39. use const CASE_LOWER;
  40. final class TableMetadataStorage implements MetadataStorage
  41. {
  42. private bool $isInitialized = false;
  43. private bool $schemaUpToDate = false;
  44. /** @var AbstractSchemaManager<AbstractPlatform> */
  45. private readonly AbstractSchemaManager $schemaManager;
  46. private readonly AbstractPlatform $platform;
  47. private readonly TableMetadataStorageConfiguration $configuration;
  48. public function __construct(
  49. private readonly Connection $connection,
  50. private readonly MigrationsComparator $comparator,
  51. MetadataStorageConfiguration|null $configuration = null,
  52. private readonly MigrationsRepository|null $migrationRepository = null,
  53. ) {
  54. $this->schemaManager = $connection->createSchemaManager();
  55. $this->platform = $connection->getDatabasePlatform();
  56. if ($configuration !== null && ! ($configuration instanceof TableMetadataStorageConfiguration)) {
  57. throw new InvalidArgumentException(sprintf(
  58. '%s accepts only %s as configuration',
  59. self::class,
  60. TableMetadataStorageConfiguration::class,
  61. ));
  62. }
  63. $this->configuration = $configuration ?? new TableMetadataStorageConfiguration();
  64. }
  65. public function getExecutedMigrations(): ExecutedMigrationsList
  66. {
  67. if (! $this->isInitialized()) {
  68. return new ExecutedMigrationsList([]);
  69. }
  70. $this->checkInitialization();
  71. $rows = $this->connection->fetchAllAssociative(sprintf('SELECT * FROM %s', $this->configuration->getTableName()));
  72. $migrations = [];
  73. foreach ($rows as $row) {
  74. $row = array_change_key_case($row, CASE_LOWER);
  75. $version = new Version($row[strtolower($this->configuration->getVersionColumnName())]);
  76. $executedAt = $row[strtolower($this->configuration->getExecutedAtColumnName())] ?? '';
  77. $executedAt = $executedAt !== ''
  78. ? DateTimeImmutable::createFromFormat($this->platform->getDateTimeFormatString(), $executedAt)
  79. : null;
  80. $executionTime = isset($row[strtolower($this->configuration->getExecutionTimeColumnName())])
  81. ? floatval($row[strtolower($this->configuration->getExecutionTimeColumnName())] / 1000)
  82. : null;
  83. $migration = new ExecutedMigration(
  84. $version,
  85. $executedAt instanceof DateTimeImmutable ? $executedAt : null,
  86. $executionTime,
  87. );
  88. $migrations[(string) $version] = $migration;
  89. }
  90. uasort($migrations, fn (ExecutedMigration $a, ExecutedMigration $b): int => $this->comparator->compare($a->getVersion(), $b->getVersion()));
  91. return new ExecutedMigrationsList($migrations);
  92. }
  93. public function reset(): void
  94. {
  95. $this->checkInitialization();
  96. $this->connection->executeStatement(
  97. sprintf(
  98. 'DELETE FROM %s WHERE 1 = 1',
  99. $this->configuration->getTableName(),
  100. ),
  101. );
  102. }
  103. public function complete(ExecutionResult $result): void
  104. {
  105. $this->checkInitialization();
  106. if ($result->getDirection() === Direction::DOWN) {
  107. $this->connection->delete($this->configuration->getTableName(), [
  108. $this->configuration->getVersionColumnName() => (string) $result->getVersion(),
  109. ]);
  110. } else {
  111. $this->connection->insert($this->configuration->getTableName(), [
  112. $this->configuration->getVersionColumnName() => (string) $result->getVersion(),
  113. $this->configuration->getExecutedAtColumnName() => $result->getExecutedAt(),
  114. $this->configuration->getExecutionTimeColumnName() => $result->getTime() === null ? null : (int) round($result->getTime() * 1000),
  115. ], [
  116. Types::STRING,
  117. Types::DATETIME_IMMUTABLE,
  118. Types::INTEGER,
  119. ]);
  120. }
  121. }
  122. /** @return iterable<Query> */
  123. public function getSql(ExecutionResult $result): iterable
  124. {
  125. yield new Query('-- Version ' . (string) $result->getVersion() . ' update table metadata');
  126. if ($result->getDirection() === Direction::DOWN) {
  127. yield new Query(sprintf(
  128. 'DELETE FROM %s WHERE %s = %s',
  129. $this->configuration->getTableName(),
  130. $this->configuration->getVersionColumnName(),
  131. $this->connection->quote((string) $result->getVersion()),
  132. ));
  133. return;
  134. }
  135. yield new Query(sprintf(
  136. 'INSERT INTO %s (%s, %s, %s) VALUES (%s, %s, 0)',
  137. $this->configuration->getTableName(),
  138. $this->configuration->getVersionColumnName(),
  139. $this->configuration->getExecutedAtColumnName(),
  140. $this->configuration->getExecutionTimeColumnName(),
  141. $this->connection->quote((string) $result->getVersion()),
  142. $this->connection->quote(($result->getExecutedAt() ?? new DateTimeImmutable())->format('Y-m-d H:i:s')),
  143. ));
  144. }
  145. public function ensureInitialized(): void
  146. {
  147. if (! $this->isInitialized()) {
  148. $expectedSchemaChangelog = $this->getExpectedTable();
  149. $this->schemaManager->createTable($expectedSchemaChangelog);
  150. $this->schemaUpToDate = true;
  151. $this->isInitialized = true;
  152. return;
  153. }
  154. $this->isInitialized = true;
  155. $expectedSchemaChangelog = $this->getExpectedTable();
  156. $diff = $this->needsUpdate($expectedSchemaChangelog);
  157. if ($diff === null) {
  158. $this->schemaUpToDate = true;
  159. return;
  160. }
  161. $this->schemaUpToDate = true;
  162. $this->schemaManager->alterTable($diff);
  163. $this->updateMigratedVersionsFromV1orV2toV3();
  164. }
  165. private function needsUpdate(Table $expectedTable): TableDiff|null
  166. {
  167. if ($this->schemaUpToDate) {
  168. return null;
  169. }
  170. if (class_exists(ComparatorConfig::class)) {
  171. $comparator = $this->schemaManager->createComparator((new ComparatorConfig())->withReportModifiedIndexes(false));
  172. } else {
  173. $comparator = $this->schemaManager->createComparator();
  174. }
  175. /** @phpstan-ignore function.alreadyNarrowedType */
  176. if (method_exists($this->schemaManager, 'introspectTableByUnquotedName')) {
  177. if (str_contains($this->configuration->getTableName(), '.')) {
  178. [$namespace, $tableName] = explode('.', $this->configuration->getTableName(), 2);
  179. assert($namespace !== '' && $tableName !== '');
  180. $currentTable = $this->schemaManager->introspectTableByUnquotedName(
  181. $tableName,
  182. $namespace,
  183. );
  184. } else {
  185. $currentTable = $this->schemaManager->introspectTableByUnquotedName($this->configuration->getTableName());
  186. }
  187. } else {
  188. /** @phpstan-ignore method.deprecated */
  189. $currentTable = $this->schemaManager->introspectTable($this->configuration->getTableName());
  190. }
  191. $diff = $comparator->compareTables($currentTable, $expectedTable);
  192. return $diff->isEmpty() ? null : $diff;
  193. }
  194. private function isInitialized(): bool
  195. {
  196. if ($this->isInitialized) {
  197. return $this->isInitialized;
  198. }
  199. if ($this->connection instanceof PrimaryReadReplicaConnection) {
  200. $this->connection->ensureConnectedToPrimary();
  201. }
  202. return $this->schemaManager->tablesExist([$this->configuration->getTableName()]);
  203. }
  204. private function checkInitialization(): void
  205. {
  206. if (! $this->isInitialized()) {
  207. throw MetadataStorageError::notInitialized();
  208. }
  209. $expectedTable = $this->getExpectedTable();
  210. if ($this->needsUpdate($expectedTable) !== null) {
  211. throw MetadataStorageError::notUpToDate();
  212. }
  213. }
  214. private function getExpectedTable(): Table
  215. {
  216. $schemaChangelog = new Table($this->configuration->getTableName());
  217. $schemaChangelog->addColumn(
  218. $this->configuration->getVersionColumnName(),
  219. 'string',
  220. ['notnull' => true, 'length' => $this->configuration->getVersionColumnLength()],
  221. );
  222. $schemaChangelog->addColumn($this->configuration->getExecutedAtColumnName(), 'datetime', ['notnull' => false]);
  223. $schemaChangelog->addColumn($this->configuration->getExecutionTimeColumnName(), 'integer', ['notnull' => false]);
  224. if (class_exists(PrimaryKeyConstraint::class)) {
  225. $constraint = PrimaryKeyConstraint::editor()
  226. ->setColumnNames(UnqualifiedName::unquoted($this->configuration->getVersionColumnName()))
  227. ->create();
  228. $schemaChangelog->addPrimaryKeyConstraint($constraint);
  229. } else {
  230. $schemaChangelog->setPrimaryKey([$this->configuration->getVersionColumnName()]);
  231. }
  232. return $schemaChangelog;
  233. }
  234. private function updateMigratedVersionsFromV1orV2toV3(): void
  235. {
  236. if ($this->migrationRepository === null) {
  237. return;
  238. }
  239. $availableMigrations = $this->migrationRepository->getMigrations()->getItems();
  240. $executedMigrations = $this->getExecutedMigrations()->getItems();
  241. foreach ($availableMigrations as $availableMigration) {
  242. foreach ($executedMigrations as $k => $executedMigration) {
  243. if ($this->isAlreadyV3Format($availableMigration, $executedMigration)) {
  244. continue;
  245. }
  246. $this->connection->update(
  247. $this->configuration->getTableName(),
  248. [
  249. $this->configuration->getVersionColumnName() => (string) $availableMigration->getVersion(),
  250. ],
  251. [
  252. $this->configuration->getVersionColumnName() => (string) $executedMigration->getVersion(),
  253. ],
  254. );
  255. unset($executedMigrations[$k]);
  256. }
  257. }
  258. }
  259. private function isAlreadyV3Format(AvailableMigration $availableMigration, ExecutedMigration $executedMigration): bool
  260. {
  261. return (string) $availableMigration->getVersion() === (string) $executedMigration->getVersion()
  262. || strpos(
  263. (string) $availableMigration->getVersion(),
  264. (string) $executedMigration->getVersion(),
  265. ) !== strlen((string) $availableMigration->getVersion()) -
  266. strlen((string) $executedMigration->getVersion());
  267. }
  268. }