Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CTE support to select in QueryBuilder #6621

Open
wants to merge 6 commits into
base: 4.3.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/en/reference/query-builder.rst
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,26 @@ or QueryBuilder instances to one of the following methods:
->orderBy('field', 'DESC')
->setMaxResults(100);

WITH-Clause
nio-dtp marked this conversation as resolved.
Show resolved Hide resolved
~~~~~~~~~~~

To define Common Table Expressions (CTEs) that can be used in select query.

* ``with(string $name, string|QueryBuilder $queryBuilder, array $columns = [])``

.. code-block:: php

<?php

$queryBuilder
->with('cte_a', 'SELECT id FROM table_a')
->with('cte_b', 'SELECT id FROM table_b')
->select('id')
->from('cte_b', 'b')
->join('b', 'cte_a', 'a', 'a.id = b.id');

Multiple CTEs can be defined by calling the with method multiple times.

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

Expand Down
6 changes: 6 additions & 0 deletions src/Platforms/AbstractPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -802,6 +803,11 @@ public function createUnionSQLBuilder(): UnionSQLBuilder
return new DefaultUnionSQLBuilder($this);
}

public function createWithSQLBuilder(): WithSQLBuilder
{
return new WithSQLBuilder();
}

/**
* @internal
*
Expand Down
6 changes: 6 additions & 0 deletions src/Platforms/MySQL80Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -32,4 +33,9 @@
{
return AbstractPlatform::createSelectSQLBuilder();
}

public function createWithSQLBuilder(): WithSQLBuilder

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 AbstractPlatform::createWithSQLBuilder();
}

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

View check run for this annotation

Codecov / codecov/patch

src/Platforms/MySQL80Platform.php#L39-L40

Added lines #L39 - L40 were not covered by tests
}
7 changes: 7 additions & 0 deletions src/Platforms/MySQLPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,6 +37,11 @@
return parent::getDefaultValueDeclarationSQL($column);
}

public function createWithSQLBuilder(): WithSQLBuilder
{
throw NotSupported::new(__METHOD__);

Check warning on line 42 in src/Platforms/MySQLPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/MySQLPlatform.php#L42

Added line #L42 was not covered by tests
}

/**
* {@inheritDoc}
*/
Expand Down
87 changes: 84 additions & 3 deletions src/Query/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@
*/
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 @@ -302,14 +309,51 @@
*/
public function executeQuery(): Result
{
[$params, $types] = $this->boundParameters();

return $this->connection->executeQuery(
$this->getSQL(),
$this->params,
$this->types,
$params,
$types,
$this->resultCacheProfile,
);
}

/**
* Retrieve parameters and types bound to all queries (optional CTEs and main query).
*
* @return array{
* list<mixed>|array<string, mixed>,
* WrapperParameterTypeArray,
* } The parameters and types bound to the CTE queries merged with those bound to the main query.
nio-dtp marked this conversation as resolved.
Show resolved Hide resolved
*/
private function boundParameters(): array
nio-dtp marked this conversation as resolved.
Show resolved Hide resolved
{
if (count($this->withParts) === 0) {
return [$this->params, $this->types];
}
morozov marked this conversation as resolved.
Show resolved Hide resolved

$cteParams = $cteParamsTypes = [];

foreach ($this->withParts as $withPart) {
if (! $withPart->query instanceof self) {
continue;

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

View check run for this annotation

Codecov / codecov/patch

src/Query/QueryBuilder.php#L340

Added line #L340 was not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code coverage complains about this check not beeing covered.

I'd say additional tests using plain sql strings as parts instead of QueryBuilder instances should be added anyway, and would cover this line here two (guessing).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a test with a sql string as cte query

}

$cteParams = array_merge($cteParams, $withPart->query->params);
nio-dtp marked this conversation as resolved.
Show resolved Hide resolved
$cteParamsTypes = array_merge($cteParamsTypes, $withPart->query->types);
nio-dtp marked this conversation as resolved.
Show resolved Hide resolved
}

if (count($cteParams) === 0) {
return [$this->params, $this->types];
}

return [
array_merge($cteParams, $this->params),
array_merge($cteParamsTypes, $this->types),
];

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

View check run for this annotation

Codecov / codecov/patch

src/Query/QueryBuilder.php#L351-L354

Added lines #L351 - L354 were not covered by tests
}

/**
* Executes an SQL statement and returns the number of affected rows.
*
Expand Down Expand Up @@ -557,6 +601,33 @@
return $this;
}

/**
* Add a Common Table Expression to be used for a select query.
*
* <code>
* // WITH cte_name AS (SELECT 1 AS column1)
* $qb = $conn->createQueryBuilder()
* ->with('cte_name', 'SELECT 1 AS column1');
*
* // WITH cte_name(column1) AS (SELECT 1 AS column1)
* $qb = $conn->createQueryBuilder()
* ->with('cte_name', 'SELECT 1 AS column1', ['column1']);
* </code>
*
* @param string $name The name of the CTE
* @param string[] $columns The optional columns list to select in the CTE.
*
* @return $this This QueryBuilder instance.
*/
public function with(string $name, string|QueryBuilder $part, array $columns = []): self
nio-dtp marked this conversation as resolved.
Show resolved Hide resolved
{
$this->withParts[] = new With($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.
Expand Down Expand Up @@ -1266,7 +1337,15 @@
throw new QueryException('No SELECT expressions given. Please use select() or addSelect().');
}

return $this->connection->getDatabasePlatform()
$databasePlatform = $this->connection->getDatabasePlatform();
$selectParts = [];
if (count($this->withParts) > 0) {
$selectParts[] = $databasePlatform
->createWithSQLBuilder()
->buildSQL(...$this->withParts);
}

$selectParts[] = $databasePlatform
->createSelectSQLBuilder()
->buildSQL(
new SelectQuery(
Expand All @@ -1281,6 +1360,8 @@
$this->forUpdate,
),
);

return implode(' ', $selectParts);
}

/**
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[] $columns */
public function __construct(
public readonly string $name,
public readonly string|QueryBuilder $query,
public readonly array $columns = [],
) {
}
}
31 changes: 31 additions & 0 deletions src/SQL/Builder/WithSQLBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\SQL\Builder;

use Doctrine\DBAL\Query\With;

use function array_merge;
use function count;
use function implode;

final class WithSQLBuilder
{
public function buildSQL(With $firstExpression, With ...$otherExpressions): string
{
$withParts = [];

foreach (array_merge([$firstExpression], $otherExpressions) as $part) {
$withPart = [$part->name];
if (count($part->columns) > 0) {
$withPart[] = '(' . implode(', ', $part->columns) . ')';
}

$withPart[] = ' AS (' . $part->query . ')';
$withParts[] = implode('', $withPart);
}

return 'WITH ' . implode(', ', $withParts);
}
}
Loading