Skip to content

Commit 2936e39

Browse files
committed
Add CTE support to select in QueryBuilder
1 parent 640a5ce commit 2936e39

File tree

2 files changed

+81
-1
lines changed

2 files changed

+81
-1
lines changed

src/Query/QueryBuilder.php

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use function is_object;
2727
use function key;
2828
use function method_exists;
29+
use function sprintf;
2930
use function strtoupper;
3031
use function substr;
3132
use function ucfirst;
@@ -82,6 +83,7 @@ class QueryBuilder
8283
'orderBy' => [],
8384
'values' => [],
8485
'for_update' => null,
86+
'with_cte' => [],
8587
];
8688

8789
/**
@@ -425,7 +427,7 @@ public function getSQL()
425427
break;
426428

427429
case self::SELECT:
428-
$sql = $this->getSQLForSelect();
430+
$sql = $this->getCommonTableExpressions() . $this->getSQLForSelect();
429431
break;
430432
}
431433

@@ -1491,6 +1493,29 @@ public function resetOrderBy(): self
14911493
return $this;
14921494
}
14931495

1496+
/**
1497+
* Adds a CTE to a select query.
1498+
*
1499+
* @param string $name The name of the CTE.
1500+
* @param self $queryBuilder The query builder to use within the CTE.
1501+
*
1502+
* @return $this This QueryBuilder instance.
1503+
*/
1504+
public function with(string $name, self $queryBuilder): self
1505+
{
1506+
if ($queryBuilder->hasCommonTableExpressions()) {
1507+
throw new QueryException('CTEs cannot be nested.');
1508+
}
1509+
$this->sqlParts['with_cte'][$name] = $queryBuilder;
1510+
1511+
return $this;
1512+
}
1513+
1514+
private function hasCommonTableExpressions(): bool
1515+
{
1516+
return 0 < count($this->sqlParts['with_cte']);
1517+
}
1518+
14941519
/** @throws Exception */
14951520
private function getSQLForSelect(): string
14961521
{
@@ -1511,6 +1536,21 @@ private function getSQLForSelect(): string
15111536
);
15121537
}
15131538

1539+
private function getCommonTableExpressions(): string
1540+
{
1541+
if (!$this->hasCommonTableExpressions()) {
1542+
return '';
1543+
}
1544+
/** @var array<string,self> $cteParts */
1545+
$cteParts = $this->sqlParts['with_cte'];
1546+
$cte = [];
1547+
foreach ($cteParts as $name => $queryBuilder) {
1548+
$cte[] = sprintf('%s AS (%s)', $name, $queryBuilder->getSQL());
1549+
}
1550+
1551+
return 'WITH ' . implode(', ', $cte) . ' ';
1552+
}
1553+
15141554
/**
15151555
* @return string[]
15161556
*

tests/Query/QueryBuilderTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,46 @@ public function testSelectAllWithoutTableAlias(): void
10301030
self::assertEquals('SELECT * FROM users', (string) $qb);
10311031
}
10321032

1033+
public function testSelectWithCTE(): void
1034+
{
1035+
$qbWith = new QueryBuilder($this->conn);
1036+
$qbWith->select('id', 'name')
1037+
->from('users')
1038+
->where('name LIKE :name');
1039+
1040+
$qb = new QueryBuilder($this->conn);
1041+
$qb->with('filtered_users', $qbWith)
1042+
->select('*')
1043+
->from('filtered_users', 'fu');
1044+
1045+
self::assertEquals(
1046+
'WITH filtered_users AS (SELECT id, name FROM users WHERE name LIKE :name)'
1047+
. ' SELECT * FROM filtered_users fu',
1048+
$qb->getSQL(),
1049+
);
1050+
}
1051+
1052+
public function testSelectNestedCTE(): void
1053+
{
1054+
$qbNested = new QueryBuilder($this->conn);
1055+
$qbNested->select('*')
1056+
->from('country');
1057+
1058+
$qbWith = new QueryBuilder($this->conn);
1059+
$qbWith->select('id', 'name')
1060+
->with('countries', $qbNested)
1061+
->from('users')
1062+
->where('name LIKE :name');
1063+
1064+
self::expectException(QueryException::class);
1065+
self::expectExceptionMessage('CTEs cannot be nested.');
1066+
1067+
$qb = new QueryBuilder($this->conn);
1068+
$qb->with('filtered_users', $qbWith)
1069+
->select('*')
1070+
->from('filtered_users', 'fu');
1071+
}
1072+
10331073
public function testGetParameterType(): void
10341074
{
10351075
$qb = new QueryBuilder($this->conn);

0 commit comments

Comments
 (0)