<?php declare(strict_types=1); namespace Doctrine\DBAL\Schema; use Doctrine\DBAL\Platforms\AbstractPlatform; use function array_map; use function assert; use function count; use function strtolower; /** * Compares two Schemas and return an instance of SchemaDiff. */ class Comparator { /** @internal The comparator can be only instantiated by a schema manager. */ public function __construct(private readonly AbstractPlatform $platform) { } /** * Returns the differences between the schemas. */ public function compareSchemas(Schema $oldSchema, Schema $newSchema): SchemaDiff { $createdSchemas = []; $droppedSchemas = []; $createdTables = []; $alteredTables = []; $droppedTables = []; $createdSequences = []; $alteredSequences = []; $droppedSequences = []; foreach ($newSchema->getNamespaces() as $newNamespace) { if ($oldSchema->hasNamespace($newNamespace)) { continue; } $createdSchemas[] = $newNamespace; } foreach ($oldSchema->getNamespaces() as $oldNamespace) { if ($newSchema->hasNamespace($oldNamespace)) { continue; } $droppedSchemas[] = $oldNamespace; } foreach ($newSchema->getTables() as $newTable) { $newTableName = $newTable->getShortestName($newSchema->getName()); if (! $oldSchema->hasTable($newTableName)) { $createdTables[] = $newSchema->getTable($newTableName); } else { $tableDiff = $this->compareTables( $oldSchema->getTable($newTableName), $newSchema->getTable($newTableName), ); if (! $tableDiff->isEmpty()) { $alteredTables[] = $tableDiff; } } } // Check if there are tables removed foreach ($oldSchema->getTables() as $oldTable) { $oldTableName = $oldTable->getShortestName($oldSchema->getName()); $oldTable = $oldSchema->getTable($oldTableName); if ($newSchema->hasTable($oldTableName)) { continue; } $droppedTables[] = $oldTable; } foreach ($newSchema->getSequences() as $newSequence) { $newSequenceName = $newSequence->getShortestName($newSchema->getName()); if (! $oldSchema->hasSequence($newSequenceName)) { if (! $this->isAutoIncrementSequenceInSchema($oldSchema, $newSequence)) { $createdSequences[] = $newSequence; } } else { if ($this->diffSequence($newSequence, $oldSchema->getSequence($newSequenceName))) { $alteredSequences[] = $newSchema->getSequence($newSequenceName); } } } foreach ($oldSchema->getSequences() as $oldSequence) { if ($this->isAutoIncrementSequenceInSchema($newSchema, $oldSequence)) { continue; } $oldSequenceName = $oldSequence->getShortestName($oldSchema->getName()); if ($newSchema->hasSequence($oldSequenceName)) { continue; } $droppedSequences[] = $oldSequence; } return new SchemaDiff( $createdSchemas, $droppedSchemas, $createdTables, $alteredTables, $droppedTables, $createdSequences, $alteredSequences, $droppedSequences, ); } private function isAutoIncrementSequenceInSchema(Schema $schema, Sequence $sequence): bool { foreach ($schema->getTables() as $table) { if ($sequence->isAutoIncrementsFor($table)) { return true; } } return false; } public function diffSequence(Sequence $sequence1, Sequence $sequence2): bool { if ($sequence1->getAllocationSize() !== $sequence2->getAllocationSize()) { return true; } return $sequence1->getInitialValue() !== $sequence2->getInitialValue(); } /** * Compares the tables and returns the difference between them. */ public function compareTables(Table $oldTable, Table $newTable): TableDiff { $addedColumns = []; $modifiedColumns = []; $droppedColumns = []; $addedIndexes = []; $modifiedIndexes = []; $droppedIndexes = []; $addedForeignKeys = []; $modifiedForeignKeys = []; $droppedForeignKeys = []; $oldColumns = $oldTable->getColumns(); $newColumns = $newTable->getColumns(); // See if all the columns in the old table exist in the new table foreach ($newColumns as $newColumn) { $newColumnName = strtolower($newColumn->getName()); if ($oldTable->hasColumn($newColumnName)) { continue; } $addedColumns[$newColumnName] = $newColumn; } // See if there are any removed columns in the new table foreach ($oldColumns as $oldColumn) { $oldColumnName = strtolower($oldColumn->getName()); // See if column is removed in the new table. if (! $newTable->hasColumn($oldColumnName)) { $droppedColumns[$oldColumnName] = $oldColumn; continue; } $newColumn = $newTable->getColumn($oldColumnName); if ($this->columnsEqual($oldColumn, $newColumn)) { continue; } $modifiedColumns[] = new ColumnDiff($oldColumn, $newColumn); } $renamedColumns = $this->detectRenamedColumns($addedColumns, $droppedColumns); $oldIndexes = $oldTable->getIndexes(); $newIndexes = $newTable->getIndexes(); // See if all the indexes from the old table exist in the new one foreach ($newIndexes as $newIndexName => $newIndex) { if (($newIndex->isPrimary() && $oldTable->getPrimaryKey() !== null) || $oldTable->hasIndex($newIndexName)) { continue; } $addedIndexes[$newIndexName] = $newIndex; } // See if there are any removed indexes in the new table foreach ($oldIndexes as $oldIndexName => $oldIndex) { // See if the index is removed in the new table. if ( ($oldIndex->isPrimary() && $newTable->getPrimaryKey() === null) || ! $oldIndex->isPrimary() && ! $newTable->hasIndex($oldIndexName) ) { $droppedIndexes[$oldIndexName] = $oldIndex; continue; } // See if index has changed in the new table. $newIndex = $oldIndex->isPrimary() ? $newTable->getPrimaryKey() : $newTable->getIndex($oldIndexName); assert($newIndex instanceof Index); if (! $this->diffIndex($oldIndex, $newIndex)) { continue; } $modifiedIndexes[] = $newIndex; } $renamedIndexes = $this->detectRenamedIndexes($addedIndexes, $droppedIndexes); $oldForeignKeys = $oldTable->getForeignKeys(); $newForeignKeys = $newTable->getForeignKeys(); foreach ($oldForeignKeys as $oldKey => $oldForeignKey) { foreach ($newForeignKeys as $newKey => $newForeignKey) { if ($this->diffForeignKey($oldForeignKey, $newForeignKey) === false) { unset($oldForeignKeys[$oldKey], $newForeignKeys[$newKey]); } else { if (strtolower($oldForeignKey->getName()) === strtolower($newForeignKey->getName())) { $modifiedForeignKeys[] = $newForeignKey; unset($oldForeignKeys[$oldKey], $newForeignKeys[$newKey]); } } } } foreach ($oldForeignKeys as $oldForeignKey) { $droppedForeignKeys[] = $oldForeignKey; } foreach ($newForeignKeys as $newForeignKey) { $addedForeignKeys[] = $newForeignKey; } return new TableDiff( $oldTable, $addedColumns, $modifiedColumns, $droppedColumns, $renamedColumns, $addedIndexes, $modifiedIndexes, $droppedIndexes, $renamedIndexes, $addedForeignKeys, $modifiedForeignKeys, $droppedForeignKeys, ); } /** * Try to find columns that only changed their name, rename operations maybe cheaper than add/drop * however ambiguities between different possibilities should not lead to renaming at all. * * @param array<string,Column> $addedColumns * @param array<string,Column> $removedColumns * * @return array<string,Column> */ private function detectRenamedColumns(array &$addedColumns, array &$removedColumns): array { $candidatesByName = []; foreach ($addedColumns as $addedColumnName => $addedColumn) { foreach ($removedColumns as $removedColumn) { if (! $this->columnsEqual($addedColumn, $removedColumn)) { continue; } $candidatesByName[$addedColumn->getName()][] = [$removedColumn, $addedColumn, $addedColumnName]; } } $renamedColumns = []; foreach ($candidatesByName as $candidates) { if (count($candidates) !== 1) { continue; } [$removedColumn, $addedColumn] = $candidates[0]; $removedColumnName = $removedColumn->getName(); $addedColumnName = strtolower($addedColumn->getName()); if (isset($renamedColumns[$removedColumnName])) { continue; } $renamedColumns[$removedColumnName] = $addedColumn; unset( $addedColumns[$addedColumnName], $removedColumns[strtolower($removedColumnName)], ); } return $renamedColumns; } /** * Try to find indexes that only changed their name, rename operations maybe cheaper than add/drop * however ambiguities between different possibilities should not lead to renaming at all. * * @param array<string,Index> $addedIndexes * @param array<string,Index> $removedIndexes * * @return array<string,Index> */ private function detectRenamedIndexes(array &$addedIndexes, array &$removedIndexes): array { $candidatesByName = []; // Gather possible rename candidates by comparing each added and removed index based on semantics. foreach ($addedIndexes as $addedIndexName => $addedIndex) { foreach ($removedIndexes as $removedIndex) { if ($this->diffIndex($addedIndex, $removedIndex)) { continue; } $candidatesByName[$addedIndex->getName()][] = [$removedIndex, $addedIndex, $addedIndexName]; } } $renamedIndexes = []; foreach ($candidatesByName as $candidates) { // If the current rename candidate contains exactly one semantically equal index, // we can safely rename it. // Otherwise, it is unclear if a rename action is really intended, // therefore we let those ambiguous indexes be added/dropped. if (count($candidates) !== 1) { continue; } [$removedIndex, $addedIndex] = $candidates[0]; $removedIndexName = strtolower($removedIndex->getName()); $addedIndexName = strtolower($addedIndex->getName()); if (isset($renamedIndexes[$removedIndexName])) { continue; } $renamedIndexes[$removedIndexName] = $addedIndex; unset( $addedIndexes[$addedIndexName], $removedIndexes[$removedIndexName], ); } return $renamedIndexes; } protected function diffForeignKey(ForeignKeyConstraint $key1, ForeignKeyConstraint $key2): bool { if ( array_map('strtolower', $key1->getUnquotedLocalColumns()) !== array_map('strtolower', $key2->getUnquotedLocalColumns()) ) { return true; } if ( array_map('strtolower', $key1->getUnquotedForeignColumns()) !== array_map('strtolower', $key2->getUnquotedForeignColumns()) ) { return true; } if ($key1->getUnqualifiedForeignTableName() !== $key2->getUnqualifiedForeignTableName()) { return true; } if ($key1->onUpdate() !== $key2->onUpdate()) { return true; } return $key1->onDelete() !== $key2->onDelete(); } /** * Compares the definitions of the given columns */ protected function columnsEqual(Column $column1, Column $column2): bool { return $this->platform->columnsEqual($column1, $column2); } /** * Finds the difference between the indexes $index1 and $index2. * * Compares $index1 with $index2 and returns true if there are any * differences or false in case there are no differences. */ protected function diffIndex(Index $index1, Index $index2): bool { return ! ($index1->isFulfilledBy($index2) && $index2->isFulfilledBy($index1)); } }