diff --git a/application/Espo/Core/Select/Where/ItemGeneralConverter.php b/application/Espo/Core/Select/Where/ItemGeneralConverter.php index 504a917ed9..aa5d468e7d 100644 --- a/application/Espo/Core/Select/Where/ItemGeneralConverter.php +++ b/application/Espo/Core/Select/Where/ItemGeneralConverter.php @@ -38,6 +38,8 @@ use Espo\Entities\ArrayValue; use Espo\Entities\User; use Espo\ORM\Defs as ORMDefs; use Espo\ORM\Entity; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Join; use Espo\ORM\Query\Part\WhereClause; use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; use Espo\ORM\Query\SelectBuilder as QueryBuilder; @@ -1319,9 +1321,11 @@ class ItemGeneralConverter implements ItemConverter if ($relationType == Entity::MANY_MANY) { $key = $defs->getForeignMidKey(); + $nearKey = $defs->getMidKey(); $middleEntityType = ucfirst($defs->getRelationshipName()); + // IN-sub-query performs faster than EXISTS on MariaDB when multiple IDs. // Left-join performs faster than Inner-join. // Not joining a foreign table as it affects performance in MySQL. $subQuery = QueryBuilder::create() @@ -1385,20 +1389,26 @@ class ItemGeneralConverter implements ItemConverter if ($relationType == Entity::MANY_MANY) { $key = $defs->getForeignMidKey(); - $nearKey = $defs->getMidKey(); - $middleEntityType = ucfirst($defs->getRelationshipName()); - $subQuery = QueryBuilder::create() - ->select('id') - ->from($this->entityType) - ->leftJoin($middleEntityType, $alias, [ - "{$alias}.{$nearKey}:" => 'id', - "{$alias}.deleted" => false, - ]) - ->where(["{$alias}.{$key}=" => $value]) - ->build(); - - return ['id!=s' => $subQuery->getRaw()]; + // MariaDB and MySQL performs slow, PostgreSQL – fast. + return Cond::not( + Cond::exists( + QueryBuilder::create() + ->from($this->entityType, 'sq') + ->join( + Join::create($link, $alias) + ->withOnlyMiddle() + ) + ->where(["{$alias}.{$key}" => $value]) + ->where( + Cond::equal( + Cond::column('sq.id'), + Cond::column(lcfirst($this->entityType) . '.id') + ) + ) + ->build() + ) + )->getRaw(); } if ( @@ -1455,6 +1465,7 @@ class ItemGeneralConverter implements ItemConverter $whereList = []; foreach ($value as $targetId) { + // Only-middle join performs slower on MariaDB. $sq = QueryBuilder::create() ->from($this->entityType) ->select('id') diff --git a/application/Espo/ORM/Query/Part/Join.php b/application/Espo/ORM/Query/Part/Join.php index b80893870c..0bd38d98fb 100644 --- a/application/Espo/ORM/Query/Part/Join.php +++ b/application/Espo/ORM/Query/Part/Join.php @@ -41,6 +41,7 @@ class Join private string $target; private ?string $alias = null; private ?WhereItem $conditions = null; + private bool $onlyMiddle = false; private function __construct(string $target, ?string $alias = null) { @@ -87,6 +88,11 @@ class Join return !$this->isTable(); } + public function isOnlyMiddle(): bool + { + return $this->onlyMiddle; + } + /** * Create. * @@ -137,4 +143,15 @@ class Join return $obj; } + + /** + * Join only middle table. For many-to-many relationships. + */ + public function withOnlyMiddle(bool $onlyMiddle = true): self + { + $obj = clone $this; + $obj->onlyMiddle = $onlyMiddle; + + return $obj; + } } diff --git a/application/Espo/ORM/Query/SelectingBuilderTrait.php b/application/Espo/ORM/Query/SelectingBuilderTrait.php index 1102764740..52a576fb65 100644 --- a/application/Espo/ORM/Query/SelectingBuilderTrait.php +++ b/application/Espo/ORM/Query/SelectingBuilderTrait.php @@ -184,15 +184,18 @@ trait SelectingBuilderTrait * @param Join|string $target * A relation name or table. A relation name should be in camelCase, a table in CamelCase. * @param string|null $alias An alias. - * @param WhereItem|array|null $conditions Join conditions. + * @param WhereItem|array|null $conditions Join conditions. * * @todo Support USE INDEX in Join. */ private function joinInternal(string $type, $target, ?string $alias = null, $conditions = null): self { + $onlyMiddle = false; + if ($target instanceof Join) { $alias = $alias ?? $target->getAlias(); $conditions = $conditions ?? $target->getConditions(); + $onlyMiddle = $target->isOnlyMiddle(); $target = $target->getTarget(); } @@ -229,6 +232,22 @@ trait SelectingBuilderTrait return $this; } + $params = []; + + if ($noLeftAlias) { + $params['noLeftAlias'] = true; + } + + if ($onlyMiddle) { + $params['onlyMiddle'] = true; + } + + if ($params !== []) { + $this->params[$type][] = [$target, $alias, $conditions, $params]; + + return $this; + } + if (is_null($alias) && is_null($conditions)) { $this->params[$type][] = $target; @@ -241,13 +260,7 @@ trait SelectingBuilderTrait return $this; } - $item = [$target, $alias, $conditions]; - - if ($noLeftAlias) { - $item[] = ['noLeftAlias' => true]; - } - - $this->params[$type][] = $item; + $this->params[$type][] = [$target, $alias, $conditions]; return $this; } diff --git a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php index 7900d5bc7d..72727678af 100644 --- a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php +++ b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php @@ -3274,7 +3274,11 @@ abstract class BaseQueryComposer implements QueryComposer $distantTable = $this->toDb($foreignEntityType); - $midAlias = $alias . 'Middle'; + $onlyMiddle = $joinParams['onlyMiddle'] ?? false; + + $midAlias = $onlyMiddle ? + $alias : + $alias . 'Middle'; $indexKeyList = null; $indexList = $joinParams['useIndex'] ?? null; @@ -3339,8 +3343,6 @@ abstract class BaseQueryComposer implements QueryComposer $sql .= " AND " . implode(" AND ", $joinSqlList); } - $onlyMiddle = $joinParams['onlyMiddle'] ?? false; - if (!$onlyMiddle) { $rightKeyColumn = $this->quoteColumn("{$alias}." . $this->toDb($foreignKey)); $middleDistantKeyColumn = $this->quoteColumn("{$midAlias}." . $this->toDb($distantKey)); diff --git a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php index 8c9fa5c48e..ba6e0e22b5 100644 --- a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php +++ b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php @@ -954,7 +954,7 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase $expectedSql = "SELECT post.id AS `id` FROM `post` " . - "LEFT JOIN `post_tag` AS `tagsMiddle` ON post.id = tagsMiddle.post_id AND tagsMiddle.deleted = 0 " . + "LEFT JOIN `post_tag` AS `tags` ON post.id = tags.post_id AND tags.deleted = 0 " . "WHERE post.deleted = 0"; $this->assertEquals($expectedSql, $sql);