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 rudimentary UNION support to the QueryBuilder #6369

Open
wants to merge 1 commit into
base: 4.1.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/en/reference/query-builder.rst
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,53 @@ user-input:
->setParameter(0, $userInputLastLogin)
;

UNION-Clause
~~~~~~~~~~~~

To combine multiple ``SELECT`` queries into one result-set you can pass SQL Part strings
or QueryBuilder instances to one of the following methods:

* ``union('SELECT 1 as field', $partQueryBuilder)``
* ``addUnion('SELECT 1 as field', $partQueryBuilder)``
* ``unionAll('SELECT 1 as field', $partQueryBuilder)``
* ``addUnionAll('SELECT 1 as field', $partQueryBuilder)``

.. code-block:: php

<?php

$queryBuilder
->union('SELECT 1 AS field', 'SELECT 2 AS field')
->addUnion('SELECT 3 AS field', 'SELECT 3 as field')
;

$queryBuilder
->unionAll('SELECT 1 AS field', 'SELECT 2 AS field')
->addUnionAll('SELECT 3 AS field', 'SELECT 3 as field')
;

$subQueryBuilder1
->select('id AS field')
->from('a_table');
$subQueryBuilder2
->select('id AS field')
->from('a_table');
$queryBuilder
->union($subQueryBuilder1)
->addUnion($subQueryBuilder2)
;

$subQueryBuilder1
->select('id AS field')
->from('a_table');
$subQueryBuilder2
->select('id AS field')
->from('a_table');
$queryBuilder
->unionAll($subQueryBuilder1)
->addUnionAll($subQueryBuilder2)
;

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

Expand Down
7 changes: 7 additions & 0 deletions src/Platforms/AbstractPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
use Doctrine\DBAL\Schema\TableDiff;
use Doctrine\DBAL\Schema\UniqueConstraint;
use Doctrine\DBAL\SQL\Builder\DefaultSelectSQLBuilder;
use Doctrine\DBAL\SQL\Builder\DefaultUnionSQLBuilder;
use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder;
use Doctrine\DBAL\SQL\Builder\UnionSQLBuilder;
use Doctrine\DBAL\SQL\Parser;
use Doctrine\DBAL\TransactionIsolationLevel;
use Doctrine\DBAL\Types;
Expand Down Expand Up @@ -770,6 +772,11 @@ public function createSelectSQLBuilder(): SelectSQLBuilder
return new DefaultSelectSQLBuilder($this, 'FOR UPDATE', 'SKIP LOCKED');
}

public function createUnionSQLBuilder(): UnionSQLBuilder
{
return new DefaultUnionSQLBuilder($this);
}

/**
* @internal
*
Expand Down
123 changes: 123 additions & 0 deletions src/Query/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ class QueryBuilder
*/
private array $values = [];

/**
* The QueryBuilder for the union parts.
*
* @var string[]|QueryBuilder[]
*/
private array $union = [];

/**
* The query cache profile used for caching results.
*/
Expand Down Expand Up @@ -336,6 +343,8 @@ public function getSQL(): string
QueryType::DELETE => $this->getSQLForDelete(),
QueryType::UPDATE => $this->getSQLForUpdate(),
QueryType::SELECT => $this->getSQLForSelect(),
QueryType::UNION_ALL,
QueryType::UNION_DISTINCT => $this->getSQLForUnion(),
};
}

Expand Down Expand Up @@ -501,6 +510,94 @@ public function forUpdate(ConflictResolutionMode $conflictResolutionMode = Confl
return $this;
}

/**
* Specifies union parts to be used to build a UNION query.
* Replaces any previously specified parts.
*
* <code>
* $qb = $conn->createQueryBuilder()
* ->union('SELECT 1 AS field1', 'SELECT 2 AS field1');
* </code>
*
* @return $this
*/
public function union(string|QueryBuilder ...$parts): self
{
$this->type = QueryType::UNION_DISTINCT;

$this->union = $parts;

$this->sql = null;

return $this;
}

/**
* Add parts to be used to build a UNION query.
*
* <code>
* $qb = $conn->createQueryBuilder()
* ->union('SELECT 1 AS field1')
* ->addUnion('SELECT 2 AS field1', 'SELECT 3 AS field1')
* </code>
*
* @return $this
*/
public function addUnion(string|QueryBuilder ...$parts): self
{
$this->type = QueryType::UNION_DISTINCT;

$this->union = array_merge($this->union, $parts);

$this->sql = null;

return $this;
}

/**
* Specifies UNION ALL parts to be used to build a UNION query.
* Replaces any previously specified parts.
*
* <code>
* $qb = $conn->createQueryBuilder()
* ->unionAll('SELECT 1 AS field1', 'SELECT 2 AS field1');
* </code>
*
* @return $this
*/
public function unionAll(string|QueryBuilder ...$parts): self
{
$this->type = QueryType::UNION_ALL;

$this->union = $parts;

$this->sql = null;

return $this;
}

/**
* Add parts to be used to build a UNION ALL query.
*
* <code>
* $qb = $conn->createQueryBuilder()
* ->unionAll('SELECT 1 AS field1')
* ->addUnionAll('SELECT 2 AS field1', 'SELECT 3 AS field1')
* </code>
*
* @return $this
*/
public function addUnionAll(string|QueryBuilder ...$parts): self
{
$this->type = QueryType::UNION_ALL;

$this->union = array_merge($this->union, $parts);

$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 @@ -1309,6 +1406,32 @@ private function getSQLForDelete(): string
return $query;
}

/**
* Converts this instance into a UNION string in SQL.
*/
private function getSQLForUnion(): string
{
$countUnions = count($this->union);
if ($countUnions < 2) {
$message = $countUnions === 0
? 'No UNION parts given. Please use union(), unionAll, addUnion or addUnionAll().'
: 'Insufficient UNION parts give, need at least 2. Please use addUnion() or addUnionAll() to add more.';

throw new QueryException($message);
}

return $this->connection->getDatabasePlatform()
->createUnionSQLBuilder()
->buildSQL(
new UnionQuery(
$this->type === QueryType::UNION_DISTINCT,
$this->union,
$this->orderBy,
new Limit($this->maxResults, $this->firstResult),
),
);
}

/**
* Gets a string representation of this QueryBuilder which corresponds to
* the final SQL query being constructed.
Expand Down
2 changes: 2 additions & 0 deletions src/Query/QueryType.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ enum QueryType
case DELETE;
case UPDATE;
case INSERT;
case UNION_ALL;
case UNION_DISTINCT;
}
44 changes: 44 additions & 0 deletions src/Query/UnionQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Query;

final class UnionQuery
{
/**
* @internal This class should be instantiated only by {@link QueryBuilder}.
*
* @param string[]|QueryBuilder[] $unionParts
* @param string[] $orderBy
*/
public function __construct(
private readonly bool $unionDistinct,
private readonly array $unionParts,
private readonly array $orderBy,
private readonly Limit $limit,
) {
}

public function isUnionDistinct(): bool
{
return $this->unionDistinct;
}

/** @return string[]|QueryBuilder[] */
public function getUnionParts(): array
{
return $this->unionParts;
}

/** @return string[] */
public function getOrderBy(): array
{
return $this->orderBy;
}

public function getLimit(): Limit
{
return $this->limit;
}
}
53 changes: 53 additions & 0 deletions src/SQL/Builder/DefaultUnionSQLBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\SQL\Builder;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Query\UnionQuery;

use function count;
use function implode;

final class DefaultUnionSQLBuilder implements UnionSQLBuilder
{
public function __construct(
private readonly AbstractPlatform $platform,
) {
}

public function buildSQL(UnionQuery $query): string
{
$parts = [];
$modifier = $query->isUnionDistinct() ? ' UNION ' : ' UNION ALL ';
$unionParts = $this->prepareUnionParts($query);
$parts[] = implode($modifier, $unionParts);

$orderBy = $query->getOrderBy();
if (count($orderBy) > 0) {
$parts[] = 'ORDER BY ' . implode(', ', $orderBy);
}

$sql = implode(' ', $parts);
$limit = $query->getLimit();

if ($limit->isDefined()) {
$sql = $this->platform->modifyLimitQuery($sql, $limit->getMaxResults(), $limit->getFirstResult());
}

return $sql;
}

/** @return string[] */
private function prepareUnionParts(UnionQuery $query): array
{
$return = [];
$unionParts = $query->getUnionParts();
foreach ($unionParts as $part) {
$return[] = (string) $part;
}

return $return;
}
}
14 changes: 14 additions & 0 deletions src/SQL/Builder/UnionSQLBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\SQL\Builder;

use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Query\UnionQuery;

interface UnionSQLBuilder
{
/** @throws Exception */
public function buildSQL(UnionQuery $query): string;
}