Skip to content

Commit

Permalink
Add CTE support to select in QueryBuilder
Browse files Browse the repository at this point in the history
  • Loading branch information
nio-dtp committed Nov 23, 2024
1 parent 052545f commit 79cf2fb
Show file tree
Hide file tree
Showing 12 changed files with 214 additions and 1 deletion.
10 changes: 10 additions & 0 deletions src/Platforms/AbstractPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -2034,6 +2034,16 @@ public function supportsColumnCollation(): bool
return false;
}

/**
* Does this platform support column common table expressions?
*
* @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy.
*/
public function supportsCTEs(): bool

Check warning on line 2042 in src/Platforms/AbstractPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/AbstractPlatform.php#L2042

Added line #L2042 was not covered by tests
{
return false;

Check warning on line 2044 in src/Platforms/AbstractPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/AbstractPlatform.php#L2044

Added line #L2044 was not covered by tests
}

/**
* Gets the format string, as accepted by the date() function, that describes
* the format of a stored datetime value of this platform.
Expand Down
6 changes: 6 additions & 0 deletions src/Platforms/DB2Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,12 @@ public function supportsSavepoints(): bool
return false;
}

/** @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy. */
public function supportsCTEs(): bool

Check warning on line 596 in src/Platforms/DB2Platform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/DB2Platform.php#L596

Added line #L596 was not covered by tests
{
return true;

Check warning on line 598 in src/Platforms/DB2Platform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/DB2Platform.php#L598

Added line #L598 was not covered by tests
}

/** @deprecated */
protected function createReservedKeywordsList(): KeywordList
{
Expand Down
6 changes: 6 additions & 0 deletions src/Platforms/MariaDBPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ public function getColumnDeclarationSQL(string $name, array $column): string
return parent::getColumnDeclarationSQL($name, $column);
}

/** @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy. */
public function supportsCTEs(): bool

Check warning on line 163 in src/Platforms/MariaDBPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/MariaDBPlatform.php#L163

Added line #L163 was not covered by tests
{
return true;

Check warning on line 165 in src/Platforms/MariaDBPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/MariaDBPlatform.php#L165

Added line #L165 was not covered by tests
}

/** @deprecated */
protected function createReservedKeywordsList(): KeywordList
{
Expand Down
6 changes: 6 additions & 0 deletions src/Platforms/MySQL80Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ public function createSelectSQLBuilder(): SelectSQLBuilder
{
return AbstractPlatform::createSelectSQLBuilder();
}

/** @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy. */
public function supportsCTEs(): bool

Check warning on line 37 in src/Platforms/MySQL80Platform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/MySQL80Platform.php#L37

Added line #L37 was not covered by tests
{
return true;

Check warning on line 39 in src/Platforms/MySQL80Platform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/MySQL80Platform.php#L39

Added line #L39 was not covered by tests
}
}
6 changes: 6 additions & 0 deletions src/Platforms/MySQL84Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,10 @@ protected function createReservedKeywordsList(): KeywordList

return new MySQL84Keywords();
}

/** @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy. */
public function supportsCTEs(): bool

Check warning on line 32 in src/Platforms/MySQL84Platform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/MySQL84Platform.php#L32

Added line #L32 was not covered by tests
{
return true;

Check warning on line 34 in src/Platforms/MySQL84Platform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/MySQL84Platform.php#L34

Added line #L34 was not covered by tests
}
}
6 changes: 6 additions & 0 deletions src/Platforms/OraclePlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,12 @@ public function supportsReleaseSavepoints(): bool
return false;
}

/** @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy. */
public function supportsCTEs(): bool

Check warning on line 743 in src/Platforms/OraclePlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/OraclePlatform.php#L743

Added line #L743 was not covered by tests
{
return true;

Check warning on line 745 in src/Platforms/OraclePlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/OraclePlatform.php#L745

Added line #L745 was not covered by tests
}

public function getTruncateTableSQL(string $tableName, bool $cascade = false): string
{
$tableIdentifier = new Identifier($tableName);
Expand Down
6 changes: 6 additions & 0 deletions src/Platforms/PostgreSQLPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ public function supportsCommentOnStatement(): bool
return true;
}

/** @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy. */
public function supportsCTEs(): bool

Check warning on line 146 in src/Platforms/PostgreSQLPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/PostgreSQLPlatform.php#L146

Added line #L146 was not covered by tests
{
return true;

Check warning on line 148 in src/Platforms/PostgreSQLPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/PostgreSQLPlatform.php#L148

Added line #L148 was not covered by tests
}

/** @internal The method should be only used from within the {@see AbstractSchemaManager} class hierarchy. */
public function getListDatabasesSQL(): string
{
Expand Down
6 changes: 6 additions & 0 deletions src/Platforms/SQLServerPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ public function supportsSequences(): bool
return true;
}

/** @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy. */
public function supportsCTEs(): bool
{
return true;
}

public function getAlterSequenceSQL(Sequence $sequence): string
{
return 'ALTER SEQUENCE ' . $sequence->getQuotedName($this) .
Expand Down
6 changes: 6 additions & 0 deletions src/Platforms/SQLitePlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,12 @@ public function supportsInlineColumnComments(): bool
return true;
}

/** @internal The method should be only used from within the {@see AbstractPlatform} class hierarchy. */
public function supportsCTEs(): bool

Check warning on line 417 in src/Platforms/SQLitePlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/SQLitePlatform.php#L417

Added line #L417 was not covered by tests
{
return true;

Check warning on line 419 in src/Platforms/SQLitePlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/SQLitePlatform.php#L419

Added line #L419 was not covered by tests
}

public function getTruncateTableSQL(string $tableName, bool $cascade = false): string
{
$tableIdentifier = new Identifier($tableName);
Expand Down
52 changes: 51 additions & 1 deletion src/Query/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use function count;
use function implode;
use function is_object;
use function sprintf;
use function substr;

/**
Expand Down Expand Up @@ -160,6 +161,13 @@ class QueryBuilder
*/
private array $unionParts = [];

/**
* The common table expression parts.
*
* @var array<string, self>
*/
private array $with = [];

/**
* The query cache profile used for caching results.
*/
Expand Down Expand Up @@ -342,7 +350,7 @@ public function getSQL(): string
QueryType::INSERT => $this->getSQLForInsert(),
QueryType::DELETE => $this->getSQLForDelete(),
QueryType::UPDATE => $this->getSQLForUpdate(),
QueryType::SELECT => $this->getSQLForSelect(),
QueryType::SELECT => $this->getSQLForCTEs() . $this->getSQLForSelect(),
QueryType::UNION => $this->getSQLForUnion(),
};
}
Expand Down Expand Up @@ -1259,6 +1267,34 @@ public function resetOrderBy(): self
return $this;
}

/**
* Adds a CTE to a select query.
*
* @param string $name The name of the CTE.
* @param self $queryBuilder The query builder to use within the CTE.
*
* @return $this This QueryBuilder instance.
*/
public function with(string $name, self $queryBuilder): self
{
if (! $this->connection->getDatabasePlatform()->supportsCTEs()) {
throw new QueryException('CTEs are not supported by this database platform.');

Check warning on line 1281 in src/Query/QueryBuilder.php

View check run for this annotation

Codecov / codecov/patch

src/Query/QueryBuilder.php#L1281

Added line #L1281 was not covered by tests
}

if ($queryBuilder->hasCTEs()) {
throw new QueryException('CTEs cannot be nested.');
}

$this->with[$name] = $queryBuilder;

return $this;
}

private function hasCTEs(): bool
{
return 0 < count($this->with);
}

/** @throws Exception */
private function getSQLForSelect(): string
{
Expand All @@ -1283,6 +1319,20 @@ private function getSQLForSelect(): string
);
}

private function getSQLForCTEs(): string
{
if (! $this->hasCTEs()) {
return '';
}

$cte = [];
foreach ($this->with as $name => $queryBuilder) {
$cte[] = sprintf('%s AS (%s)', $name, $queryBuilder->getSQL());
}

return 'WITH ' . implode(', ', $cte) . ' ';
}

/**
* @return array<string, string>
*
Expand Down
63 changes: 63 additions & 0 deletions tests/Functional/Query/QueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Doctrine\DBAL\Tests\Functional\Query;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\ParameterType;
Expand All @@ -14,6 +15,7 @@
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode;
use Doctrine\DBAL\Query\QueryException;
use Doctrine\DBAL\Query\UnionType;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Tests\FunctionalTestCase;
Expand Down Expand Up @@ -332,6 +334,67 @@ public function testUnionAndAddUnionWorksWithQueryBuilderPartsAndReturnsExpected
self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative());
}

public function testNotSupportedCTE(): void
{
if ($this->connection->getDatabasePlatform()->supportsCTEs()) {
self::markTestSkipped('The database platform does support CTE.');
}

$qb = $this->connection->createQueryBuilder();

$cteQueryBuilder1 = $this->connection->createQueryBuilder();
$cteQueryBuilder1->select('id')
->from('for_update');

self::expectException(QueryException::class);
self::expectExceptionMessage('CTEs are not supported by this database platform.');

$qb->with('filtered_for_update', $cteQueryBuilder1);
}

public function testWithNamedParameterCTE(): void
{
if (! $this->connection->getDatabasePlatform()->supportsCTEs()) {
self::markTestSkipped('The database platform does not support CTE.');
}

$expectedRows = $this->prepareExpectedRows([['id' => 1]]);
$qb = $this->connection->createQueryBuilder();

$cteQueryBuilder1 = $this->connection->createQueryBuilder();
$cteQueryBuilder1->select('id')
->from('for_update')
->where($qb->expr()->eq('id', $qb->createNamedParameter(1, ParameterType::INTEGER)));

$qb->with('filtered_for_update', $cteQueryBuilder1)
->select('id')
->from('filtered_for_update');

self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative());
}

public function testWithPositionalParameterCTE(): void
{
if (! $this->connection->getDatabasePlatform()->supportsCTEs()) {
self::markTestSkipped('The database platform does not support CTE.');
}

$expectedRows = $this->prepareExpectedRows([['id' => 1]]);
$qb = $this->connection->createQueryBuilder();

$cteQueryBuilder1 = $this->connection->createQueryBuilder();
$cteQueryBuilder1->select('id')
->from('for_update')
->where($qb->expr()->in('id', $qb->createPositionalParameter([1, 2], ArrayParameterType::INTEGER)));

$qb->with('filtered_for_update', $cteQueryBuilder1)
->select('id')
->from('filtered_for_update')
->where($qb->expr()->eq('id', $qb->createPositionalParameter(1, ParameterType::INTEGER)));

self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative());
}

/**
* @param array<array<string, int>> $rows
*
Expand Down
42 changes: 42 additions & 0 deletions tests/Query/QueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ protected function setUp(): void
->willReturn(new DefaultSelectSQLBuilder($platform, null, null));
$platform->method('createUnionSQLBuilder')
->willReturn(new DefaultUnionSQLBuilder($platform));
$platform->method('supportsCTEs')
->willReturn(true);

$this->conn->method('getDatabasePlatform')
->willReturn($platform);
Expand Down Expand Up @@ -850,6 +852,46 @@ public function testSelectAllWithoutTableAlias(): void
self::assertEquals('SELECT * FROM users', (string) $qb);
}

public function testSelectWithCTE(): void
{
$qbWith = new QueryBuilder($this->conn);
$qbWith->select('id', 'name')
->from('users')
->where('name LIKE :name');

$qb = new QueryBuilder($this->conn);
$qb->with('filtered_users', $qbWith)
->select('*')
->from('filtered_users', 'fu');

self::assertEquals(
'WITH filtered_users AS (SELECT id, name FROM users WHERE name LIKE :name)'
. ' SELECT * FROM filtered_users fu',
$qb->getSQL(),
);
}

public function testSelectNestedCTE(): void
{
$qbNested = new QueryBuilder($this->conn);
$qbNested->select('*')
->from('country');

$qbWith = new QueryBuilder($this->conn);
$qbWith->select('id', 'name')
->with('countries', $qbNested)
->from('users')
->where('name LIKE :name');

self::expectException(QueryException::class);
self::expectExceptionMessage('CTEs cannot be nested.');

$qb = new QueryBuilder($this->conn);
$qb->with('filtered_users', $qbWith)
->select('*')
->from('filtered_users', 'fu');
}

public function testGetParameterType(): void
{
$qb = new QueryBuilder($this->conn);
Expand Down

0 comments on commit 79cf2fb

Please sign in to comment.