<?php
namespace Doctrine\DBAL\Schema;
use BadMethodCallException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types;
use Doctrine\Deprecations\Deprecation;
use function array_intersect_key;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_unique;
use function assert;
use function count;
use function get_class;
use function sprintf;
use function strtolower;
/**
* Compares two Schemas and return an instance of SchemaDiff.
*
* @method SchemaDiff compareSchemas(Schema $fromSchema, Schema $toSchema)
*/
class Comparator
{
private ?AbstractPlatform $platform;
/** @internal The comparator can be only instantiated by a schema manager. */
public function __construct(?AbstractPlatform $platform = null)
{
if ($platform === null) {
Deprecation::triggerIfCalledFromOutside(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/4746',
'Not passing a $platform to %s is deprecated.'
. ' Use AbstractSchemaManager::createComparator() to instantiate the comparator.',
__METHOD__,
);
}
$this->platform = $platform;
}
/** @param list<mixed> $args */
public function __call(string $method, array $args): SchemaDiff
{
if ($method !== 'compareSchemas') {
throw new BadMethodCallException(sprintf('Unknown method "%s"', $method));
}
return $this->doCompareSchemas(...$args);
}
/** @param list<mixed> $args */
public static function __callStatic(string $method, array $args): SchemaDiff
{
if ($method !== 'compareSchemas') {
throw new BadMethodCallException(sprintf('Unknown method "%s"', $method));
}
Deprecation::trigger(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/4707',
'Calling %s::%s() statically is deprecated.',
self::class,
$method,
);
$comparator = new self();
return $comparator->doCompareSchemas(...$args);
}
/**
* Returns a SchemaDiff object containing the differences between the schemas $fromSchema and $toSchema.
*
* This method should be called non-statically since it will be declared as non-static in the next major release.
*
* @return SchemaDiff
*
* @throws SchemaException
*/
private function doCompareSchemas(
Schema $fromSchema,
Schema $toSchema
) {
$createdSchemas = [];
$droppedSchemas = [];
$createdTables = [];
$alteredTables = [];
$droppedTables = [];
$createdSequences = [];
$alteredSequences = [];
$droppedSequences = [];
$orphanedForeignKeys = [];
$foreignKeysToTable = [];
foreach ($toSchema->getNamespaces() as $namespace) {
if ($fromSchema->hasNamespace($namespace)) {
continue;
}
$createdSchemas[$namespace] = $namespace;
}
foreach ($fromSchema->getNamespaces() as $namespace) {
if ($toSchema->hasNamespace($namespace)) {
continue;
}
$droppedSchemas[$namespace] = $namespace;
}
foreach ($toSchema->getTables() as $table) {
$tableName = $table->getShortestName($toSchema->getName());
if (! $fromSchema->hasTable($tableName)) {
$createdTables[$tableName] = $toSchema->getTable($tableName);
} else {
$tableDifferences = $this->diffTable(
$fromSchema->getTable($tableName),
$toSchema->getTable($tableName),
);
if ($tableDifferences !== false) {
$alteredTables[$tableName] = $tableDifferences;
}
}
}
/* Check if there are tables removed */
foreach ($fromSchema->getTables() as $table) {
$tableName = $table->getShortestName($fromSchema->getName());
$table = $fromSchema->getTable($tableName);
if (! $toSchema->hasTable($tableName)) {
$droppedTables[$tableName] = $table;
}
// also remember all foreign keys that point to a specific table
foreach ($table->getForeignKeys() as $foreignKey) {
$foreignTable = strtolower($foreignKey->getForeignTableName());
if (! isset($foreignKeysToTable[$foreignTable])) {
$foreignKeysToTable[$foreignTable] = [];
}
$foreignKeysToTable[$foreignTable][] = $foreignKey;
}
}
foreach ($droppedTables as $tableName => $table) {
if (! isset($foreignKeysToTable[$tableName])) {
continue;
}
foreach ($foreignKeysToTable[$tableName] as $foreignKey) {
if (isset($droppedTables[strtolower($foreignKey->getLocalTableName())])) {
continue;
}
$orphanedForeignKeys[] = $foreignKey;
}
// deleting duplicated foreign keys present on both on the orphanedForeignKey
// and the removedForeignKeys from changedTables
foreach ($foreignKeysToTable[$tableName] as $foreignKey) {
// strtolower the table name to make if compatible with getShortestName
$localTableName = strtolower($foreignKey->getLocalTableName());
if (! isset($alteredTables[$localTableName])) {
continue;
}
foreach ($alteredTables[$localTableName]->getDroppedForeignKeys() as $droppedForeignKey) {
assert($droppedForeignKey instanceof ForeignKeyConstraint);
// We check if the key is from the removed table if not we skip.
if ($tableName !== strtolower($droppedForeignKey->getForeignTableName())) {
continue;
}
$alteredTables[$localTableName]->unsetDroppedForeignKey($droppedForeignKey);
}
}
}
foreach ($toSchema->getSequences() as $sequence) {
$sequenceName = $sequence->getShortestName($toSchema->getName());
if (! $fromSchema->hasSequence($sequenceName)) {
if (! $this->isAutoIncrementSequenceInSchema($fromSchema, $sequence)) {
$createdSequences[] = $sequence;
}
} else {
if ($this->diffSequence($sequence, $fromSchema->getSequence($sequenceName))) {
$alteredSequences[] = $toSchema->getSequence($sequenceName);
}
}
}
foreach ($fromSchema->getSequences() as $sequence) {
if ($this->isAutoIncrementSequenceInSchema($toSchema, $sequence)) {
continue;
}
$sequenceName = $sequence->getShortestName($fromSchema->getName());
if ($toSchema->hasSequence($sequenceName)) {
continue;
}
$droppedSequences[] = $sequence;
}
$diff = new SchemaDiff(
$createdTables,
$alteredTables,
$droppedTables,
$fromSchema,
$createdSchemas,
$droppedSchemas,
$createdSequences,
$alteredSequences,
$droppedSequences,
);
$diff->orphanedForeignKeys = $orphanedForeignKeys;
return $diff;
}
/**
* @deprecated Use non-static call to {@see compareSchemas()} instead.
*
* @return SchemaDiff
*
* @throws SchemaException
*/
public function compare(Schema $fromSchema, Schema $toSchema)
{
Deprecation::trigger(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/4707',
'Method compare() is deprecated. Use a non-static call to compareSchemas() instead.',
);
return $this->compareSchemas($fromSchema, $toSchema);
}
/**
* @param Schema $schema
* @param Sequence $sequence
*/
private function isAutoIncrementSequenceInSchema($schema, $sequence): bool
{
foreach ($schema->getTables() as $table) {
if ($sequence->isAutoIncrementsFor($table)) {
return true;
}
}
return false;
}
/** @return bool */
public function diffSequence(Sequence $sequence1, Sequence $sequence2)
{
if ($sequence1->getAllocationSize() !== $sequence2->getAllocationSize()) {
return true;
}
return $sequence1->getInitialValue() !== $sequence2->getInitialValue();
}
/**
* Returns the difference between the tables $fromTable and $toTable.
*
* If there are no differences this method returns the boolean false.
*
* @deprecated Use {@see compareTables()} and, optionally, {@see TableDiff::isEmpty()} instead.
*
* @return TableDiff|false
*
* @throws Exception
*/
public function diffTable(Table $fromTable, Table $toTable)
{
Deprecation::triggerIfCalledFromOutside(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/5770',
'%s is deprecated. Use compareTables() instead.',
__METHOD__,
);
$diff = $this->compareTables($fromTable, $toTable);
if ($diff->isEmpty()) {
return false;
}
return $diff;
}
/**
* Compares the tables and returns the difference between them.
*
* @throws Exception
*/
public function compareTables(Table $fromTable, Table $toTable): TableDiff
{
$addedColumns = [];
$modifiedColumns = [];
$droppedColumns = [];
$addedIndexes = [];
$modifiedIndexes = [];
$droppedIndexes = [];
$addedForeignKeys = [];
$modifiedForeignKeys = [];
$droppedForeignKeys = [];
$fromTableColumns = $fromTable->getColumns();
$toTableColumns = $toTable->getColumns();
/* See if all the columns in "from" table exist in "to" table */
foreach ($toTableColumns as $columnName => $column) {
if ($fromTable->hasColumn($columnName)) {
continue;
}
$addedColumns[$columnName] = $column;
}
/* See if there are any removed columns in "to" table */
foreach ($fromTableColumns as $columnName => $column) {
// See if column is removed in "to" table.
if (! $toTable->hasColumn($columnName)) {
$droppedColumns[$columnName] = $column;
continue;
}
$toColumn = $toTable->getColumn($columnName);
// See if column has changed properties in "to" table.
$changedProperties = $this->diffColumn($column, $toColumn);
if ($this->platform !== null) {
if ($this->columnsEqual($column, $toColumn)) {
continue;
}
} elseif (count($changedProperties) === 0) {
continue;
}
$modifiedColumns[$column->getName()] = new ColumnDiff(
$column->getName(),
$toColumn,
$changedProperties,
$column,
);
}
$renamedColumns = $this->detectRenamedColumns($addedColumns, $droppedColumns);
$fromTableIndexes = $fromTable->getIndexes();
$toTableIndexes = $toTable->getIndexes();
/* See if all the indexes in "from" table exist in "to" table */
foreach ($toTableIndexes as $indexName => $index) {
if (($index->isPrimary() && $fromTable->getPrimaryKey() !== null) || $fromTable->hasIndex($indexName)) {
continue;
}
$addedIndexes[$indexName] = $index;
}
/* See if there are any removed indexes in "to" table */
foreach ($fromTableIndexes as $indexName => $index) {
// See if index is removed in "to" table.
if (
($index->isPrimary() && $toTable->getPrimaryKey() === null) ||
! $index->isPrimary() && ! $toTable->hasIndex($indexName)
) {
$droppedIndexes[$indexName] = $index;
continue;
}
// See if index has changed in "to" table.
$toTableIndex = $index->isPrimary() ? $toTable->getPrimaryKey() : $toTable->getIndex($indexName);
assert($toTableIndex instanceof Index);
if (! $this->diffIndex($index, $toTableIndex)) {
continue;
}
$modifiedIndexes[$indexName] = $toTableIndex;
}
$renamedIndexes = $this->detectRenamedIndexes($addedIndexes, $droppedIndexes);
$fromForeignKeys = $fromTable->getForeignKeys();
$toForeignKeys = $toTable->getForeignKeys();
foreach ($fromForeignKeys as $fromKey => $fromConstraint) {
foreach ($toForeignKeys as $toKey => $toConstraint) {
if ($this->diffForeignKey($fromConstraint, $toConstraint) === false) {
unset($fromForeignKeys[$fromKey], $toForeignKeys[$toKey]);
} else {
if (strtolower($fromConstraint->getName()) === strtolower($toConstraint->getName())) {
$modifiedForeignKeys[] = $toConstraint;
unset($fromForeignKeys[$fromKey], $toForeignKeys[$toKey]);
}
}
}
}
foreach ($fromForeignKeys as $fromConstraint) {
$droppedForeignKeys[] = $fromConstraint;
}
foreach ($toForeignKeys as $toConstraint) {
$addedForeignKeys[] = $toConstraint;
}
return new TableDiff(
$toTable->getName(),
$addedColumns,
$modifiedColumns,
$droppedColumns,
$addedIndexes,
$modifiedIndexes,
$droppedIndexes,
$fromTable,
$addedForeignKeys,
$modifiedForeignKeys,
$droppedForeignKeys,
$renamedColumns,
$renamedIndexes,
);
}
/**
* 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>
*
* @throws Exception
*/
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;
}
/**
* @internal The method should be only used from within the {@see Comparator} class hierarchy.
*
* @return bool
*/
public function diffForeignKey(ForeignKeyConstraint $key1, ForeignKeyConstraint $key2)
{
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
*
* @internal The method should be only used from within the {@see Comparator} class hierarchy.
*
* @throws Exception
*/
public function columnsEqual(Column $column1, Column $column2): bool
{
if ($this->platform === null) {
return $this->diffColumn($column1, $column2) === [];
}
return $this->platform->columnsEqual($column1, $column2);
}
/**
* Returns the difference between the columns
*
* If there are differences this method returns the changed properties as a
* string array, otherwise an empty array gets returned.
*
* @deprecated Use {@see columnsEqual()} instead.
*
* @return string[]
*/
public function diffColumn(Column $column1, Column $column2)
{
Deprecation::triggerIfCalledFromOutside(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/5650',
'%s is deprecated. Use diffTable() instead.',
__METHOD__,
);
$properties1 = $column1->toArray();
$properties2 = $column2->toArray();
$changedProperties = [];
if (get_class($properties1['type']) !== get_class($properties2['type'])) {
$changedProperties[] = 'type';
}
foreach (['notnull', 'unsigned', 'autoincrement'] as $property) {
if ($properties1[$property] === $properties2[$property]) {
continue;
}
$changedProperties[] = $property;
}
// Null values need to be checked additionally as they tell whether to create or drop a default value.
// null != 0, null != false, null != '' etc. This affects platform's table alteration SQL generation.
if (
($properties1['default'] === null) !== ($properties2['default'] === null)
|| $properties1['default'] != $properties2['default']
) {
$changedProperties[] = 'default';
}
if (
($properties1['type'] instanceof Types\StringType && ! $properties1['type'] instanceof Types\GuidType) ||
$properties1['type'] instanceof Types\BinaryType
) {
// check if value of length is set at all, default value assumed otherwise.
$length1 = $properties1['length'] ?? 255;
$length2 = $properties2['length'] ?? 255;
if ($length1 !== $length2) {
$changedProperties[] = 'length';
}
if ($properties1['fixed'] !== $properties2['fixed']) {
$changedProperties[] = 'fixed';
}
} elseif ($properties1['type'] instanceof Types\DecimalType) {
if (($properties1['precision'] ?? 10) !== ($properties2['precision'] ?? 10)) {
$changedProperties[] = 'precision';
}
if ($properties1['scale'] !== $properties2['scale']) {
$changedProperties[] = 'scale';
}
}
// A null value and an empty string are actually equal for a comment so they should not trigger a change.
if (
$properties1['comment'] !== $properties2['comment'] &&
! ($properties1['comment'] === null && $properties2['comment'] === '') &&
! ($properties2['comment'] === null && $properties1['comment'] === '')
) {
$changedProperties[] = 'comment';
}
$customOptions1 = $column1->getCustomSchemaOptions();
$customOptions2 = $column2->getCustomSchemaOptions();
foreach (array_merge(array_keys($customOptions1), array_keys($customOptions2)) as $key) {
if (! array_key_exists($key, $properties1) || ! array_key_exists($key, $properties2)) {
$changedProperties[] = $key;
} elseif ($properties1[$key] !== $properties2[$key]) {
$changedProperties[] = $key;
}
}
$platformOptions1 = $column1->getPlatformOptions();
$platformOptions2 = $column2->getPlatformOptions();
foreach (array_keys(array_intersect_key($platformOptions1, $platformOptions2)) as $key) {
if ($properties1[$key] === $properties2[$key]) {
continue;
}
$changedProperties[] = $key;
}
return array_unique($changedProperties);
}
/**
* 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.
*
* @internal The method should be only used from within the {@see Comparator} class hierarchy.
*
* @return bool
*/
public function diffIndex(Index $index1, Index $index2)
{
return ! ($index1->isFulfilledBy($index2) && $index2->isFulfilledBy($index1));
}
}