From 934ccfc57f9e6bcebf08cc219b747ae9eabdf79e Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Fri, 31 Jul 2026 23:40:23 -0700 Subject: [PATCH 01/11] Introduce derived-object providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DerivedObject describes, without a name, a schema object a platform creates on its own — an index enforcing a constraint, or a constraint a unique index carries. Each platform's provider says which objects it derives from a table's declared ones. --- UPGRADE.md | 5 + .../AbstractDerivedObjectProvider.php | 105 +++++++ src/Platforms/AbstractMySQLPlatform.php | 7 + src/Platforms/AbstractPlatform.php | 9 + src/Platforms/DB2Platform.php | 7 + .../Db2/Db2DerivedObjectProvider.php | 12 + .../MySQL/MySQLDerivedObjectProvider.php | 140 ++++++++++ .../Oracle/OracleDerivedObjectProvider.php | 12 + src/Platforms/OraclePlatform.php | 7 + .../PostgreSQLDerivedObjectProvider.php | 12 + src/Platforms/PostgreSQLPlatform.php | 7 + .../SQLServerDerivedObjectProvider.php | 12 + src/Platforms/SQLServerPlatform.php | 7 + .../SQLite/SQLiteDerivedObjectProvider.php | 63 +++++ src/Platforms/SQLitePlatform.php | 7 + src/Schema/DerivedObject.php | 79 ++++++ src/Schema/DerivedObjectKind.php | 17 ++ src/Schema/DerivedObjectProvider.php | 14 + tests/Schema/DerivedObjectTest.php | 258 ++++++++++++++++++ 19 files changed, 780 insertions(+) create mode 100644 src/Platforms/AbstractDerivedObjectProvider.php create mode 100644 src/Platforms/Db2/Db2DerivedObjectProvider.php create mode 100644 src/Platforms/MySQL/MySQLDerivedObjectProvider.php create mode 100644 src/Platforms/Oracle/OracleDerivedObjectProvider.php create mode 100644 src/Platforms/PostgreSQL/PostgreSQLDerivedObjectProvider.php create mode 100644 src/Platforms/SQLServer/SQLServerDerivedObjectProvider.php create mode 100644 src/Platforms/SQLite/SQLiteDerivedObjectProvider.php create mode 100644 src/Schema/DerivedObject.php create mode 100644 src/Schema/DerivedObjectKind.php create mode 100644 src/Schema/DerivedObjectProvider.php create mode 100644 tests/Schema/DerivedObjectTest.php diff --git a/UPGRADE.md b/UPGRADE.md index 8a7a03ec29..85ae7d8304 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,11 @@ awareness about deprecated code. # Upgrade to 5.0 +## BC BREAK: Added `AbstractPlatform::createDerivedObjectProvider()` + +`Doctrine\DBAL\Platforms\AbstractPlatform` now declares `createDerivedObjectProvider()`. Platforms extending it must +implement the method. + ## BC BREAK: Foreign key constraints are compared by name `Comparator::compareTables()` compares the names of foreign key constraints, so one whose name differs is reported as diff --git a/src/Platforms/AbstractDerivedObjectProvider.php b/src/Platforms/AbstractDerivedObjectProvider.php new file mode 100644 index 0000000000..bf5809ee1a --- /dev/null +++ b/src/Platforms/AbstractDerivedObjectProvider.php @@ -0,0 +1,105 @@ + + */ + #[Override] + final public function getDerivedObjects(Table $table): array + { + return array_merge( + $this->derive($table, $table->getIndexes(), $this->deriveObjectFromIndex(...)), + $this->deriveFromPrimaryKeyConstraint($table), + $this->derive($table, $table->getUniqueConstraints(), $this->deriveObjectFromUniqueConstraint(...)), + $this->deriveObjectsFromForeignKeyConstraints($table), + ); + } + + protected function deriveObjectFromIndex(Table $table, Index $index): ?DerivedObject + { + return null; + } + + /** @return list */ + private function deriveFromPrimaryKeyConstraint(Table $table): array + { + $primaryKeyConstraint = $table->getPrimaryKeyConstraint(); + + if ($primaryKeyConstraint === null) { + return []; + } + + $derivedObject = $this->deriveObjectFromPrimaryKeyConstraint($table, $primaryKeyConstraint); + + if ($derivedObject === null) { + return []; + } + + return [$derivedObject]; + } + + protected function deriveObjectFromPrimaryKeyConstraint( + Table $table, + PrimaryKeyConstraint $constraint, + ): ?DerivedObject { + return new DerivedObject(DerivedObjectKind::UniqueIndex, $constraint->getColumnNames()); + } + + protected function deriveObjectFromUniqueConstraint(Table $table, UniqueConstraint $constraint): ?DerivedObject + { + return new DerivedObject(DerivedObjectKind::UniqueIndex, $constraint->getColumnNames()); + } + + /** + * The foreign key constraints are derived from as a whole: a platform that indexes them may serve + * more than one with a single index. + * + * @return list + */ + protected function deriveObjectsFromForeignKeyConstraints(Table $table): array + { + return []; + } + + /** + * @param iterable $sources + * @param callable(Table, T): ?DerivedObject $deriveOne + * + * @return list + * + * @template T + */ + private function derive(Table $table, iterable $sources, callable $deriveOne): array + { + $derivedObjects = []; + + foreach ($sources as $source) { + $derivedObject = $deriveOne($table, $source); + + if ($derivedObject !== null) { + $derivedObjects[] = $derivedObject; + } + } + + return $derivedObjects; + } +} diff --git a/src/Platforms/AbstractMySQLPlatform.php b/src/Platforms/AbstractMySQLPlatform.php index 4abca1fe80..3e7a815ac8 100644 --- a/src/Platforms/AbstractMySQLPlatform.php +++ b/src/Platforms/AbstractMySQLPlatform.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\InvalidColumnType\ColumnValuesRequired; +use Doctrine\DBAL\Platforms\MySQL\MySQLDerivedObjectProvider; use Doctrine\DBAL\Platforms\MySQL\MySQLMetadataProvider; use Doctrine\DBAL\Schema\ForeignKeyConstraint; use Doctrine\DBAL\Schema\ForeignKeyConstraint\MatchType; @@ -637,6 +638,12 @@ public function createMetadataProvider(Connection $connection): MySQLMetadataPro return new MySQLMetadataProvider($connection, $this); } + #[Override] + public function createDerivedObjectProvider(): MySQLDerivedObjectProvider + { + return new MySQLDerivedObjectProvider($this->getUnquotedIdentifierFolding()); + } + #[Override] public function createSchemaManager(Connection $connection): MySQLSchemaManager { diff --git a/src/Platforms/AbstractPlatform.php b/src/Platforms/AbstractPlatform.php index cadb5032a1..e75d1212a9 100644 --- a/src/Platforms/AbstractPlatform.php +++ b/src/Platforms/AbstractPlatform.php @@ -20,6 +20,7 @@ use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\DBAL\Schema\Column; use Doctrine\DBAL\Schema\DefaultExpression; +use Doctrine\DBAL\Schema\DerivedObjectProvider; use Doctrine\DBAL\Schema\Exception\InvalidName; use Doctrine\DBAL\Schema\Exception\UnspecifiedConstraintName; use Doctrine\DBAL\Schema\ForeignKeyConstraint; @@ -2199,6 +2200,14 @@ public function getUnquotedIdentifierFolding(): UnquotedIdentifierFolding */ abstract public function createMetadataProvider(Connection $connection): MetadataProvider; + /** + * Creates a provider of the schema objects this platform derives from the ones a table declares. + * + * A caller holding a desired table can ask what the database will add to it: the indexes and + * constraints the table will have without having declared them. + */ + abstract public function createDerivedObjectProvider(): DerivedObjectProvider; + /** * Creates the schema manager that can be used to inspect and change the underlying * database schema according to the dialect of the platform. diff --git a/src/Platforms/DB2Platform.php b/src/Platforms/DB2Platform.php index ce85cb9d8d..2c53099751 100644 --- a/src/Platforms/DB2Platform.php +++ b/src/Platforms/DB2Platform.php @@ -5,6 +5,7 @@ namespace Doctrine\DBAL\Platforms; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\Db2\Db2DerivedObjectProvider; use Doctrine\DBAL\Platforms\Db2\Db2MetadataProvider; use Doctrine\DBAL\Platforms\Exception\NotSupported; use Doctrine\DBAL\Schema\ColumnDiff; @@ -583,6 +584,12 @@ public function createMetadataProvider(Connection $connection): Db2MetadataProvi return new Db2MetadataProvider($connection, $this); } + #[Override] + public function createDerivedObjectProvider(): Db2DerivedObjectProvider + { + return new Db2DerivedObjectProvider(); + } + #[Override] public function createSchemaManager(Connection $connection): DB2SchemaManager { diff --git a/src/Platforms/Db2/Db2DerivedObjectProvider.php b/src/Platforms/Db2/Db2DerivedObjectProvider.php new file mode 100644 index 0000000000..33be424be2 --- /dev/null +++ b/src/Platforms/Db2/Db2DerivedObjectProvider.php @@ -0,0 +1,12 @@ +getType() !== IndexType::UNIQUE) { + return null; + } + + return new DerivedObject(DerivedObjectKind::UniqueConstraint, array_map( + static fn (IndexedColumn $column) => $column->getColumnName(), + $index->getIndexedColumns(), + )); + } + + /** + * A foreign key may get an index over its referencing columns. + * + * @link https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html + */ + #[Override] + protected function deriveObjectsFromForeignKeyConstraints(Table $table): array + { + // The columns of every index the table will have: the ones it declares, and the ones derived + // for its foreign keys below. + $indexColumnNameLists = []; + + $primaryKeyConstraint = $table->getPrimaryKeyConstraint(); + + foreach ($table->getIndexes() as $index) { + $indexColumnNameLists[] = $this->getColumnNamesIndexedInFull($index); + } + + if ($primaryKeyConstraint !== null) { + $indexColumnNameLists[] = $primaryKeyConstraint->getColumnNames(); + } + + foreach ($table->getUniqueConstraints() as $uniqueConstraint) { + $indexColumnNameLists[] = $uniqueConstraint->getColumnNames(); + } + + $foreignKeyColumnNameLists = []; + + foreach ($table->getForeignKeys() as $constraint) { + $foreignKeyColumnNameLists[] = $constraint->getReferencingColumnNames(); + } + + // The widest first: an index over a foreign key's columns serves every foreign key whose + // columns it starts with, so the one derived below serves those behind it. + usort( + $foreignKeyColumnNameLists, + static fn (array $a, array $b): int => count($b) <=> count($a), + ); + + $derivedObjects = []; + + foreach ($foreignKeyColumnNameLists as $columnNames) { + foreach ($indexColumnNameLists as $indexColumnNames) { + if ($this->columnNamesStartWith($indexColumnNames, $columnNames)) { + continue 2; + } + } + + $derivedObjects[] = new DerivedObject(DerivedObjectKind::RegularIndex, $columnNames); + + $indexColumnNameLists[] = $columnNames; + } + + return $derivedObjects; + } + + /** + * Returns the index's leading columns, stopping before the first one it indexes by a prefix of + * its value. + * + * @return list + */ + private function getColumnNamesIndexedInFull(Index $index): array + { + $columnNames = []; + + foreach ($index->getIndexedColumns() as $indexedColumn) { + if ($indexedColumn->getLength() !== null) { + break; + } + + $columnNames[] = $indexedColumn->getColumnName(); + } + + return $columnNames; + } + + /** + * @param list $columnNames + * @param non-empty-list $leadingColumnNames + */ + private function columnNamesStartWith(array $columnNames, array $leadingColumnNames): bool + { + if (count($columnNames) < count($leadingColumnNames)) { + return false; + } + + return array_all( + $leadingColumnNames, + fn ($leadingColumnName, $i) => $columnNames[$i]->equals($leadingColumnName, $this->folding), + ); + } +} diff --git a/src/Platforms/Oracle/OracleDerivedObjectProvider.php b/src/Platforms/Oracle/OracleDerivedObjectProvider.php new file mode 100644 index 0000000000..b7aa498a93 --- /dev/null +++ b/src/Platforms/Oracle/OracleDerivedObjectProvider.php @@ -0,0 +1,12 @@ +isIntegerPrimaryKey($table, $constraint)) { + return null; + } + + return parent::deriveObjectFromPrimaryKeyConstraint($table, $constraint); + } + + /** + * Returns whether the primary key is an integer primary key, which SQLite implements as an alias + * for the ROWID instead of an index. + * + * SQLite requires the declared type to be exactly INTEGER, so which types qualify follows from + * what the platform declares them as: {@see SQLitePlatform::getIntegerTypeDeclarationSQL()}, + * {@see SQLitePlatform::getBigIntTypeDeclarationSQL()} and + * {@see SQLitePlatform::getSmallIntTypeDeclarationSQL()}. Keep this method in sync with them. + */ + private function isIntegerPrimaryKey(Table $table, PrimaryKeyConstraint $constraint): bool + { + $columnNames = $constraint->getColumnNames(); + + if (count($columnNames) !== 1) { + return false; + } + + $column = $table->getColumn($columnNames[0]->toString()); + $type = $column->getType(); + + if ($type instanceof IntegerType) { + return true; + } + + return $column->getAutoincrement() && ($type instanceof SmallIntType || $type instanceof BigIntType); + } +} diff --git a/src/Platforms/SQLitePlatform.php b/src/Platforms/SQLitePlatform.php index 5852e46eb2..8208481faa 100644 --- a/src/Platforms/SQLitePlatform.php +++ b/src/Platforms/SQLitePlatform.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\Exception\NotSupported; use Doctrine\DBAL\Platforms\Exception\UnsupportedTableDefinition; +use Doctrine\DBAL\Platforms\SQLite\SQLiteDerivedObjectProvider; use Doctrine\DBAL\Platforms\SQLite\SQLiteMetadataProvider; use Doctrine\DBAL\Schema\Collections\UnqualifiedNamedObjectSet; use Doctrine\DBAL\Schema\Column; @@ -959,6 +960,12 @@ public function createMetadataProvider(Connection $connection): SQLiteMetadataPr return new SQLiteMetadataProvider($connection, $this); } + #[Override] + public function createDerivedObjectProvider(): SQLiteDerivedObjectProvider + { + return new SQLiteDerivedObjectProvider(); + } + #[Override] public function createSchemaManager(Connection $connection): SQLiteSchemaManager { diff --git a/src/Schema/DerivedObject.php b/src/Schema/DerivedObject.php new file mode 100644 index 0000000000..0ca1c12213 --- /dev/null +++ b/src/Schema/DerivedObject.php @@ -0,0 +1,79 @@ + $columnNames */ + public function __construct(private DerivedObjectKind $kind, private array $columnNames) + { + } + + /** + * Tests if this derived object matches the index introspected from the database. + */ + public function matchesIndex(Index $index, UnquotedIdentifierFolding $folding): bool + { + $type = match ($this->kind) { + DerivedObjectKind::UniqueIndex => IndexType::UNIQUE, + DerivedObjectKind::RegularIndex => IndexType::REGULAR, + DerivedObjectKind::UniqueConstraint => null, + }; + + if ($index->getType() !== $type) { + return false; + } + + if ($index->getPredicate() !== null) { + return false; + } + + $columns = $index->getIndexedColumns(); + + if (count($columns) !== count($this->columnNames)) { + return false; + } + + foreach ($columns as $i => $column) { + if ($column->getLength() !== null) { + return false; + } + + if (! $column->getColumnName()->equals($this->columnNames[$i], $folding)) { + return false; + } + } + + return true; + } + + /** + * Tests if this derived object matches the unique constraint introspected from the database. + */ + public function matchesUniqueConstraint(UniqueConstraint $constraint, UnquotedIdentifierFolding $folding): bool + { + if ($this->kind !== DerivedObjectKind::UniqueConstraint) { + return false; + } + + $columnNames = $constraint->getColumnNames(); + + if (count($columnNames) !== count($this->columnNames)) { + return false; + } + + return array_all($columnNames, fn ($columnName, $i) => $columnName->equals($this->columnNames[$i], $folding)); + } +} diff --git a/src/Schema/DerivedObjectKind.php b/src/Schema/DerivedObjectKind.php new file mode 100644 index 0000000000..9a1ca13205 --- /dev/null +++ b/src/Schema/DerivedObjectKind.php @@ -0,0 +1,17 @@ + */ + public function getDerivedObjects(Table $table): array; +} diff --git a/tests/Schema/DerivedObjectTest.php b/tests/Schema/DerivedObjectTest.php new file mode 100644 index 0000000000..e4a87528d8 --- /dev/null +++ b/tests/Schema/DerivedObjectTest.php @@ -0,0 +1,258 @@ +uniqueIndexOverAB()->matchesIndex( + $this->index(['a', 'b'], IndexType::UNIQUE), + UnquotedIdentifierFolding::NONE, + ), + ); + } + + /** @param non-empty-list $indexedColumnNames */ + #[DataProvider('indexesNotMatchingUniqueIndexOverABProvider')] + public function testNonMatchingIndex(array $indexedColumnNames, IndexType $indexType): void + { + self::assertFalse( + $this->uniqueIndexOverAB()->matchesIndex( + $this->index($indexedColumnNames, $indexType), + UnquotedIdentifierFolding::NONE, + ), + ); + } + + /** @return iterable, IndexType}> */ + public static function indexesNotMatchingUniqueIndexOverABProvider(): iterable + { + yield 'columns in a different order' => [['b', 'a'], IndexType::UNIQUE]; + + yield 'more columns' => [['a', 'b', 'c'], IndexType::UNIQUE]; + + yield 'fewer columns' => [['a'], IndexType::UNIQUE]; + yield 'different column' => [['a', 'c'], IndexType::UNIQUE]; + yield 'different type' => [['a', 'b'], IndexType::REGULAR]; + } + + public function testRegularIndexKindMatchesARegularIndex(): void + { + $derived = new DerivedObject(DerivedObjectKind::RegularIndex, [UnqualifiedName::unquoted('parent_id')]); + + self::assertTrue( + $derived->matchesIndex($this->index(['parent_id'], IndexType::REGULAR), UnquotedIdentifierFolding::NONE), + ); + } + + public function testRegularIndexKindDoesNotMatchAUniqueIndex(): void + { + $derived = new DerivedObject(DerivedObjectKind::RegularIndex, [UnqualifiedName::unquoted('parent_id')]); + + self::assertFalse( + $derived->matchesIndex($this->index(['parent_id'], IndexType::UNIQUE), UnquotedIdentifierFolding::NONE), + ); + } + + public function testUniqueConstraintKindNeverMatchesAnIndex(): void + { + $derived = new DerivedObject(DerivedObjectKind::UniqueConstraint, [UnqualifiedName::unquoted('a')]); + + self::assertFalse( + $derived->matchesIndex($this->index(['a'], IndexType::UNIQUE), UnquotedIdentifierFolding::NONE), + ); + } + + public function testIndexKindNeverMatchesAUniqueConstraint(): void + { + self::assertFalse( + $this->uniqueIndexOverAB()->matchesUniqueConstraint( + $this->uniqueConstraint(['a', 'b']), + UnquotedIdentifierFolding::NONE, + ), + ); + } + + private function uniqueIndexOverAB(): DerivedObject + { + return new DerivedObject( + DerivedObjectKind::UniqueIndex, + [UnqualifiedName::unquoted('a'), UnqualifiedName::unquoted('b')], + ); + } + + public function testMatchingUniqueConstraint(): void + { + $derived = new DerivedObject( + DerivedObjectKind::UniqueConstraint, + [UnqualifiedName::unquoted('a'), UnqualifiedName::unquoted('b')], + ); + + self::assertTrue( + $derived->matchesUniqueConstraint($this->uniqueConstraint(['a', 'b']), UnquotedIdentifierFolding::NONE), + ); + } + + /** @param non-empty-list $columnNames */ + #[DataProvider('constraintsNotMatchingUniqueConstraintOverABProvider')] + public function testNonMatchingUniqueConstraint(array $columnNames): void + { + $derived = new DerivedObject( + DerivedObjectKind::UniqueConstraint, + [UnqualifiedName::unquoted('a'), UnqualifiedName::unquoted('b')], + ); + + self::assertFalse( + $derived->matchesUniqueConstraint($this->uniqueConstraint($columnNames), UnquotedIdentifierFolding::NONE), + ); + } + + /** @return iterable}> */ + public static function constraintsNotMatchingUniqueConstraintOverABProvider(): iterable + { + yield 'columns in a different order' => [['b', 'a']]; + yield 'more columns' => [['a', 'b', 'c']]; + yield 'fewer columns' => [['a']]; + yield 'different column' => [['a', 'c']]; + } + + /** @param non-empty-list $columnNames */ + private function uniqueConstraint(array $columnNames): UniqueConstraint + { + return UniqueConstraint::editor() + ->setUnquotedName('uc') + ->setUnquotedColumnNames(...$columnNames) + ->create(); + } + + public function testIndexWithPrefixLength(): void + { + $derived = new DerivedObject(DerivedObjectKind::UniqueIndex, [UnqualifiedName::unquoted('a')]); + + $index = new Index( + UnqualifiedName::unquoted('i'), + IndexType::UNIQUE, + [new Index\IndexedColumn(UnqualifiedName::unquoted('a'), 10)], + false, + null, + ); + + self::assertFalse($derived->matchesIndex($index, UnquotedIdentifierFolding::NONE)); + } + + public function testPartialIndex(): void + { + $derived = new DerivedObject(DerivedObjectKind::UniqueIndex, [UnqualifiedName::unquoted('a')]); + + $index = new Index( + UnqualifiedName::unquoted('i'), + IndexType::UNIQUE, + [new Index\IndexedColumn(UnqualifiedName::unquoted('a'), null)], + false, + 'a IS NOT NULL', + ); + + self::assertFalse($derived->matchesIndex($index, UnquotedIdentifierFolding::NONE)); + } + + public function testClusteredIndex(): void + { + $derived = new DerivedObject(DerivedObjectKind::UniqueIndex, [UnqualifiedName::unquoted('a')]); + + $index = new Index( + UnqualifiedName::unquoted('i'), + IndexType::UNIQUE, + [new Index\IndexedColumn(UnqualifiedName::unquoted('a'), null)], + true, + null, + ); + + self::assertTrue($derived->matchesIndex($index, UnquotedIdentifierFolding::NONE)); + } + + /** @param non-empty-list $columnNames */ + private function index(array $columnNames, IndexType $type): Index + { + return new Index( + UnqualifiedName::unquoted('i'), + $type, + array_map( + static fn (string $columnName): Index\IndexedColumn => new Index\IndexedColumn( + UnqualifiedName::unquoted($columnName), + null, + ), + $columnNames, + ), + false, + null, + ); + } + + #[DataProvider('foldingProvider')] + public function testFolding( + UnqualifiedName $derivedColumnName, + UnqualifiedName $introspectedColumnName, + UnquotedIdentifierFolding $folding, + bool $expected, + ): void { + $derived = new DerivedObject(DerivedObjectKind::UniqueIndex, [$derivedColumnName]); + + $index = new Index( + UnqualifiedName::unquoted('i'), + IndexType::UNIQUE, + [new Index\IndexedColumn($introspectedColumnName, null)], + false, + null, + ); + + self::assertSame($expected, $derived->matchesIndex($index, $folding)); + } + + /** @return iterable */ + public static function foldingProvider(): iterable + { + yield 'unquoted against the folded name it becomes' => [ + UnqualifiedName::unquoted('id'), + UnqualifiedName::quoted('ID'), + UnquotedIdentifierFolding::UPPER, + true, + ]; + + yield 'quoted lower against upper' => [ + UnqualifiedName::quoted('id'), + UnqualifiedName::quoted('ID'), + UnquotedIdentifierFolding::UPPER, + false, + ]; + + yield 'unquoted against the folded name, lower-folding platform' => [ + UnqualifiedName::unquoted('ID'), + UnqualifiedName::quoted('id'), + UnquotedIdentifierFolding::LOWER, + true, + ]; + + yield 'no folding, differing case' => [ + UnqualifiedName::unquoted('id'), + UnqualifiedName::quoted('ID'), + UnquotedIdentifierFolding::NONE, + false, + ]; + } +} From b4372f1df336887a297a342729eccf42efd05c0d Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Fri, 31 Jul 2026 23:54:49 -0700 Subject: [PATCH 02/11] Expect the objects a platform derives from a table The comparator asks the platform's provider which objects the database adds to a table, so it can tell them apart from the ones the application declared. --- src/Platforms/MySQL/Comparator.php | 4 +- src/Platforms/SQLServer/Comparator.php | 4 +- src/Platforms/SQLite/Comparator.php | 10 +++-- src/Schema/AbstractSchemaManager.php | 2 +- src/Schema/Comparator.php | 37 ++++++++++++++++++- src/Schema/MySQLSchemaManager.php | 1 + src/Schema/PostgreSQLSchemaManager.php | 2 +- src/Schema/SQLServerSchemaManager.php | 1 + src/Schema/SQLiteSchemaManager.php | 2 +- tests/Functional/Schema/ComparatorTest.php | 5 +-- .../AbstractMySQLPlatformTestCase.php | 1 + tests/Platforms/AbstractPlatformTestCase.php | 6 ++- tests/Platforms/MySQL/ComparatorTest.php | 5 ++- .../MySQL/MariaDBJsonComparatorTest.php | 5 ++- tests/Platforms/PostgreSQL/ComparatorTest.php | 8 +++- tests/Platforms/SQLServer/ComparatorTest.php | 9 ++++- tests/Platforms/SQLServerPlatformTest.php | 7 +++- tests/Platforms/SQLite/ComparatorTest.php | 8 +++- tests/Platforms/SQLitePlatformTest.php | 6 ++- tests/Schema/Platforms/MySQLSchemaTest.php | 5 ++- 20 files changed, 106 insertions(+), 22 deletions(-) diff --git a/src/Platforms/MySQL/Comparator.php b/src/Platforms/MySQL/Comparator.php index 64dcc88fb4..3b5e942f5b 100644 --- a/src/Platforms/MySQL/Comparator.php +++ b/src/Platforms/MySQL/Comparator.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Schema\ColumnEditor; use Doctrine\DBAL\Schema\Comparator as BaseComparator; use Doctrine\DBAL\Schema\ComparatorConfig; +use Doctrine\DBAL\Schema\DerivedObjectProvider; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; use Override; @@ -26,12 +27,13 @@ class Comparator extends BaseComparator /** @internal The comparator can be only instantiated by a schema manager. */ public function __construct( AbstractMySQLPlatform $platform, + DerivedObjectProvider $derivedObjectProvider, private readonly CharsetMetadataProvider $charsetMetadataProvider, private readonly CollationMetadataProvider $collationMetadataProvider, private readonly DefaultTableOptions $defaultTableOptions, ComparatorConfig $config = new ComparatorConfig(), ) { - parent::__construct($platform, $config); + parent::__construct($platform, $derivedObjectProvider, $config); } #[Override] diff --git a/src/Platforms/SQLServer/Comparator.php b/src/Platforms/SQLServer/Comparator.php index c5d5d092d8..0042d8b9f7 100644 --- a/src/Platforms/SQLServer/Comparator.php +++ b/src/Platforms/SQLServer/Comparator.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Schema\ColumnEditor; use Doctrine\DBAL\Schema\Comparator as BaseComparator; use Doctrine\DBAL\Schema\ComparatorConfig; +use Doctrine\DBAL\Schema\DerivedObjectProvider; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; use Override; @@ -22,10 +23,11 @@ class Comparator extends BaseComparator /** @internal The comparator can be only instantiated by a schema manager. */ public function __construct( SQLServerPlatform $platform, + DerivedObjectProvider $derivedObjectProvider, private readonly string $databaseCollation, ComparatorConfig $config = new ComparatorConfig(), ) { - parent::__construct($platform, $config); + parent::__construct($platform, $derivedObjectProvider, $config); } #[Override] diff --git a/src/Platforms/SQLite/Comparator.php b/src/Platforms/SQLite/Comparator.php index 99d87754aa..8c89e0e08f 100644 --- a/src/Platforms/SQLite/Comparator.php +++ b/src/Platforms/SQLite/Comparator.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Schema\ColumnEditor; use Doctrine\DBAL\Schema\Comparator as BaseComparator; use Doctrine\DBAL\Schema\ComparatorConfig; +use Doctrine\DBAL\Schema\DerivedObjectProvider; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; use Override; @@ -22,9 +23,12 @@ class Comparator extends BaseComparator { /** @internal The comparator can be only instantiated by a schema manager. */ - public function __construct(SQLitePlatform $platform, ComparatorConfig $config = new ComparatorConfig()) - { - parent::__construct($platform, $config); + public function __construct( + SQLitePlatform $platform, + DerivedObjectProvider $derivedObjectProvider, + ComparatorConfig $config = new ComparatorConfig(), + ) { + parent::__construct($platform, $derivedObjectProvider, $config); } #[Override] diff --git a/src/Schema/AbstractSchemaManager.php b/src/Schema/AbstractSchemaManager.php index 6fb1e343f5..4c4be04fd9 100644 --- a/src/Schema/AbstractSchemaManager.php +++ b/src/Schema/AbstractSchemaManager.php @@ -952,7 +952,7 @@ public function createSchemaConfig(): SchemaConfig public function createComparator(ComparatorConfig $config = new ComparatorConfig()): Comparator { - return new Comparator($this->platform, $config); + return new Comparator($this->platform, $this->platform->createDerivedObjectProvider(), $config); } protected function parseUnqualifiedName(string $name): UnqualifiedName diff --git a/src/Schema/Comparator.php b/src/Schema/Comparator.php index f1abe54328..bc463baa52 100644 --- a/src/Schema/Comparator.php +++ b/src/Schema/Comparator.php @@ -19,6 +19,7 @@ class Comparator /** @internal The comparator can be only instantiated by a schema manager. */ public function __construct( private readonly AbstractPlatform $platform, + private readonly DerivedObjectProvider $derivedObjectProvider, private readonly ComparatorConfig $config = new ComparatorConfig(), ) { } @@ -212,9 +213,13 @@ public function compareTables(Table $oldTable, Table $newTable): TableDiff $addedPrimaryKeyConstraint = $newPrimaryKeyConstraint; } + $folding = $this->platform->getUnquotedIdentifierFolding(); + + $derivedFromNewTable = $this->derivedObjectProvider->getDerivedObjects($newTable); + $derivedFromOldTable = $this->derivedObjectProvider->getDerivedObjects($oldTable); + $oldIndexes = $oldTable->getIndexes(); $newIndexes = $newTable->getIndexes(); - $folding = $this->platform->getUnquotedIdentifierFolding(); // See if all the indexes from the old table exist in the new one foreach ($newIndexes as $newIndex) { @@ -230,6 +235,15 @@ public function compareTables(Table $oldTable, Table $newTable): TableDiff $oldIndexName = $oldIndex->getObjectName(); if (! $newTable->hasIndex($oldIndexName->toString())) { + $matchesIndex = static fn (DerivedObject $d): bool => $d->matchesIndex($oldIndex, $folding); + + if ( + $this->consumeDerivedObject($derivedFromNewTable, $matchesIndex) + || $this->consumeDerivedObject($derivedFromOldTable, $matchesIndex) + ) { + continue; + } + $droppedIndexes[] = $oldIndex; continue; @@ -366,6 +380,27 @@ private function primaryKeyConstraintsEqual( return $oldPrimaryKeyConstraint === null && $newPrimaryKeyConstraint === null; } + /** + * Removes the first derived object the predicate matches and reports whether there was one. + * + * @param array $derivedObjects + * @param callable(DerivedObject): bool $matches + */ + private function consumeDerivedObject(array &$derivedObjects, callable $matches): bool + { + foreach ($derivedObjects as $key => $derivedObject) { + if (! $matches($derivedObject)) { + continue; + } + + unset($derivedObjects[$key]); + + return true; + } + + return false; + } + /** * 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. diff --git a/src/Schema/MySQLSchemaManager.php b/src/Schema/MySQLSchemaManager.php index 7f68716ef8..58a5f9d2ed 100644 --- a/src/Schema/MySQLSchemaManager.php +++ b/src/Schema/MySQLSchemaManager.php @@ -31,6 +31,7 @@ public function createComparator(ComparatorConfig $config = new ComparatorConfig { return new MySQL\Comparator( $this->platform, + $this->platform->createDerivedObjectProvider(), new CachingCharsetMetadataProvider( new ConnectionCharsetMetadataProvider($this->connection), ), diff --git a/src/Schema/PostgreSQLSchemaManager.php b/src/Schema/PostgreSQLSchemaManager.php index 2fea51d28c..4bd3d574e3 100644 --- a/src/Schema/PostgreSQLSchemaManager.php +++ b/src/Schema/PostgreSQLSchemaManager.php @@ -21,7 +21,7 @@ class PostgreSQLSchemaManager extends AbstractSchemaManager #[Override] public function createComparator(ComparatorConfig $config = new ComparatorConfig()): Comparator { - return new PostgreSQL\Comparator($this->platform, $config); + return new PostgreSQL\Comparator($this->platform, $this->platform->createDerivedObjectProvider(), $config); } #[Override] diff --git a/src/Schema/SQLServerSchemaManager.php b/src/Schema/SQLServerSchemaManager.php index 2c6cdfd469..a3ee8c1fba 100644 --- a/src/Schema/SQLServerSchemaManager.php +++ b/src/Schema/SQLServerSchemaManager.php @@ -27,6 +27,7 @@ public function createComparator(ComparatorConfig $config = new ComparatorConfig { return new SQLServer\Comparator( $this->platform, + $this->platform->createDerivedObjectProvider(), $this->getDatabaseCollation(), $config, ); diff --git a/src/Schema/SQLiteSchemaManager.php b/src/Schema/SQLiteSchemaManager.php index 8119e981fe..7648de89cf 100644 --- a/src/Schema/SQLiteSchemaManager.php +++ b/src/Schema/SQLiteSchemaManager.php @@ -45,6 +45,6 @@ private function introspectTableByStringName(string $tableName): Table #[Override] public function createComparator(ComparatorConfig $config = new ComparatorConfig()): Comparator { - return new SQLite\Comparator($this->platform, $config); + return new SQLite\Comparator($this->platform, $this->platform->createDerivedObjectProvider(), $config); } } diff --git a/tests/Functional/Schema/ComparatorTest.php b/tests/Functional/Schema/ComparatorTest.php index 2d3e1b22c5..c2b83fed7b 100644 --- a/tests/Functional/Schema/ComparatorTest.php +++ b/tests/Functional/Schema/ComparatorTest.php @@ -10,8 +10,6 @@ use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\DBAL\Schema\Column; use Doctrine\DBAL\Schema\ColumnEditor; -use Doctrine\DBAL\Schema\Comparator; -use Doctrine\DBAL\Schema\ComparatorConfig; use Doctrine\DBAL\Schema\Exception\UnspecifiedConstraintName; use Doctrine\DBAL\Schema\ForeignKeyConstraint; use Doctrine\DBAL\Schema\Name\UnqualifiedName; @@ -69,8 +67,7 @@ public function testDefaultValueComparison(string $typeName, mixed $value): void public function testRenameColumnComparison(): void { - $platform = $this->connection->getDatabasePlatform(); - $comparator = new Comparator($platform, new ComparatorConfig()); + $comparator = $this->schemaManager->createComparator(); $onlineTable = Table::editor() ->setUnquotedName('rename_table') diff --git a/tests/Platforms/AbstractMySQLPlatformTestCase.php b/tests/Platforms/AbstractMySQLPlatformTestCase.php index 188711ab32..e040e088db 100644 --- a/tests/Platforms/AbstractMySQLPlatformTestCase.php +++ b/tests/Platforms/AbstractMySQLPlatformTestCase.php @@ -582,6 +582,7 @@ protected function createComparator(): Comparator { return new MySQL\Comparator( $this->platform, + $this->platform->createDerivedObjectProvider(), self::createStub(CharsetMetadataProvider::class), self::createStub(CollationMetadataProvider::class), new DefaultTableOptions('utf8mb4', 'utf8mb4_general_ci'), diff --git a/tests/Platforms/AbstractPlatformTestCase.php b/tests/Platforms/AbstractPlatformTestCase.php index f843727fb3..4062058bd6 100644 --- a/tests/Platforms/AbstractPlatformTestCase.php +++ b/tests/Platforms/AbstractPlatformTestCase.php @@ -58,7 +58,11 @@ protected function setUp(): void protected function createComparator(): Comparator { - return new Comparator($this->platform, new ComparatorConfig()); + return new Comparator( + $this->platform, + $this->platform->createDerivedObjectProvider(), + new ComparatorConfig(), + ); } /** @return list */ diff --git a/tests/Platforms/MySQL/ComparatorTest.php b/tests/Platforms/MySQL/ComparatorTest.php index 023c1f9bef..2d576c2412 100644 --- a/tests/Platforms/MySQL/ComparatorTest.php +++ b/tests/Platforms/MySQL/ComparatorTest.php @@ -18,8 +18,11 @@ class ComparatorTest extends AbstractComparatorTestCase #[Override] protected function createComparator(ComparatorConfig $config): Comparator { + $platform = new MySQLPlatform(); + return new Comparator( - new MySQLPlatform(), + $platform, + $platform->createDerivedObjectProvider(), self::createStub(CharsetMetadataProvider::class), self::createStub(CollationMetadataProvider::class), new DefaultTableOptions('utf8mb4', 'utf8mb4_general_ci'), diff --git a/tests/Platforms/MySQL/MariaDBJsonComparatorTest.php b/tests/Platforms/MySQL/MariaDBJsonComparatorTest.php index d4f4bdeea2..6b8b818a57 100644 --- a/tests/Platforms/MySQL/MariaDBJsonComparatorTest.php +++ b/tests/Platforms/MySQL/MariaDBJsonComparatorTest.php @@ -29,8 +29,11 @@ class MariaDBJsonComparatorTest extends TestCase #[Override] protected function setUp(): void { + $platform = new MariaDBPlatform(); + $this->comparator = new Comparator( - new MariaDBPlatform(), + $platform, + $platform->createDerivedObjectProvider(), new class implements CharsetMetadataProvider { #[Override] public function getDefaultCharsetCollation(string $charset): ?string diff --git a/tests/Platforms/PostgreSQL/ComparatorTest.php b/tests/Platforms/PostgreSQL/ComparatorTest.php index a1f660747e..74ef332e68 100644 --- a/tests/Platforms/PostgreSQL/ComparatorTest.php +++ b/tests/Platforms/PostgreSQL/ComparatorTest.php @@ -15,6 +15,12 @@ class ComparatorTest extends AbstractComparatorTestCase #[Override] protected function createComparator(ComparatorConfig $config): Comparator { - return new Comparator(new PostgreSQLPlatform(), $config); + $platform = new PostgreSQLPlatform(); + + return new Comparator( + $platform, + $platform->createDerivedObjectProvider(), + $config, + ); } } diff --git a/tests/Platforms/SQLServer/ComparatorTest.php b/tests/Platforms/SQLServer/ComparatorTest.php index 1529b4830e..3b65d3b81e 100644 --- a/tests/Platforms/SQLServer/ComparatorTest.php +++ b/tests/Platforms/SQLServer/ComparatorTest.php @@ -15,6 +15,13 @@ class ComparatorTest extends AbstractComparatorTestCase #[Override] protected function createComparator(ComparatorConfig $config): Comparator { - return new Comparator(new SQLServerPlatform(), '', $config); + $platform = new SQLServerPlatform(); + + return new Comparator( + $platform, + $platform->createDerivedObjectProvider(), + '', + $config, + ); } } diff --git a/tests/Platforms/SQLServerPlatformTest.php b/tests/Platforms/SQLServerPlatformTest.php index f5daa0879a..d348c487d9 100644 --- a/tests/Platforms/SQLServerPlatformTest.php +++ b/tests/Platforms/SQLServerPlatformTest.php @@ -36,7 +36,12 @@ public function createPlatform(): AbstractPlatform #[Override] protected function createComparator(): Comparator { - return new SQLServer\Comparator($this->platform, '', new ComparatorConfig()); + return new SQLServer\Comparator( + $this->platform, + $this->platform->createDerivedObjectProvider(), + '', + new ComparatorConfig(), + ); } #[Override] diff --git a/tests/Platforms/SQLite/ComparatorTest.php b/tests/Platforms/SQLite/ComparatorTest.php index 8ece0494b3..547b1f6b66 100644 --- a/tests/Platforms/SQLite/ComparatorTest.php +++ b/tests/Platforms/SQLite/ComparatorTest.php @@ -15,7 +15,13 @@ class ComparatorTest extends AbstractComparatorTestCase #[Override] protected function createComparator(ComparatorConfig $config): Comparator { - return new Comparator(new SQLitePlatform(), $config); + $platform = new SQLitePlatform(); + + return new Comparator( + $platform, + $platform->createDerivedObjectProvider(), + $config, + ); } public function testCompareChangedBinaryColumn(): void diff --git a/tests/Platforms/SQLitePlatformTest.php b/tests/Platforms/SQLitePlatformTest.php index 540dd89d98..c90e9e301b 100644 --- a/tests/Platforms/SQLitePlatformTest.php +++ b/tests/Platforms/SQLitePlatformTest.php @@ -35,7 +35,11 @@ public function createPlatform(): AbstractPlatform #[Override] protected function createComparator(): Comparator { - return new SQLite\Comparator($this->platform, new ComparatorConfig()); + return new SQLite\Comparator( + $this->platform, + $this->platform->createDerivedObjectProvider(), + new ComparatorConfig(), + ); } #[Override] diff --git a/tests/Schema/Platforms/MySQLSchemaTest.php b/tests/Schema/Platforms/MySQLSchemaTest.php index 54ee1f560b..75869bff42 100644 --- a/tests/Schema/Platforms/MySQLSchemaTest.php +++ b/tests/Schema/Platforms/MySQLSchemaTest.php @@ -98,8 +98,11 @@ public function testClobNoAlterTable(): void private function createComparator(): Comparator { + $platform = new MySQLPlatform(); + return new MySQL\Comparator( - new MySQLPlatform(), + $platform, + $platform->createDerivedObjectProvider(), self::createStub(CharsetMetadataProvider::class), self::createStub(CollationMetadataProvider::class), new DefaultTableOptions('utf8mb4', 'utf8mb4_general_ci'), From 243aea65f42a747a9c7eccce7b41d8c4e43f0690 Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Sat, 1 Aug 2026 00:05:46 -0700 Subject: [PATCH 03/11] Stop fabricating an index for a foreign key constraint The comparator expects the index the engine creates over a foreign key's referencing columns, so the definition layer no longer mirrors it with an invented one. --- UPGRADE.md | 5 + docs/en/explanation/implicit-indexes.rst | 75 ------ docs/en/sidebar.rst | 6 - .../Exception/SetAlreadyContainsName.php | 20 -- .../Exception/SetDoesNotContainName.php | 20 -- src/Schema/Collections/NameSet.php | 46 ---- src/Schema/Collections/UnqualifiedNameSet.php | 88 ------ src/Schema/Table.php | 19 +- src/Schema/TableEditor.php | 79 +----- tests/Functional/Schema/ColumnRenameTest.php | 2 +- .../SchemaManagerFunctionalTestCase.php | 68 ----- .../AbstractMySQLPlatformTestCase.php | 2 +- tests/Platforms/DB2PlatformTest.php | 2 - tests/Platforms/MariaDBPlatformTest.php | 2 +- tests/Platforms/OraclePlatformTest.php | 1 - tests/Platforms/PostgreSQLPlatformTest.php | 2 - tests/Platforms/SQLServerPlatformTest.php | 1 - tests/Platforms/SQLitePlatformTest.php | 6 - .../Collections/UnqualifiedNameSetTest.php | 75 ------ tests/Schema/IndexTest.php | 11 + tests/Schema/TableTest.php | 251 ------------------ 21 files changed, 21 insertions(+), 760 deletions(-) delete mode 100644 docs/en/explanation/implicit-indexes.rst delete mode 100644 src/Schema/Collections/Exception/SetAlreadyContainsName.php delete mode 100644 src/Schema/Collections/Exception/SetDoesNotContainName.php delete mode 100644 src/Schema/Collections/NameSet.php delete mode 100644 src/Schema/Collections/UnqualifiedNameSet.php delete mode 100644 tests/Schema/Collections/UnqualifiedNameSetTest.php diff --git a/UPGRADE.md b/UPGRADE.md index 85ae7d8304..615b89f477 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,11 @@ awareness about deprecated code. # Upgrade to 5.0 +## BC BREAK: No index is created for a foreign key constraint + +Declaring a foreign key constraint no longer adds an index over its referencing columns. MySQL and MariaDB create one +themselves; on the other platforms, an application that wants such an index must declare it. + ## BC BREAK: Added `AbstractPlatform::createDerivedObjectProvider()` `Doctrine\DBAL\Platforms\AbstractPlatform` now declares `createDerivedObjectProvider()`. Platforms extending it must diff --git a/docs/en/explanation/implicit-indexes.rst b/docs/en/explanation/implicit-indexes.rst deleted file mode 100644 index cf1a56abff..0000000000 --- a/docs/en/explanation/implicit-indexes.rst +++ /dev/null @@ -1,75 +0,0 @@ -Implicit indexes -================ - -Ever noticed the DBAL creating indexes you did not remember asking for, -with names such as ``IDX_885DBAFAA76ED395``? In this document, we will -distinguish three types of indexes: - -user-defined indexes - indexes you did ask for - -DBAL-defined indexes - indexes you did not ask for, created on your behalf by the DBAL - -RDBMS-defined indexes - indexes you did not ask for, created on your behalf by the RDBMS - -RDBMS-defined indexes can be created by some database platforms when you -create a foreign key: they will create an index on the referencing -table, using the referencing columns. - -The rationale behind this is that these indexes improve performance, for -instance for checking that a delete operation can be performed on a -referenced table without violating the constraint in the referencing -table. - -Here are some database platforms that are known to create indexes when -creating a foreign key: - -- `MySQL `_ -- `MariaDB `_ - -These platforms can drop an existing implicit index once it is fulfilled -by a newly created user-defined index. - -Some other will not do so, on grounds that such indexes are not always -needed, and can be created in many different ways. They instead leave -that responsibility to the user: - -- `PostgreSQL `_ -- `SQLite `_ -- `SQL Server `_ - -So why does the DBAL create this index on every platform, even the ones -that neither require it nor create it themselves? - -Because of how the DBAL compares schemas on MySQL and MariaDB. When the -DBAL reads a table back, it cannot tell an index you added from one the -database created for the foreign key: the metadata records nothing about -who created it. So a table you defined without a backing index comes -back with one. If the DBAL hadn't created it up-front, the comparison -would report a change that isn't real: the migration would drop the -index, the database would recreate it immediately, and the next -comparison would report the same change again — a migration that never -converges. Implicit indexes work around this limitation. - -The DBAL's schema model is platform-agnostic, so it cannot apply this -only to MySQL and MariaDB. The index ends up on every database, including -the ones that never needed it. - -This is a detail, but these indexes will be prefixed with ``IDX_``, and -typically look like this: - -.. code-block:: sql - - CREATE INDEX IDX_885DBAFAA76ED395 ON posts (user_id) - -The generated name fits within the platform's identifier-length limit. - -In the case of MariaDB and MySQL, the creation of that DBAL-defined -index will result in the RDBMS-defined index being dropped. - -You can still explicitly create such indexes yourself, and the DBAL will -notice when your index fulfills the indexing and constraint needs of the -implicit index it would create, and will refrain from doing so, much -like some platforms drop indexes that are redundant as explained above. diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index 3d637b8338..0674f2016d 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -24,12 +24,6 @@ /reference/testing -.. toctree:: - :caption: Explanation - :depth: 3 - - /explanation/implicit-indexes - .. toctree:: :caption: How To :depth: 3 diff --git a/src/Schema/Collections/Exception/SetAlreadyContainsName.php b/src/Schema/Collections/Exception/SetAlreadyContainsName.php deleted file mode 100644 index 3a34b70f84..0000000000 --- a/src/Schema/Collections/Exception/SetAlreadyContainsName.php +++ /dev/null @@ -1,20 +0,0 @@ -toString())); - } -} diff --git a/src/Schema/Collections/Exception/SetDoesNotContainName.php b/src/Schema/Collections/Exception/SetDoesNotContainName.php deleted file mode 100644 index 50df2d5335..0000000000 --- a/src/Schema/Collections/Exception/SetDoesNotContainName.php +++ /dev/null @@ -1,20 +0,0 @@ -toString())); - } -} diff --git a/src/Schema/Collections/NameSet.php b/src/Schema/Collections/NameSet.php deleted file mode 100644 index b7aecfb0d2..0000000000 --- a/src/Schema/Collections/NameSet.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ -interface NameSet extends IteratorAggregate -{ - /** - * Returns whether the set contains the given name. - * - * @phpstan-param N $name - */ - public function contains(Name $name): bool; - - /** - * Adds the given name to the set. - * - * @phpstan-param N $name - * - * @throws SetAlreadyContainsName If the set already contains the name. - */ - public function add(Name $name): void; - - /** - * Removes the given name from the set. - * - * @phpstan-param N $name - * - * @throws SetDoesNotContainName If the set does not contain the name. - */ - public function remove(Name $name): void; -} diff --git a/src/Schema/Collections/UnqualifiedNameSet.php b/src/Schema/Collections/UnqualifiedNameSet.php deleted file mode 100644 index 552816b757..0000000000 --- a/src/Schema/Collections/UnqualifiedNameSet.php +++ /dev/null @@ -1,88 +0,0 @@ - - */ -final class UnqualifiedNameSet implements NameSet -{ - /** @var array */ - private array $elements = []; - - public function __construct(UnqualifiedName ...$names) - { - foreach ($names as $name) { - $this->add($name); - } - } - - /** @return list */ - public function toList(): array - { - return array_values($this->elements); - } - - #[Override] - public function contains(Name $name): bool - { - $key = $this->getKey($name); - - return isset($this->elements[$key]); - } - - #[Override] - public function add(Name $name): void - { - $key = $this->getKey($name); - - if (isset($this->elements[$key])) { - throw SetAlreadyContainsName::new($name); - } - - $this->elements[$key] = $name; - } - - #[Override] - public function remove(Name $name): void - { - $key = $this->getKey($name); - - if (! isset($this->elements[$key])) { - throw SetDoesNotContainName::new($name); - } - - unset($this->elements[$key]); - } - - /** @return Traversable */ - #[Override] - public function getIterator(): Traversable - { - foreach ($this->elements as $element) { - yield $element; - } - } - - /** @param UnqualifiedName $name */ - private function getKey(Name $name): string - { - return strtolower($name->getIdentifier()->getValue()); - } -} diff --git a/src/Schema/Table.php b/src/Schema/Table.php index 89063f192f..1c35e7731a 100644 --- a/src/Schema/Table.php +++ b/src/Schema/Table.php @@ -7,7 +7,6 @@ use Doctrine\DBAL\Schema\Collections\OptionallyUnqualifiedNamedObjectSet; use Doctrine\DBAL\Schema\Collections\ReadableObjectSet; use Doctrine\DBAL\Schema\Collections\UnqualifiedNamedObjectSet; -use Doctrine\DBAL\Schema\Collections\UnqualifiedNameSet; use Doctrine\DBAL\Schema\Exception\ColumnDoesNotExist; use Doctrine\DBAL\Schema\Exception\ForeignKeyDoesNotExist; use Doctrine\DBAL\Schema\Exception\IndexDoesNotExist; @@ -37,11 +36,6 @@ /** @var ReadableObjectSet */ private ReadableObjectSet $indexes; - /** - * The names of the indexes that were implicitly created as backing for foreign key constraints. - */ - private UnqualifiedNameSet $implicitIndexNames; - /** @var ReadableObjectSet */ private ReadableObjectSet $uniqueConstraints; @@ -57,7 +51,6 @@ * * @param non-empty-list $columns * @param list $indexes - * @param list $implicitIndexNames * @param list $uniqueConstraints * @param list $foreignKeyConstraints * @param array $options @@ -67,7 +60,6 @@ public function __construct( private OptionallyQualifiedName $name, array $columns, array $indexes, - array $implicitIndexNames, array $uniqueConstraints, array $foreignKeyConstraints, array $options, @@ -81,7 +73,6 @@ public function __construct( $this->columns = new UnqualifiedNamedObjectSet(...$columns); $this->indexes = new UnqualifiedNamedObjectSet(...$indexes); - $this->implicitIndexNames = new UnqualifiedNameSet(...$implicitIndexNames); $this->uniqueConstraints = new OptionallyUnqualifiedNamedObjectSet(...$uniqueConstraints); $this->foreignKeyConstraints = new OptionallyUnqualifiedNamedObjectSet(...$foreignKeyConstraints); @@ -322,18 +313,10 @@ public static function editor(): TableEditor */ public function edit(): TableEditor { - $explicitIndexes = []; - - foreach ($this->indexes as $index) { - if (! $this->implicitIndexNames->contains($index->getObjectName())) { - $explicitIndexes[] = $index; - } - } - $editor = self::editor() ->setName($this->getObjectName()) ->setColumns(...$this->columns->toList()) - ->setIndexes(...$explicitIndexes) + ->setIndexes(...$this->indexes->toList()) ->setPrimaryKeyConstraint($this->primaryKeyConstraint) ->setUniqueConstraints(...$this->uniqueConstraints->toList()) ->setForeignKeyConstraints(...$this->foreignKeyConstraints->toList()); diff --git a/src/Schema/TableEditor.php b/src/Schema/TableEditor.php index 0f3af4110e..293e61a25c 100644 --- a/src/Schema/TableEditor.php +++ b/src/Schema/TableEditor.php @@ -8,7 +8,6 @@ use Doctrine\DBAL\Schema\Collections\Exception\ObjectDoesNotExist; use Doctrine\DBAL\Schema\Collections\OptionallyUnqualifiedNamedObjectSet; use Doctrine\DBAL\Schema\Collections\UnqualifiedNamedObjectSet; -use Doctrine\DBAL\Schema\Collections\UnqualifiedNameSet; use Doctrine\DBAL\Schema\Exception\IndexAlreadyExists; use Doctrine\DBAL\Schema\Exception\InvalidTableDefinition; use Doctrine\DBAL\Schema\Exception\InvalidTableModification; @@ -582,8 +581,7 @@ public function create(): Table $unqualifiedName = $name->getUnqualifiedName(); /** @var UnqualifiedNamedObjectSet $indexes */ - $indexes = new UnqualifiedNamedObjectSet(); - $implicitIndexNames = new UnqualifiedNameSet(); + $indexes = new UnqualifiedNamedObjectSet(); foreach ($this->indexes as $index) { $this->registerIndex($name, $indexes, $index); @@ -597,26 +595,6 @@ public function create(): Table ); } - foreach ($this->uniqueConstraints as $uniqueConstraint) { - $this->registerUniqueConstraint( - $name, - $maxIdentifierLength, - $indexes, - $implicitIndexNames, - $uniqueConstraint, - ); - } - - foreach ($this->foreignKeyConstraints as $foreignKeyConstraint) { - $this->registerForeignKeyConstraint( - $name, - $maxIdentifierLength, - $indexes, - $implicitIndexNames, - $foreignKeyConstraint, - ); - } - $options = $this->options; if ($this->comment !== '') { @@ -627,7 +605,6 @@ public function create(): Table $name, $columns, $indexes->toList(), - $implicitIndexNames->toList(), $this->uniqueConstraints->toList(), $this->foreignKeyConstraints->toList(), $options, @@ -651,58 +628,4 @@ private function registerIndex( $indexes->add($index); } - - /** - * @param positive-int $maxIdentifierLength - * @param UnqualifiedNamedObjectSet $indexes - */ - private function registerUniqueConstraint( - OptionallyQualifiedName $tableName, - int $maxIdentifierLength, - UnqualifiedNamedObjectSet $indexes, - UnqualifiedNameSet $implicitIndexNames, - UniqueConstraint $constraint, - ): void { - $columnNames = $constraint->getColumnNames(); - - $indexCandidate = Index::editor() - ->setType(Index\IndexType::UNIQUE) - ->setColumnNames(...$columnNames) - ->createForTable($tableName->getUnqualifiedName(), $maxIdentifierLength); - - foreach ($indexes as $existingIndex) { - if ($indexCandidate->isFulfilledBy($existingIndex)) { - return; - } - } - - $implicitIndexNames->add($indexCandidate->getObjectName()); - } - - /** - * @param positive-int $maxIdentifierLength - * @param UnqualifiedNamedObjectSet $indexes - */ - private function registerForeignKeyConstraint( - OptionallyQualifiedName $tableName, - int $maxIdentifierLength, - UnqualifiedNamedObjectSet $indexes, - UnqualifiedNameSet $implicitIndexNames, - ForeignKeyConstraint $constraint, - ): void { - $columnNames = $constraint->getReferencingColumnNames(); - - $indexCandidate = Index::editor() - ->setColumnNames(...$columnNames) - ->createForTable($tableName->getUnqualifiedName(), $maxIdentifierLength); - - foreach ($indexes as $existingIndex) { - if ($indexCandidate->isFulfilledBy($existingIndex)) { - return; - } - } - - $this->registerIndex($tableName, $indexes, $indexCandidate); - $implicitIndexNames->add($indexCandidate->getObjectName()); - } } diff --git a/tests/Functional/Schema/ColumnRenameTest.php b/tests/Functional/Schema/ColumnRenameTest.php index bc60b08ed5..b677447094 100644 --- a/tests/Functional/Schema/ColumnRenameTest.php +++ b/tests/Functional/Schema/ColumnRenameTest.php @@ -114,8 +114,8 @@ private function testRenameColumn(callable $modifier): void $this->connection->createSchemaManager()->createTable($table); self::assertTrue($this->comparator->compareTables( - $table, $this->schemaManager->introspectTableByUnquotedName('rename_column'), + $table, )->isEmpty()); } } diff --git a/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php b/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php index d166d42c9e..5f707fbde0 100644 --- a/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php +++ b/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php @@ -1536,72 +1536,6 @@ public function testListTableDetailsWithFullQualifiedTableName(): void ); } - public function testDoesNotListIndexesImplicitlyCreatedByForeignKeys(): void - { - $primaryTable = Table::editor() - ->setUnquotedName('test_list_index_impl_primary') - ->setColumns( - Column::editor() - ->setUnquotedName('id') - ->setTypeName(Types::INTEGER) - ->create(), - ) - ->setPrimaryKeyConstraint( - PrimaryKeyConstraint::editor() - ->setUnquotedColumnNames('id') - ->create(), - ) - ->create(); - - $foreignTable = Table::editor() - ->setUnquotedName('test_list_index_impl_foreign') - ->setColumns( - Column::editor() - ->setUnquotedName('fk1') - ->setTypeName(Types::INTEGER) - ->create(), - Column::editor() - ->setUnquotedName('fk2') - ->setTypeName(Types::INTEGER) - ->create(), - ) - ->setIndexes( - Index::editor() - ->setUnquotedName('explicit_fk1_idx') - ->setUnquotedColumnNames('fk1') - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('fk1') - ->setUnquotedReferencedTableName('test_list_index_impl_primary') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('fk2') - ->setUnquotedReferencedTableName('test_list_index_impl_primary') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ) - ->create(); - - $this->dropAndCreateTable($primaryTable); - $this->dropAndCreateTable($foreignTable); - - $this->assertIndexListEquals([ - Index::editor() - ->setName(UnqualifiedName::unquoted('IDX_3D6C147FDC58D6C')) - ->setColumnNames( - UnqualifiedName::unquoted('fk2'), - ) - ->create(), - Index::editor() - ->setName(UnqualifiedName::unquoted('explicit_fk1_idx')) - ->setColumnNames(UnqualifiedName::unquoted('fk1')) - ->create(), - ], $this->schemaManager->introspectTableIndexesByUnquotedName('test_list_index_impl_foreign')); - } - public function testCreateAndListSequences(): void { if (! $this->connection->getDatabasePlatform()->supportsSequences()) { @@ -1790,7 +1724,6 @@ public function testIntrospectReservedKeywordTableViaListTableDetails(): void $user = $this->schemaManager->introspectTableByUnquotedName('user'); self::assertCount(2, $user->getColumns()); - self::assertCount(1, $user->getIndexes()); self::assertCount(1, $user->getForeignKeys()); } @@ -1803,7 +1736,6 @@ public function testIntrospectReservedKeywordTableViaListTables(): void $user = $this->findObjectByName($tables, OptionallyQualifiedName::unquoted('user')); self::assertNotNull($user); self::assertCount(2, $user->getColumns()); - self::assertCount(1, $user->getIndexes()); self::assertCount(1, $user->getForeignKeys()); } diff --git a/tests/Platforms/AbstractMySQLPlatformTestCase.php b/tests/Platforms/AbstractMySQLPlatformTestCase.php index e040e088db..27a3318957 100644 --- a/tests/Platforms/AbstractMySQLPlatformTestCase.php +++ b/tests/Platforms/AbstractMySQLPlatformTestCase.php @@ -246,7 +246,7 @@ protected function getQuotedColumnInForeignKeySQL(): array { return [ 'CREATE TABLE `quoted` (`create` VARCHAR(255) NOT NULL, `foo` VARCHAR(255) NOT NULL, ' - . '`bar` VARCHAR(255) NOT NULL, INDEX `IDX_22660D028FD6E0FB8C73652176FF8CAA` (`create`, `foo`, `bar`))', + . '`bar` VARCHAR(255) NOT NULL)', 'ALTER TABLE `quoted` ADD CONSTRAINT `FK_WITH_RESERVED_KEYWORD` FOREIGN KEY (`create`, `foo`, `bar`)' . ' REFERENCES `foreign` (`create`, `bar`, `foo-bar`)', 'ALTER TABLE `quoted` ADD CONSTRAINT `FK_WITH_NON_RESERVED_KEYWORD` FOREIGN KEY (`create`, `foo`, `bar`)' diff --git a/tests/Platforms/DB2PlatformTest.php b/tests/Platforms/DB2PlatformTest.php index 4e633319e8..6eb8b038d4 100644 --- a/tests/Platforms/DB2PlatformTest.php +++ b/tests/Platforms/DB2PlatformTest.php @@ -73,7 +73,6 @@ protected function getQuotedColumnInForeignKeySQL(): array . ' REFERENCES "FOO" ("CREATE", "BAR", "foo-bar")', 'ALTER TABLE "quoted" ADD CONSTRAINT "FK_WITH_INTENDED_QUOTATION" FOREIGN KEY ("CREATE", "FOO", "bar")' . ' REFERENCES "foo-bar" ("CREATE", "BAR", "foo-bar")', - 'CREATE INDEX "IDX_22660D028FD6E0FB8C73652176FF8CAA" ON "quoted" ("CREATE", "FOO", "bar")', ]; } @@ -200,7 +199,6 @@ public function testGeneratesCreateTableSQLWithForeignKeyConstraints(): void . ' REFERENCES "FOREIGN_TABLE" ("PK_1", "PK_2")', 'ALTER TABLE "TEST" ADD CONSTRAINT "NAMED_FK" FOREIGN KEY ("FK_1", "FK_2")' . ' REFERENCES "FOREIGN_TABLE2" ("PK_1", "PK_2")', - 'CREATE INDEX "IDX_D87F7E0C177612A38E7F4319" ON "TEST" ("FK_1", "FK_2")', ], $this->platform->getCreateTableSQL($table), ); diff --git a/tests/Platforms/MariaDBPlatformTest.php b/tests/Platforms/MariaDBPlatformTest.php index 3c7da7e87e..870e7520a7 100644 --- a/tests/Platforms/MariaDBPlatformTest.php +++ b/tests/Platforms/MariaDBPlatformTest.php @@ -81,7 +81,7 @@ protected function getQuotedColumnInForeignKeySQL(): array { return [ 'CREATE TABLE `quoted` (`create` VARCHAR(255) NOT NULL, `foo` VARCHAR(255) NOT NULL, ' - . '`bar` VARCHAR(255) NOT NULL, INDEX `IDX_22660D028FD6E0FB8C73652176FF8CAA` (`create`, `foo`, `bar`))', + . '`bar` VARCHAR(255) NOT NULL)', 'ALTER TABLE `quoted` ADD CONSTRAINT `FK_WITH_RESERVED_KEYWORD` FOREIGN KEY (`create`, `foo`, `bar`)' . ' REFERENCES `foreign` (`create`, `bar`, `foo-bar`) ON UPDATE NO ACTION ON DELETE NO ACTION', 'ALTER TABLE `quoted` ADD CONSTRAINT `FK_WITH_NON_RESERVED_KEYWORD` FOREIGN KEY (`create`, `foo`, `bar`)' diff --git a/tests/Platforms/OraclePlatformTest.php b/tests/Platforms/OraclePlatformTest.php index 7c0daf705a..e681e5f264 100644 --- a/tests/Platforms/OraclePlatformTest.php +++ b/tests/Platforms/OraclePlatformTest.php @@ -259,7 +259,6 @@ protected function getQuotedColumnInForeignKeySQL(): array . ' REFERENCES "FOO" ("CREATE", "BAR", "foo-bar")', 'ALTER TABLE "quoted" ADD CONSTRAINT "FK_WITH_INTENDED_QUOTATION" FOREIGN KEY ("CREATE", "FOO", "bar")' . ' REFERENCES "foo-bar" ("CREATE", "BAR", "foo-bar")', - 'CREATE INDEX "IDX_22660D028FD6E0FB8C73652176FF8CAA" ON "quoted" ("CREATE", "FOO", "bar")', ]; } diff --git a/tests/Platforms/PostgreSQLPlatformTest.php b/tests/Platforms/PostgreSQLPlatformTest.php index e5eb2fd6c5..34f00056c4 100644 --- a/tests/Platforms/PostgreSQLPlatformTest.php +++ b/tests/Platforms/PostgreSQLPlatformTest.php @@ -299,7 +299,6 @@ protected function getQuotedColumnInForeignKeySQL(): array return [ 'CREATE TABLE "quoted" ("create" VARCHAR(255) NOT NULL, ' . '"foo" VARCHAR(255) NOT NULL, "bar" VARCHAR(255) NOT NULL)', - 'CREATE INDEX "idx_22660d028fd6e0fb8c73652176ff8caa" ON "quoted" ("create", "foo", "bar")', 'ALTER TABLE "quoted" ADD CONSTRAINT "fk_with_reserved_keyword" FOREIGN KEY ("create", "foo", "bar")' . ' REFERENCES "foreign" ("create", "bar", "foo-bar")', 'ALTER TABLE "quoted" ADD CONSTRAINT "fk_with_non_reserved_keyword" FOREIGN KEY ("create", "foo", "bar")' @@ -560,7 +559,6 @@ public function testDroppingConstraintsBeforeColumns(): void $expectedSql = [ 'ALTER TABLE "mytable" DROP CONSTRAINT "fk_parent"', - 'DROP INDEX "idx_6b2bd609727aca70"', 'ALTER TABLE "mytable" DROP "parent_id"', ]; diff --git a/tests/Platforms/SQLServerPlatformTest.php b/tests/Platforms/SQLServerPlatformTest.php index d348c487d9..993096cb15 100644 --- a/tests/Platforms/SQLServerPlatformTest.php +++ b/tests/Platforms/SQLServerPlatformTest.php @@ -629,7 +629,6 @@ protected function getQuotedColumnInForeignKeySQL(): array return [ 'CREATE TABLE [quoted] ([create] NVARCHAR(255) NOT NULL, ' . '[foo] NVARCHAR(255) NOT NULL, [bar] NVARCHAR(255) NOT NULL)', - 'CREATE INDEX [IDX_22660D028FD6E0FB8C73652176FF8CAA] ON [quoted] ([create], [foo], [bar])', 'ALTER TABLE [quoted] ADD CONSTRAINT [FK_WITH_RESERVED_KEYWORD]' . ' FOREIGN KEY ([create], [foo], [bar]) REFERENCES [foreign] ([create], [bar], [foo-bar])', 'ALTER TABLE [quoted] ADD CONSTRAINT [FK_WITH_NON_RESERVED_KEYWORD]' diff --git a/tests/Platforms/SQLitePlatformTest.php b/tests/Platforms/SQLitePlatformTest.php index c90e9e301b..a5bf67826e 100644 --- a/tests/Platforms/SQLitePlatformTest.php +++ b/tests/Platforms/SQLitePlatformTest.php @@ -350,9 +350,6 @@ public function testCreateTableWithDeferredForeignKeys(): void . ', FOREIGN KEY ("parent")' . ' REFERENCES "user" ("id") DEFERRABLE INITIALLY DEFERRED' . ')', - 'CREATE INDEX "IDX_8D93D64923A0E66" ON "user" ("article")', - 'CREATE INDEX "IDX_8D93D6495A8A6C8D" ON "user" ("post")', - 'CREATE INDEX "IDX_8D93D6493D8E604F" ON "user" ("parent")', ]; self::assertEquals($sql, $this->platform->getCreateTableSQL($table)); @@ -455,8 +452,6 @@ public function testAlterTable(): void . ')', 'INSERT INTO "user" ("key", "article", "comment") SELECT "id", "article", "post" FROM "__temp__user"', 'DROP TABLE "__temp__user"', - 'CREATE INDEX "IDX_8D93D64923A0E66" ON "user" ("article")', - 'CREATE INDEX "IDX_8D93D6495A8A6C8D" ON "user" ("comment")', ]; self::assertEquals($sql, $this->platform->getAlterTableSQL($diff)); @@ -498,7 +493,6 @@ protected function getQuotedColumnInForeignKeySQL(): array 'REFERENCES "foo" ("create", "bar", "foo-bar"), ' . 'CONSTRAINT "FK_WITH_INTENDED_QUOTATION" FOREIGN KEY ("create", "foo", "bar") ' . 'REFERENCES "foo-bar" ("create", "bar", "foo-bar"))', - 'CREATE INDEX "IDX_22660D028FD6E0FB8C73652176FF8CAA" ON "quoted" ("create", "foo", "bar")', ]; } diff --git a/tests/Schema/Collections/UnqualifiedNameSetTest.php b/tests/Schema/Collections/UnqualifiedNameSetTest.php deleted file mode 100644 index 66c44f098d..0000000000 --- a/tests/Schema/Collections/UnqualifiedNameSetTest.php +++ /dev/null @@ -1,75 +0,0 @@ -createName('name1'); - $name2 = $this->createName('name2'); - - $set = new UnqualifiedNameSet(); - - $set->add($name1); - - self::assertTrue($set->contains($name1)); - self::assertFalse($set->contains($name2)); - } - - public function testAddExistingName(): void - { - $name = $this->createName('name'); - - $set = new UnqualifiedNameSet(); - - $set->add($name); - - $this->expectException(SetAlreadyContainsName::class); - - $set->add($name); - } - - public function testRemove(): void - { - $name1 = $this->createName('name1'); - $name2 = $this->createName('name2'); - - $set = new UnqualifiedNameSet(); - - $set->add($name1); - $set->add($name2); - $set->remove($name1); - - self::assertFalse($set->contains($name1)); - self::assertTrue($set->contains($name2)); - } - - public function testRemoveNonExistingObject(): void - { - $name1 = $this->createName('name1'); - $name2 = $this->createName('name2'); - - $set = new UnqualifiedNameSet(); - - $set->add($name1); - - $this->expectException(SetDoesNotContainName::class); - - $set->remove($name2); - } - - /** @param non-empty-string $name */ - private function createName(string $name): UnqualifiedName - { - return UnqualifiedName::unquoted($name); - } -} diff --git a/tests/Schema/IndexTest.php b/tests/Schema/IndexTest.php index 6747117372..b80ad2601b 100644 --- a/tests/Schema/IndexTest.php +++ b/tests/Schema/IndexTest.php @@ -46,6 +46,14 @@ public static function fulfilledByProvider(): iterable ->setUnquotedColumnNames('USER_ID') ->create(); + $otherColumnIndex = $regularIndex->edit() + ->setUnquotedColumnNames('account_id') + ->create(); + + $twoColumnIndex = $regularIndex->edit() + ->setUnquotedColumnNames('user_id', 'account_id') + ->create(); + yield 'regular-by-regular' => [$regularIndex, $regularIndex, true]; yield 'regular-by-unique' => [$regularIndex, $uniqueIndex, true]; yield 'unique-by-regular' => [$uniqueIndex, $regularIndex, false]; @@ -56,6 +64,9 @@ public static function fulfilledByProvider(): iterable yield 'partial-by-partial' => [$regularIndex, $upperCaseIndex, true]; yield 'upper-case-by-lower-case' => [$upperCaseIndex, $regularIndex, true]; + + yield 'different-column' => [$regularIndex, $otherColumnIndex, false]; + yield 'different-column-count' => [$regularIndex, $twoColumnIndex, false]; } /** diff --git a/tests/Schema/TableTest.php b/tests/Schema/TableTest.php index 71479bbde5..d0491ee08a 100644 --- a/tests/Schema/TableTest.php +++ b/tests/Schema/TableTest.php @@ -43,7 +43,6 @@ public function testEmptyColumns(): void [], [], [], - [], new TableConfiguration(64), null, [], @@ -807,135 +806,6 @@ public function testAddForeignKeyConstraintWithUnknownReferencingColumnThrowsExc $editor->create(); } - public function testAddForeignKeyIndexImplicitly(): void - { - $table = Table::editor() - ->setUnquotedName('foo') - ->setColumns( - Column::editor() - ->setUnquotedName('id') - ->setTypeName(Types::INTEGER) - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('id') - ->setUnquotedReferencedTableName('bar') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('id') - ->setUnquotedReferencedTableName('bar') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ) - ->create(); - - $indexes = $table->getIndexes(); - self::assertCount(1, $indexes); - $index = $indexes[0]; - - self::assertTrue($table->hasIndex($index->getObjectName()->toString())); - - self::assertEquals([ - new IndexedColumn(UnqualifiedName::unquoted('id'), null), - ], $index->getIndexedColumns()); - } - - public function testAddForeignKeyDoesNotCreateDuplicateIndex(): void - { - $table = Table::editor() - ->setUnquotedName('foo') - ->setColumns( - Column::editor() - ->setUnquotedName('bar') - ->setTypeName(Types::INTEGER) - ->create(), - ) - ->setIndexes( - Index::editor() - ->setUnquotedName('bar_idx') - ->setUnquotedColumnNames('bar') - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('bar') - ->setUnquotedReferencedTableName('foo') - ->setUnquotedReferencedColumnNames('foo') - ->create(), - ) - ->create(); - - self::assertCount(1, $table->getIndexes()); - self::assertTrue($table->hasIndex('bar_idx')); - - self::assertEquals([ - new IndexedColumn(UnqualifiedName::unquoted('bar'), null), - ], $table->getIndex('bar_idx')->getIndexedColumns()); - } - - public function testAddForeignKeyAddsImplicitIndexIfIndexColumnsDoNotSpan(): void - { - $table = Table::editor() - ->setUnquotedName('foo') - ->setColumns( - Column::editor() - ->setUnquotedName('bar') - ->setTypeName(Types::INTEGER) - ->create(), - Column::editor() - ->setUnquotedName('baz') - ->setTypeName(Types::STRING) - ->create(), - Column::editor() - ->setUnquotedName('bloo') - ->setTypeName(Types::STRING) - ->create(), - ) - ->setIndexes( - Index::editor() - ->setUnquotedName('composite_idx') - ->setUnquotedColumnNames('baz', 'bar') - ->create(), - Index::editor() - ->setUnquotedName('full_idx') - ->setUnquotedColumnNames('bar', 'baz', 'bloo') - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('bar', 'baz') - ->setUnquotedReferencedTableName('bar') - ->setUnquotedReferencedColumnNames('foo', 'baz') - ->create(), - ) - ->create(); - - self::assertCount(3, $table->getIndexes()); - self::assertTrue($table->hasIndex('composite_idx')); - self::assertTrue($table->hasIndex('full_idx')); - self::assertTrue($table->hasIndex('idx_8c73652176ff8caa78240498')); - - self::assertEquals([ - new IndexedColumn(UnqualifiedName::unquoted('baz'), null), - new IndexedColumn(UnqualifiedName::unquoted('bar'), null), - ], $table->getIndex('composite_idx')->getIndexedColumns()); - - self::assertEquals([ - new IndexedColumn(UnqualifiedName::unquoted('bar'), null), - new IndexedColumn(UnqualifiedName::unquoted('baz'), null), - new IndexedColumn(UnqualifiedName::unquoted('bloo'), null), - ], $table->getIndex('full_idx')->getIndexedColumns()); - - self::assertEquals([ - new IndexedColumn(UnqualifiedName::unquoted('bar'), null), - new IndexedColumn(UnqualifiedName::unquoted('baz'), null), - ], $table->getIndex('idx_8c73652176ff8caa78240498')->getIndexedColumns()); - } - public function testOverrulingIndexDoesNotDropOverruledIndex(): void { $table = Table::editor() @@ -1053,127 +923,6 @@ public function testAllowsAddingFulfillingIndexesBasedOnColumns(): void ], $table->getIndex('fulfilling_idx')->getIndexedColumns()); } - public function testAddingFulfillingRegularIndexOverridesImplicitForeignKeyConstraintIndex(): void - { - $localTable = Table::editor() - ->setUnquotedName('local') - ->setColumns( - Column::editor() - ->setUnquotedName('id') - ->setTypeName(Types::INTEGER) - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('id') - ->setUnquotedReferencedTableName('foreign') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('id') - ->setUnquotedReferencedTableName('foreign') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ) - ->create(); - - self::assertCount(1, $localTable->getIndexes()); - - $localTable = $localTable->edit() - ->addIndex( - Index::editor() - ->setUnquotedName('explicit_idx') - ->setUnquotedColumnNames('id') - ->create(), - ) - ->create(); - - self::assertCount(1, $localTable->getIndexes()); - self::assertTrue($localTable->hasIndex('explicit_idx')); - } - - public function testAddingFulfillingUniqueIndexOverridesImplicitForeignKeyConstraintIndex(): void - { - $localTable = Table::editor() - ->setUnquotedName('local') - ->setColumns( - Column::editor() - ->setUnquotedName('id') - ->setTypeName(Types::INTEGER) - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('id') - ->setUnquotedReferencedTableName('foreign') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ) - ->create(); - - self::assertCount(1, $localTable->getIndexes()); - - $localTable = $localTable->edit() - ->addIndex( - Index::editor() - ->setUnquotedName('explicit_idx') - ->setType(IndexType::UNIQUE) - ->setUnquotedColumnNames('id') - ->create(), - ) - ->create(); - - self::assertCount(1, $localTable->getIndexes()); - self::assertTrue($localTable->hasIndex('explicit_idx')); - } - - public function testAddingFulfillingExplicitIndexOverridingImplicitForeignKeyConstraintIndexWithSameName(): void - { - $localTable = Table::editor() - ->setUnquotedName('local') - ->setColumns( - Column::editor() - ->setUnquotedName('id') - ->setTypeName(Types::INTEGER) - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('id') - ->setUnquotedReferencedTableName('foreign') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ) - ->setForeignKeyConstraints( - ForeignKeyConstraint::editor() - ->setUnquotedReferencingColumnNames('id') - ->setUnquotedReferencedTableName('foreign') - ->setUnquotedReferencedColumnNames('id') - ->create(), - ) - ->create(); - - self::assertCount(1, $localTable->getIndexes()); - self::assertTrue($localTable->hasIndex('IDX_8BD688E8BF396750')); - - $implicitIndex = $localTable->getIndex('IDX_8BD688E8BF396750'); - - $localTable = $localTable->edit() - ->addIndex( - Index::editor() - ->setUnquotedName('IDX_8BD688E8BF396750') - ->setUnquotedColumnNames('id') - ->create(), - ) - ->create(); - - self::assertCount(1, $localTable->getIndexes()); - self::assertTrue($localTable->hasIndex('IDX_8BD688E8BF396750')); - self::assertNotSame($implicitIndex, $localTable->getIndex('IDX_8BD688E8BF396750')); - } - public function testQuotedTableName(): void { $table = Table::editor() From 94aef7c3484151f01f8e253c3a567845cd14d6a8 Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Sat, 1 Aug 2026 00:06:25 -0700 Subject: [PATCH 04/11] Add UniqueConstraint::equals() Comparing schemas requires telling whether two unique constraints describe the same constraint. --- src/Schema/UniqueConstraint.php | 26 ++++++++ tests/Schema/UniqueConstraintTest.php | 88 +++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/src/Schema/UniqueConstraint.php b/src/Schema/UniqueConstraint.php index 010e4dd2fa..d289f534e0 100644 --- a/src/Schema/UniqueConstraint.php +++ b/src/Schema/UniqueConstraint.php @@ -6,8 +6,10 @@ use Doctrine\DBAL\Schema\Exception\InvalidUniqueConstraintDefinition; use Doctrine\DBAL\Schema\Name\UnqualifiedName; +use Doctrine\DBAL\Schema\Name\UnquotedIdentifierFolding; use Override; +use function array_all; use function count; /** @@ -61,6 +63,30 @@ public function isClustered(): bool return $this->isClustered; } + public function equals(self $other, UnquotedIdentifierFolding $folding): bool + { + if ($this === $other) { + return true; + } + + if ($this->isClustered !== $other->isClustered) { + return false; + } + + if ($this->name !== null && $other->name !== null && ! $this->name->equals($other->name, $folding)) { + return false; + } + + if (count($this->columnNames) !== count($other->columnNames)) { + return false; + } + + return array_all( + $this->columnNames, + static fn ($columnName, $i) => $columnName->equals($other->columnNames[$i], $folding), + ); + } + /** * Instantiates a new unique constraint editor. */ diff --git a/tests/Schema/UniqueConstraintTest.php b/tests/Schema/UniqueConstraintTest.php index 86add6f35c..4dc8f97f7e 100644 --- a/tests/Schema/UniqueConstraintTest.php +++ b/tests/Schema/UniqueConstraintTest.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Exception; use Doctrine\DBAL\Schema\Exception\InvalidUniqueConstraintDefinition; use Doctrine\DBAL\Schema\Name\UnqualifiedName; +use Doctrine\DBAL\Schema\Name\UnquotedIdentifierFolding; use Doctrine\DBAL\Schema\UniqueConstraint; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -70,4 +71,91 @@ public static function isClusteredProvider(): iterable yield 'clustered' => [true]; yield 'not clustered' => [false]; } + + public function testEqualsToSelf(): void + { + $uniqueConstraint = UniqueConstraint::editor() + ->setUnquotedColumnNames('user_id') + ->create(); + + self::assertTrue($uniqueConstraint->equals($uniqueConstraint, UnquotedIdentifierFolding::NONE)); + } + + public function testEqualUniqueConstraints(): void + { + $uniqueConstraint1 = UniqueConstraint::editor() + ->setUnquotedName('uq_user_id') + ->setUnquotedColumnNames('user_id') + ->create(); + + $uniqueConstraint2 = UniqueConstraint::editor() + ->setUnquotedName('uq_user_id') + ->setUnquotedColumnNames('user_id') + ->create(); + + self::assertTrue($uniqueConstraint1->equals($uniqueConstraint2, UnquotedIdentifierFolding::NONE)); + self::assertTrue($uniqueConstraint2->equals($uniqueConstraint1, UnquotedIdentifierFolding::NONE)); + } + + public function testUnnamedUniqueConstraintEqualsANamedOne(): void + { + $named = UniqueConstraint::editor() + ->setUnquotedName('uq_user_id') + ->setUnquotedColumnNames('user_id') + ->create(); + + $unnamed = UniqueConstraint::editor() + ->setUnquotedColumnNames('user_id') + ->create(); + + self::assertTrue($named->equals($unnamed, UnquotedIdentifierFolding::NONE)); + self::assertTrue($unnamed->equals($named, UnquotedIdentifierFolding::NONE)); + } + + #[DataProvider('unequalUniqueConstraintProvider')] + public function testUnequalUniqueConstraints( + UniqueConstraint $uniqueConstraint1, + UniqueConstraint $uniqueConstraint2, + ): void { + self::assertFalse($uniqueConstraint1->equals($uniqueConstraint2, UnquotedIdentifierFolding::NONE)); + self::assertFalse($uniqueConstraint2->equals($uniqueConstraint1, UnquotedIdentifierFolding::NONE)); + } + + /** @return iterable */ + public static function unequalUniqueConstraintProvider(): iterable + { + $prototype = UniqueConstraint::editor() + ->setUnquotedColumnNames('user_id') + ->create(); + + yield 'name' => [ + $prototype->edit() + ->setUnquotedName('uq_user_id') + ->create(), + $prototype->edit() + ->setUnquotedName('uq_another_name') + ->create(), + ]; + + yield 'clustering' => [ + $prototype, + $prototype->edit() + ->setIsClustered(true) + ->create(), + ]; + + yield 'column count' => [ + $prototype, + $prototype->edit() + ->setUnquotedColumnNames('user_id', 'is_active') + ->create(), + ]; + + yield 'column name' => [ + $prototype, + $prototype->edit() + ->setUnquotedColumnNames('user_name') + ->create(), + ]; + } } From 37f4c6f6df78968b6125df013194f79f0da96c44 Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Sat, 1 Aug 2026 20:21:36 -0700 Subject: [PATCH 05/11] Name the alter-table helpers for their purpose The get*ForAlteredTable() helpers return the objects to create the rebuilt table with. --- src/Platforms/SQLitePlatform.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Platforms/SQLitePlatform.php b/src/Platforms/SQLitePlatform.php index 8208481faa..a029a7ecbf 100644 --- a/src/Platforms/SQLitePlatform.php +++ b/src/Platforms/SQLitePlatform.php @@ -636,8 +636,8 @@ public function getAlterTableSQL(TableDiff $diff): array $newTable = Table::editor() ->setName($table->getObjectName()) ->setColumns(...array_values($columns)) - ->setForeignKeyConstraints(...$this->getForeignKeysInAlteredTable($diff)) - ->setPrimaryKeyConstraint($this->getPrimaryKeyConstraintInAlteredTable($diff, $table)) + ->setForeignKeyConstraints(...$this->getForeignKeysForAlteredTable($diff)) + ->setPrimaryKeyConstraint($this->getPrimaryKeyConstraintForAlteredTable($diff, $table)) ->setOptions( array_merge($table->getOptions(), ['alter' => true]), ) @@ -663,7 +663,7 @@ public function getAlterTableSQL(TableDiff $diff): array ); $sql[] = $this->getDropTableSQL($dataTableName->toSQL($this)); - foreach ($this->getIndexesInAlteredTable($diff) as $index) { + foreach ($this->getIndexesForAlteredTable($diff) as $index) { $sql[] = $this->getCreateIndexSQL($index, $tableNameSQL); } @@ -762,7 +762,7 @@ private function getKey(UnqualifiedName $name): string } /** @return array */ - private function getIndexesInAlteredTable(TableDiff $diff): array + private function getIndexesForAlteredTable(TableDiff $diff): array { $oldTable = $diff->getOldTable(); $indexes = new UnqualifiedNamedObjectSet(...$oldTable->getIndexes()); @@ -828,7 +828,7 @@ private function getIndexesInAlteredTable(TableDiff $diff): array } /** @return array */ - private function getForeignKeysInAlteredTable(TableDiff $diff): array + private function getForeignKeysForAlteredTable(TableDiff $diff): array { $oldTable = $diff->getOldTable(); $foreignKeys = $oldTable->getForeignKeys(); @@ -908,7 +908,7 @@ private function getForeignKeysInAlteredTable(TableDiff $diff): array return $foreignKeys; } - private function getPrimaryKeyConstraintInAlteredTable(TableDiff $diff, Table $oldTable): ?PrimaryKeyConstraint + private function getPrimaryKeyConstraintForAlteredTable(TableDiff $diff, Table $oldTable): ?PrimaryKeyConstraint { $addedPrimaryKeyConstraint = $diff->getAddedPrimaryKeyConstraint(); From c98b6ee5c457fcd6d8d01b3d9fcf92916d5a1ff3 Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Sat, 1 Aug 2026 00:08:55 -0700 Subject: [PATCH 06/11] Extract a method to rebuild a table's constraints on SQLite The method takes how a constraint reports its columns and how it is rebuilt with new ones, so a second kind of constraint can reuse it. --- src/Platforms/SQLitePlatform.php | 114 +++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 35 deletions(-) diff --git a/src/Platforms/SQLitePlatform.php b/src/Platforms/SQLitePlatform.php index a029a7ecbf..ed4f627631 100644 --- a/src/Platforms/SQLitePlatform.php +++ b/src/Platforms/SQLitePlatform.php @@ -19,6 +19,7 @@ use Doctrine\DBAL\Schema\Name\OptionallyQualifiedName; use Doctrine\DBAL\Schema\Name\UnqualifiedName; use Doctrine\DBAL\Schema\Name\UnquotedIdentifierFolding; +use Doctrine\DBAL\Schema\OptionallyNamedObject; use Doctrine\DBAL\Schema\PrimaryKeyConstraint; use Doctrine\DBAL\Schema\SQLiteSchemaManager; use Doctrine\DBAL\Schema\Table; @@ -29,6 +30,9 @@ use Doctrine\DBAL\Types; use Override; +use function array_any; +use function array_filter; +use function array_map; use function array_merge; use function array_values; use function assert; @@ -36,6 +40,7 @@ use function implode; use function sprintf; use function str_replace; +use function str_starts_with; use function strtolower; /** @@ -765,8 +770,18 @@ private function getKey(UnqualifiedName $name): string private function getIndexesForAlteredTable(TableDiff $diff): array { $oldTable = $diff->getOldTable(); - $indexes = new UnqualifiedNamedObjectSet(...$oldTable->getIndexes()); - $nameMap = $this->getDiffColumnNameMap($diff); + + // Exclude SQLite's own indexes starting with the sqlite_ prefix. They are returned as a result of introspection + // but do not need to and cannot be explicitly created. + $indexes = new UnqualifiedNamedObjectSet(...array_filter( + $oldTable->getIndexes(), + static fn (Index $index): bool => ! str_starts_with( + strtolower($index->getObjectName()->getIdentifier()->getValue()), + 'sqlite_', + ), + )); + + $nameMap = $this->getDiffColumnNameMap($diff); $alreadyDropped = []; @@ -830,13 +845,49 @@ private function getIndexesForAlteredTable(TableDiff $diff): array /** @return array */ private function getForeignKeysForAlteredTable(TableDiff $diff): array { - $oldTable = $diff->getOldTable(); - $foreignKeys = $oldTable->getForeignKeys(); - $nameMap = $this->getDiffColumnNameMap($diff); + return $this->getConstraintsForAlteredTable( + $diff->getOldTable()->getForeignKeys(), + $this->getDiffColumnNameMap($diff), + $diff->getDroppedForeignKeyConstraintNames(), + $diff->getAddedForeignKeys(), + static fn (ForeignKeyConstraint $constraint): array => $constraint->getReferencingColumnNames(), + static fn (ForeignKeyConstraint $constraint, array $columnNames): ForeignKeyConstraint => $constraint + ->edit() + ->setUnquotedReferencingColumnNames(...$columnNames) + ->create(), + ); + } + + /** + * Returns the constraints to declare on the altered table. + * + * @param array $constraints + * @param array $nameMap + * @param array $droppedNames + * @param array $addedConstraints + * @param callable(T): non-empty-list $getColumnNames Returns the names of the altered + * table's columns that the given + * constraint references. + * @param callable(T, non-empty-list): T $withColumnNames Returns the given constraint with the + * names of those columns replaced with + * the given ones. + * + * @return array + * + * @template T of OptionallyNamedObject + */ + private function getConstraintsForAlteredTable( + array $constraints, + array $nameMap, + array $droppedNames, + array $addedConstraints, + callable $getColumnNames, + callable $withColumnNames, + ): array { $keysByName = []; $alreadyDropped = []; - foreach ($foreignKeys as $key => $constraint) { + foreach ($constraints as $key => $constraint) { $constraintName = $constraint->getObjectName(); if ($constraintName !== null) { @@ -845,41 +896,34 @@ private function getForeignKeysForAlteredTable(TableDiff $diff): array $constraintKey = null; } - $changed = false; - - $referencingColumnNames = []; - foreach ($constraint->getReferencingColumnNames() as $columnName) { - $originalColumnName = $columnName->getIdentifier()->getValue(); - $normalizedColumnName = $this->getKey($columnName); - if (! isset($nameMap[$normalizedColumnName])) { - unset($foreignKeys[$key]); + $columnNames = $getColumnNames($constraint); - if ($constraintKey !== null) { - $alreadyDropped[$constraintKey] = true; - } + if ( + array_any( + $columnNames, + static fn ($columnName) => ! isset($nameMap[$columnName->getIdentifier()->getValue()]), + ) + ) { + unset($constraints[$key]); - continue 2; + if ($constraintKey !== null) { + $alreadyDropped[$constraintKey] = true; } - $referencingColumnNames[] = $nameMap[$normalizedColumnName]; - - if ($originalColumnName !== $nameMap[$normalizedColumnName]) { - $changed = true; - } + continue; } if ($constraintKey !== null) { $keysByName[$constraintKey] = $key; } - if ($changed) { - $foreignKeys[$key] = $constraint->edit() - ->setUnquotedReferencingColumnNames(...$referencingColumnNames) - ->create(); - } + $constraints[$key] = $withColumnNames($constraint, array_map( + static fn (UnqualifiedName $columnName) => $nameMap[$columnName->getIdentifier()->getValue()], + $columnNames, + )); } - foreach ($diff->getDroppedForeignKeyConstraintNames() as $constraintName) { + foreach ($droppedNames as $constraintName) { $constraintKey = $this->getKey($constraintName); if (isset($alreadyDropped[$constraintKey])) { @@ -887,25 +931,25 @@ private function getForeignKeysForAlteredTable(TableDiff $diff): array } assert(isset($keysByName[$constraintKey])); - unset($foreignKeys[$keysByName[$constraintKey]], $keysByName[$constraintKey]); + unset($constraints[$keysByName[$constraintKey]], $keysByName[$constraintKey]); } - foreach ($diff->getAddedForeignKeys() as $constraint) { + foreach ($addedConstraints as $constraint) { $constraintName = $constraint->getObjectName(); if ($constraintName !== null) { $constraintKey = $this->getKey($constraintName); assert(! isset($keysByName[$constraintKey])); - $foreignKeys[] = $constraint; + $constraints[] = $constraint; - $keysByName[$constraintKey] = count($foreignKeys) - 1; + $keysByName[$constraintKey] = count($constraints) - 1; } else { - $foreignKeys[] = $constraint; + $constraints[] = $constraint; } } - return $foreignKeys; + return $constraints; } private function getPrimaryKeyConstraintForAlteredTable(TableDiff $diff, Table $oldTable): ?PrimaryKeyConstraint From e946afc91f1fe2c0ee648be37ac39fed8d5ad629 Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Sat, 1 Aug 2026 00:10:07 -0700 Subject: [PATCH 07/11] Build the SQL that adds and drops unique constraints A table diff carries the unique constraints to add and the names of the ones to drop, and each platform renders them. --- src/Platforms/AbstractMySQLPlatform.php | 8 ++++++++ src/Platforms/DB2Platform.php | 8 ++++++++ src/Platforms/OraclePlatform.php | 8 ++++++++ src/Platforms/PostgreSQLPlatform.php | 8 ++++++++ src/Platforms/SQLServerPlatform.php | 8 ++++++++ src/Platforms/SQLitePlatform.php | 26 ++++++++++++++++++++++--- src/Schema/TableDiff.php | 20 ++++++++++++++++++- 7 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/Platforms/AbstractMySQLPlatform.php b/src/Platforms/AbstractMySQLPlatform.php index 3e7a815ac8..85a56fbc40 100644 --- a/src/Platforms/AbstractMySQLPlatform.php +++ b/src/Platforms/AbstractMySQLPlatform.php @@ -330,6 +330,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getDropForeignKeySQL($constraintName->toSQL($this), $tableNameSQL); } + foreach ($diff->getDroppedUniqueConstraintNames() as $constraintName) { + $sql[] = $this->getDropUniqueConstraintSQL($constraintName->toSQL($this), $tableNameSQL); + } + $queryParts = []; foreach ($diff->getAddedColumns() as $column) { @@ -384,6 +388,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getCreateForeignKeySQL($addedForeignKeyConstraint, $tableNameSQL); } + foreach ($diff->getAddedUniqueConstraints() as $uniqueConstraint) { + $sql[] = $this->getCreateUniqueConstraintSQL($uniqueConstraint, $tableNameSQL); + } + return $sql; } diff --git a/src/Platforms/DB2Platform.php b/src/Platforms/DB2Platform.php index 2c53099751..f9fe05cbc1 100644 --- a/src/Platforms/DB2Platform.php +++ b/src/Platforms/DB2Platform.php @@ -261,6 +261,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getDropForeignKeySQL($constraintName->toSQL($this), $tableNameSQL); } + foreach ($diff->getDroppedUniqueConstraintNames() as $constraintName) { + $sql[] = $this->getDropUniqueConstraintSQL($constraintName->toSQL($this), $tableNameSQL); + } + foreach ($diff->getDroppedIndexes() as $index) { $sql[] = $this->getDropIndexSQL($index->getObjectName()->toSQL($this), $tableNameSQL); } @@ -350,6 +354,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getCreateForeignKeySQL($foreignKey, $tableNameSQL); } + foreach ($diff->getAddedUniqueConstraints() as $uniqueConstraint) { + $sql[] = $this->getCreateUniqueConstraintSQL($uniqueConstraint, $tableNameSQL); + } + foreach ($diff->getAddedIndexes() as $index) { $sql[] = $this->getCreateIndexSQL($index, $tableNameSQL); } diff --git a/src/Platforms/OraclePlatform.php b/src/Platforms/OraclePlatform.php index 2133be3e3b..ecc65cf38c 100644 --- a/src/Platforms/OraclePlatform.php +++ b/src/Platforms/OraclePlatform.php @@ -514,6 +514,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getDropForeignKeySQL($constraintName->toSQL($this), $tableNameSQL); } + foreach ($diff->getDroppedUniqueConstraintNames() as $constraintName) { + $sql[] = $this->getDropUniqueConstraintSQL($constraintName->toSQL($this), $tableNameSQL); + } + foreach ($diff->getDroppedIndexes() as $index) { $sql[] = $this->getDropIndexSQL($index->getObjectName()->toSQL($this), $tableNameSQL); } @@ -630,6 +634,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getCreateForeignKeySQL($foreignKey, $tableNameSQL); } + foreach ($diff->getAddedUniqueConstraints() as $uniqueConstraint) { + $sql[] = $this->getCreateUniqueConstraintSQL($uniqueConstraint, $tableNameSQL); + } + foreach ($diff->getAddedIndexes() as $index) { $sql[] = $this->getCreateIndexSQL($index, $tableNameSQL); } diff --git a/src/Platforms/PostgreSQLPlatform.php b/src/Platforms/PostgreSQLPlatform.php index f22254fef9..c15fc2228d 100644 --- a/src/Platforms/PostgreSQLPlatform.php +++ b/src/Platforms/PostgreSQLPlatform.php @@ -194,6 +194,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getDropForeignKeySQL($constraintName->toSQL($this), $tableNameSQL); } + foreach ($diff->getDroppedUniqueConstraintNames() as $constraintName) { + $sql[] = $this->getDropUniqueConstraintSQL($constraintName->toSQL($this), $tableNameSQL); + } + foreach ($diff->getDroppedIndexes() as $index) { $sql[] = $this->getDropIndexSQL($index->getObjectName()->toSQL($this), $tableNameSQL); } @@ -306,6 +310,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getCreateForeignKeySQL($foreignKey, $tableNameSQL); } + foreach ($diff->getAddedUniqueConstraints() as $uniqueConstraint) { + $sql[] = $this->getCreateUniqueConstraintSQL($uniqueConstraint, $tableNameSQL); + } + foreach ($diff->getAddedIndexes() as $index) { $sql[] = $this->getCreateIndexSQL($index, $tableNameSQL); } diff --git a/src/Platforms/SQLServerPlatform.php b/src/Platforms/SQLServerPlatform.php index 644ca65213..a14a6c7ef9 100644 --- a/src/Platforms/SQLServerPlatform.php +++ b/src/Platforms/SQLServerPlatform.php @@ -329,6 +329,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getDropForeignKeySQL($constraintName->toSQL($this), $tableNameSQL); } + foreach ($diff->getDroppedUniqueConstraintNames() as $constraintName) { + $sql[] = $this->getDropUniqueConstraintSQL($constraintName->toSQL($this), $tableNameSQL); + } + foreach ($diff->getDroppedIndexes() as $index) { $sql[] = $this->getDropIndexSQL($index->getObjectName()->toSQL($this), $tableNameSQL); } @@ -462,6 +466,10 @@ public function getAlterTableSQL(TableDiff $diff): array $sql[] = $this->getCreateForeignKeySQL($foreignKey, $tableNameSQL); } + foreach ($diff->getAddedUniqueConstraints() as $uniqueConstraint) { + $sql[] = $this->getCreateUniqueConstraintSQL($uniqueConstraint, $tableNameSQL); + } + foreach ($diff->getAddedIndexes() as $index) { $sql[] = $this->getCreateIndexSQL($index, $tableNameSQL); } diff --git a/src/Platforms/SQLitePlatform.php b/src/Platforms/SQLitePlatform.php index ed4f627631..3b7a33fe15 100644 --- a/src/Platforms/SQLitePlatform.php +++ b/src/Platforms/SQLitePlatform.php @@ -24,6 +24,7 @@ use Doctrine\DBAL\Schema\SQLiteSchemaManager; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\Schema\UniqueConstraint; use Doctrine\DBAL\SQL\Builder\DefaultSelectSQLBuilder; use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder; use Doctrine\DBAL\TransactionIsolationLevel; @@ -641,8 +642,9 @@ public function getAlterTableSQL(TableDiff $diff): array $newTable = Table::editor() ->setName($table->getObjectName()) ->setColumns(...array_values($columns)) - ->setForeignKeyConstraints(...$this->getForeignKeysForAlteredTable($diff)) ->setPrimaryKeyConstraint($this->getPrimaryKeyConstraintForAlteredTable($diff, $table)) + ->setUniqueConstraints(...$this->getUniqueConstraintsForAlteredTable($diff)) + ->setForeignKeyConstraints(...$this->getForeignKeysForAlteredTable($diff)) ->setOptions( array_merge($table->getOptions(), ['alter' => true]), ) @@ -684,10 +686,12 @@ private function getSimpleAlterTableSQL(TableDiff $diff): array|false || count($diff->getAddedIndexes()) > 0 || count($diff->getDroppedIndexes()) > 0 || count($diff->getIndexRenames()) > 0 - || count($diff->getAddedForeignKeys()) > 0 - || count($diff->getDroppedForeignKeyConstraintNames()) > 0 + || count($diff->getDroppedUniqueConstraintNames()) > 0 + || count($diff->getAddedUniqueConstraints()) > 0 || $diff->getDroppedPrimaryKeyConstraint() !== null || $diff->getAddedPrimaryKeyConstraint() !== null + || count($diff->getDroppedForeignKeyConstraintNames()) > 0 + || count($diff->getAddedForeignKeys()) > 0 ) { return false; } @@ -858,6 +862,22 @@ private function getForeignKeysForAlteredTable(TableDiff $diff): array ); } + /** @return array */ + private function getUniqueConstraintsForAlteredTable(TableDiff $diff): array + { + return $this->getConstraintsForAlteredTable( + $diff->getOldTable()->getUniqueConstraints(), + $this->getDiffColumnNameMap($diff), + $diff->getDroppedUniqueConstraintNames(), + $diff->getAddedUniqueConstraints(), + static fn (UniqueConstraint $constraint): array => $constraint->getColumnNames(), + static fn (UniqueConstraint $constraint, array $columnNames): UniqueConstraint => $constraint + ->edit() + ->setUnquotedColumnNames(...$columnNames) + ->create(), + ); + } + /** * Returns the constraints to declare on the altered table. * diff --git a/src/Schema/TableDiff.php b/src/Schema/TableDiff.php index 8f00036340..ca09fe9cb4 100644 --- a/src/Schema/TableDiff.php +++ b/src/Schema/TableDiff.php @@ -29,6 +29,8 @@ * @param list $indexRenames * @param array $addedForeignKeys * @param array $droppedForeignKeyConstraintNames + * @param array $addedUniqueConstraints + * @param array $droppedUniqueConstraintNames */ public function __construct( private Table $oldTable, @@ -42,6 +44,8 @@ public function __construct( private array $droppedForeignKeyConstraintNames = [], private ?PrimaryKeyConstraint $addedPrimaryKeyConstraint = null, private ?PrimaryKeyConstraint $droppedPrimaryKeyConstraint = null, + private array $addedUniqueConstraints = [], + private array $droppedUniqueConstraintNames = [], ) { } @@ -158,6 +162,18 @@ public function getDroppedPrimaryKeyConstraint(): ?PrimaryKeyConstraint return $this->droppedPrimaryKeyConstraint; } + /** @return array */ + public function getAddedUniqueConstraints(): array + { + return $this->addedUniqueConstraints; + } + + /** @return array */ + public function getDroppedUniqueConstraintNames(): array + { + return $this->droppedUniqueConstraintNames; + } + /** * Returns whether the diff is empty (contains no changes). */ @@ -172,6 +188,8 @@ public function isEmpty(): bool && count($this->addedForeignKeys) === 0 && count($this->droppedForeignKeyConstraintNames) === 0 && $this->addedPrimaryKeyConstraint === null - && $this->droppedPrimaryKeyConstraint === null; + && $this->droppedPrimaryKeyConstraint === null + && count($this->addedUniqueConstraints) === 0 + && count($this->droppedUniqueConstraintNames) === 0; } } From b82506b4de073ba4f4233e762e116e14596aecb7 Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Thu, 20 Aug 2026 08:59:16 -0700 Subject: [PATCH 08/11] Match a table's constraints through one method The method takes how two constraints of a kind compare, so a second kind can reuse it. A constraint that matches no longer goes on being compared with the rest. --- src/Schema/Comparator.php | 91 +++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/src/Schema/Comparator.php b/src/Schema/Comparator.php index bc463baa52..8153d656ac 100644 --- a/src/Schema/Comparator.php +++ b/src/Schema/Comparator.php @@ -6,8 +6,11 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\Exception\UnspecifiedConstraintName; +use Doctrine\DBAL\Schema\Name\UnqualifiedName; use Doctrine\DBAL\Schema\Name\UnquotedIdentifierFolding; +use function array_values; +use function assert; use function count; use function strtolower; @@ -264,29 +267,18 @@ public function compareTables(Table $oldTable, Table $newTable): TableDiff $indexRenames = $this->detectIndexRenames($addedIndexes, $droppedIndexes, $folding); } - $oldForeignKeys = $oldTable->getForeignKeys(); - $newForeignKeys = $newTable->getForeignKeys(); - - foreach ($oldForeignKeys as $oldKey => $oldForeignKey) { - foreach ($newForeignKeys as $newKey => $newForeignKey) { - if ($newForeignKey->equals($oldForeignKey, $folding)) { - unset($oldForeignKeys[$oldKey], $newForeignKeys[$newKey]); - } else { - $oldForeignKeyName = $oldForeignKey->getObjectName(); - $newForeignKeyName = $newForeignKey->getObjectName(); - if ( - $oldForeignKeyName !== null - && $newForeignKeyName !== null - && strtolower($oldForeignKeyName->getIdentifier()->getValue()) - === strtolower($newForeignKeyName->getIdentifier()->getValue()) - ) { - $droppedForeignKeyConstraintNames[$oldKey] = $oldForeignKeyName; - $addedForeignKeys[$newKey] = $newForeignKey; - - unset($oldForeignKeys[$oldKey], $newForeignKeys[$newKey]); - } - } - } + [$oldForeignKeys, $newForeignKeys, $modifiedForeignKeys] = $this->matchConstraints( + $oldTable->getForeignKeys(), + $newTable->getForeignKeys(), + static fn (ForeignKeyConstraint $old, ForeignKeyConstraint $new): bool => $old->equals($new, $folding), + ); + + foreach ($modifiedForeignKeys as [$oldForeignKey, $newForeignKey]) { + $constraintName = $oldForeignKey->getObjectName(); + assert($constraintName !== null); + + $droppedForeignKeyConstraintNames[] = $constraintName; + $addedForeignKeys[] = $newForeignKey; } foreach ($oldForeignKeys as $oldForeignKey) { @@ -380,6 +372,59 @@ private function primaryKeyConstraintsEqual( return $oldPrimaryKeyConstraint === null && $newPrimaryKeyConstraint === null; } + /** + * Matches the old constraints of a table with the new ones. + * + * A constraint equal to one on the other side matches it and is left out of the result. One + * that keeps its name but changes its definition is modified: it has to be dropped and added + * back. What remains unmatched is dropped or added outright. + * + * @param array $oldConstraints + * @param array $newConstraints + * @param callable(T, T): bool $equals Returns whether an old constraint and a new one + * are equal. + * + * @return array{list, list, list} the unmatched old constraints, the + * unmatched new ones, and the modified ones + * as old and new + * + * @template T of OptionallyNamedObject + */ + private function matchConstraints(array $oldConstraints, array $newConstraints, callable $equals): array + { + $modifiedConstraints = []; + + foreach ($oldConstraints as $oldKey => $oldConstraint) { + foreach ($newConstraints as $newKey => $newConstraint) { + if ($equals($oldConstraint, $newConstraint)) { + unset($oldConstraints[$oldKey], $newConstraints[$newKey]); + + continue 2; + } + + $oldName = $oldConstraint->getObjectName(); + $newName = $newConstraint->getObjectName(); + + if ( + $oldName === null + || $newName === null + || strtolower($oldName->getIdentifier()->getValue()) + !== strtolower($newName->getIdentifier()->getValue()) + ) { + continue; + } + + $modifiedConstraints[] = [$oldConstraint, $newConstraint]; + + unset($oldConstraints[$oldKey], $newConstraints[$newKey]); + + continue 2; + } + } + + return [array_values($oldConstraints), array_values($newConstraints), $modifiedConstraints]; + } + /** * Removes the first derived object the predicate matches and reports whether there was one. * From 88f025d467895862467aa4b53a99874c39c9057c Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Thu, 20 Aug 2026 09:05:44 -0700 Subject: [PATCH 09/11] Compare unique constraints The comparator tells the unique constraints of two tables apart, so a table diff carries the ones to add and drop. The method that does it serves the foreign key constraints as well. --- UPGRADE.md | 10 + src/Platforms/Db2/Db2MetadataProvider.php | 2 +- .../Oracle/OracleMetadataProvider.php | 2 - src/Schema/Comparator.php | 137 +++-- .../Exception/UnspecifiedConstraintName.php | 5 + .../Schema/MigrationDeterminismTest.php | 484 ++++++++++++++++++ .../Schema/UniqueConstraintTest.php | 235 +++++++++ tests/FunctionalTestCase.php | 30 ++ tests/Schema/AbstractComparatorTestCase.php | 133 +++++ 9 files changed, 998 insertions(+), 40 deletions(-) create mode 100644 tests/Functional/Schema/MigrationDeterminismTest.php create mode 100644 tests/Functional/Schema/UniqueConstraintTest.php diff --git a/UPGRADE.md b/UPGRADE.md index 615b89f477..0c1489c73a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -13,6 +13,16 @@ awareness about deprecated code. Declaring a foreign key constraint no longer adds an index over its referencing columns. MySQL and MariaDB create one themselves; on the other platforms, an application that wants such an index must declare it. +## BC BREAK: A unique constraint to be dropped must have a name + +`Comparator::compareTables()` now compares unique constraints, and raises +`Doctrine\DBAL\Schema\Exception\UnspecifiedConstraintName` when one that has to be dropped carries no name. + +## BC BREAK: `MetadataProvider` requires unique constraint introspection + +`Doctrine\DBAL\Schema\Metadata\MetadataProvider` now declares `getUniqueConstraintColumnsForAllTables()` and +`getUniqueConstraintColumnsForTable()`. Implementations must provide them. + ## BC BREAK: Added `AbstractPlatform::createDerivedObjectProvider()` `Doctrine\DBAL\Platforms\AbstractPlatform` now declares `createDerivedObjectProvider()`. Platforms extending it must diff --git a/src/Platforms/Db2/Db2MetadataProvider.php b/src/Platforms/Db2/Db2MetadataProvider.php index b63a9f1dfe..eb3400ae8e 100644 --- a/src/Platforms/Db2/Db2MetadataProvider.php +++ b/src/Platforms/Db2/Db2MetadataProvider.php @@ -125,6 +125,7 @@ private function getTableColumns(?string $tableName): iterable AND T.TABNAME = C.TABNAME WHERE %s AND T.TYPE = 'T' + AND I.UNIQUERULE != 'P' ORDER BY C.TABNAME, C.COLNO SQL, @@ -263,7 +264,6 @@ private function getIndexColumns(?string $tableName): iterable AND I.INDNAME = ICU.INDNAME WHERE %s AND T.TYPE = 'T' - AND I.UNIQUERULE != 'P' ORDER BY I.TABNAME, I.INDNAME, ICU.COLSEQ diff --git a/src/Platforms/Oracle/OracleMetadataProvider.php b/src/Platforms/Oracle/OracleMetadataProvider.php index e2ed534c72..d462554faf 100644 --- a/src/Platforms/Oracle/OracleMetadataProvider.php +++ b/src/Platforms/Oracle/OracleMetadataProvider.php @@ -294,8 +294,6 @@ private function getIndexColumns(?string $tableName): iterable I.UNIQUENESS, IC.COLUMN_NAME FROM USER_INDEXES I - LEFT JOIN USER_CONSTRAINTS C - ON C.INDEX_NAME = I.INDEX_NAME JOIN USER_IND_COLUMNS IC ON IC.INDEX_NAME = I.INDEX_NAME WHERE %s diff --git a/src/Schema/Comparator.php b/src/Schema/Comparator.php index 8153d656ac..6788fb92d6 100644 --- a/src/Schema/Comparator.php +++ b/src/Schema/Comparator.php @@ -124,16 +124,14 @@ public function diffSequence(Sequence $sequence1, Sequence $sequence2): bool */ public function compareTables(Table $oldTable, Table $newTable): TableDiff { - $addedColumns = []; - $modifiedColumns = []; - $droppedColumns = []; - $addedIndexes = []; - $droppedIndexes = []; - $indexRenames = []; - $addedForeignKeys = []; - $droppedForeignKeyConstraintNames = []; - $addedPrimaryKeyConstraint = null; - $droppedPrimaryKeyConstraint = null; + $addedColumns = []; + $modifiedColumns = []; + $droppedColumns = []; + $addedIndexes = []; + $droppedIndexes = []; + $indexRenames = []; + $addedPrimaryKeyConstraint = null; + $droppedPrimaryKeyConstraint = null; $oldColumns = $oldTable->getColumns(); $newColumns = $newTable->getColumns(); @@ -221,6 +219,26 @@ public function compareTables(Table $oldTable, Table $newTable): TableDiff $derivedFromNewTable = $this->derivedObjectProvider->getDerivedObjects($newTable); $derivedFromOldTable = $this->derivedObjectProvider->getDerivedObjects($oldTable); + [$addedUniqueConstraints, $droppedUniqueConstraintNames] = $this->compareConstraints( + $oldTable->getUniqueConstraints(), + $newTable->getUniqueConstraints(), + static fn (UniqueConstraint $old, UniqueConstraint $new): bool => $old->equals($new, $folding), + UnspecifiedConstraintName::forUniqueConstraint(...), + $derivedFromNewTable, + static fn (DerivedObject $d, UniqueConstraint $c): bool => $d->matchesUniqueConstraint($c, $folding), + ); + + [$addedForeignKeys, $droppedForeignKeyConstraintNames] = $this->compareConstraints( + $oldTable->getForeignKeys(), + $newTable->getForeignKeys(), + static fn (ForeignKeyConstraint $old, ForeignKeyConstraint $new): bool => $old->equals($new, $folding), + UnspecifiedConstraintName::forForeignKeyConstraint(...), + $derivedFromNewTable, + // A database can expose a unique constraint the table did not declare, but not a + // foreign key one. + static fn (): bool => false, + ); + $oldIndexes = $oldTable->getIndexes(); $newIndexes = $newTable->getIndexes(); @@ -267,33 +285,6 @@ public function compareTables(Table $oldTable, Table $newTable): TableDiff $indexRenames = $this->detectIndexRenames($addedIndexes, $droppedIndexes, $folding); } - [$oldForeignKeys, $newForeignKeys, $modifiedForeignKeys] = $this->matchConstraints( - $oldTable->getForeignKeys(), - $newTable->getForeignKeys(), - static fn (ForeignKeyConstraint $old, ForeignKeyConstraint $new): bool => $old->equals($new, $folding), - ); - - foreach ($modifiedForeignKeys as [$oldForeignKey, $newForeignKey]) { - $constraintName = $oldForeignKey->getObjectName(); - assert($constraintName !== null); - - $droppedForeignKeyConstraintNames[] = $constraintName; - $addedForeignKeys[] = $newForeignKey; - } - - foreach ($oldForeignKeys as $oldForeignKey) { - $constraintName = $oldForeignKey->getObjectName(); - if ($constraintName === null) { - throw UnspecifiedConstraintName::forForeignKeyConstraint(); - } - - $droppedForeignKeyConstraintNames[] = $constraintName; - } - - foreach ($newForeignKeys as $newForeignKey) { - $addedForeignKeys[] = $newForeignKey; - } - return new TableDiff( $oldTable, addedColumns: $addedColumns, @@ -306,6 +297,8 @@ public function compareTables(Table $oldTable, Table $newTable): TableDiff droppedForeignKeyConstraintNames: $droppedForeignKeyConstraintNames, addedPrimaryKeyConstraint: $addedPrimaryKeyConstraint, droppedPrimaryKeyConstraint: $droppedPrimaryKeyConstraint, + addedUniqueConstraints: $addedUniqueConstraints, + droppedUniqueConstraintNames: $droppedUniqueConstraintNames, ); } @@ -372,6 +365,76 @@ private function primaryKeyConstraintsEqual( return $oldPrimaryKeyConstraint === null && $newPrimaryKeyConstraint === null; } + /** + * Compares the old constraints of a table with the new ones. + * + * An old constraint that one of the derived objects matches is consumed along with it: the + * database will have the constraint without the table declaring it. + * + * @param array $oldConstraints + * @param array $newConstraints + * @param callable(T, T): bool $equals Returns whether an old constraint and a new one are + * equal. + * @param callable(): UnspecifiedConstraintName $unspecifiedName Returns the exception reporting a constraint of + * this kind whose name is unspecified. + * @param array $derivedObjects The objects the platform derives from the new + * table. A matched one is removed. + * @param callable(DerivedObject, T): bool $matches Returns whether a derived object is the one the + * platform derives for an old constraint. + * + * @return array{list, list} the constraints to add and the names of the + * ones to drop + * + * @template T of OptionallyNamedObject + */ + private function compareConstraints( + array $oldConstraints, + array $newConstraints, + callable $equals, + callable $unspecifiedName, + array &$derivedObjects, + callable $matches, + ): array { + $addedConstraints = []; + $droppedConstraintNames = []; + + [$oldConstraints, $newConstraints, $modifiedConstraints] = $this->matchConstraints( + $oldConstraints, + $newConstraints, + $equals, + ); + + foreach ($modifiedConstraints as [$oldConstraint, $newConstraint]) { + $name = $oldConstraint->getObjectName(); + assert($name !== null); + + $droppedConstraintNames[] = $name; + $addedConstraints[] = $newConstraint; + } + + foreach ($oldConstraints as $oldConstraint) { + $matchesConstraint = static fn (DerivedObject $d): bool => $matches($d, $oldConstraint); + + if ($this->consumeDerivedObject($derivedObjects, $matchesConstraint)) { + continue; + } + + $name = $oldConstraint->getObjectName(); + + if ($name === null) { + throw $unspecifiedName(); + } + + $droppedConstraintNames[] = $name; + } + + foreach ($newConstraints as $newConstraint) { + $addedConstraints[] = $newConstraint; + } + + return [$addedConstraints, $droppedConstraintNames]; + } + /** * Matches the old constraints of a table with the new ones. * diff --git a/src/Schema/Exception/UnspecifiedConstraintName.php b/src/Schema/Exception/UnspecifiedConstraintName.php index 5e4b987025..0a11f208e2 100644 --- a/src/Schema/Exception/UnspecifiedConstraintName.php +++ b/src/Schema/Exception/UnspecifiedConstraintName.php @@ -18,4 +18,9 @@ public static function forForeignKeyConstraint(): self { return new self('Foreign key constraint name is not specified.'); } + + public static function forUniqueConstraint(): self + { + return new self('Unique constraint name is not specified.'); + } } diff --git a/tests/Functional/Schema/MigrationDeterminismTest.php b/tests/Functional/Schema/MigrationDeterminismTest.php new file mode 100644 index 0000000000..2ce2449f60 --- /dev/null +++ b/tests/Functional/Schema/MigrationDeterminismTest.php @@ -0,0 +1,484 @@ +assertMigrationReachesCreation($oldTable, $newTable); + } + + /** + * An index the application withdraws is dropped, though a wider one still covers the foreign key. + * + * @throws Exception + */ + public function testWithdrawnIndexCoveredByAWiderOne(): void + { + $wide = Index::editor() + ->setUnquotedName('wide_idx') + ->setUnquotedColumnNames('parent_id', 'code') + ->create(); + + $narrow = Index::editor() + ->setUnquotedName('narrow_idx') + ->setUnquotedColumnNames('parent_id') + ->create(); + + $this->assertMigrationReachesCreation( + self::childTableWithIndexes($wide, $narrow), + self::childTableWithIndexes($wide), + ); + } + + /** + * Withdrawing every index over a foreign key's columns leaves whatever the engine makes of it. + * + * @throws Exception + */ + public function testEveryCoveringIndexWithdrawn(): void + { + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof OraclePlatform || $platform instanceof DB2Platform) { + self::markTestSkipped(sprintf( + '%s rejects a second index over the columns another one already indexes.', + $platform::class, + )); + } + + $this->assertMigrationReachesCreation( + self::childTableWithIndexes( + Index::editor() + ->setUnquotedName('a_idx') + ->setUnquotedColumnNames('parent_id') + ->create(), + Index::editor() + ->setUnquotedName('b_idx') + ->setUnquotedColumnNames('parent_id') + ->create(), + ), + self::childTableWithIndexes(), + ); + } + + /** + * Two foreign keys, one over the columns the other starts with, need one index between them. + * + * @throws Exception + */ + public function testNestedForeignKeysNeedOneIndex(): void + { + $wide = Index::editor() + ->setUnquotedName('wide_idx') + ->setUnquotedColumnNames('parent_id', 'code') + ->create(); + + $narrow = Index::editor() + ->setUnquotedName('narrow_idx') + ->setUnquotedColumnNames('parent_id') + ->create(); + + $this->assertMigrationReachesCreation( + self::childTableWithNestedForeignKeys($wide, $narrow), + self::childTableWithNestedForeignKeys(), + ); + } + + private static function childTableWithNestedForeignKeys(Index ...$indexes): Table + { + return Table::editor() + ->setUnquotedName('child') + ->setColumns(self::intColumn('id'), self::intColumn('parent_id'), self::intColumn('code')) + ->setPrimaryKeyConstraint( + PrimaryKeyConstraint::editor() + ->setUnquotedColumnNames('id') + ->create(), + ) + ->setIndexes(...$indexes) + ->setForeignKeyConstraints( + ForeignKeyConstraint::editor() + ->setUnquotedName('parent_fk') + ->setUnquotedReferencingColumnNames('parent_id') + ->setUnquotedReferencedTableName('parent') + ->setUnquotedReferencedColumnNames('id') + ->create(), + ForeignKeyConstraint::editor() + ->setUnquotedName('composite_fk') + ->setUnquotedReferencingColumnNames('parent_id', 'code') + ->setUnquotedReferencedTableName('composite_parent') + ->setUnquotedReferencedColumnNames('id', 'code') + ->create(), + ) + ->create(); + } + + private static function childTableWithIndexes(Index ...$indexes): Table + { + return Table::editor() + ->setUnquotedName('child') + ->setColumns(self::intColumn('id'), self::intColumn('parent_id'), self::intColumn('code')) + ->setPrimaryKeyConstraint( + PrimaryKeyConstraint::editor() + ->setUnquotedColumnNames('id') + ->create(), + ) + ->setIndexes(...$indexes) + ->setForeignKeyConstraints( + ForeignKeyConstraint::editor() + ->setUnquotedName('parent_fk') + ->setUnquotedReferencingColumnNames('parent_id') + ->setUnquotedReferencedTableName('parent') + ->setUnquotedReferencedColumnNames('id') + ->create(), + ) + ->create(); + } + + /** @throws Exception */ + private function assertMigrationReachesCreation(Table $oldTable, Table $newTable): void + { + $schemaManager = $this->connection->createSchemaManager(); + + $this->dropTableIfExists('child'); + $this->dropTableIfExists('parent'); + $this->dropTableIfExists('composite_parent'); + $schemaManager->createTable(self::parentTable()); + $schemaManager->createTable(self::compositeParentTable()); + + $schemaManager->createTable($oldTable); + + $diff = $schemaManager->createComparator() + ->compareTables($schemaManager->introspectTableByUnquotedName('child'), $newTable); + + if (! $diff->isEmpty()) { + $schemaManager->alterTable($diff); + } + + $migrated = $schemaManager->introspectTableByUnquotedName('child'); + + $this->dropTableIfExists('child'); + $schemaManager->createTable($newTable); + + $created = $schemaManager->introspectTableByUnquotedName('child'); + + $this->assertIndistinguishable($created, $migrated, $newTable); + } + + /** @return iterable */ + public static function migrationProvider(): iterable + { + yield 'a foreign key is added' => [ + self::childTable(), + self::childTable(foreignKey: true), + ]; + + yield 'a foreign key is dropped' => [ + self::childTable(foreignKey: true), + self::childTable(), + ]; + + yield 'an index over a foreign key is declared' => [ + self::childTable(foreignKey: true), + self::childTable(foreignKey: true, index: true), + ]; + + yield 'an index over a foreign key is withdrawn' => [ + self::childTable(foreignKey: true, index: true), + self::childTable(foreignKey: true), + ]; + + yield 'the primary key moves to another column' => [ + self::childTable(), + self::childTable(primaryKeyColumnName: 'code'), + ]; + + yield 'a unique constraint is added' => [ + self::childTable(), + self::childTable(uniqueConstraint: true), + ]; + + yield 'a unique constraint is dropped' => [ + self::childTable(uniqueConstraint: true), + self::childTable(), + ]; + + // On MySQL a unique index is at once a unique constraint, so a migration that dropped the + // derived constraint would void the user's unique index. + yield 'a unique index is added' => [ + self::childTable(), + self::childTable(uniqueIndex: true), + ]; + + yield 'a table with a unique index is unchanged' => [ + self::childTable(uniqueIndex: true), + self::childTable(uniqueIndex: true), + ]; + + yield 'nothing changes' => [ + self::childTable(uniqueConstraint: true, foreignKey: true), + self::childTable(uniqueConstraint: true, foreignKey: true), + ]; + } + + private static function compositeParentTable(): Table + { + return Table::editor() + ->setUnquotedName('composite_parent') + ->setColumns(self::intColumn('id'), self::intColumn('code')) + ->setPrimaryKeyConstraint( + PrimaryKeyConstraint::editor() + ->setUnquotedColumnNames('id', 'code') + ->create(), + ) + ->create(); + } + + private static function parentTable(): Table + { + return Table::editor() + ->setUnquotedName('parent') + ->setColumns(self::intColumn('id')) + ->setPrimaryKeyConstraint( + PrimaryKeyConstraint::editor() + ->setUnquotedColumnNames('id') + ->create(), + ) + ->create(); + } + + /** @param non-empty-string $primaryKeyColumnName */ + private static function childTable( + bool $uniqueConstraint = false, + bool $foreignKey = false, + bool $index = false, + bool $uniqueIndex = false, + string $primaryKeyColumnName = 'id', + ): Table { + $editor = Table::editor() + ->setUnquotedName('child') + ->setColumns( + self::intColumn('id'), + self::intColumn('parent_id'), + self::intColumn('code'), + ) + ->setPrimaryKeyConstraint( + PrimaryKeyConstraint::editor() + ->setUnquotedColumnNames($primaryKeyColumnName) + ->create(), + ); + + if ($uniqueConstraint) { + $editor->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedName('code_uq') + ->setUnquotedColumnNames('code') + ->create(), + ); + } + + if ($foreignKey) { + $editor->setForeignKeyConstraints( + ForeignKeyConstraint::editor() + ->setUnquotedName('parent_fk') + ->setUnquotedReferencingColumnNames('parent_id') + ->setUnquotedReferencedTableName('parent') + ->setUnquotedReferencedColumnNames('id') + ->create(), + ); + } + + $indexes = []; + + if ($index) { + $indexes[] = Index::editor() + ->setUnquotedName('parent_idx') + ->setUnquotedColumnNames('parent_id') + ->create(); + } + + if ($uniqueIndex) { + $indexes[] = Index::editor() + ->setUnquotedName('code_uidx') + ->setUnquotedColumnNames('code') + ->setType(IndexType::UNIQUE) + ->create(); + } + + if ($indexes !== []) { + $editor->setIndexes(...$indexes); + } + + return $editor->create(); + } + + /** @param non-empty-string $name */ + private static function intColumn(string $name): Column + { + return Column::editor() + ->setUnquotedName($name) + ->setTypeName(Types::INTEGER) + // Every column is a candidate for the primary key, which cannot be nullable. + ->setNotNull(true) + ->create(); + } + + /** + * Asserts that two introspected tables differ in nothing but the names of the indexes the engine + * created for itself. + * + * @throws Exception + */ + private function assertIndistinguishable(Table $expected, Table $actual, Table $declared): void + { + $this->assertColumnNamesEqual($expected->getColumns(), $actual->getColumns()); + + $declaredPrimaryKeyConstraint = $declared->getPrimaryKeyConstraint(); + self::assertNotNull($declaredPrimaryKeyConstraint); + + $expectedPrimaryKeyConstraint = $expected->getPrimaryKeyConstraint(); + self::assertNotNull($expectedPrimaryKeyConstraint); + + $actualPrimaryKeyConstraint = $actual->getPrimaryKeyConstraint(); + self::assertNotNull($actualPrimaryKeyConstraint); + + if ($declaredPrimaryKeyConstraint->getObjectName() === null) { + // The application did not name the constraint, so the engine did — and it names each one + // it creates, not each one it is asked for: Oracle hands out SYS_C0010548 to the create + // and SYS_C0010553 to the migration. Everything but the name must still agree. + $expectedPrimaryKeyConstraint = self::withoutName($expectedPrimaryKeyConstraint); + $actualPrimaryKeyConstraint = self::withoutName($actualPrimaryKeyConstraint); + } + + $this->assertPrimaryKeyConstraintEquals($expectedPrimaryKeyConstraint, $actualPrimaryKeyConstraint); + $this->assertForeignKeyConstraintListEquals($expected->getForeignKeys(), $actual->getForeignKeys()); + $this->assertUniqueConstraintListEquals($expected->getUniqueConstraints(), $actual->getUniqueConstraints()); + + $this->assertIndexListEquals( + $this->declaredIndexes($expected, $declared), + $this->declaredIndexes($actual, $declared), + ); + + self::assertSame( + $this->indexShapes($this->engineIndexes($expected, $declared)), + $this->indexShapes($this->engineIndexes($actual, $declared)), + 'the engine\'s own indexes differ in more than their names', + ); + } + + private static function withoutName(PrimaryKeyConstraint $constraint): PrimaryKeyConstraint + { + return $constraint->edit() + ->setName(null) + ->create(); + } + + /** + * @param array $indexes + * + * @return list + */ + private function indexShapes(array $indexes): array + { + $shapes = array_map( + static function (Index $index): string { + $columns = array_map( + static fn (Index\IndexedColumn $column): string => $column->getColumnName() + ->getIdentifier() + ->getValue() . '(' . ($column->getLength() ?? '') . ')', + $index->getIndexedColumns(), + ); + + return $index->getType()->name + . ' [' . implode(', ', $columns) . ']' + . ' ' . ($index->getPredicate() ?? ''); + }, + $indexes, + ); + + sort($shapes); + + return $shapes; + } + + /** + * @return list + * + * @throws Exception + */ + private function declaredIndexes(Table $table, Table $declared): array + { + return $this->partitionIndexes($table, $declared, true); + } + + /** + * @return list + * + * @throws Exception + */ + private function engineIndexes(Table $table, Table $declared): array + { + return $this->partitionIndexes($table, $declared, false); + } + + /** + * @return list + * + * @throws Exception + */ + private function partitionIndexes(Table $table, Table $declared, bool $keepDeclared): array + { + $folding = $this->connection->getDatabasePlatform() + ->getUnquotedIdentifierFolding(); + + $indexes = []; + + foreach ($table->getIndexes() as $index) { + if ($this->isDeclared($index, $declared, $folding) === $keepDeclared) { + $indexes[] = $index; + } + } + + return $indexes; + } + + private function isDeclared(Index $index, Table $declared, UnquotedIdentifierFolding $folding): bool + { + foreach ($declared->getIndexes() as $declaredIndex) { + if ($index->getObjectName()->equals($declaredIndex->getObjectName(), $folding)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Functional/Schema/UniqueConstraintTest.php b/tests/Functional/Schema/UniqueConstraintTest.php new file mode 100644 index 0000000000..f5e425ca92 --- /dev/null +++ b/tests/Functional/Schema/UniqueConstraintTest.php @@ -0,0 +1,235 @@ +dropTableIfExists('uc_users'); + + $users = $this->usersTable(); + + $schemaManager = $this->connection->createSchemaManager(); + $schemaManager->createTable($users); + + $diff = $schemaManager->createComparator() + ->compareTables($schemaManager->introspectTableByUnquotedName('uc_users'), $users); + + self::assertTrue($diff->isEmpty()); + } + + /** + * A table whose foreign key and unique constraint cover the same column must round-trip. + * + * @throws Exception + */ + public function testTableWithUniqueConstraintOnForeignKeyColumn(): void + { + $this->dropTableIfExists('uc_orders'); + $this->dropTableIfExists('uc_articles'); + + $articles = Table::editor() + ->setUnquotedName('uc_articles') + ->setColumns($this->intColumn('id')) + ->setPrimaryKeyConstraint($this->primaryKeyOn('id')) + ->create(); + + $orders = Table::editor() + ->setUnquotedName('uc_orders') + ->setColumns($this->intColumn('id'), $this->intColumn('article_id')) + ->setPrimaryKeyConstraint($this->primaryKeyOn('id')) + ->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedName('uc_orders_article_uq') + ->setUnquotedColumnNames('article_id') + ->create(), + ) + ->setForeignKeyConstraints( + ForeignKeyConstraint::editor() + // Prefixed, like this test's tables: MySQL scopes foreign key names to the + // schema and compares them case-insensitively, and Db2 folds unquoted names to + // upper case, where they would meet SchemaManagerTest's quoted 'Articles'. + ->setUnquotedName('uc_orders_articles_fk') + ->setUnquotedReferencingColumnNames('article_id') + ->setUnquotedReferencedTableName('uc_articles') + ->setUnquotedReferencedColumnNames('id') + ->create(), + ) + ->create(); + + $schemaManager = $this->connection->createSchemaManager(); + $schemaManager->createTable($articles); + $schemaManager->createTable($orders); + + $diff = $schemaManager->createComparator() + ->compareTables($schemaManager->introspectTableByUnquotedName('uc_orders'), $orders); + + self::assertTrue($diff->isEmpty()); + } + + /** + * Migrating a table towards a schema that declares a unique constraint must leave the + * constraint enforced. + * + * @throws Exception + */ + public function testUniquenessIsEnforcedAfterMigratingToDeclaredSchema(): void + { + $this->dropTableIfExists('uc_users'); + + $users = $this->usersTable(); + + $schemaManager = $this->connection->createSchemaManager(); + $schemaManager->createTable($users); + + $diff = $schemaManager->createComparator() + ->compareTables($schemaManager->introspectTableByUnquotedName('uc_users'), $users); + + if (! $diff->isEmpty()) { + $schemaManager->alterTable($diff); + } + + $this->connection->insert('uc_users', ['id' => 1, 'email' => 'jwage@example.com']); + + $this->expectException(UniqueConstraintViolationException::class); + + $this->connection->insert('uc_users', ['id' => 2, 'email' => 'jwage@example.com']); + } + + /** + * A table declaring a unique index must round-trip, and keep enforcing uniqueness. + * + * @throws Exception + */ + public function testTableWithUniqueIndex(): void + { + $this->dropTableIfExists('uc_members'); + + $members = $this->membersTable(); + + $schemaManager = $this->connection->createSchemaManager(); + $schemaManager->createTable($members); + + $diff = $schemaManager->createComparator() + ->compareTables($schemaManager->introspectTableByUnquotedName('uc_members'), $members); + + self::assertTrue($diff->isEmpty()); + + $this->connection->insert('uc_members', ['id' => 1, 'email' => 'jwage@example.com']); + + $this->expectException(UniqueConstraintViolationException::class); + + $this->connection->insert('uc_members', ['id' => 2, 'email' => 'jwage@example.com']); + } + + /** + * Dropping a column drops the unique constraint that covers it. + * + * @throws Exception + */ + public function testDropColumnCoveredByUniqueConstraint(): void + { + $this->dropTableIfExists('uc_users'); + + $schemaManager = $this->connection->createSchemaManager(); + $schemaManager->createTable($this->usersTable()); + + $desired = Table::editor() + ->setUnquotedName('uc_users') + ->setColumns($this->intColumn('id')) + ->setPrimaryKeyConstraint($this->primaryKeyOn('id')) + ->create(); + + $diff = $schemaManager->createComparator() + ->compareTables($schemaManager->introspectTableByUnquotedName('uc_users'), $desired); + + $schemaManager->alterTable($diff); + + $introspected = $schemaManager->introspectTableByUnquotedName('uc_users'); + + self::assertFalse($introspected->hasColumn('email')); + self::assertCount(0, $introspected->getUniqueConstraints()); + } + + private function membersTable(): Table + { + return Table::editor() + ->setUnquotedName('uc_members') + ->setColumns( + $this->intColumn('id'), + Column::editor() + ->setUnquotedName('email') + ->setTypeName(Types::STRING) + ->setLength(64) + ->create(), + ) + ->setPrimaryKeyConstraint($this->primaryKeyOn('id')) + ->setIndexes( + Index::editor() + ->setUnquotedName('uc_members_email_uidx') + ->setUnquotedColumnNames('email') + ->setType(IndexType::UNIQUE) + ->create(), + ) + ->create(); + } + + private function usersTable(): Table + { + return Table::editor() + ->setUnquotedName('uc_users') + ->setColumns( + $this->intColumn('id'), + Column::editor() + ->setUnquotedName('email') + ->setTypeName(Types::STRING) + ->setLength(64) + ->create(), + ) + ->setPrimaryKeyConstraint($this->primaryKeyOn('id')) + ->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedName('uc_users_email_uq') + ->setUnquotedColumnNames('email') + ->create(), + ) + ->create(); + } + + /** @param non-empty-string $name */ + private function intColumn(string $name): Column + { + return Column::editor() + ->setUnquotedName($name) + ->setTypeName(Types::INTEGER) + ->create(); + } + + /** @param non-empty-string $column */ + private function primaryKeyOn(string $column): PrimaryKeyConstraint + { + return PrimaryKeyConstraint::editor() + ->setUnquotedColumnNames($column) + ->create(); + } +} diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 788b0feded..2ef6d3814a 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -484,6 +484,36 @@ protected function toQuotedIndex(Index $index): Index ->create(); } + /** + * Asserts that each of the expected indexes is present in the given list, which may contain others. + * + * @param array $expected + * @param array $actual + * + * @throws Exception + */ + protected function assertIndexListContainsAll(array $expected, array $actual): void + { + $folding = $this->connection->getDatabasePlatform() + ->getUnquotedIdentifierFolding(); + + foreach ($expected as $expectedIndex) { + $name = $expectedIndex->getObjectName(); + + foreach ($actual as $actualIndex) { + if (! $actualIndex->getObjectName()->equals($name, $folding)) { + continue; + } + + $this->assertIndexEquals($expectedIndex, $actualIndex); + + continue 2; + } + + self::fail(sprintf('The list contains no index named "%s".', $name->toString())); + } + } + /** * @param array $expected * @param array $actual diff --git a/tests/Schema/AbstractComparatorTestCase.php b/tests/Schema/AbstractComparatorTestCase.php index 25d87a4bfc..99c5832ac9 100644 --- a/tests/Schema/AbstractComparatorTestCase.php +++ b/tests/Schema/AbstractComparatorTestCase.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Schema\ColumnDiff; use Doctrine\DBAL\Schema\Comparator; use Doctrine\DBAL\Schema\ComparatorConfig; +use Doctrine\DBAL\Schema\Exception\UnspecifiedConstraintName; use Doctrine\DBAL\Schema\ForeignKeyConstraint; use Doctrine\DBAL\Schema\ForeignKeyConstraint\ReferentialAction; use Doctrine\DBAL\Schema\Index; @@ -22,6 +23,7 @@ use Doctrine\DBAL\Schema\Sequence; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\TableDiff; +use Doctrine\DBAL\Schema\UniqueConstraint; use Doctrine\DBAL\Tests\Functional\Platform\RenameColumnTest; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; @@ -390,6 +392,137 @@ public function testTableAddForeignKey(): void self::assertCount(1, $tableDiff->getAddedForeignKeys()); } + public function testTableAddUniqueConstraint(): void + { + $table1 = $this->tableWithUserId(); + + $table2 = Table::editor() + ->setUnquotedName('foo') + ->setColumns($this->userIdColumn()) + ->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedName('uq_foo_user_id') + ->setUnquotedColumnNames('user_id') + ->create(), + ) + ->create(); + + $tableDiff = $this->comparator->compareTables($table1, $table2); + + self::assertCount(1, $tableDiff->getAddedUniqueConstraints()); + self::assertCount(0, $tableDiff->getDroppedUniqueConstraintNames()); + } + + public function testTableDropUniqueConstraint(): void + { + $table1 = Table::editor() + ->setUnquotedName('foo') + ->setColumns($this->userIdColumn()) + ->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedName('uq_foo_user_id') + ->setUnquotedColumnNames('user_id') + ->create(), + ) + ->create(); + + $table2 = $this->tableWithUserId(); + + $tableDiff = $this->comparator->compareTables($table1, $table2); + + self::assertCount(1, $tableDiff->getDroppedUniqueConstraintNames()); + self::assertCount(0, $tableDiff->getAddedUniqueConstraints()); + } + + public function testTableUpdateUniqueConstraint(): void + { + $columns = [ + $this->userIdColumn(), + Column::editor() + ->setUnquotedName('account_id') + ->setTypeName(Types::INTEGER) + ->create(), + ]; + + $table1 = Table::editor() + ->setUnquotedName('foo') + ->setColumns(...$columns) + ->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedName('uq_foo') + ->setUnquotedColumnNames('user_id') + ->create(), + ) + ->create(); + + $table2 = Table::editor() + ->setUnquotedName('foo') + ->setColumns(...$columns) + ->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedName('uq_foo') + ->setUnquotedColumnNames('user_id', 'account_id') + ->create(), + ) + ->create(); + + $tableDiff = $this->comparator->compareTables($table1, $table2); + + self::assertCount(1, $tableDiff->getDroppedUniqueConstraintNames()); + self::assertCount(1, $tableDiff->getAddedUniqueConstraints()); + } + + public function testUnchangedUniqueConstraint(): void + { + $table = Table::editor() + ->setUnquotedName('foo') + ->setColumns($this->userIdColumn()) + ->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedName('uq_foo_user_id') + ->setUnquotedColumnNames('user_id') + ->create(), + ) + ->create(); + + self::assertTrue($this->comparator->compareTables($table, $table)->isEmpty()); + } + + public function testDropUnnamedUniqueConstraint(): void + { + $table1 = Table::editor() + ->setUnquotedName('foo') + ->setColumns($this->userIdColumn()) + ->setUniqueConstraints( + UniqueConstraint::editor() + ->setUnquotedColumnNames('user_id') + ->create(), + ) + ->create(); + + $table2 = $this->tableWithUserId(); + + $this->expectException(UnspecifiedConstraintName::class); + + $this->comparator->compareTables($table1, $table2); + } + + private function tableWithUserId(): Table + { + return Table::editor() + ->setUnquotedName('foo') + ->setColumns($this->userIdColumn()) + ->create(); + } + + private function userIdColumn(): Column + { + return Column::editor() + ->setUnquotedName('user_id') + ->setTypeName(Types::INTEGER) + ->create(); + } + public function testTableUpdateForeignKey(): void { $table1 = Table::editor() From 33d6939db4d5d5dfe932336eeeee87d4e29c17b0 Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Sat, 1 Aug 2026 00:37:47 -0700 Subject: [PATCH 10/11] Report the objects backing constraints Introspection describes what the database has. A table with a primary key or a unique constraint has a unique index over its columns, so the per-platform predicates that withheld it are gone. The comparator expects each such index, so reporting it does not turn a round trip from the database towards the declared schema into a difference. --- UPGRADE.md | 16 ++- src/Platforms/Db2/Db2MetadataProvider.php | 1 - src/Platforms/MySQL/MySQLMetadataProvider.php | 1 - .../Oracle/OracleMetadataProvider.php | 1 - .../PostgreSQL/PostgreSQLMetadataProvider.php | 1 - .../SQLServer/SQLServerMetadataProvider.php | 1 - .../SQLite/SQLiteMetadataProvider.php | 1 - tests/Functional/Schema/AlterTableTest.php | 11 +- .../Schema/MySQL/ComparatorTest.php | 102 ++++++++++++++++++ .../SchemaManagerFunctionalTestCase.php | 45 +++----- 10 files changed, 127 insertions(+), 53 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 0c1489c73a..26b5af22e2 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,11 @@ awareness about deprecated code. # Upgrade to 5.0 +## BC BREAK: Introspection reports the indexes backing constraints + +`AbstractSchemaManager::introspectTableIndexes()` now returns the indexes the database created to enforce a table's +primary key and unique constraints. + ## BC BREAK: No index is created for a foreign key constraint Declaring a foreign key constraint no longer adds an index over its referencing columns. MySQL and MariaDB create one @@ -18,11 +23,6 @@ themselves; on the other platforms, an application that wants such an index must `Comparator::compareTables()` now compares unique constraints, and raises `Doctrine\DBAL\Schema\Exception\UnspecifiedConstraintName` when one that has to be dropped carries no name. -## BC BREAK: `MetadataProvider` requires unique constraint introspection - -`Doctrine\DBAL\Schema\Metadata\MetadataProvider` now declares `getUniqueConstraintColumnsForAllTables()` and -`getUniqueConstraintColumnsForTable()`. Implementations must provide them. - ## BC BREAK: Added `AbstractPlatform::createDerivedObjectProvider()` `Doctrine\DBAL\Platforms\AbstractPlatform` now declares `createDerivedObjectProvider()`. Platforms extending it must @@ -302,10 +302,8 @@ The following conflicting index configurations are no longer allowed: ## BC BREAK: Changes in features related to primary key constraints -1. The `Index` class can no longer represent a primary key constraint. As a result: - 1. The `Table::getIndexes()` and `AbstractSchemaManager::listTableIndexes()` methods no longer return the index that - backs the primary key constraint. - 2. The index that backs the primary key constraint is no longer considered during implicit index management. +1. The `Index` class can no longer represent a primary key constraint. The index that backs one is still reported as an + ordinary index by `Table::getIndexes()` and `AbstractSchemaManager::introspectTableIndexes()`. 2. The `Table::getPrimaryKey()` and `Table::setPrimaryKey()` methods have been removed. 3. The `Table::renameIndex()` method can no longer be used to rename a primary key constraint. 4. The `AbstractPlatform::getCreatePrimaryKeySQL()` method has been removed. diff --git a/src/Platforms/Db2/Db2MetadataProvider.php b/src/Platforms/Db2/Db2MetadataProvider.php index eb3400ae8e..7123501299 100644 --- a/src/Platforms/Db2/Db2MetadataProvider.php +++ b/src/Platforms/Db2/Db2MetadataProvider.php @@ -125,7 +125,6 @@ private function getTableColumns(?string $tableName): iterable AND T.TABNAME = C.TABNAME WHERE %s AND T.TYPE = 'T' - AND I.UNIQUERULE != 'P' ORDER BY C.TABNAME, C.COLNO SQL, diff --git a/src/Platforms/MySQL/MySQLMetadataProvider.php b/src/Platforms/MySQL/MySQLMetadataProvider.php index fa66d77ff3..dfc4624e90 100644 --- a/src/Platforms/MySQL/MySQLMetadataProvider.php +++ b/src/Platforms/MySQL/MySQLMetadataProvider.php @@ -409,7 +409,6 @@ private function getIndexColumns(?string $tableName): iterable SUB_PART FROM information_schema.STATISTICS WHERE %s - AND INDEX_NAME != 'PRIMARY' ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX diff --git a/src/Platforms/Oracle/OracleMetadataProvider.php b/src/Platforms/Oracle/OracleMetadataProvider.php index d462554faf..d585cf1eab 100644 --- a/src/Platforms/Oracle/OracleMetadataProvider.php +++ b/src/Platforms/Oracle/OracleMetadataProvider.php @@ -297,7 +297,6 @@ private function getIndexColumns(?string $tableName): iterable JOIN USER_IND_COLUMNS IC ON IC.INDEX_NAME = I.INDEX_NAME WHERE %s - AND (C.CONSTRAINT_TYPE IS NULL OR C.CONSTRAINT_TYPE != 'P') ORDER BY I.TABLE_NAME, I.INDEX_NAME, IC.COLUMN_POSITION diff --git a/src/Platforms/PostgreSQL/PostgreSQLMetadataProvider.php b/src/Platforms/PostgreSQL/PostgreSQLMetadataProvider.php index 3ec6ed6685..a96869867b 100644 --- a/src/Platforms/PostgreSQL/PostgreSQLMetadataProvider.php +++ b/src/Platforms/PostgreSQL/PostgreSQLMetadataProvider.php @@ -383,7 +383,6 @@ private function getIndexColumns(?string $schemaName, ?string $tableName): itera ON a.attrelid = c.oid AND a.attnum = keys.attnum WHERE %s - AND i.indisprimary = false ORDER BY n.nspname, c.relname, ic.relname, diff --git a/src/Platforms/SQLServer/SQLServerMetadataProvider.php b/src/Platforms/SQLServer/SQLServerMetadataProvider.php index 8b3f93b02c..106ef3f409 100644 --- a/src/Platforms/SQLServer/SQLServerMetadataProvider.php +++ b/src/Platforms/SQLServer/SQLServerMetadataProvider.php @@ -328,7 +328,6 @@ private function getIndexColumns(?string $schemaName, ?string $tableName): itera ON idxcol.object_id = c.object_id AND idxcol.column_id = c.column_id WHERE %s - AND i.is_primary_key = 0 ORDER BY s.name, t.name, i.name, diff --git a/src/Platforms/SQLite/SQLiteMetadataProvider.php b/src/Platforms/SQLite/SQLiteMetadataProvider.php index f0d9d574c6..9aa37f0773 100644 --- a/src/Platforms/SQLite/SQLiteMetadataProvider.php +++ b/src/Platforms/SQLite/SQLiteMetadataProvider.php @@ -323,7 +323,6 @@ private function getIndexColumns(?string $tableName): iterable JOIN pragma_index_list(t.name) i JOIN pragma_index_info(i.name) c WHERE %s - AND i.name NOT LIKE 'sqlite_%%' ORDER BY t.name, i.name, c.seqno diff --git a/tests/Functional/Schema/AlterTableTest.php b/tests/Functional/Schema/AlterTableTest.php index 103e0b7c60..db0b609d56 100644 --- a/tests/Functional/Schema/AlterTableTest.php +++ b/tests/Functional/Schema/AlterTableTest.php @@ -502,7 +502,7 @@ public function testDropColumnCoveredByForeignKey(): void $introspected = $schemaManager->introspectTable($desired->getObjectName()); self::assertTrue( - $comparator->compareTables($desired, $introspected)->isEmpty(), + $comparator->compareTables($introspected, $desired)->isEmpty(), ); } @@ -591,16 +591,17 @@ private function testMigration(Table $oldTable, callable $migration): void $newTable = $editor->create(); $diff = $schemaManager->createComparator() - ->compareTables($oldTable, $newTable); + ->compareTables($schemaManager->introspectTable($oldTable->getObjectName()), $newTable); self::assertFalse($diff->isEmpty()); $schemaManager->alterTable($diff); - $introspectedTable = $schemaManager->introspectTable($newTable->getObjectName()); - $diff = $schemaManager->createComparator() - ->compareTables($newTable, $introspectedTable); + ->compareTables( + $schemaManager->introspectTable($newTable->getObjectName()), + $newTable, + ); self::assertTrue($diff->isEmpty()); } diff --git a/tests/Functional/Schema/MySQL/ComparatorTest.php b/tests/Functional/Schema/MySQL/ComparatorTest.php index c8cfe06e8e..41eed1d6a8 100644 --- a/tests/Functional/Schema/MySQL/ComparatorTest.php +++ b/tests/Functional/Schema/MySQL/ComparatorTest.php @@ -11,6 +11,11 @@ use Doctrine\DBAL\Schema\Column; use Doctrine\DBAL\Schema\ColumnEditor; use Doctrine\DBAL\Schema\Comparator; +use Doctrine\DBAL\Schema\ForeignKeyConstraint; +use Doctrine\DBAL\Schema\Index; +use Doctrine\DBAL\Schema\Index\IndexedColumn; +use Doctrine\DBAL\Schema\Name\UnqualifiedName; +use Doctrine\DBAL\Schema\PrimaryKeyConstraint; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Tests\Functional\Schema\ComparatorTestUtils; use Doctrine\DBAL\Tests\FunctionalTestCase; @@ -66,6 +71,103 @@ public function testLobLengthIncrementOverLimit(string $type, int $length): void ComparatorTestUtils::assertDiffNotEmpty($this->connection, $this->comparator, $table); } + /** + * A column indexed by a prefix of its value cannot serve a foreign key, so MySQL indexes the + * referencing columns itself even though the declared index leads with them. + * + * @link https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html + * + * @throws Exception + */ + public function testForeignKeyIsIndexedDespiteAnIndexPrefixingItsLastColumn(): void + { + $table = $this->createTableWithAForeignKeyIndexedByAPrefix(); + + $introspected = $this->schemaManager->introspectTable($table->getObjectName()); + + self::assertIndexedColumnListEquals( + [ + new IndexedColumn(UnqualifiedName::unquoted('parent_id'), null), + new IndexedColumn(UnqualifiedName::unquoted('parent_code'), null), + ], + $introspected->getIndex('prefix_fk')->getIndexedColumns(), + ); + } + + /** @throws Exception */ + public function testTheIndexMySQLAddsForSuchAForeignKeyIsNoDifference(): void + { + $table = $this->createTableWithAForeignKeyIndexedByAPrefix(); + + self::assertTrue(ComparatorTestUtils::diffFromActualToDesiredTable( + $this->schemaManager, + $this->comparator, + $table, + )->isEmpty()); + } + + /** @throws Exception */ + private function createTableWithAForeignKeyIndexedByAPrefix(): Table + { + $this->dropTableIfExists('prefix_child'); + $this->dropTableIfExists('prefix_parent'); + + $this->schemaManager->createTable( + Table::editor() + ->setUnquotedName('prefix_parent') + ->setColumns($this->intColumn('id'), $this->stringColumn('code')) + ->setPrimaryKeyConstraint( + PrimaryKeyConstraint::editor() + ->setUnquotedColumnNames('id', 'code') + ->create(), + ) + ->create(), + ); + + $table = Table::editor() + ->setUnquotedName('prefix_child') + ->setColumns($this->intColumn('parent_id'), $this->stringColumn('parent_code')) + ->setIndexes( + Index::editor() + ->setUnquotedName('prefix_idx') + ->addUnquotedColumnName('parent_id') + ->addUnquotedColumnName('parent_code', 10) + ->create(), + ) + ->setForeignKeyConstraints( + ForeignKeyConstraint::editor() + ->setUnquotedName('prefix_fk') + ->setUnquotedReferencingColumnNames('parent_id', 'parent_code') + ->setUnquotedReferencedTableName('prefix_parent') + ->setUnquotedReferencedColumnNames('id', 'code') + ->create(), + ) + ->create(); + + $this->schemaManager->createTable($table); + + return $table; + } + + /** @param non-empty-string $name */ + private function intColumn(string $name): Column + { + return Column::editor() + ->setUnquotedName($name) + ->setTypeName(Types::INTEGER) + ->create(); + } + + /** @param non-empty-string $name */ + private function stringColumn(string $name): Column + { + return Column::editor() + ->setUnquotedName($name) + ->setTypeName(Types::STRING) + ->setLength(64) + ->create(); + } + /** @return iterable */ public static function lobColumnProvider(): iterable { diff --git a/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php b/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php index 5f707fbde0..a805135035 100644 --- a/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php +++ b/tests/Functional/Schema/SchemaManagerFunctionalTestCase.php @@ -499,17 +499,10 @@ public function testListTableIndexes(): void $this->dropAndCreateTable($table); - $this->assertIndexListEquals([ - Index::editor() - ->setUnquotedName('test_index_name') - ->setUnquotedColumnNames('test') - ->setType(IndexType::UNIQUE) - ->create(), - Index::editor() - ->setUnquotedName('test_composite_idx') - ->setUnquotedColumnNames('id', 'test') - ->create(), - ], $this->schemaManager->introspectTableIndexesByUnquotedName('list_table_indexes_test')); + $this->assertIndexListContainsAll( + $table->getIndexes(), + $this->schemaManager->introspectTableIndexesByUnquotedName('list_table_indexes_test'), + ); } public function testDropAndCreateIndex(): void @@ -540,13 +533,10 @@ public function testDropAndCreateIndex(): void $table->getObjectName()->toSQL($platform), ); - $this->assertIndexListEquals([ - Index::editor() - ->setUnquotedName('test') - ->setUnquotedColumnNames('test') - ->setType(IndexType::UNIQUE) - ->create(), - ], $this->schemaManager->introspectTableIndexesByUnquotedName('test_create_index')); + $this->assertIndexListContainsAll( + $table->getIndexes(), + $this->schemaManager->introspectTableIndexesByUnquotedName('test_create_index'), + ); } public function testDropAndCreateUniqueConstraint(): void @@ -677,8 +667,6 @@ public function testAlterTableScenario(): void self::assertTrue($table->hasColumn('test')); self::assertTrue($table->hasColumn('foreign_key_test')); self::assertCount(0, $table->getForeignKeys()); - self::assertCount(0, $table->getIndexes()); - $newTable = $table->edit() ->addColumn( Column::editor() @@ -713,9 +701,6 @@ public function testAlterTableScenario(): void $this->schemaManager->alterTable($diff); $table = $this->schemaManager->introspectTableByUnquotedName('alter_table'); - self::assertCount(1, $table->getIndexes()); - self::assertTrue($table->hasIndex('foo_idx')); - $this->assertIndexEquals( Index::editor() ->setUnquotedName('foo_idx') @@ -739,9 +724,6 @@ public function testAlterTableScenario(): void $this->schemaManager->alterTable($diff); $table = $this->schemaManager->introspectTableByUnquotedName('alter_table'); - self::assertCount(1, $table->getIndexes()); - self::assertTrue($table->hasIndex('foo_idx')); - $this->assertIndexEquals($fooIndex, $table->getIndex('foo_idx')); $barIndex = Index::editor() @@ -759,11 +741,8 @@ public function testAlterTableScenario(): void $this->schemaManager->alterTable($diff); $table = $this->schemaManager->introspectTableByUnquotedName('alter_table'); - self::assertCount(1, $table->getIndexes()); - self::assertTrue($table->hasIndex('bar_idx')); - self::assertFalse($table->hasIndex('foo_idx')); - $this->assertIndexEquals($barIndex, $table->getIndex('bar_idx')); + self::assertFalse($table->hasIndex('foo_idx')); $newTable = $table->edit() ->dropIndexByUnquotedName('bar_idx') @@ -782,7 +761,6 @@ public function testAlterTableScenario(): void $table = $this->schemaManager->introspectTableByUnquotedName('alter_table'); - // don't check for index size here, some platforms automatically add indexes for foreign keys. self::assertFalse($table->hasIndex('bar_idx')); /** @var list $fks */ @@ -1891,12 +1869,13 @@ public function testQuotedIdentifiers(): void $artists->getColumn('"Name"')->getObjectName(), ); - $this->assertIndexListEquals([ + $this->assertIndexEquals( Index::editor() ->setQuotedName('Idx_Artist_Name') ->setQuotedColumnNames('Name') ->create(), - ], $artists->getIndexes()); + $artists->getIndex('"Idx_Artist_Name"'), + ); $primaryKey = $artists->getPrimaryKeyConstraint(); self::assertNotNull($primaryKey); From d479f2c41cafd2fd53aa3c7757f29ebd33c01070 Mon Sep 17 00:00:00 2001 From: Sergei Morozov Date: Wed, 19 Aug 2026 19:13:59 -0700 Subject: [PATCH 11/11] Compare only towards the declared schema The comparison from the declared schema to the one the database reports is no longer supported. --- .../Functional/Schema/ComparatorTestUtils.php | 18 ------------------ .../Functional/Schema/MySQL/ComparatorTest.php | 18 ------------------ .../Schema/Oracle/ComparatorTest.php | 6 ------ .../Schema/PostgreSQL/ComparatorTest.php | 6 ------ tests/Functional/Types/JsonbTest.php | 6 ------ 5 files changed, 54 deletions(-) diff --git a/tests/Functional/Schema/ComparatorTestUtils.php b/tests/Functional/Schema/ComparatorTestUtils.php index a143bdf591..0c03d20de8 100644 --- a/tests/Functional/Schema/ComparatorTestUtils.php +++ b/tests/Functional/Schema/ComparatorTestUtils.php @@ -28,20 +28,6 @@ public static function diffFromActualToDesiredTable( ); } - /** @throws Exception */ - public static function diffFromDesiredToActualTable( - AbstractSchemaManager $schemaManager, - Comparator $comparator, - Table $desiredTable, - ): TableDiff { - return $comparator->compareTables( - $desiredTable, - $schemaManager->introspectTable( - $desiredTable->getObjectName(), - ), - ); - } - public static function assertDiffNotEmpty(Connection $connection, Comparator $comparator, Table $table): void { $schemaManager = $connection->createSchemaManager(); @@ -56,9 +42,5 @@ public static function assertDiffNotEmpty(Connection $connection, Comparator $co self::diffFromActualToDesiredTable($schemaManager, $comparator, $table) ->isEmpty(), ); - TestCase::assertTrue( - self::diffFromDesiredToActualTable($schemaManager, $comparator, $table) - ->isEmpty(), - ); } } diff --git a/tests/Functional/Schema/MySQL/ComparatorTest.php b/tests/Functional/Schema/MySQL/ComparatorTest.php index 41eed1d6a8..f76fd13e13 100644 --- a/tests/Functional/Schema/MySQL/ComparatorTest.php +++ b/tests/Functional/Schema/MySQL/ComparatorTest.php @@ -55,12 +55,6 @@ public function testLobLengthIncrementWithinLimit(string $type, int $length): vo $this->comparator, $table, )->isEmpty()); - - self::assertTrue(ComparatorTestUtils::diffFromDesiredToActualTable( - $this->schemaManager, - $this->comparator, - $table, - )->isEmpty()); } #[DataProvider('lobColumnProvider')] @@ -223,12 +217,6 @@ public function testExplicitDefaultCollation(): void $this->comparator, $table, )->isEmpty()); - - self::assertTrue(ComparatorTestUtils::diffFromDesiredToActualTable( - $this->schemaManager, - $this->comparator, - $table, - )->isEmpty()); } public function testChangeColumnCharsetAndCollation(): void @@ -289,12 +277,6 @@ public function testTableAndColumnOptions( $this->comparator, $table, )->isEmpty()); - - self::assertTrue(ComparatorTestUtils::diffFromDesiredToActualTable( - $this->schemaManager, - $this->comparator, - $table, - )->isEmpty()); } /** @return iterable,?non-empty-string,?non-empty-string}> */ diff --git a/tests/Functional/Schema/Oracle/ComparatorTest.php b/tests/Functional/Schema/Oracle/ComparatorTest.php index 9ce1308bdd..f4e335dbdc 100644 --- a/tests/Functional/Schema/Oracle/ComparatorTest.php +++ b/tests/Functional/Schema/Oracle/ComparatorTest.php @@ -68,11 +68,5 @@ public function testChangeBinaryColumnFixed(): void $this->comparator, $table, )->isEmpty()); - - self::assertTrue(ComparatorTestUtils::diffFromDesiredToActualTable( - $this->schemaManager, - $this->comparator, - $table, - )->isEmpty()); } } diff --git a/tests/Functional/Schema/PostgreSQL/ComparatorTest.php b/tests/Functional/Schema/PostgreSQL/ComparatorTest.php index 2055f0305f..f51a7f1dd2 100644 --- a/tests/Functional/Schema/PostgreSQL/ComparatorTest.php +++ b/tests/Functional/Schema/PostgreSQL/ComparatorTest.php @@ -131,11 +131,5 @@ private function testColumnModification(callable $initializeColumn, callable $mo $this->comparator, $table, )->isEmpty()); - - self::assertTrue(ComparatorTestUtils::diffFromDesiredToActualTable( - $this->schemaManager, - $this->comparator, - $table, - )->isEmpty()); } } diff --git a/tests/Functional/Types/JsonbTest.php b/tests/Functional/Types/JsonbTest.php index aa7b986bd8..061b089d32 100644 --- a/tests/Functional/Types/JsonbTest.php +++ b/tests/Functional/Types/JsonbTest.php @@ -34,11 +34,5 @@ public function testJsonbColumnIntrospection(): void $comparator, $table, )->isEmpty()); - - self::assertTrue(ComparatorTestUtils::diffFromDesiredToActualTable( - $schemaManager, - $comparator, - $table, - )->isEmpty()); } }