diff --git a/.doctrine-project.json b/.doctrine-project.json index 1bf091b733e..ccc71f8187c 100644 --- a/.doctrine-project.json +++ b/.doctrine-project.json @@ -35,6 +35,12 @@ "slug": "4.0", "maintained": false }, + { + "name": "3.10", + "branchName": "3.10.x", + "slug": "3.10", + "upcoming": true + }, { "name": "3.9", "branchName": "3.9.x", diff --git a/README.md b/README.md index 8d68192eb78..849d8f41106 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Doctrine DBAL -| [5.0-dev][5.0] | [4.3-dev][4.3] | [4.2][4.2] | [3.9][3.9] | -|:---------------------------------------------------:|:---------------------------------------------------:|:---------------------------------------------------:|:---------------------------------------------------:| -| [![GitHub Actions][GA 5.0 image]][GA 5.0] | [![GitHub Actions][GA 4.3 image]][GA 4.3] | [![GitHub Actions][GA 4.2 image]][GA 4.2] | [![GitHub Actions][GA 3.9 image]][GA 3.9] | -| [![AppVeyor][AppVeyor 5.0 image]][AppVeyor 5.0] | [![AppVeyor][AppVeyor 4.3 image]][AppVeyor 4.3] | [![AppVeyor][AppVeyor 4.2 image]][AppVeyor 4.2] | [![AppVeyor][AppVeyor 3.9 image]][AppVeyor 3.9] | -| [![Code Coverage][Coverage 5.0 image]][CodeCov 5.0] | [![Code Coverage][Coverage 4.3 image]][CodeCov 4.3] | [![Code Coverage][Coverage 4.2 image]][CodeCov 4.2] | [![Code Coverage][Coverage 3.9 image]][CodeCov 3.9] | -| N/A | N/A | [![Type Coverage][TypeCov image]][TypeCov] | N/A | +| [5.0-dev][5.0] | [4.3-dev][4.3] | [4.2][4.2] | [3.10][3.10] | [3.9][3.9] | +|:---------------------------------------------------:|:---------------------------------------------------:|:---------------------------------------------------:|:-----------------------------------------------------:|:---------------------------------------------------:| +| [![GitHub Actions][GA 5.0 image]][GA 5.0] | [![GitHub Actions][GA 4.3 image]][GA 4.3] | [![GitHub Actions][GA 4.2 image]][GA 4.2] | [![GitHub Actions][GA 3.10 image]][GA 3.10] | [![GitHub Actions][GA 3.9 image]][GA 3.9] | +| [![AppVeyor][AppVeyor 5.0 image]][AppVeyor 5.0] | [![AppVeyor][AppVeyor 4.3 image]][AppVeyor 4.3] | [![AppVeyor][AppVeyor 4.2 image]][AppVeyor 4.2] | [![AppVeyor][AppVeyor 3.10 image]][AppVeyor 3.10] | [![AppVeyor][AppVeyor 3.9 image]][AppVeyor 3.9] | +| [![Code Coverage][Coverage 5.0 image]][CodeCov 5.0] | [![Code Coverage][Coverage 4.3 image]][CodeCov 4.3] | [![Code Coverage][Coverage 4.2 image]][CodeCov 4.2] | [![Code Coverage][Coverage 3.10 image]][CodeCov 3.10] | [![Code Coverage][Coverage 3.9 image]][CodeCov 3.9] | +| N/A | N/A | [![Type Coverage][TypeCov image]][TypeCov] | N/A | N/A | Powerful ***D***ata***B***ase ***A***bstraction ***L***ayer with many features for database schema introspection and schema management. @@ -21,7 +21,7 @@ Powerful ***D***ata***B***ase ***A***bstraction ***L***ayer with many features f [AppVeyor 5.0]: https://ci.appveyor.com/project/doctrine/dbal/branch/5.0.x [AppVeyor 5.0 image]: https://ci.appveyor.com/api/projects/status/i88kitq8qpbm0vie/branch/5.0.x?svg=true [GA 5.0]: https://github.com/doctrine/dbal/actions?query=workflow%3A%22Continuous+Integration%22+branch%3A5.0.x - [GA 5.0 image]: https://github.com/doctrine/dbal/workflows/Continuous%20Integration/badge.svg?branch=5.0.x + [GA 5.0 image]: https://github.com/doctrine/dbal/actions/workflows/continuous-integration.yml/badge.svg?branch=5.0.x [Coverage 4.3 image]: https://codecov.io/gh/doctrine/dbal/branch/4.3.x/graph/badge.svg [4.3]: https://github.com/doctrine/dbal/tree/4.3.x @@ -29,7 +29,7 @@ Powerful ***D***ata***B***ase ***A***bstraction ***L***ayer with many features f [AppVeyor 4.3]: https://ci.appveyor.com/project/doctrine/dbal/branch/4.3.x [AppVeyor 4.3 image]: https://ci.appveyor.com/api/projects/status/i88kitq8qpbm0vie/branch/4.3.x?svg=true [GA 4.3]: https://github.com/doctrine/dbal/actions?query=workflow%3A%22Continuous+Integration%22+branch%3A4.3.x - [GA 4.3 image]: https://github.com/doctrine/dbal/workflows/Continuous%20Integration/badge.svg?branch=4.3.x + [GA 4.3 image]: https://github.com/doctrine/dbal/actions/workflows/continuous-integration.yml/badge.svg?branch=4.3.x [Coverage 4.2 image]: https://codecov.io/gh/doctrine/dbal/branch/4.2.x/graph/badge.svg [4.2]: https://github.com/doctrine/dbal/tree/4.2.x @@ -37,14 +37,22 @@ Powerful ***D***ata***B***ase ***A***bstraction ***L***ayer with many features f [AppVeyor 4.2]: https://ci.appveyor.com/project/doctrine/dbal/branch/4.2.x [AppVeyor 4.2 image]: https://ci.appveyor.com/api/projects/status/i88kitq8qpbm0vie/branch/4.2.x?svg=true [GA 4.2]: https://github.com/doctrine/dbal/actions?query=workflow%3A%22Continuous+Integration%22+branch%3A4.2.x - [GA 4.2 image]: https://github.com/doctrine/dbal/workflows/Continuous%20Integration/badge.svg?branch=4.2.x + [GA 4.2 image]: https://github.com/doctrine/dbal/actions/workflows/continuous-integration.yml/badge.svg?branch=4.2.x [TypeCov]: https://shepherd.dev/github/doctrine/dbal [TypeCov image]: https://shepherd.dev/github/doctrine/dbal/coverage.svg + [Coverage 3.10 image]: https://codecov.io/gh/doctrine/dbal/branch/3.10.x/graph/badge.svg + [3.10]: https://github.com/doctrine/dbal/tree/3.10.x + [CodeCov 3.10]: https://codecov.io/gh/doctrine/dbal/branch/3.10.x + [AppVeyor 3.10]: https://ci.appveyor.com/project/doctrine/dbal/branch/3.10.x + [AppVeyor 3.10 image]: https://ci.appveyor.com/api/projects/status/i88kitq8qpbm0vie/branch/3.10.x?svg=true + [GA 3.10]: https://github.com/doctrine/dbal/actions?query=workflow%3A%22Continuous+Integration%22+branch%3A3.10.x + [GA 3.10 image]: https://github.com/doctrine/dbal/actions/workflows/continuous-integration.yml/badge.svg?branch=3.10.x + [Coverage 3.9 image]: https://codecov.io/gh/doctrine/dbal/branch/3.9.x/graph/badge.svg [3.9]: https://github.com/doctrine/dbal/tree/3.9.x [CodeCov 3.9]: https://codecov.io/gh/doctrine/dbal/branch/3.9.x [AppVeyor 3.9]: https://ci.appveyor.com/project/doctrine/dbal/branch/3.9.x [AppVeyor 3.9 image]: https://ci.appveyor.com/api/projects/status/i88kitq8qpbm0vie/branch/3.9.x?svg=true [GA 3.9]: https://github.com/doctrine/dbal/actions?query=workflow%3A%22Continuous+Integration%22+branch%3A3.9.x - [GA 3.9 image]: https://github.com/doctrine/dbal/workflows/Continuous%20Integration/badge.svg?branch=3.9.x + [GA 3.9 image]: https://github.com/doctrine/dbal/actions/workflows/continuous-integration.yml/badge.svg?branch=3.9.x diff --git a/docs/en/reference/schema-representation.rst b/docs/en/reference/schema-representation.rst index f5aa7f4e0ac..72d4198915c 100644 --- a/docs/en/reference/schema-representation.rst +++ b/docs/en/reference/schema-representation.rst @@ -93,7 +93,7 @@ and absolutely not portable. - **engine** (string): The DB engine used for the table. Currently only supported on MySQL. - **unlogged** (boolean): Set a PostgreSQL table type as - `unlogged `_ + `unlogged `_ Column ~~~~~~ diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 44dfb1315e4..c96c52b066f 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -102,9 +102,7 @@ - src/Connection.php - src/Schema/Comparator.php - src/SQLParserUtils.php + src/Schema/Table.php diff --git a/src/Connection.php b/src/Connection.php index 5a66e1ad21c..a81e57e2c7d 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -811,7 +811,7 @@ public function executeCacheQuery(string $sql, array $params, array $types, Quer } if (isset($value[$realKey]) && $value[$realKey] instanceof ArrayResult) { - return new Result($value[$realKey], $this); + return new Result(clone $value[$realKey], $this); } } else { $value = []; @@ -837,7 +837,7 @@ public function executeCacheQuery(string $sql, array $params, array $types, Quer $resultCache->save($item); - return new Result($value[$realKey], $this); + return new Result(clone $value[$realKey], $this); } /** diff --git a/src/Schema/Exception/PrimaryKeyAlreadyExists.php b/src/Schema/Exception/PrimaryKeyAlreadyExists.php new file mode 100644 index 00000000000..70fd1f82436 --- /dev/null +++ b/src/Schema/Exception/PrimaryKeyAlreadyExists.php @@ -0,0 +1,21 @@ + keys are new names, values are old names */ protected array $renamedColumns = []; /** @var Index[] */ protected array $_indexes = []; + /** + * The keys of this array are the names of the indexes that were implicitly created as backing for foreign key + * constraints. The values are not used but must be non-null for {@link isset()} to work correctly. + * + * @var array + */ + private array $implicitIndexNames = []; + protected ?string $_primaryKeyName = null; /** @var UniqueConstraint[] */ @@ -607,36 +611,41 @@ protected function _addColumn(Column $column): void /** * Adds an index to the table. */ - protected function _addIndex(Index $indexCandidate): self + protected function _addIndex(Index $index): self { - $indexName = $indexCandidate->getName(); - $indexName = $this->normalizeIdentifier($indexName); - $replacedImplicitIndexes = []; + $indexName = $this->normalizeIdentifier($index->getName()); - foreach ($this->implicitIndexes as $name => $implicitIndex) { - if (! $implicitIndex->isFulfilledBy($indexCandidate) || ! isset($this->_indexes[$name])) { + $replacedImplicitIndexNames = []; + + foreach ($this->implicitIndexNames as $implicitIndexName => $_) { + if (! isset($this->_indexes[$implicitIndexName])) { continue; } - $replacedImplicitIndexes[] = $name; + if (! $this->_indexes[$implicitIndexName]->isFulfilledBy($index)) { + continue; + } + + $replacedImplicitIndexNames[$implicitIndexName] = true; } - if ( - (isset($this->_indexes[$indexName]) && ! in_array($indexName, $replacedImplicitIndexes, true)) || - ($this->_primaryKeyName !== null && $indexCandidate->isPrimary()) - ) { + if (isset($this->_indexes[$indexName]) && ! isset($replacedImplicitIndexNames[$indexName])) { throw IndexAlreadyExists::new($indexName, $this->_name); } - foreach ($replacedImplicitIndexes as $name) { - unset($this->_indexes[$name], $this->implicitIndexes[$name]); + if ($this->_primaryKeyName !== null && $index->isPrimary()) { + throw PrimaryKeyAlreadyExists::new($this->_name); + } + + foreach ($replacedImplicitIndexNames as $name => $_) { + unset($this->_indexes[$name], $this->implicitIndexNames[$name]); } - if ($indexCandidate->isPrimary()) { + if ($index->isPrimary()) { $this->_primaryKeyName = $indexName; } - $this->_indexes[$indexName] = $indexCandidate; + $this->_indexes[$indexName] = $index; return $this; } @@ -672,7 +681,7 @@ protected function _addUniqueConstraint(UniqueConstraint $constraint): self } } - $this->implicitIndexes[$this->normalizeIdentifier($indexName)] = $indexCandidate; + $this->implicitIndexNames[$this->normalizeIdentifier($indexName)] = true; return $this; } @@ -710,7 +719,7 @@ protected function _addForeignKeyConstraint(ForeignKeyConstraint $constraint): s } $this->_addIndex($indexCandidate); - $this->implicitIndexes[$this->normalizeIdentifier($indexName)] = $indexCandidate; + $this->implicitIndexNames[$this->normalizeIdentifier($indexName)] = true; return $this; } @@ -757,13 +766,7 @@ private function _createUniqueConstraint( throw IndexNameInvalid::new($indexName); } - foreach ($columns as $index => $value) { - if (is_string($index)) { - $columnName = $index; - } else { - $columnName = $value; - } - + foreach ($columns as $columnName) { if (! $this->hasColumn($columnName)) { throw ColumnDoesNotExist::new($columnName, $this->_name); } diff --git a/tests/Connection/CachedQueryTest.php b/tests/Connection/CachedQueryTest.php index 3d9c825a1bd..7f2fe62ad44 100644 --- a/tests/Connection/CachedQueryTest.php +++ b/tests/Connection/CachedQueryTest.php @@ -8,27 +8,37 @@ use Doctrine\DBAL\Cache\QueryCacheProfile; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Driver; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; class CachedQueryTest extends TestCase { - public function testCachedQuery(): void + #[DataProvider('providePsrCacheImplementations')] + public function testCachedQuery(callable $psrCacheProvider): void { - $cache = new ArrayAdapter(); + $cache = $psrCacheProvider(); $connection = $this->createConnection(1, ['foo'], [['bar']]); $qcp = new QueryCacheProfile(0, __FUNCTION__, $cache); - self::assertSame([['foo' => 'bar']], $connection->executeCacheQuery('SELECT 1', [], [], $qcp) + $firstResult = $connection->executeCacheQuery('SELECT 1', [], [], $qcp); + self::assertSame([['foo' => 'bar']], $firstResult + ->fetchAllAssociative()); + $firstResult->free(); + $secondResult = $connection->executeCacheQuery('SELECT 1', [], [], $qcp); + self::assertSame([['foo' => 'bar']], $secondResult ->fetchAllAssociative()); + $secondResult->free(); self::assertSame([['foo' => 'bar']], $connection->executeCacheQuery('SELECT 1', [], [], $qcp) ->fetchAllAssociative()); self::assertCount(1, $cache->getItem(__FUNCTION__)->get()); } - public function testCachedQueryWithChangedImplementationIsExecutedTwice(): void + #[DataProvider('providePsrCacheImplementations')] + public function testCachedQueryWithChangedImplementationIsExecutedTwice(callable $psrCacheProvider): void { $connection = $this->createConnection(2, ['baz'], [['qux']]); @@ -36,21 +46,22 @@ public function testCachedQueryWithChangedImplementationIsExecutedTwice(): void 'SELECT 1', [], [], - new QueryCacheProfile(0, __FUNCTION__, new ArrayAdapter()), + new QueryCacheProfile(0, __FUNCTION__, $psrCacheProvider()), )->fetchAllAssociative()); self::assertSame([['baz' => 'qux']], $connection->executeCacheQuery( 'SELECT 1', [], [], - new QueryCacheProfile(0, __FUNCTION__, new ArrayAdapter()), + new QueryCacheProfile(0, __FUNCTION__, $psrCacheProvider()), )->fetchAllAssociative()); } - public function testOldCacheFormat(): void + #[DataProvider('providePsrCacheImplementations')] + public function testOldCacheFormat(callable $psrCacheProvider): void { $connection = $this->createConnection(1, ['foo'], [['bar']]); - $cache = new ArrayAdapter(); + $cache = $psrCacheProvider(); $qcp = new QueryCacheProfile(0, __FUNCTION__, $cache); [$cacheKey, $realKey] = $qcp->generateCacheKeys('SELECT 1', [], [], []); @@ -83,4 +94,13 @@ private function createConnection(int $expectedQueryCount, array $columnNames, a return new Connection([], $driver); } + + /** @return array> */ + public static function providePsrCacheImplementations(): array + { + return [ + 'serialized' => [static fn () => new ArrayAdapter(0, true)], + 'by-reference' => [static fn () => new ArrayAdapter(0, false)], + ]; + } } diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index cac8873a755..3d085ca9314 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -47,11 +47,11 @@ protected function setUp(): void private function getExecuteStatementMockConnection(): Connection&MockObject { - $driverMock = $this->createMock(Driver::class); + $driver = self::createStub(Driver::class); return $this->getMockBuilder(Connection::class) ->onlyMethods(['executeStatement']) - ->setConstructorArgs([[], $driverMock]) + ->setConstructorArgs([[], $driver]) ->getMock(); } @@ -146,9 +146,9 @@ public function testSetAutoCommit(): void public function testConnectStartsTransactionInNoAutoCommitMode(): void { - $driverMock = $this->createMock(Driver::class); + $driver = self::createStub(Driver::class); - $conn = new Connection([], $driverMock); + $conn = new Connection([], $driver); $conn->setAutoCommit(false); @@ -161,9 +161,9 @@ public function testConnectStartsTransactionInNoAutoCommitMode(): void public function testCommitStartsTransactionInNoAutoCommitMode(): void { - $driverMock = $this->createMock(Driver::class); + $driver = self::createStub(Driver::class); - $conn = new Connection([], $driverMock); + $conn = new Connection([], $driver); $conn->setAutoCommit(false); $conn->executeQuery('SELECT 1'); @@ -180,9 +180,9 @@ public static function resultProvider(): array public function testRollBackStartsTransactionInNoAutoCommitMode(): void { - $driverMock = $this->createMock(Driver::class); + $driver = self::createStub(Driver::class); - $conn = new Connection([], $driverMock); + $conn = new Connection([], $driver); $conn->setAutoCommit(false); $conn->executeQuery('SELECT 1'); @@ -198,12 +198,12 @@ public function testSwitchingAutoCommitModeCommitsAllCurrentTransactions(): void ->method('supportsSavepoints') ->willReturn(true); - $driverMock = $this->createMock(Driver::class); - $driverMock + $driver = self::createStub(Driver::class); + $driver ->method('getDatabasePlatform') ->willReturn($platform); - $conn = new Connection([], $driverMock); + $conn = new Connection([], $driver); $conn->beginTransaction(); $conn->beginTransaction(); diff --git a/tests/Functional/TransactionTest.php b/tests/Functional/TransactionTest.php index 693271f83ee..d21c2e6d2c1 100644 --- a/tests/Functional/TransactionTest.php +++ b/tests/Functional/TransactionTest.php @@ -7,7 +7,9 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\ConnectionLost; use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; +use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Tests\FunctionalTestCase; +use Doctrine\DBAL\Types\Types; use function func_get_args; use function restore_error_handler; @@ -18,15 +20,6 @@ class TransactionTest extends FunctionalTestCase { - protected function setUp(): void - { - if ($this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { - return; - } - - self::markTestSkipped('Restricted to MySQL.'); - } - public function testCommitFailure(): void { $this->expectConnectionLoss(static function (Connection $connection): void { @@ -43,6 +36,10 @@ public function testRollbackFailure(): void private function expectConnectionLoss(callable $scenario): void { + if (! $this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { + self::markTestSkipped('Restricted to MySQL.'); + } + $this->connection->executeStatement('SET SESSION wait_timeout=1'); $this->connection->beginTransaction(); @@ -67,4 +64,34 @@ private function expectConnectionLoss(callable $scenario): void restore_error_handler(); } } + + public function testNestedTransactionWalkthrough(): void + { + if (! $this->connection->getDatabasePlatform()->supportsSavepoints()) { + self::markTestIncomplete('Broken when savepoints are not supported.'); + } + + $table = new Table('storage'); + $table->addColumn('test_int', Types::INTEGER); + $table->setPrimaryKey(['test_int']); + + $this->dropAndCreateTable($table); + + $query = 'SELECT count(test_int) FROM storage'; + + self::assertSame('0', (string) $this->connection->fetchOne($query)); + + $result = $this->connection->transactional( + static fn (Connection $connection) => $connection->transactional( + static function (Connection $connection) use ($query) { + $connection->insert('storage', ['test_int' => 1]); + + return $connection->fetchOne($query); + }, + ), + ); + + self::assertSame('1', (string) $result); + self::assertSame('1', (string) $this->connection->fetchOne($query)); + } } diff --git a/tests/Query/Expression/ExpressionBuilderTest.php b/tests/Query/Expression/ExpressionBuilderTest.php index 06cc0a7dbd6..4ec5d5badca 100644 --- a/tests/Query/Expression/ExpressionBuilderTest.php +++ b/tests/Query/Expression/ExpressionBuilderTest.php @@ -16,11 +16,11 @@ class ExpressionBuilderTest extends TestCase protected function setUp(): void { - $conn = $this->createMock(Connection::class); + $conn = self::createStub(Connection::class); $this->expr = new ExpressionBuilder($conn); - $conn->expects(self::any()) + $conn ->method('createExpressionBuilder') ->willReturn($this->expr); } diff --git a/tests/Types/DateImmutableTypeTest.php b/tests/Types/DateImmutableTypeTest.php index 31f5cdce1fe..3a3ca8a15cc 100644 --- a/tests/Types/DateImmutableTypeTest.php +++ b/tests/Types/DateImmutableTypeTest.php @@ -90,7 +90,7 @@ public function testConvertsDateStringToPHPValue(): void public function testResetTimeFractionsWhenConvertingToPHPValue(): void { - $this->platform->expects(self::any()) + $this->platform ->method('getDateFormatString') ->willReturn('Y-m-d'); diff --git a/tests/Types/DateTimeImmutableTypeTest.php b/tests/Types/DateTimeImmutableTypeTest.php index cce9557636b..358267895e2 100644 --- a/tests/Types/DateTimeImmutableTypeTest.php +++ b/tests/Types/DateTimeImmutableTypeTest.php @@ -90,7 +90,7 @@ public function testConvertsDateTimeStringToPHPValue(): void public function testConvertsDateTimeStringWithMicrosecondsToPHPValue(): void { - $this->platform->expects(self::any()) + $this->platform ->method('getDateTimeFormatString') ->willReturn('Y-m-d H:i:s'); diff --git a/tests/Types/TimeImmutableTypeTest.php b/tests/Types/TimeImmutableTypeTest.php index f1738546868..103328b0aae 100644 --- a/tests/Types/TimeImmutableTypeTest.php +++ b/tests/Types/TimeImmutableTypeTest.php @@ -90,7 +90,7 @@ public function testConvertsTimeStringToPHPValue(): void public function testResetDateFractionsWhenConvertingToPHPValue(): void { - $this->platform->expects(self::any()) + $this->platform ->method('getTimeFormatString') ->willReturn('H:i:s');