orm: join refactor (#3408)

This commit is contained in:
Yurii Kuznietsov
2025-06-15 11:30:04 +03:00
committed by GitHub
parent 94b15e8e8e
commit f1b89fd6c4
16 changed files with 604 additions and 216 deletions
@@ -145,7 +145,7 @@ class Main implements AdditionalApplier
}
}
if ($queryBuilder->hasLeftJoinAlias('teamsAccess')) {
if ($queryBuilder->hasJoinAlias('teamsAccess')) {
return false;
}
@@ -36,7 +36,7 @@ class JoinHelper
{
public function joinEmailUser(QueryBuilder $queryBuilder, string $userId): void
{
if ($queryBuilder->hasLeftJoinAlias(Email::ALIAS_INBOX)) {
if ($queryBuilder->hasJoinAlias(Email::ALIAS_INBOX)) {
return;
}
@@ -114,8 +114,8 @@ class Applier
{
$queryAfter = $queryBuilder->build();
$joinCountBefore = count($queryBefore->getJoins()) + count($queryBefore->getLeftJoins());
$joinCountAfter = count($queryAfter->getJoins()) + count($queryAfter->getLeftJoins());
$joinCountBefore = count($queryBefore->getJoins());
$joinCountAfter = count($queryAfter->getJoins());
if ($joinCountBefore < $joinCountAfter) {
$queryBuilder->distinct();
+62 -9
View File
@@ -29,6 +29,7 @@
namespace Espo\ORM\Query\Part;
use Espo\ORM\Query\Part\Join\JoinType;
use Espo\ORM\Query\Select;
use LogicException;
use RuntimeException;
@@ -39,15 +40,16 @@ use RuntimeException;
class Join
{
/** A table join. */
public const TYPE_TABLE = 0;
public const MODE_TABLE = 0;
/** A relation join. */
public const TYPE_RELATION = 1;
public const MODE_RELATION = 1;
/** A sub-query join. */
public const TYPE_SUB_QUERY = 3;
public const MODE_SUB_QUERY = 3;
private ?WhereItem $conditions = null;
private bool $onlyMiddle = false;
private bool $isLateral = false;
private ?JoinType $type = null;
private function __construct(
private string|Select $target,
@@ -108,21 +110,21 @@ class Join
}
/**
* Get a join type.
* Get a join mode.
*
* @return self::TYPE_TABLE|self::TYPE_RELATION|self::TYPE_SUB_QUERY
* @return self::MODE_TABLE|self::MODE_RELATION|self::MODE_SUB_QUERY
*/
public function getType(): int
public function getMode(): int
{
if ($this->isSubQuery()) {
return self::TYPE_SUB_QUERY;
return self::MODE_SUB_QUERY;
}
if ($this->isRelation()) {
return self::TYPE_RELATION;
return self::MODE_RELATION;
}
return self::TYPE_TABLE;
return self::MODE_TABLE;
}
/**
@@ -143,6 +145,18 @@ class Join
return $this->isLateral;
}
/**
* Get a join type.
*
* @return JoinType|null
*
* @since 9.2.0
*/
public function getType(): ?JoinType
{
return $this->type;
}
/**
* Create.
*
@@ -244,4 +258,43 @@ class Join
return $obj;
}
/**
* With LEFT type.
*
* @since 9.2.0.
*/
public function withLeft(): self
{
$obj = clone $this;
$obj->type = JoinType::left;
return $obj;
}
/**
* With INNER type.
*
* @since 9.2.0.
*/
public function withInner(): self
{
$obj = clone $this;
$obj->type = JoinType::inner;
return $obj;
}
/**
* With a join type.
*
* @since 9.2.0.
*/
public function withType(JoinType $type): self
{
$obj = clone $this;
$obj->type = $type;
return $obj;
}
}
@@ -0,0 +1,46 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\ORM\Query\Part\Join;
/**
* @since 9.2.0
*/
enum JoinType: string
{
/**
* An INNER join.
*/
case inner = 'inner';
/**
* A LEFT join.
*/
case left = 'left';
}
@@ -189,7 +189,6 @@ trait SelectingBuilderTrait
/**
* @param 'leftJoins'|'joins' $type
* @todo Support USE INDEX in Join.
* $target can be an array for backward compatibility.
* @param Join|string|Select $target $target
* @param WhereItem|array<string|int, mixed>|null $conditions
*/
@@ -205,15 +204,22 @@ trait SelectingBuilderTrait
/** @var string|Join|array<int, mixed> $target */
$joinType = null;
if ($target instanceof Join) {
$alias = $alias ?? $target->getAlias();
$conditions = $conditions ?? $target->getConditions();
$onlyMiddle = $target->isOnlyMiddle();
$isLateral = $target->isLateral();
$joinType = $target->getType();
$target = $target->getTarget();
}
if ($type === 'leftJoins') {
$joinType = Join\JoinType::left;
}
if ($target instanceof Select && !$alias) {
throw new LogicException("Sub-query join can't be used w/o alias.");
}
@@ -226,13 +232,17 @@ trait SelectingBuilderTrait
$noLeftAlias = true;
}
if (empty($this->params[$type])) {
$this->params[$type] = [];
}
$this->params['joins'] ??= [];
// For bc.
// @todo Remove in v10.0.
if (is_array($target)) {
// @todo Log deprecation.
$joinList = $target;
$this->params[$type] ??= [];
foreach ($joinList as $item) {
$this->params[$type][] = $item;
}
@@ -244,7 +254,7 @@ trait SelectingBuilderTrait
is_null($alias) &&
is_null($conditions) &&
is_string($target) &&
$this->hasJoinAliasInternal($type, $target)
$this->hasJoinAliasInternal('joins', $target)
) {
return $this;
}
@@ -263,25 +273,9 @@ trait SelectingBuilderTrait
$params['isLateral'] = true;
}
if ($params !== []) {
$this->params[$type][] = [$target, $alias, $conditions, $params];
$params['type'] = $joinType;
return $this;
}
if (is_null($alias) && is_null($conditions)) {
$this->params[$type][] = $target;
return $this;
}
if (is_null($conditions)) {
$this->params[$type][] = [$target, $alias];
return $this;
}
$this->params[$type][] = [$target, $alias, $conditions];
$this->params['joins'][] = [$target, $alias, $conditions, $params];
return $this;
}
@@ -299,6 +293,14 @@ trait SelectingBuilderTrait
if ($item[1] === $alias) {
return true;
}
if (
$item[1] === null &&
$item[0] === $alias &&
lcfirst($item[0]) === $alias
) {
return true;
}
}
}
@@ -306,11 +308,11 @@ trait SelectingBuilderTrait
}
/**
* Whether an alias is in left joins.
* @deprecated As of v9.2.0. Use `hasJoinAlias`.
*/
public function hasLeftJoinAlias(string $alias): bool
{
return $this->hasJoinAliasInternal('leftJoins', $alias);
return $this->hasJoinAlias($alias);
}
/**
@@ -318,7 +320,9 @@ trait SelectingBuilderTrait
*/
public function hasJoinAlias(string $alias): bool
{
return $this->hasJoinAliasInternal('joins', $alias);
return $this->hasJoinAliasInternal('joins', $alias) ||
// For bc.
$this->hasJoinAliasInternal('leftJoins', $alias);
}
/**
+12 -8
View File
@@ -97,21 +97,25 @@ trait SelectingTrait
}
$conditions = isset($item[2]) ?
WhereClause::fromRaw($item[2]) :
null;
WhereClause::fromRaw($item[2]) : null;
$params = $item[3] ?? [];
$type = $params['type'] ?? null;
$type ??= Join\JoinType::inner;
return Join::create($item[0])
->withAlias($item[1] ?? null)
->withConditions($conditions);
->withConditions($conditions)
->withType($type);
},
$this->params['joins'] ?? []
);
}
/**
* Get LEFT JOIN items.
*
* @return Join[]
* @deprecated As of 9.2.0. Use getJoins and check join type.
*/
public function getLeftJoins(): array
{
@@ -122,12 +126,12 @@ trait SelectingTrait
}
$conditions = isset($item[2]) ?
WhereClause::fromRaw($item[2]) :
null;
WhereClause::fromRaw($item[2]) : null;
return Join::create($item[0])
->withAlias($item[1] ?? null)
->withConditions($conditions);
->withConditions($conditions)
->withLeft();
},
$this->params['leftJoins'] ?? []
);
@@ -42,6 +42,7 @@ use Espo\ORM\Metadata;
use Espo\ORM\Mapper\Helper;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\Join\JoinType;
use Espo\ORM\Query\Query;
use Espo\ORM\Query\SelectingQuery;
use Espo\ORM\Query\Select;
@@ -758,21 +759,20 @@ abstract class BaseQueryComposer implements QueryComposer
if ($includeBelongsTo) {
$joinsPart = $this->getBelongsToJoinsPart(
$entity,
$params['select'],
array_merge($params['joins'], $params['leftJoins']),
$params
entity: $entity,
select: $params['select'],
explicitJoins: array_merge($params['joins'], $params['leftJoins']),
params: $params,
);
}
if (!empty($params['joins']) && is_array($params['joins'])) {
// @todo array unique
$joinsItemPart = $this->getJoinsTypePart(
$entity,
$params['joins'],
false,
$params['joinConditions'],
$params
entity: $entity,
joins: $params['joins'],
params: $params,
joinConditions: $params['joinConditions'],
);
if (!empty($joinsItemPart)) {
@@ -784,14 +784,14 @@ abstract class BaseQueryComposer implements QueryComposer
}
}
// For bc.
if (!empty($params['leftJoins']) && is_array($params['leftJoins'])) {
// @todo array unique
$joinsItemPart = $this->getJoinsTypePart(
$entity,
$params['leftJoins'],
true,
$params['joinConditions'],
$params
entity: $entity,
joins: $params['leftJoins'],
params: $params,
joinConditions: $params['joinConditions'],
isLeft: true,
);
if (!empty($joinsItemPart)) {
@@ -803,7 +803,7 @@ abstract class BaseQueryComposer implements QueryComposer
}
}
// @todo remove custom join
// @todo Remove custom join.
if (!empty($params['customJoin'])) {
if (!empty($joinsPart)) {
$joinsPart .= ' ';
@@ -1527,6 +1527,10 @@ abstract class BaseQueryComposer implements QueryComposer
if (!empty($defs['leftJoins'])) {
foreach ($defs['leftJoins'] as $j) {
if (is_string($j)) {
$j = [$j];
}
$jAlias = $this->obtainJoinAlias($j);
if ($alias) {
@@ -1537,7 +1541,7 @@ abstract class BaseQueryComposer implements QueryComposer
$j[1] = $jAlias;
}
foreach ($params['leftJoins'] as $jE) {
foreach ($params['joins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
@@ -1545,33 +1549,42 @@ abstract class BaseQueryComposer implements QueryComposer
}
}
if ($alias) {
if (count($j) >= 3) {
$conditions = [];
if ($alias && count($j) >= 3 && is_array($j[2])) {
$conditions = [];
foreach ($j[2] as $k => $value) {
if (is_string($value)) {
$value = str_replace('{alias}', $alias, $value);
}
/** @var string $left */
$left = $k;
$left = str_replace('{alias}', $alias, $left);
$conditions[$left] = $value;
foreach ($j[2] as $k => $value) {
if (is_string($value)) {
$value = str_replace('{alias}', $alias, $value);
}
$j[2] = $conditions;
/** @var string $left */
$left = $k;
$left = str_replace('{alias}', $alias, $left);
$conditions[$left] = $value;
}
$j[2] = $conditions;
}
$params['leftJoins'][] = $j;
$j[1] ??= null;
$j[2] ??= null;
$j[3] ??= [];
$j[3]['type'] = JoinType::left;
$params['joins'][] = $j;
}
}
if (!empty($defs['joins'])) {
foreach ($defs['joins'] as $j) {
if (is_string($j)) {
$j = [$j];
}
$jAlias = $this->obtainJoinAlias($j);
$jAlias = str_replace('{alias}', $alias ?? '', $jAlias);
if (isset($j[1])) {
@@ -1586,26 +1599,32 @@ abstract class BaseQueryComposer implements QueryComposer
}
}
if ($alias) {
if (count($j) >= 3) {
$conditions = [];
if ($alias && count($j) >= 3 && is_array($j[2])) {
$conditions = [];
foreach ($j[2] as $k => $value) {
if (is_string($value)) {
$value = str_replace('{alias}', $alias, $value);
}
/** @var string $left */
$left = $k;
$left = str_replace('{alias}', $alias, $left);
$conditions[$left] = $value;
foreach ($j[2] as $k => $value) {
if (is_string($value)) {
$value = str_replace('{alias}', $alias, $value);
}
$j[2] = $conditions;
/** @var string $left */
$left = $k;
$left = str_replace('{alias}', $alias, $left);
$conditions[$left] = $value;
}
$j[2] = $conditions;
}
$j[1] ??= null;
$j[2] ??= null;
$j[3] ??= [];
$joinType = $j[3]['type'] ?? null;
$j[3]['type'] = $joinType ? JoinType::from($joinType) : JoinType::inner;
$params['joins'][] = $j;
}
}
@@ -2034,11 +2053,16 @@ abstract class BaseQueryComposer implements QueryComposer
/**
* @param string[]|array<string[]> $select
* @param string[] $skipList
* @param string[] $explicitJoins
* @param array<string, mixed> $params
*/
protected function getBelongsToJoinsPart(Entity $entity, ?array $select, array $skipList, array $params): string
{
protected function getBelongsToJoinsPart(
Entity $entity,
?array $select,
array $explicitJoins,
array $params,
): string {
$joinsArr = [];
$relationsToJoin = [];
@@ -2082,11 +2106,28 @@ abstract class BaseQueryComposer implements QueryComposer
continue;
}
if (in_array($relationName, $skipList)) {
if (in_array($relationName, $explicitJoins)) {
// Never suppose to happen.
continue;
} else {
foreach ($explicitJoins as $skipItem) {
if (!is_array($skipItem)) {
continue;
}
if (
($skipItem[0] ?? null) === $relationName &&
(
($skipItem[1] ?? null) === null ||
($skipItem[1] ?? null) === $relationName
)
) {
continue 2;
}
}
}
foreach ($skipList as $sItem) {
foreach ($explicitJoins as $sItem) {
if (is_array($sItem) && count($sItem) > 1) {
if ($sItem[1] === $relationName) {
continue 2;
@@ -2834,20 +2875,10 @@ abstract class BaseQueryComposer implements QueryComposer
foreach ($leftJoins as $j) {
$jAlias = $this->obtainJoinAlias($j);
foreach ($params['leftJoins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
if (is_string($j)) {
$j = [$j];
}
$params['leftJoins'][] = $j;
}
foreach ($joins as $j) {
$jAlias = $this->obtainJoinAlias($j);
foreach ($params['joins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
@@ -2856,6 +2887,38 @@ abstract class BaseQueryComposer implements QueryComposer
}
}
$j[1] ??= null;
$j[2] ??= null;
$j[3] ??= [];
$j[3]['type'] = JoinType::left;
$params['joins'][] = $j;
}
foreach ($joins as $j) {
$jAlias = $this->obtainJoinAlias($j);
if (is_string($j)) {
$j = [$j];
}
foreach ($params['joins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
}
$j[1] ??= null;
$j[2] ??= null;
$j[3] ??= [];
$joinType = $j[3]['type'] ?? null;
$j[3]['type'] = $joinType ? JoinType::from($joinType) : JoinType::inner;
$params['joins'][] = $j;
}
@@ -2982,16 +3045,16 @@ abstract class BaseQueryComposer implements QueryComposer
}
/**
* @param array<string, mixed> $params
* @param array<string|int, mixed> $joinConditions
* @param array<string, mixed[]> $joins
* @param array<string, mixed> $params
*/
protected function getJoinsTypePart(
Entity $entity,
array $joins,
bool $isLeft,
array $params,
$joinConditions,
array $params
bool $isLeft = false,
): string {
$joinSqlList = [];
@@ -3000,6 +3063,8 @@ abstract class BaseQueryComposer implements QueryComposer
$itemConditions = [];
$itemParams = [];
$isItemLeft = $isLeft;
if (is_array($item)) {
$target = $item[0];
@@ -3017,6 +3082,10 @@ abstract class BaseQueryComposer implements QueryComposer
$alias = $target;
}
if (($itemParams['type'] ?? null) === JoinType::left) {
$isItemLeft = true;
}
if ($target instanceof Select && !is_string($alias)) {
throw new LogicException("Sub-query join can't be w/o alias");
}
@@ -3036,13 +3105,13 @@ abstract class BaseQueryComposer implements QueryComposer
}
$sql = $this->getJoinItemPart(
$entity,
$target,
$isLeft,
$conditions,
$alias,
$itemParams,
$params
entity: $entity,
target: $target,
isLeft: $isItemLeft,
conditions: $conditions,
alias: $alias,
joinParams: $itemParams,
params: $params,
);
if ($sql) {
+16 -1
View File
@@ -1105,7 +1105,8 @@
},
"joins": {
"description": "JOINs",
"type": "array"
"type": "array",
"$ref": "#/definitions/joins"
}
}
},
@@ -1466,6 +1467,20 @@
"type": "object",
"additionalProperties": true,
"description": "Join conditions."
},
{
"type": "object",
"description": "Parameters.",
"properties": {
"type": {
"type": "string",
"description": "A join type",
"enum": [
"inner",
"left"
]
}
}
}
]
}
@@ -46,6 +46,7 @@ use Espo\ORM\Defs\EntityDefs;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Metadata as ormMetadata;
use Espo\ORM\Query\Part\Join\JoinType;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Select;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
@@ -359,7 +360,7 @@ class ConverterTest extends TestCase
'id=s' => Select::fromRaw([
'select' => ['id'],
'from' => 'Test',
'leftJoins' => [
'joins' => [
[
'test',
$alias,
@@ -367,6 +368,7 @@ class ConverterTest extends TestCase
[
'noLeftAlias' => true,
'onlyMiddle' => true,
'type' => JoinType::left,
],
],
],
+190 -43
View File
@@ -54,13 +54,14 @@ use Espo\ORM\Query\Update;
use Espo\ORM\QueryComposer\Util;
use LogicException;
use PHPUnit\Framework\TestCase;
use RuntimeException;
require_once 'tests/unit/testData/DB/Entities.php';
require_once 'tests/unit/testData/DB/MockPDO.php';
require_once 'tests/unit/testData/DB/MockDBResult.php';
class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
class MysqlQueryComposerTest extends TestCase
{
protected ?QueryComposer $query = null;
protected $pdo = null;
@@ -616,7 +617,7 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
$sql = $this->query->compose(Select::fromRaw([
'from' => 'Comment',
'select' => ['id', 'name', 'postName'],
'leftJoins' => ['post'],
'leftJoins' => ['post'], // bc
]));
$this->assertEquals($expectedSql, $sql);
@@ -624,11 +625,12 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
$sql = $this->query->compose(Select::fromRaw([
'from' => 'Comment',
'select' => ['id', 'name'],
'leftJoins' => ['post']
'joins' => ['post']
]));
$expectedSql =
"SELECT comment.id AS `id`, comment.name AS `name` FROM `comment` " .
"LEFT JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"WHERE comment.deleted = 0";
$this->assertEquals($expectedSql, $sql);
@@ -713,30 +715,31 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
$sql = $this->query->compose(Select::fromRaw([
'from' => 'Comment',
'select' => ['id', 'postId', 'post.name', 'COUNT:id'],
'leftJoins' => ['post'],
'joins' => ['post'],
'groupBy' => ['postId', 'post.name']
]));
$expectedSql =
"SELECT comment.id AS `id`, comment.post_id AS `postId`, post.name AS `post.name`, ".
"COUNT(comment.id) AS `COUNT:id` FROM `comment` " .
"LEFT JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"WHERE comment.deleted = 0 " .
"GROUP BY comment.post_id, post.name";
$this->assertEquals($expectedSql, $sql);
$sql = $this->query->compose(Select::fromRaw([
'from' => 'Comment',
'select' => ['id', 'COUNT:id', 'MONTH:post.createdAt'],
'leftJoins' => ['post'],
'joins' => ['post'],
'groupBy' => ['MONTH:post.createdAt']
]));
$expectedSql =
"SELECT comment.id AS `id`, COUNT(comment.id) AS `COUNT:id`, ".
"DATE_FORMAT(post.created_at, '%Y-%m') AS `MONTH:post.createdAt` FROM `comment` " .
"LEFT JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"WHERE comment.deleted = 0 " .
"GROUP BY DATE_FORMAT(post.created_at, '%Y-%m')";
@@ -1090,6 +1093,104 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
);
}
public function testJoinOrder1(): void
{
$sql =
"SELECT post.id AS `id` FROM `post` " .
"LEFT JOIN LATERAL (SELECT post.id AS `id` FROM `post` LIMIT 0, 1) AS `a` ON TRUE " .
"JOIN LATERAL (SELECT post.id AS `id` FROM `post` LIMIT 0, 1) AS `b` ON TRUE";
$select = SelectBuilder::create()
->select('id')
->from('Post')
->leftJoin(
Join::createWithSubQuery(
SelectBuilder::create()
->select('id')
->from('Post')
->limit(0, 1)
->withDeleted()
->build(),
'a',
)
->withLateral()
->withConditions(
Expression::value(true)
)
)
->join(
Join::createWithSubQuery(
SelectBuilder::create()
->select('id')
->from('Post')
->limit(0, 1)
->withDeleted()
->build(),
'b',
)
->withLateral()
->withConditions(
Expression::value(true)
)
)
->withDeleted()
->build();
$this->assertEquals(
$sql,
$this->query->composeSelect($select)
);
}
public function testJoinOrder2(): void
{
$sql =
"SELECT post.id AS `id` FROM `post` " .
"JOIN LATERAL (SELECT post.id AS `id` FROM `post` LIMIT 0, 1) AS `a` ON TRUE " .
"LEFT JOIN LATERAL (SELECT post.id AS `id` FROM `post` LIMIT 0, 1) AS `b` ON TRUE";
$select = SelectBuilder::create()
->select('id')
->from('Post')
->join(
Join::createWithSubQuery(
SelectBuilder::create()
->select('id')
->from('Post')
->limit(0, 1)
->withDeleted()
->build(),
'a',
)
->withLateral()
->withConditions(
Expression::value(true)
)
)
->leftJoin(
Join::createWithSubQuery(
SelectBuilder::create()
->select('id')
->from('Post')
->limit(0, 1)
->withDeleted()
->build(),
'b',
)
->withLateral()
->withConditions(
Expression::value(true)
)
)
->withDeleted()
->build();
$this->assertEquals(
$sql,
$this->query->composeSelect($select)
);
}
public function testJoinSubQueryInOn(): void
{
$sql =
@@ -1562,28 +1663,34 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
public function testOrderBy1()
{
$sql = $this->query->compose(Select::fromRaw([
'from' => 'Comment',
'select' => array('COUNT:id', 'YEAR:post.createdAt'),
'leftJoins' => array('post'),
'groupBy' => array('YEAR:post.createdAt'),
'orderBy' => 2
]));
$sql = $this->query->composeSelect(
SelectBuilder::create()
->from('Comment')
->select(['COUNT:id', 'YEAR:post.createdAt'])
->leftJoin('post')
->group('YEAR:post.createdAt')
->order(2)
->build()
);
$expectedSql =
"SELECT COUNT(comment.id) AS `COUNT:id`, YEAR(post.created_at) AS `YEAR:post.createdAt` FROM `comment` " .
"LEFT JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"WHERE comment.deleted = 0 " .
"GROUP BY YEAR(post.created_at) ".
"ORDER BY 2 ASC";
$this->assertEquals($expectedSql, $sql);
$sql = $this->query->compose(Select::fromRaw([
'from' => 'Comment',
'select' => ['COUNT:id', 'post.name'],
'leftJoins' => ['post'],
'groupBy' => ['post.name'],
'orderBy' => 'LIST:post.name:Test,Hello',
]));
$sql = $this->query->composeSelect(
SelectBuilder::create()
->from('Comment')
->select(['COUNT:id', 'post.name'])
->leftJoin('post')
->group('post.name')
->order(Order::createByPositionInList(Expression::column('post.name'), ['Test', 'Hello']))
->build()
);
$expectedSql =
"SELECT COUNT(comment.id) AS `COUNT:id`, post.name AS `post.name` FROM `comment` " .
@@ -1591,18 +1698,21 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
"WHERE comment.deleted = 0 " .
"GROUP BY post.name ".
"ORDER BY FIELD(post.name, 'Hello', 'Test') DESC";
$this->assertEquals($expectedSql, $sql);
$sql = $this->query->compose(Select::fromRaw([
'from' => 'Comment',
'select' => ['COUNT:id', 'YEAR:post.createdAt', 'post.name'],
'leftJoins' => ['post'],
'groupBy' => ['YEAR:post.createdAt', 'post.name'],
'orderBy' => [
[2, 'DESC'],
['LIST:post.name:Test,Hello']
]
]));
$sql = $this->query->composeSelect(
SelectBuilder::create()
->from('Comment')
->select(['COUNT:id', 'YEAR:post.createdAt', 'post.name'])
->leftJoin('post')
->group('YEAR:post.createdAt')
->group('post.name')
->order(2, Order::DESC)
->order(Order::createByPositionInList(Expression::column('post.name'), ['Test', 'Hello']))
->build()
);
$expectedSql =
"SELECT COUNT(comment.id) AS `COUNT:id`, YEAR(post.created_at) AS `YEAR:post.createdAt`, ".
"post.name AS `post.name` ".
@@ -1611,6 +1721,7 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
"WHERE comment.deleted = 0 " .
"GROUP BY YEAR(post.created_at), post.name ".
"ORDER BY 2 DESC, FIELD(post.name, 'Hello', 'Test') DESC";
$this->assertEquals($expectedSql, $sql);
}
@@ -1747,21 +1858,22 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
public function testForeign()
{
$sql = $this->query->compose(Select::fromRaw([
'from' => 'Comment',
'select' => array('COUNT:comment.id', 'postId', 'postName'),
'leftJoins' => array('post'),
'groupBy' => array('postId'),
'whereClause' => array(
'post.createdById' => 'id_1'
),
]));
$sql = $this->query->composeSelect(
SelectBuilder::create()->from('Comment')
->select(['COUNT:comment.id', 'postId', 'postName'])
->leftJoin('post')
->group('postId')
->where(['post.createdById' => 'id_1'])
->build()
);
$expectedSql =
"SELECT COUNT(comment.id) AS `COUNT:comment.id`, comment.post_id AS `postId`, post.name AS `postName` ".
"FROM `comment` " .
"LEFT JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"WHERE post.created_by_id = 'id_1' AND comment.deleted = 0 " .
"GROUP BY comment.post_id";
$this->assertEquals($expectedSql, $sql);
}
@@ -2131,12 +2243,24 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
]
]));
$sql = $this->query->composeSelect(
SelectBuilder::create()
->from('Comment')
->select(['COUNT:comment.id', 'postId', 'postName'])
->leftJoin('post')
->where(['post.createdById' => 'id_1'])
->having(['COUNT:comment.id>' => 1])
->group('postId')
->build()
);
$expectedSql =
"SELECT COUNT(comment.id) AS `COUNT:comment.id`, comment.post_id AS `postId`, post.name AS `postName` " .
"FROM `comment` LEFT JOIN `post` AS `post` ON comment.post_id = post.id AND post.deleted = 0 " .
"WHERE post.created_by_id = 'id_1' AND comment.deleted = 0 " .
"GROUP BY comment.post_id " .
"HAVING COUNT(comment.id) > 1";
$this->assertEquals($expectedSql, $sql);
}
@@ -2343,7 +2467,7 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
$expectedSql =
"SELECT test_where.id AS `id` ".
"FROM `test_where` ".
"JOIN `test` AS `t` ON t.id = test_where.id ".
"LEFT JOIN `test` AS `t` ON t.id = test_where.id ".
"WHERE (" .
"((test_where.test = 'hello\\' test') OR (test_where.test = '1') OR (test_where.test = LOWER('hello\\' test')))" .
")";
@@ -2419,6 +2543,29 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
$this->assertEquals($expectedSql, $sql);
}
public function testCustomWhereLeftJoin()
{
$queryBuilder = new QueryBuilder();
$select = $queryBuilder->select()
->from('TestWhere')
->select(['id'])
->where([
'test3' => 1,
])
->build();
$sql = $this->query->compose($select);
$expectedSql =
"SELECT test_where.id AS `id` ".
"FROM `test_where` ".
"LEFT JOIN `test` AS `t` ON t.id = test_where.id ".
"WHERE (test_where.test = 1)";
$this->assertEquals($expectedSql, $sql);
}
public function testCustomOrder1()
{
$queryBuilder = new QueryBuilder();
@@ -2434,7 +2581,7 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase
$expectedSql =
"SELECT test_where.id AS `id` ".
"FROM `test_where` ".
"JOIN `test` AS `t` ON t.id = test_where.id ".
"LEFT JOIN `test` AS `t` ON t.id = test_where.id ".
"ORDER BY test_where.test DESC, t.id DESC";
$this->assertEquals($expectedSql, $sql);
@@ -42,12 +42,13 @@ use Espo\ORM\Query\SelectBuilder;
use Espo\ORM\Query\UpdateBuilder;
use Espo\ORM\QueryBuilder;
use Espo\ORM\QueryComposer\PostgresqlQueryComposer as QueryComposer;
use PHPUnit\Framework\TestCase;
require_once 'tests/unit/testData/DB/Entities.php';
require_once 'tests/unit/testData/DB/MockPDO.php';
require_once 'tests/unit/testData/DB/MockDBResult.php';
class PostgresqlQueryComposerTest extends \PHPUnit\Framework\TestCase
class PostgresqlQueryComposerTest extends TestCase
{
private ?QueryComposer $queryComposer;
private ?EntityManager $entityManager;
+3 -3
View File
@@ -53,7 +53,7 @@ class JoinTest extends \PHPUnit\Framework\TestCase
$this->assertTrue($join->isRelation());
$this->assertFalse($join->isTable());
$this->assertEquals(Join::TYPE_RELATION, $join->getType());
$this->assertEquals(Join::MODE_RELATION, $join->getMode());
}
public function testCreate2(): void
@@ -73,7 +73,7 @@ class JoinTest extends \PHPUnit\Framework\TestCase
$this->assertTrue($join->isTable());
$this->assertFalse($join->isRelation());
$this->assertEquals(Join::TYPE_TABLE, $join->getType());
$this->assertEquals(Join::MODE_TABLE, $join->getMode());
}
public function testCreate3(): void
@@ -95,6 +95,6 @@ class JoinTest extends \PHPUnit\Framework\TestCase
);
$this->assertTrue($join->isSubQuery());
$this->assertEquals(Join::TYPE_SUB_QUERY, $join->getType());
$this->assertEquals(Join::MODE_SUB_QUERY, $join->getMode());
}
}
+49 -32
View File
@@ -29,17 +29,17 @@
namespace tests\unit\Espo\ORM\Query;
use Espo\ORM\{
Query\SelectBuilder,
Query\Part\Condition as Cond,
Query\Part\Expression as Expr,
Query\Part\Selection,
Query\Part\Order,
Query\Part\Join,
Query\Part\WhereClause,
};
use PHPUnit\Framework\TestCase;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Part\Join;
use Espo\ORM\Query\Part\Order;
use Espo\ORM\Query\Part\Selection;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\SelectBuilder;
use RuntimeException;
class SelectBuilderTest extends \PHPUnit\Framework\TestCase
class SelectBuilderTest extends TestCase
{
/**
* @var SelectBuilder
@@ -410,7 +410,7 @@ class SelectBuilderTest extends \PHPUnit\Framework\TestCase
$builder = new SelectBuilder();
$this->expectException(\RuntimeException::class);
$this->expectException(RuntimeException::class);
$builder
->from('Test')
@@ -478,10 +478,17 @@ class SelectBuilderTest extends \PHPUnit\Framework\TestCase
->leftJoin('link1')
->leftJoin('link1')
->leftJoin('link2')
->build()
->getRaw();
->build();
$this->assertEquals(['link1', 'link2'], $params['leftJoins']);
$this->assertEquals(
[
Join::create('link1')
->withLeft(),
Join::create('link2')
->withLeft()
],
$params->getJoins()
);
}
public function testLeftJoin2()
@@ -495,10 +502,12 @@ class SelectBuilderTest extends \PHPUnit\Framework\TestCase
$this->assertEquals(
[
Join::create('link1', 'alias1')
->withConditions(WhereClause::fromRaw(['name' => 'test'])),
Join::create('link2', 'alias2'),
->withConditions(WhereClause::fromRaw(['name' => 'test']))
->withLeft(),
Join::create('link2', 'alias2')
->withLeft(),
],
$query->getLeftJoins()
$query->getJoins()
);
}
@@ -520,10 +529,12 @@ class SelectBuilderTest extends \PHPUnit\Framework\TestCase
$this->assertEquals(
[
Join::create('link1', 'alias1')
->withConditions(WhereClause::fromRaw(['name' => 'test'])),
Join::create('link2', 'alias2'),
->withConditions(WhereClause::fromRaw(['name' => 'test']))
->withLeft(),
Join::create('link2', 'alias2')
->withLeft(),
],
$query->getLeftJoins()
$query->getJoins()
);
}
@@ -537,7 +548,13 @@ class SelectBuilderTest extends \PHPUnit\Framework\TestCase
->build()
->getRaw();
$this->assertEquals(['link1', 'link2'], $params['joins']);
$this->assertEquals(
[
['link1', null, null, ['type' => null]],
['link2', null, null, ['type' => null]],
],
$params['joins']
);
}
public function testJoin2()
@@ -551,8 +568,10 @@ class SelectBuilderTest extends \PHPUnit\Framework\TestCase
$this->assertEquals(
[
Join::create('link1', 'alias1')
->withConditions(WhereClause::fromRaw(['name' => 'test'])),
Join::create('link2', 'alias2'),
->withConditions(WhereClause::fromRaw(['name' => 'test']))
->withInner(),
Join::create('link2', 'alias2')
->withInner(),
],
$query->getJoins()
);
@@ -572,8 +591,10 @@ class SelectBuilderTest extends \PHPUnit\Framework\TestCase
$this->assertEquals(
[
Join::create('link1', 'alias1')
->withConditions(WhereClause::fromRaw(['name' => 'test'])),
Join::create('link2', 'alias2'),
->withConditions(WhereClause::fromRaw(['name' => 'test']))
->withInner(),
Join::create('link2', 'alias2')
->withInner(),
],
$query->getJoins()
);
@@ -617,24 +638,20 @@ class SelectBuilderTest extends \PHPUnit\Framework\TestCase
[
'table1.testId=:' => 'id'
],
['noLeftAlias' => true]
]
];
$expectedLeftJoins = [
['noLeftAlias' => true, 'type' => null]
],
[
'Table2',
'table2',
[
'table2.testId=:' => 'id'
],
['noLeftAlias' => true]
['noLeftAlias' => true, 'type' => Join\JoinType::left]
]
];
$this->assertEquals($expectedWhere, $raw['whereClause']);
$this->assertEquals($expectedJoins, $raw['joins']);
$this->assertEquals($expectedLeftJoins, $raw['leftJoins']);
}
public function testExists1(): void
@@ -40,6 +40,7 @@ use Espo\ORM\Metadata;
use Espo\ORM\MetadataDataProvider;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Part\Join\JoinType;
use Espo\ORM\Query\Part\Order as OrderExpr;
use Espo\ORM\Query\Select;
use Espo\ORM\QueryBuilder;
@@ -425,7 +426,9 @@ class RDBRepositoryTest extends TestCase
->will($this->returnValue($this->collection))
->with($paramsExpected);
$this->repository->where('name', 'test')->findOne();
$this->repository
->where('name', 'test')
->findOne();
}
public function testJoin1()
@@ -433,7 +436,7 @@ class RDBRepositoryTest extends TestCase
$paramsExpected = Select::fromRaw([
'from' => 'Test',
'joins' => [
'Test',
['Test', null, null, ['type' => null]],
],
]);
@@ -443,7 +446,9 @@ class RDBRepositoryTest extends TestCase
->will($this->returnValue($this->collection))
->with($paramsExpected);
$this->repository->join('Test')->find();
$this->repository
->join('Test')
->find();
}
public function testJoin2()
@@ -465,6 +470,7 @@ class RDBRepositoryTest extends TestCase
$this->repository->join(['Test1', 'Test2'])->find();
}
// Bc.
public function testJoin3()
{
$paramsExpected = Select::fromRaw([
@@ -481,7 +487,9 @@ class RDBRepositoryTest extends TestCase
->will($this->returnValue($this->collection))
->with($paramsExpected);
$this->repository->join([['Test1', 'test1'], ['Test2', 'test2']])->find();
$this->repository
->join([['Test1', 'test1'], ['Test2', 'test2']])
->find();
}
public function testJoin4()
@@ -489,7 +497,7 @@ class RDBRepositoryTest extends TestCase
$paramsExpected = Select::fromRaw([
'from' => 'Test',
'joins' => [
['Test1', 'test1', ['k' => 'v']],
['Test1', 'test1', ['k' => 'v'], ['type' => null]],
],
]);
@@ -499,7 +507,9 @@ class RDBRepositoryTest extends TestCase
->will($this->returnValue($this->collection))
->with($paramsExpected);
$this->repository->join('Test1', 'test1', ['k' => 'v'])->find();
$this->repository
->join('Test1', 'test1', ['k' => 'v'])
->find();
}
public function testJoin5()
@@ -525,8 +535,8 @@ class RDBRepositoryTest extends TestCase
{
$paramsExpected = Select::fromRaw([
'from' => 'Test',
'leftJoins' => [
'Test',
'joins' => [
['Test', null, null, ['type' => JoinType::left]],
],
]);
@@ -536,7 +546,9 @@ class RDBRepositoryTest extends TestCase
->will($this->returnValue($this->collection))
->with($paramsExpected);
$this->repository->leftJoin('Test')->find();
$this->repository
->leftJoin('Test')
->find();
}
public function testLeftJoin2()
@@ -562,9 +574,9 @@ class RDBRepositoryTest extends TestCase
{
$paramsExpected = Select::fromRaw([
'from' => 'Test',
'leftJoins' => [
'Test1',
['Test2', 'test2'],
'joins' => [
['Test1', null, null, ['type' => JoinType::left]],
['Test2', 'test2', null, ['type' => JoinType::left]],
],
]);
@@ -574,7 +586,10 @@ class RDBRepositoryTest extends TestCase
->will($this->returnValue($this->collection))
->with($paramsExpected);
$this->repository->leftJoin('Test1')->leftJoin('Test2', 'test2')->find();
$this->repository
->leftJoin('Test1')
->leftJoin('Test2', 'test2')
->find();
}
public function testDistinct()
+17 -2
View File
@@ -1,6 +1,7 @@
<?php
use Espo\ORM\Entity;
use Espo\ORM\Query\Part\Join\JoinType;
return [
'Account' => [
@@ -479,7 +480,7 @@ return [
],
],
'joins' => [
['Test', 't', ['t.id:' => 'id']],
['Test', 't', ['t.id:' => 'id'], ["type" => JoinType::left->value]],
],
],
"IN" => [
@@ -505,7 +506,7 @@ return [
['t.id', '{direction}'],
],
'joins' => [
['Test', 't', ['t.id:' => 'id']],
['Test', 't', ['t.id:' => 'id'], ["type" => JoinType::left->value]],
],
],
'select' => [
@@ -527,6 +528,20 @@ return [
],
],
],
'test3' => [
'type' => Entity::VARCHAR,
'notStorable' => true,
'where' => [
'=' => [
'whereClause' => [
'test' => '{value}',
],
'leftJoins' => [
['Test', 't', ['t.id:' => 'id']],
],
],
],
],
],
'relations' => [
],