diff --git a/docs/en/reference/query-builder.rst b/docs/en/reference/query-builder.rst index 5b54af3ca07..d17234d29eb 100644 --- a/docs/en/reference/query-builder.rst +++ b/docs/en/reference/query-builder.rst @@ -368,6 +368,38 @@ or QueryBuilder instances to one of the following methods: ->orderBy('field', 'DESC') ->setMaxResults(100); +Common Table Expressions +~~~~~~~~~~~ + +To define Common Table Expressions (CTEs) that can be used in select query. + +* ``with(string $name, string|QueryBuilder $queryBuilder, ?array $columns = null)`` + +.. code-block:: php + + select('id') + ->from('table_a') + ->where('id = :id'); + + $cteQueryBuilder2 + ->select('id') + ->from('table_b'); + + $queryBuilder + ->with('cte_a', $cteQueryBuilder1) + ->with('cte_b', $cteQueryBuilder2) + ->select('id') + ->from('cte_b', 'b') + ->join('b', 'cte_a', 'a', 'a.id = b.id') + ->setParameter('id', 1); + +Multiple CTEs can be defined by calling the with method multiple times. + +Values of parameters used in a CTE should be defined in the main QueryBuilder. + Building Expressions -------------------- diff --git a/src/Platforms/AbstractPlatform.php b/src/Platforms/AbstractPlatform.php index 7144b56b857..e62a43ac6ca 100644 --- a/src/Platforms/AbstractPlatform.php +++ b/src/Platforms/AbstractPlatform.php @@ -31,6 +31,7 @@ use Doctrine\DBAL\SQL\Builder\DefaultUnionSQLBuilder; use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder; use Doctrine\DBAL\SQL\Builder\UnionSQLBuilder; +use Doctrine\DBAL\SQL\Builder\WithSQLBuilder; use Doctrine\DBAL\SQL\Parser; use Doctrine\DBAL\TransactionIsolationLevel; use Doctrine\DBAL\Types; @@ -807,6 +808,11 @@ public function createUnionSQLBuilder(): UnionSQLBuilder return new DefaultUnionSQLBuilder($this); } + public function createWithSQLBuilder(): WithSQLBuilder + { + return new WithSQLBuilder(); + } + /** * @internal * diff --git a/src/Platforms/MySQL80Platform.php b/src/Platforms/MySQL80Platform.php index 547742d6a08..01dcfb40107 100644 --- a/src/Platforms/MySQL80Platform.php +++ b/src/Platforms/MySQL80Platform.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Platforms\Keywords\KeywordList; use Doctrine\DBAL\Platforms\Keywords\MySQL80Keywords; use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder; +use Doctrine\DBAL\SQL\Builder\WithSQLBuilder; use Doctrine\Deprecations\Deprecation; /** @@ -32,4 +33,9 @@ public function createSelectSQLBuilder(): SelectSQLBuilder { return AbstractPlatform::createSelectSQLBuilder(); } + + public function createWithSQLBuilder(): WithSQLBuilder + { + return AbstractPlatform::createWithSQLBuilder(); + } } diff --git a/src/Platforms/MySQLPlatform.php b/src/Platforms/MySQLPlatform.php index 0d90e38509f..ad804b36aa2 100644 --- a/src/Platforms/MySQLPlatform.php +++ b/src/Platforms/MySQLPlatform.php @@ -4,9 +4,11 @@ namespace Doctrine\DBAL\Platforms; +use Doctrine\DBAL\Platforms\Exception\NotSupported; use Doctrine\DBAL\Platforms\Keywords\KeywordList; use Doctrine\DBAL\Platforms\Keywords\MySQLKeywords; use Doctrine\DBAL\Schema\Index; +use Doctrine\DBAL\SQL\Builder\WithSQLBuilder; use Doctrine\DBAL\Types\BlobType; use Doctrine\DBAL\Types\TextType; use Doctrine\Deprecations\Deprecation; @@ -35,6 +37,11 @@ public function getDefaultValueDeclarationSQL(array $column): string return parent::getDefaultValueDeclarationSQL($column); } + public function createWithSQLBuilder(): WithSQLBuilder + { + throw NotSupported::new(__METHOD__); + } + /** * {@inheritDoc} */ diff --git a/src/Query/CommonTableExpression.php b/src/Query/CommonTableExpression.php new file mode 100644 index 00000000000..81443adc7a7 --- /dev/null +++ b/src/Query/CommonTableExpression.php @@ -0,0 +1,23 @@ + + * // WITH cte_name AS (SELECT id AS column1 FROM table_a) + * $qb = $conn->createQueryBuilder() + * ->with('cte_name', 'SELECT id AS column1 FROM table_a'); + * + * // WITH cte_name(column1) AS (SELECT id AS column1 FROM table_a) + * $qb = $conn->createQueryBuilder() + * ->with('cte_name', 'SELECT id AS column1 FROM table_a', ['column1']); + * + * + * @param string $name The name of the CTE + * @param string[]|null $columns The optional columns list to select in the CTE. + * If not provided, the columns are inferred from the CTE. + * + * @return $this This QueryBuilder instance. + * + * @throws QueryException Setting an empty array as columns is not allowed. + */ + public function with(string $name, string|QueryBuilder $part, ?array $columns = null): self + { + $this->commonTableExpressions[] = new CommonTableExpression($name, $part, $columns); + + $this->sql = null; + + return $this; + } + /** * Specifies an item that is to be returned in the query result. * Replaces any previously specified selections, if any. @@ -1266,7 +1306,15 @@ private function getSQLForSelect(): string throw new QueryException('No SELECT expressions given. Please use select() or addSelect().'); } - return $this->connection->getDatabasePlatform() + $databasePlatform = $this->connection->getDatabasePlatform(); + $selectParts = []; + if (count($this->commonTableExpressions) > 0) { + $selectParts[] = $databasePlatform + ->createWithSQLBuilder() + ->buildSQL(...$this->commonTableExpressions); + } + + $selectParts[] = $databasePlatform ->createSelectSQLBuilder() ->buildSQL( new SelectQuery( @@ -1281,6 +1329,8 @@ private function getSQLForSelect(): string $this->forUpdate, ), ); + + return implode(' ', $selectParts); } /** diff --git a/src/SQL/Builder/WithSQLBuilder.php b/src/SQL/Builder/WithSQLBuilder.php new file mode 100644 index 00000000000..2813dfb6730 --- /dev/null +++ b/src/SQL/Builder/WithSQLBuilder.php @@ -0,0 +1,31 @@ +name]; + if ($part->columns !== null && count($part->columns) > 0) { + $ctePart[] = ' (' . implode(', ', $part->columns) . ')'; + } + + $ctePart[] = ' AS (' . $part->query . ')'; + $cteParts[] = implode('', $ctePart); + } + + return 'WITH ' . implode(', ', $cteParts); + } +} diff --git a/tests/Functional/Query/QueryBuilderTest.php b/tests/Functional/Query/QueryBuilderTest.php index b54a9376804..ce997b77e4c 100644 --- a/tests/Functional/Query/QueryBuilderTest.php +++ b/tests/Functional/Query/QueryBuilderTest.php @@ -4,14 +4,17 @@ namespace Doctrine\DBAL\Tests\Functional\Query; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Exception; use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Platforms\DB2Platform; +use Doctrine\DBAL\Platforms\Exception\NotSupported; use Doctrine\DBAL\Platforms\MariaDB1060Platform; use Doctrine\DBAL\Platforms\MariaDBPlatform; use Doctrine\DBAL\Platforms\MySQL80Platform; use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\OraclePlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode; use Doctrine\DBAL\Query\UnionType; @@ -332,6 +335,206 @@ public function testUnionAndAddUnionWorksWithQueryBuilderPartsAndReturnsExpected self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative()); } + public function testSelectWithCTENamedParameter(): void + { + if (! $this->platformSupportsCTEs()) { + self::markTestSkipped('The database platform does not support CTE.'); + } + + if (! $this->platformSupportsCTEColumnsDefinition()) { + self::markTestSkipped('The database platform does not support columns definition for CTE.'); + } + + $expectedRows = $this->prepareExpectedRows([['virtual_id' => 1]]); + $qb = $this->connection->createQueryBuilder(); + + $cteQueryBuilder = $this->connection->createQueryBuilder(); + $cteQueryBuilder->select('id AS virtual_id') + ->from('for_update') + ->where('id = :id'); + + $qb->with('cte_a', $cteQueryBuilder, ['virtual_id']) + ->select('virtual_id') + ->from('cte_a') + ->setParameter('id', 1); + + self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative()); + } + + public function testSelectWithCTEAndParametersBindForEachQuery(): void + { + if (! $this->platformSupportsCTEs()) { + self::markTestSkipped('The database platform does not support CTE.'); + } + + if (! $this->platformSupportsCTEColumnsDefinition()) { + self::markTestSkipped('The database platform does not support columns definition for CTE.'); + } + + $expectedRows = $this->prepareExpectedRows([['virtual_id' => 1]]); + $qb = $this->connection->createQueryBuilder(); + + $cteQueryBuilder1 = $this->connection->createQueryBuilder(); + $cteQueryBuilder1->select('id AS virtual_id') + ->from('for_update') + ->where($qb->expr()->eq('id', '?')); + $qb->setParameter(0, 1, ParameterType::INTEGER); + + $cteQueryBuilder2 = $this->connection->createQueryBuilder(); + $cteQueryBuilder2->select('id AS virtual_id') + ->from('for_update') + ->where($qb->expr()->in('id', ':id')); + $qb->setParameter('id', [1, 2], ArrayParameterType::INTEGER); + + $qb->with('cte_a', $cteQueryBuilder1, ['virtual_id']) + ->with('cte_b', $cteQueryBuilder2, ['virtual_id']) + ->with('cte_c', 'SELECT 1 AS virtual_id') + ->select('c.virtual_id') + ->from('cte_a', 'a') + ->join('a', 'cte_b', 'b', 'a.virtual_id = b.virtual_id') + ->join('b', 'cte_c', 'c', 'b.virtual_id = c.virtual_id') + ->where($qb->expr()->eq('a.virtual_id', '?')) + ->setParameter(1, 1, ParameterType::INTEGER); + + self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative()); + } + + public function testSelectWithCTEAndCreateNamedParametersForEachQuery(): void + { + if (! $this->platformSupportsCTEs()) { + self::markTestSkipped('The database platform does not support CTE.'); + } + + if (! $this->platformSupportsCTEColumnsDefinition()) { + self::markTestSkipped('The database platform does not support columns definition for CTE.'); + } + + $expectedRows = $this->prepareExpectedRows([['virtual_id' => 1]]); + $qb = $this->connection->createQueryBuilder(); + + $cteQueryBuilder1 = $this->connection->createQueryBuilder(); + $cteQueryBuilder1->select('id AS virtual_id') + ->from('for_update') + ->where($qb->expr()->eq( + 'id', + $qb->createNamedParameter(1, ParameterType::INTEGER, ':id1'), + )); + + $cteQueryBuilder2 = $this->connection->createQueryBuilder(); + $cteQueryBuilder2->select('id AS virtual_id') + ->from('for_update') + ->where($qb->expr()->in( + 'id', + $qb->createNamedParameter([1, 2], ArrayParameterType::INTEGER, ':id2'), + )); + + $qb->with('cte_a', $cteQueryBuilder1, ['virtual_id']) + ->with('cte_b', $cteQueryBuilder2, ['virtual_id']) + ->with('cte_c', 'SELECT 1 AS virtual_id') + ->select('c.virtual_id') + ->from('cte_a', 'a') + ->join('a', 'cte_b', 'b', 'a.virtual_id = b.virtual_id') + ->join('b', 'cte_c', 'c', 'b.virtual_id = c.virtual_id') + ->where($qb->expr()->eq('a.virtual_id', '?')) + ->setParameter(0, 1, ParameterType::INTEGER); + + self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative()); + } + + public function testSelectWithCTEAndCreatePositionalParametersForEachQuery(): void + { + if (! $this->platformSupportsCTEs()) { + self::markTestSkipped('The database platform does not support CTE.'); + } + + if (! $this->platformSupportsCTEColumnsDefinition()) { + self::markTestSkipped('The database platform does not support columns definition for CTE.'); + } + + $expectedRows = $this->prepareExpectedRows([['virtual_id' => 1]]); + $qb = $this->connection->createQueryBuilder(); + + $cteQueryBuilder1 = $this->connection->createQueryBuilder(); + $cteQueryBuilder1->select('id AS virtual_id') + ->from('for_update') + ->where($qb->expr()->eq( + 'id', + $qb->createPositionalParameter(1, ParameterType::INTEGER), + )); + + $cteQueryBuilder2 = $this->connection->createQueryBuilder(); + $cteQueryBuilder2->select('id AS virtual_id') + ->from('for_update') + ->where($qb->expr()->in( + 'id', + $qb->createPositionalParameter([1, 2], ArrayParameterType::INTEGER), + )); + + $qb->with('cte_a', $cteQueryBuilder1, ['virtual_id']) + ->with('cte_b', $cteQueryBuilder2, ['virtual_id']) + ->with('cte_c', 'SELECT 1 AS virtual_id') + ->select('c.virtual_id') + ->from('cte_a', 'a') + ->join('a', 'cte_b', 'b', 'a.virtual_id = b.virtual_id') + ->join('b', 'cte_c', 'c', 'b.virtual_id = c.virtual_id') + ->where($qb->expr()->eq('a.virtual_id', '?')) + ->setParameter(2, 1, ParameterType::INTEGER); + + self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative()); + } + + public function testSelectWithCTEUnion(): void + { + if (! $this->platformSupportsCTEs()) { + self::markTestSkipped('The database platform does not support CTE.'); + } + + $expectedRows = $this->prepareExpectedRows([['id' => 2], ['id' => 1]]); + $qb = $this->connection->createQueryBuilder(); + + $subQueryBuilder1 = $this->connection->createQueryBuilder(); + $subQueryBuilder1->select('id') + ->from('for_update') + ->where($qb->expr()->eq('id', '?')); + + $subQueryBuilder2 = $this->connection->createQueryBuilder(); + $subQueryBuilder2->select('id') + ->from('for_update') + ->where($qb->expr()->eq('id', '?')); + + $subQueryBuilder3 = $this->connection->createQueryBuilder(); + $subQueryBuilder3->union($subQueryBuilder1) + ->addUnion($subQueryBuilder2); + + $qb->with('cte_a', $subQueryBuilder3) + ->select('id') + ->from('cte_a') + ->orderBy('id', 'DESC') + ->setParameters([1, 2], [ParameterType::INTEGER, ParameterType::INTEGER]); + + self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative()); + } + + public function testPlatformDoesNotSupportCTE(): void + { + if ($this->platformSupportsCTEs()) { + self::markTestSkipped('The database platform does support CTE.'); + } + + $qb = $this->connection->createQueryBuilder(); + + $cteQueryBuilder = $this->connection->createQueryBuilder(); + $cteQueryBuilder->select('id') + ->from('for_update'); + + $qb->with('cte_a', $cteQueryBuilder) + ->select('id') + ->from('cte_a'); + + self::expectException(NotSupported::class); + $qb->executeQuery(); + } + /** * @param array> $rows * @@ -380,4 +583,22 @@ private function platformSupportsSkipLocked(): bool return ! $platform instanceof SQLitePlatform; } + + private function platformSupportsCTEs(): bool + { + $platform = $this->connection->getDatabasePlatform(); + + return ! $platform instanceof MySQLPlatform || $platform instanceof MySQL80Platform; + } + + private function platformSupportsCTEColumnsDefinition(): bool + { + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof DB2Platform || $platform instanceof OraclePlatform) { + return false; + } + + return ! $platform instanceof MySQLPlatform || $platform instanceof MySQL80Platform; + } } diff --git a/tests/Query/QueryBuilderTest.php b/tests/Query/QueryBuilderTest.php index 5cfa4cb6b01..be273638661 100644 --- a/tests/Query/QueryBuilderTest.php +++ b/tests/Query/QueryBuilderTest.php @@ -16,6 +16,7 @@ use Doctrine\DBAL\Result; use Doctrine\DBAL\SQL\Builder\DefaultSelectSQLBuilder; use Doctrine\DBAL\SQL\Builder\DefaultUnionSQLBuilder; +use Doctrine\DBAL\SQL\Builder\WithSQLBuilder; use Doctrine\DBAL\Types\Types; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -48,6 +49,8 @@ protected function setUp(): void ->willReturn(new DefaultSelectSQLBuilder($platform, null, null)); $platform->method('createUnionSQLBuilder') ->willReturn(new DefaultUnionSQLBuilder($platform)); + $platform->method('createWithSQLBuilder') + ->willReturn(new WithSQLBuilder()); $this->conn->method('getDatabasePlatform') ->willReturn($platform); @@ -850,6 +853,43 @@ public function testSelectAllWithoutTableAlias(): void self::assertEquals('SELECT * FROM users', (string) $qb); } + public function testSelectWithCTE(): void + { + $cteQueryBuilder1 = new QueryBuilder($this->conn); + $cteQueryBuilder1->select('ta.id', 'ta.name', 'ta.table_b_id') + ->from('table_a', 'ta') + ->where('ta.name LIKE :name'); + + $cteQueryBuilder2 = new QueryBuilder($this->conn); + $cteQueryBuilder2->select('ca.id AS virtual_id, ca.name AS virtual_name') + ->from('cte_a', 'ca') + ->join('ca', 'table_b', 'tb', 'ca.table_b_id = tb.id'); + + $qb = new QueryBuilder($this->conn); + $qb->with('cte_a', $cteQueryBuilder1) + ->with('cte_b', $cteQueryBuilder2, ['virtual_id', 'virtual_name']) + ->select('cb.*') + ->from('cte_b', 'cb'); + + self::assertEquals( + 'WITH cte_a AS (SELECT ta.id, ta.name, ta.table_b_id FROM table_a ta WHERE ta.name LIKE :name)' + . ', cte_b (virtual_id, virtual_name) AS ' + . '(SELECT ca.id AS virtual_id, ca.name AS virtual_name ' + . 'FROM cte_a ca INNER JOIN table_b tb ON ca.table_b_id = tb.id) ' + . 'SELECT cb.* FROM cte_b cb', + (string) $qb, + ); + } + + public function testSelectWithCTEAndEmptyColumns(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Columns defined in CTE "cte_a" should not be an empty array.'); + + $qb = new QueryBuilder($this->conn); + $qb->with('cte_a', 'SELECT 1 as id', []); + } + public function testGetParameterType(): void { $qb = new QueryBuilder($this->conn);