diff --git a/application/Espo/Classes/Select/Email/AdditionalAppliers/Main.php b/application/Espo/Classes/Select/Email/AdditionalAppliers/Main.php index 9f62053eac..1c1a8acd52 100644 --- a/application/Espo/Classes/Select/Email/AdditionalAppliers/Main.php +++ b/application/Espo/Classes/Select/Email/AdditionalAppliers/Main.php @@ -145,7 +145,7 @@ class Main implements AdditionalApplier } } - if ($queryBuilder->hasLeftJoinAlias('teamsAccess')) { + if ($queryBuilder->hasJoinAlias('teamsAccess')) { return false; } diff --git a/application/Espo/Classes/Select/Email/Helpers/JoinHelper.php b/application/Espo/Classes/Select/Email/Helpers/JoinHelper.php index 2f3052a7d4..d7de56f076 100644 --- a/application/Espo/Classes/Select/Email/Helpers/JoinHelper.php +++ b/application/Espo/Classes/Select/Email/Helpers/JoinHelper.php @@ -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; } diff --git a/application/Espo/Core/Select/Bool/Applier.php b/application/Espo/Core/Select/Bool/Applier.php index 8e8513f978..17f7abdeca 100644 --- a/application/Espo/Core/Select/Bool/Applier.php +++ b/application/Espo/Core/Select/Bool/Applier.php @@ -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(); diff --git a/application/Espo/ORM/Query/Part/Join.php b/application/Espo/ORM/Query/Part/Join.php index b18f413478..e1067e881f 100644 --- a/application/Espo/ORM/Query/Part/Join.php +++ b/application/Espo/ORM/Query/Part/Join.php @@ -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; + } } diff --git a/application/Espo/ORM/Query/Part/Join/JoinType.php b/application/Espo/ORM/Query/Part/Join/JoinType.php new file mode 100644 index 0000000000..1393fe2a39 --- /dev/null +++ b/application/Espo/ORM/Query/Part/Join/JoinType.php @@ -0,0 +1,46 @@ +. + * + * 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'; +} diff --git a/application/Espo/ORM/Query/SelectingBuilderTrait.php b/application/Espo/ORM/Query/SelectingBuilderTrait.php index e078c62ef8..d2f79c289b 100644 --- a/application/Espo/ORM/Query/SelectingBuilderTrait.php +++ b/application/Espo/ORM/Query/SelectingBuilderTrait.php @@ -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|null $conditions */ @@ -205,15 +204,22 @@ trait SelectingBuilderTrait /** @var string|Join|array $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); } /** diff --git a/application/Espo/ORM/Query/SelectingTrait.php b/application/Espo/ORM/Query/SelectingTrait.php index e12bcaf13f..9d493825d8 100644 --- a/application/Espo/ORM/Query/SelectingTrait.php +++ b/application/Espo/ORM/Query/SelectingTrait.php @@ -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'] ?? [] ); diff --git a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php index a1336d0d80..9dee747186 100644 --- a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php +++ b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php @@ -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 $select - * @param string[] $skipList + * @param string[] $explicitJoins * @param array $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 $params * @param array $joinConditions * @param array $joins + * @param array $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) { diff --git a/schema/metadata/entityDefs.json b/schema/metadata/entityDefs.json index c4928153eb..d6d6392429 100644 --- a/schema/metadata/entityDefs.json +++ b/schema/metadata/entityDefs.json @@ -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" + ] + } + } } ] } diff --git a/tests/unit/Espo/Core/Select/Where/ConverterTest.php b/tests/unit/Espo/Core/Select/Where/ConverterTest.php index d13d14aedf..d8cf7982f8 100644 --- a/tests/unit/Espo/Core/Select/Where/ConverterTest.php +++ b/tests/unit/Espo/Core/Select/Where/ConverterTest.php @@ -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, ], ], ], diff --git a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php index 9504f679c5..5a3ddc6dfa 100644 --- a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php +++ b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php @@ -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); diff --git a/tests/unit/Espo/ORM/PostgresqlQueryComposerTest.php b/tests/unit/Espo/ORM/PostgresqlQueryComposerTest.php index ec4f7bf837..3797558f06 100644 --- a/tests/unit/Espo/ORM/PostgresqlQueryComposerTest.php +++ b/tests/unit/Espo/ORM/PostgresqlQueryComposerTest.php @@ -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; diff --git a/tests/unit/Espo/ORM/Query/Part/JoinTest.php b/tests/unit/Espo/ORM/Query/Part/JoinTest.php index 13887330d1..4264ddac02 100644 --- a/tests/unit/Espo/ORM/Query/Part/JoinTest.php +++ b/tests/unit/Espo/ORM/Query/Part/JoinTest.php @@ -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()); } } diff --git a/tests/unit/Espo/ORM/Query/SelectBuilderTest.php b/tests/unit/Espo/ORM/Query/SelectBuilderTest.php index fb341a1e7d..cb4dcba4b9 100644 --- a/tests/unit/Espo/ORM/Query/SelectBuilderTest.php +++ b/tests/unit/Espo/ORM/Query/SelectBuilderTest.php @@ -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 diff --git a/tests/unit/Espo/ORM/Repository/RDBRepositoryTest.php b/tests/unit/Espo/ORM/Repository/RDBRepositoryTest.php index b060536391..66897fccdf 100644 --- a/tests/unit/Espo/ORM/Repository/RDBRepositoryTest.php +++ b/tests/unit/Espo/ORM/Repository/RDBRepositoryTest.php @@ -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() diff --git a/tests/unit/testData/DB/ormMetadata.php b/tests/unit/testData/DB/ormMetadata.php index e7933c353d..112f74bab5 100644 --- a/tests/unit/testData/DB/ormMetadata.php +++ b/tests/unit/testData/DB/ormMetadata.php @@ -1,6 +1,7 @@ [ @@ -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' => [ ],