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 27, 2024
1 parent 052545f commit e4dd312
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 1 deletion.
23 changes: 23 additions & 0 deletions docs/en/reference/query-builder.rst
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,29 @@ or QueryBuilder instances to one of the following methods:
->orderBy('field', 'DESC')
->setMaxResults(100);
WITH-Clause
~~~~~~~~~~~

The with method is used to define Common Table Expressions (CTEs).

* ``with(string $name, QueryBuilder $queryBuilder)``

.. code-block:: php
<?php
$cteQueryBuilder
->select('id', 'name')
->from('a_table')
->where('name = :q');
$queryBuilder
->with('filtered_by_name', $cteQueryBuilder)
->select('id', 'name')
->from('filtered_by_name');
Multiple CTEs can be defined by calling the with method multiple times.

Building Expressions
--------------------

Expand Down
65 changes: 64 additions & 1 deletion src/Query/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Doctrine\DBAL\Query\Expression\ExpressionBuilder;
use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\SQL\Builder\WithSQLBuilder;
use Doctrine\DBAL\Statement;
use Doctrine\DBAL\Types\Type;

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

/**
* The common table expression parts.
*
* @var With[]
*/
private array $withParts = [];

/**
* The query cache profile used for caching results.
*/
Expand Down Expand Up @@ -557,6 +565,57 @@ public function addUnion(string|QueryBuilder $part, UnionType $type = UnionType:
return $this;
}

/**
* Specifies a CTE to be used to build a With query.
* Replaces any previously specified parts.
*
* <code>
* $qb = $conn->createQueryBuilder()
* ->with('cte_a', 'SELECT 1 AS field1');
* </code>
*
* @param string[] $fields
*
* @return $this
*/
public function with(string $name, string|QueryBuilder $part, array $fields = []): self
{
$this->withParts = [new With($name, $part, $fields)];

$this->sql = null;

return $this;
}

/**
* Add a CTE to be used to build a With query.
* Replaces any previously specified parts.
*
* <code>
* $qb = $conn->createQueryBuilder()
* ->with('cte_a', 'SELECT 1 AS field_a')
* ->addWith('cte_b', 'SELECT 1 AS field_b');
* </code>
*
* @param string[] $fields
*
* @return $this
*
* @throws QueryException
*/
public function addWith(string $name, string|QueryBuilder $part, array $fields = []): self
{
if (count($this->withParts) === 0) {
throw new QueryException('No initial WITH part set, use with() to set one first.');

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

View check run for this annotation

Codecov / codecov/patch

src/Query/QueryBuilder.php#L609

Added line #L609 was not covered by tests
}

$this->withParts[] = new With($name, $part, $fields);

$this->sql = null;

return $this;
}

/**
* Specifies an item that is to be returned in the query result.
* Replaces any previously specified selections, if any.
Expand Down Expand Up @@ -1266,7 +1325,9 @@ private function getSQLForSelect(): string
throw new QueryException('No SELECT expressions given. Please use select() or addSelect().');
}

return $this->connection->getDatabasePlatform()
$withSQL = (new WithSQLBuilder())->buildSQL(new WithQuery($this->withParts));

$selectSQL = $this->connection->getDatabasePlatform()
->createSelectSQLBuilder()
->buildSQL(
new SelectQuery(
Expand All @@ -1281,6 +1342,8 @@ private function getSQLForSelect(): string
$this->forUpdate,
),
);

return $withSQL . $selectSQL;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/Query/With.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Query;

/** @internal */
final class With
{
/** @param string[] $fields */
public function __construct(
public readonly string $name,
public readonly string|QueryBuilder $query,
public readonly array $fields = [],
) {
}
}
24 changes: 24 additions & 0 deletions src/Query/WithQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Query;

final class WithQuery
{
/**
* @internal This class should be instantiated only by {@link QueryBuilder}.
*
* @param With[] $withParts
*/
public function __construct(
private readonly array $withParts,
) {
}

/** @return With[] */
public function withParts(): array
{
return $this->withParts;
}
}
41 changes: 41 additions & 0 deletions src/SQL/Builder/WithSQLBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\SQL\Builder;

use Doctrine\DBAL\Query\With;
use Doctrine\DBAL\Query\WithQuery;

use function array_map;
use function count;
use function implode;
use function sprintf;

final class WithSQLBuilder
{
public function buildSQL(WithQuery $query): string
{
if (count($query->withParts()) === 0) {
return '';
}

$parts = array_map(
static fn (With $part) => sprintf(
'%s%s AS (%s)',
$part->name,
self::fields($part->fields),
$part->query,
),
$query->withParts(),
);

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

/** @param string[] $fields */
private static function fields(array $fields): string
{
return count($fields) > 0 ? '(' . implode(', ', $fields) . ')' : '';
}
}
55 changes: 55 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 Down Expand Up @@ -332,6 +333,49 @@ public function testUnionAndAddUnionWorksWithQueryBuilderPartsAndReturnsExpected
self::assertSame($expectedRows, $qb->executeQuery()->fetchAllAssociative());
}

public function testWithNamedParameterCTE(): void
{
if (! $this->platformSupportsCTEs()) {
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->platformSupportsCTEs()) {
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 Expand Up @@ -380,4 +424,15 @@ private function platformSupportsSkipLocked(): bool

return ! $platform instanceof SQLitePlatform;
}

private function platformSupportsCTEs(): bool
{
$platform = $this->connection->getDatabasePlatform();

if (! $platform instanceof MySQLPlatform) {
return true;
}

return $this->connection->getServerVersion() >= '8.0.11';
}
}
28 changes: 28 additions & 0 deletions tests/Query/QueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,34 @@ public function testSelectAllWithoutTableAlias(): void
self::assertEquals('SELECT * FROM users', (string) $qb);
}

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

$qbAddWith = new QueryBuilder($this->conn);
$qbAddWith->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', $qbWith)
->addWith('cte_b', $qbAddWith, ['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 testGetParameterType(): void
{
$qb = new QueryBuilder($this->conn);
Expand Down

0 comments on commit e4dd312

Please sign in to comment.