diff --git a/application/Espo/ORM/Query/SelectBuilder.php b/application/Espo/ORM/Query/SelectBuilder.php index ed3b89a584..9e558c7562 100644 --- a/application/Espo/ORM/Query/SelectBuilder.php +++ b/application/Espo/ORM/Query/SelectBuilder.php @@ -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 $itemList * @return array diff --git a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php index be9a959396..701cd6343a 100644 --- a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php +++ b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php @@ -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 $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)"; + } } diff --git a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php index 585c703ac2..6d6c29fb3b 100644 --- a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php +++ b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php @@ -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); + } } diff --git a/tests/unit/testData/DB/Entities.php b/tests/unit/testData/DB/Entities.php index ec18b742f1..db7de0bd57 100644 --- a/tests/unit/testData/DB/Entities.php +++ b/tests/unit/testData/DB/Entities.php @@ -66,6 +66,10 @@ class Article extends TestEntity } +class Category extends TestEntity +{ +} + class Job extends TestEntity { public function getFromContainerOriginal(string $attribute) diff --git a/tests/unit/testData/DB/ormMetadata.php b/tests/unit/testData/DB/ormMetadata.php index 112f74bab5..89363c6e30 100644 --- a/tests/unit/testData/DB/ormMetadata.php +++ b/tests/unit/testData/DB/ormMetadata.php @@ -601,4 +601,15 @@ return [ ], ], ], + + 'Category' => [ + 'attributes' => [ + 'id' => [ + 'type' => Entity::ID, + ], + 'parentId' => [ + 'type' => Entity::FOREIGN_ID, + ], + ], + ], ];