ORM: with recursive

This commit is contained in:
Yurii
2026-02-03 13:25:18 +02:00
parent 9d7d9ac29c
commit bd9bacb210
5 changed files with 167 additions and 14 deletions
@@ -289,6 +289,22 @@ class SelectBuilder implements Builder
return $this;
}
/**
* Add an item to a with recursive statement.
*
* @param Union $query A query.
* @param string $name A CTE name. In UpperCamelCase.
* @since 9.3.0
*/
public function withRecursive(Union $query, string $name): self
{
$this->params['withRecursive'] ??= [];
$this->params['withRecursive'][] = [$query, $name];
return $this;
}
/**
* @param array<Expression|Selection|mixed[]> $itemList
* @return array<array{0: string, 1?: string}|string>
@@ -97,6 +97,7 @@ abstract class BaseQueryComposer implements QueryComposer
'fromQuery',
'forUpdate',
'forShare',
'withRecursive',
];
/** @var string[] */
@@ -646,6 +647,7 @@ abstract class BaseQueryComposer implements QueryComposer
$tailPart = $this->getSelectTailPart($params);
$joinsPart = $this->getJoinsPart($entity, $params, true);
$groupByPart = $this->getGroupByPart($entity, $params);
$withRecursivePart = $this->getWithRecursivePart($params);
// @todo remove 'customWhere' support
if (!empty($params['customWhere'])) {
@@ -683,19 +685,20 @@ abstract class BaseQueryComposer implements QueryComposer
/** @var string $fromAlias */
return $this->composeSelectQuery(
$fromPart,
$selectPart,
$fromAlias,
$joinsPart,
$wherePart,
$orderPart,
$params['offset'],
$params['limit'],
$params['distinct'],
$groupByPart,
$havingPart,
$indexKeyList,
$tailPart
from: $fromPart,
select: $selectPart,
alias: $fromAlias,
joins: $joinsPart,
where: $wherePart,
order: $orderPart,
offset: $params['offset'],
limit: $params['limit'],
distinct: $params['distinct'],
groupBy: $groupByPart,
having: $havingPart,
indexKeyList: $indexKeyList,
tailPart: $tailPart,
withRecursivePart: $withRecursivePart,
);
}
@@ -3505,11 +3508,16 @@ abstract class BaseQueryComposer implements QueryComposer
?string $groupBy = null,
?string $having = null,
?array $indexKeyList = null,
?string $tailPart = null
?string $tailPart = null,
?string $withRecursivePart = null,
): string {
$sql = "SELECT";
if ($withRecursivePart !== null) {
$sql = "$withRecursivePart " . $sql;
}
if (!empty($distinct) && empty($groupBy)) {
$sql .= " DISTINCT";
}
@@ -3796,4 +3804,47 @@ abstract class BaseQueryComposer implements QueryComposer
* Add a LIMIT part to an SQL query.
*/
abstract protected function limit(string $sql, ?int $offset = null, ?int $limit = null): string;
/**
* @param array<string, mixed> $params
*/
private function getWithRecursivePart(array $params): ?string
{
$items = $params['withRecursive'] ?? null;
if (!is_array($items)) {
return null;
}
$bodyParts = [];
foreach ($items as $item) {
if (!is_array($item) || count($item) < 2) {
continue;
}
$query = $item[0];
$name = $item[1];
if (!$query instanceof Union || !is_string($name)) {
continue;
}
$bodyParts[] = $this->getWithRecursivePartItem($query, $name);
}
if ($bodyParts === []) {
return null;
}
return "WITH RECURSIVE " . implode(', ', $bodyParts);
}
private function getWithRecursivePartItem(Union $query, string $name): string
{
$unionPart = $this->composeUnion($query);
$namePart = $this->quoteIdentifier($this->toDb($name));
return "$namePart AS ($unionPart)";
}
}
@@ -29,10 +29,12 @@
namespace tests\unit\Espo\ORM;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Join;
use Espo\ORM\Query\Part\Where\Comparison;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\SelectBuilder;
use Espo\ORM\Query\UnionBuilder;
use Espo\ORM\QueryComposer\Part\FunctionConverterFactory;
use Espo\ORM\QueryComposer\Part\FunctionConverter;
@@ -3629,4 +3631,73 @@ class MysqlQueryComposerTest extends TestCase
$this->assertEquals($expectedSql, $sql);
}
public function testWithRecursive1(): void
{
$query = (new SelectBuilder())
->select('id')
->from('Test', 'test')
->withRecursive(
UnionBuilder::create()
->all()
->query(
SelectBuilder::create()
->select([
'order',
'id',
'parentId'
])
->from('Category')
->where(['parentId' => null])
->build()
)
->query(
SelectBuilder::create()
->select([
'order',
'id',
'parentId',
])
->from('Category')
->join(
Join::createWithTableTarget('CommonTable', 'comTab')
->withConditions(
Cond::equal(
Expression::column('comTab.id'),
Expression::column('parentId'),
)
)
)
->where(['parentId' => null])
->build()
)
->build(),
'CommonTable'
)
->join(
Join::createWithTableTarget('CommonTable', 'comTab')
->withConditions(
Cond::equal(
Expression::column('comTab.id'),
Expression::column('categoryId'),
)
)
)
->withDeleted()
->build();
$sql = $this->query->composeSelect($query);
$expected =
"WITH RECURSIVE `common_table` AS (" .
"(SELECT category.order AS `order`, category.id AS `id`, category.parent_id AS `parentId` FROM `category` " .
"WHERE category.parent_id IS NULL) UNION ALL " .
"(SELECT category.order AS `order`, category.id AS `id`, category.parent_id AS `parentId` FROM `category` " .
"JOIN `common_table` AS `comTab` ON comTab.id = category.parent_id " .
"WHERE category.parent_id IS NULL)) SELECT test.id AS `id` FROM `test` AS `test` " .
"JOIN `common_table` AS `comTab` ON comTab.id = test.category_id";
$this->assertEquals($expected, $sql);
}
}
+4
View File
@@ -66,6 +66,10 @@ class Article extends TestEntity
}
class Category extends TestEntity
{
}
class Job extends TestEntity
{
public function getFromContainerOriginal(string $attribute)
+11
View File
@@ -601,4 +601,15 @@ return [
],
],
],
'Category' => [
'attributes' => [
'id' => [
'type' => Entity::ID,
],
'parentId' => [
'type' => Entity::FOREIGN_ID,
],
],
],
];