diff --git a/application/Espo/ORM/Query/Part/Join.php b/application/Espo/ORM/Query/Part/Join.php index 57dcc0e4ad..041f15248c 100644 --- a/application/Espo/ORM/Query/Part/Join.php +++ b/application/Espo/ORM/Query/Part/Join.php @@ -29,6 +29,8 @@ namespace Espo\ORM\Query\Part; +use Espo\ORM\Query\Select; +use LogicException; use RuntimeException; /** @@ -38,11 +40,18 @@ use RuntimeException; */ class Join { + /** A table join. */ + public const TYPE_TABLE = 0; + /** A relation join. */ + public const TYPE_RELATION = 1; + /** A sub-query join. */ + public const TYPE_SUB_QUERY = 3; + private ?WhereItem $conditions = null; private bool $onlyMiddle = false; private function __construct( - private string $target, + private string|Select $target, private ?string $alias = null ) { if ($target === '' || $alias === '') { @@ -51,10 +60,10 @@ class Join } /** - * Get a join target. A relationName or table. - * A relationName is in camelCase, a table is in CamelCase. + * Get a join target. A relation name, table or sub-query. + * A relation name is in camelCase, a table is in CamelCase. */ - public function getTarget(): string + public function getTarget(): string|Select { return $this->target; } @@ -75,16 +84,51 @@ class Join return $this->conditions; } + /** + * Is a sub-query join. + */ + public function isSubQuery(): bool + { + return !is_string($this->target); + } + + /** + * Is a table join. + */ public function isTable(): bool { - return $this->target[0] === ucfirst($this->target[0]); + return is_string($this->target) && $this->target[0] === ucfirst($this->target[0]); } + /** + * Is a relation join. + */ public function isRelation(): bool { - return !$this->isTable(); + return !$this->isSubQuery() && !$this->isTable(); } + /** + * Get a join type. + * + * @return self::TYPE_TABLE|self::TYPE_RELATION|self::TYPE_SUB_QUERY + */ + public function getType(): int + { + if ($this->isSubQuery()) { + return self::TYPE_SUB_QUERY; + } + + if ($this->isRelation()) { + return self::TYPE_RELATION; + } + + return self::TYPE_TABLE; + } + + /** + * Is only middle table to be joined. + */ public function isOnlyMiddle(): bool { return $this->onlyMiddle; @@ -93,18 +137,23 @@ class Join /** * Create. * - * @param string $target - * A relation name or table. A relationName should be in camelCase, a table in CamelCase. - * When joining a table, conditions should be specified. - * When joining a relation, conditions will be applied automatically. + * @param string|Select $target + * A relation name, table or sub-query. A relation name should be in camelCase, a table in CamelCase. + * When joining a table or sub-query, conditions should be specified. + * When joining a relation, conditions will be applied automatically, additional conditions can + * be specified as well. + * @param ?string $alias An alias. */ - public static function create(string $target, ?string $alias = null): self + public static function create(string|Select $target, ?string $alias = null): self { return new self($target, $alias); } /** * Create with a table target. + * + * @param string $table A table name. Should start with an upper case letter. + * @param ?string $alias An alias. */ public static function createWithTableTarget(string $table, ?string $alias = null): self { @@ -113,12 +162,26 @@ class Join /** * Create with a relation target. Conditions will be applied automatically. + * + * @param string $relation A relation name. Should start with a lower case letter. + * @param ?string $alias An alias. */ public static function createWithRelationTarget(string $relation, ?string $alias = null): self { return self::create(lcfirst($relation), $alias); } + /** + * Create with a sub-query. + * + * @param Select $subQuery A sub-query. + * @param string $alias An alias. + */ + public static function createWithSubQuery(Select $subQuery, string $alias): self + { + return new self($subQuery, $alias); + } + /** * Clone with an alias. */ @@ -146,6 +209,10 @@ class Join */ public function withOnlyMiddle(bool $onlyMiddle = true): self { + if (!$this->isRelation()) { + throw new LogicException("Only-middle is compatible only with relation joins."); + } + $obj = clone $this; $obj->onlyMiddle = $onlyMiddle; diff --git a/application/Espo/ORM/Query/SelectingBuilderTrait.php b/application/Espo/ORM/Query/SelectingBuilderTrait.php index 776e9f19be..7ac7ce81e3 100644 --- a/application/Espo/ORM/Query/SelectingBuilderTrait.php +++ b/application/Espo/ORM/Query/SelectingBuilderTrait.php @@ -35,6 +35,7 @@ use Espo\ORM\Query\Part\Order; use Espo\ORM\Query\Part\Join; use InvalidArgumentException; +use LogicException; use RuntimeException; trait SelectingBuilderTrait @@ -156,42 +157,55 @@ trait SelectingBuilderTrait /** * Add JOIN. * - * @param Join|string $target - * A relation name or table. A relation name should be in camelCase, a table in CamelCase. + * @param Join|string|Select $target A relation name, table or sub-query. A relation name should be in camelCase, + * a table in CamelCase. * @param ?string $alias An alias. * @param WhereItem|array|null $conditions Join conditions. */ - public function join($target, ?string $alias = null, $conditions = null): self - { + public function join( + $target, + ?string $alias = null, + WhereItem|array|null $conditions = null + ): self { + return $this->joinInternal('joins', $target, $alias, $conditions); } /** * Add LEFT JOIN. * - * @param Join|string $target - * A relation name or table. A relation name should be in camelCase, a table in CamelCase. + * @param Join|string|Select $target A relation name, table or sub-query. A relation name should be in camelCase, + * a table in CamelCase. * @param ?string $alias An alias. * @param WhereItem|array|null $conditions Join conditions. */ - public function leftJoin($target, ?string $alias = null, $conditions = null): self - { + public function leftJoin( + $target, + ?string $alias = null, + WhereItem|array|null $conditions = null + ): self { + return $this->joinInternal('leftJoins', $target, $alias, $conditions); } /** * @param 'leftJoins'|'joins' $type - * @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. - * * @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 */ - private function joinInternal(string $type, $target, ?string $alias = null, $conditions = null): self - { + private function joinInternal( + string $type, + $target, + ?string $alias = null, + WhereItem|array|null $conditions = null + ): self { + $onlyMiddle = false; + /** @var string|Join|array $target */ + if ($target instanceof Join) { $alias = $alias ?? $target->getAlias(); $conditions = $conditions ?? $target->getConditions(); @@ -199,11 +213,8 @@ trait SelectingBuilderTrait $target = $target->getTarget(); } - /** @phpstan-var mixed $conditions */ - /** @phpstan-var mixed $target */ - - if ($conditions !== null && !is_array($conditions) && !$conditions instanceof WhereItem) { - throw new InvalidArgumentException("Conditions must be WhereItem or array."); + if ($target instanceof Select && !$alias) { + throw new LogicException("Sub-query join can't be used w/o alias."); } $noLeftAlias = false; @@ -228,7 +239,12 @@ trait SelectingBuilderTrait return $this; } - if (is_null($alias) && is_null($conditions) && $this->hasJoinAliasInternal($type, $target)) { + if ( + is_null($alias) && + is_null($conditions) && + is_string($target) && + $this->hasJoinAliasInternal($type, $target) + ) { return $this; } diff --git a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php index 2276a76b49..71e2f2adf3 100644 --- a/application/Espo/ORM/QueryComposer/BaseQueryComposer.php +++ b/application/Espo/ORM/QueryComposer/BaseQueryComposer.php @@ -2931,10 +2931,10 @@ abstract class BaseQueryComposer implements QueryComposer $itemParams = []; if (is_array($item)) { - $relationName = $item[0]; + $target = $item[0]; if (count($item) > 1) { - $alias = $item[1] ?? $relationName; + $alias = $item[1] ?? $target; if (count($item) > 2) { $itemConditions = $item[2] ?? []; @@ -2945,12 +2945,16 @@ abstract class BaseQueryComposer implements QueryComposer } } else { - $alias = $relationName; + $alias = $target; + } + + if ($target instanceof Select && !is_string($alias)) { + throw new LogicException("Sub-query join can't be w/o alias"); } } else { - $relationName = $item; - $alias = $relationName; + $target = $item; + $alias = $target; } $conditions = []; @@ -2965,7 +2969,7 @@ abstract class BaseQueryComposer implements QueryComposer $sql = $this->getJoinItemPart( $entity, - $relationName, + $target, $isLeft, $conditions, $alias, @@ -3119,7 +3123,7 @@ abstract class BaseQueryComposer implements QueryComposer */ protected function getJoinItemPart( Entity $entity, - string $name, + string|Select $target, bool $isLeft = false, array $conditions = [], ?string $alias = null, @@ -3127,27 +3131,44 @@ abstract class BaseQueryComposer implements QueryComposer array $params = [] ): string { - $prefix = ($isLeft) ? 'LEFT ' : ''; + $prefixPart = $isLeft ? 'LEFT ' : ''; - if (!$entity->hasRelation($name)) { - $alias = !$alias ? - $this->sanitize($name) : - $this->sanitizeSelectAlias($alias); + if (!is_string($target) || !$entity->hasRelation($target)) { + if ($alias === '') { + throw new LogicException("Empty alias."); + } - $table = $this->toDb($this->sanitize($name)); + if (!is_string($target)) { + if ($alias === null) { + throw new LogicException(); + } - $sql = $prefix . "JOIN " . $this->quoteIdentifier($table) . " AS " . $this->quoteIdentifier($alias); + $alias = $this->sanitizeSelectAlias($alias); + } + else { + $alias = $alias === null ? + $this->sanitize($target) : + $this->sanitizeSelectAlias($alias); + } - if (empty($conditions)) { + $targetPart = is_string($target) ? + $this->quoteIdentifier($this->toDb($this->sanitize($target))) : + '(' . $this->composeSelecting($target) . ')'; + + $aliasPart = $this->quoteIdentifier($alias); + + $sql = $prefixPart . "JOIN $targetPart AS $aliasPart"; + + if ($conditions === []) { return $sql; } $sql .= " ON"; - $joinSqlList = []; + $conditionParts = []; foreach ($conditions as $left => $right) { - $joinSqlList[] = $this->buildJoinConditionStatement( + $conditionParts[] = $this->buildJoinConditionStatement( $entity, $alias, $left, @@ -3157,12 +3178,12 @@ abstract class BaseQueryComposer implements QueryComposer ); } - $sql .= " " . implode(" AND ", $joinSqlList); + $sql .= " " . implode(" AND ", $conditionParts); return $sql; } - $relationName = $name; + $relationName = $target; $keySet = $this->helper->getRelationKeys($entity, $relationName); @@ -3247,16 +3268,16 @@ abstract class BaseQueryComposer implements QueryComposer $middleDeletedColumn = $this->quoteColumn("$midAlias.deleted"); $sql = - "{$prefix}JOIN ".$this->quoteIdentifier($relTable)." AS " . + "{$prefixPart}JOIN ".$this->quoteIdentifier($relTable)." AS " . $this->quoteIdentifier($midAlias) . "$indexPart " . "ON $leftKeyColumn = $middleKeyColumn" . " AND " . "$middleDeletedColumn = " . $this->quote(false); - $joinSqlList = []; + $conditionParts = []; foreach ($conditions as $left => $right) { - $joinSqlList[] = $this->buildJoinConditionStatement( + $conditionParts[] = $this->buildJoinConditionStatement( $entity, $midAlias, $left, @@ -3265,8 +3286,8 @@ abstract class BaseQueryComposer implements QueryComposer ); } - if (count($joinSqlList)) { - $sql .= " AND " . implode(" AND ", $joinSqlList); + if (count($conditionParts)) { + $sql .= " AND " . implode(" AND ", $conditionParts); } if (!$onlyMiddle) { @@ -3274,7 +3295,7 @@ abstract class BaseQueryComposer implements QueryComposer $middleDistantKeyColumn = $this->quoteColumn("$midAlias." . $this->toDb($distantKey)); $rightDeletedColumn = $this->quoteColumn("$alias.deleted"); - $sql .= " {$prefix}JOIN " . $this->quoteIdentifier($distantTable) . " AS " . + $sql .= " {$prefixPart}JOIN " . $this->quoteIdentifier($distantTable) . " AS " . $this->quoteIdentifier($alias) . " ON $rightKeyColumn = $middleDistantKeyColumn" . " AND " @@ -3293,19 +3314,19 @@ abstract class BaseQueryComposer implements QueryComposer $leftDeletedColumn = $this->quoteColumn("$alias.deleted"); $sql = - "{$prefix}JOIN " . $this->quoteIdentifier($distantTable) . " AS " + "{$prefixPart}JOIN " . $this->quoteIdentifier($distantTable) . " AS " . $this->quoteIdentifier($alias) . " ON " . "$leftIdColumn = $rightIdColumn AND " . "$leftDeletedColumn = " . $this->quote(false); - $joinSqlList = []; + $conditionParts = []; foreach ($conditions as $left => $right) { - $joinSqlList[] = $this->buildJoinConditionStatement($entity, $alias, $left, $right, $params); + $conditionParts[] = $this->buildJoinConditionStatement($entity, $alias, $left, $right, $params); } - if (count($joinSqlList)) { - $sql .= " AND " . implode(" AND ", $joinSqlList); + if (count($conditionParts)) { + $sql .= " AND " . implode(" AND ", $conditionParts); } return $sql; @@ -3326,27 +3347,27 @@ abstract class BaseQueryComposer implements QueryComposer $leftDeletedColumn = $this->quoteColumn("$alias.deleted"); $sql = - "{$prefix}JOIN " . $this->quoteIdentifier($distantTable) + "{$prefixPart}JOIN " . $this->quoteIdentifier($distantTable) . " AS " . $this->quoteIdentifier($alias) . " ON " . "$leftIdColumn = $rightIdColumn AND " . "$leftTypeColumn = " . $this->quote($entity->getEntityType()) . " AND " . "$leftDeletedColumn = " . $this->quote(false); - $joinSqlList = []; + $conditionParts = []; foreach ($conditions as $left => $right) { - $joinSqlList[] = $this->buildJoinConditionStatement($entity, $alias, $left, $right, $params); + $conditionParts[] = $this->buildJoinConditionStatement($entity, $alias, $left, $right, $params); } - if (count($joinSqlList)) { - $sql .= " AND " . implode(" AND ", $joinSqlList); + if (count($conditionParts)) { + $sql .= " AND " . implode(" AND ", $conditionParts); } return $sql; case Entity::BELONGS_TO: - return $prefix . $this->getBelongsToJoinItemPart($entity, $relationName, $alias, $params); + return $prefixPart . $this->getBelongsToJoinItemPart($entity, $relationName, $alias, $params); } return ''; diff --git a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php index 5ac8b05b7e..dc91b8bb70 100644 --- a/tests/unit/Espo/ORM/MysqlQueryComposerTest.php +++ b/tests/unit/Espo/ORM/MysqlQueryComposerTest.php @@ -52,6 +52,7 @@ use Espo\ORM\Query\Part\Condition; use Espo\ORM\Query\Select; use Espo\ORM\Query\Update; +use LogicException; use RuntimeException; require_once 'tests/unit/testData/DB/Entities.php'; @@ -986,6 +987,89 @@ class MysqlQueryComposerTest extends \PHPUnit\Framework\TestCase ); } + public function testJoinSubQuery1(): void + { + $sql = + "SELECT post.id AS `id` FROM `post` " . + "JOIN (SELECT post.id AS `id` FROM `post` WHERE post.deleted = 0) AS `a` ON a.id = post.id " . + "WHERE post.deleted = 0"; + + $select = SelectBuilder::create() + ->select('id') + ->from('Post') + ->join( + Join + ::createWithSubQuery( + SelectBuilder::create() + ->select('id') + ->from('Post') + ->build(), + 'a' + ) + ->withConditions( + WhereClause::create( + Condition::equal( + Expression::column('a.id'), + Expression::column('post.id') + ) + ) + ) + ) + ->build(); + + $this->assertEquals( + $sql, + $this->query->composeSelect($select) + ); + } + + public function testJoinSubQuery2(): void + { + $sql = + "SELECT post.id AS `id` FROM `post` " . + "JOIN (SELECT post.id AS `id` FROM `post` WHERE post.deleted = 0) AS `a` ON a.id = post.id " . + "WHERE post.deleted = 0"; + + $select = SelectBuilder::create() + ->select('id') + ->from('Post') + ->join( + SelectBuilder::create() + ->select('id') + ->from('Post') + ->build(), + 'a', + Condition::equal( + Expression::column('a.id'), + Expression::column('post.id') + ) + ) + ->build(); + + $this->assertEquals( + $sql, + $this->query->composeSelect($select) + ); + } + + public function testJoinSubQueryException1(): void + { + $this->expectException(LogicException::class); + + $select = SelectBuilder::create() + ->select('id') + ->from('Post') + ->join( + SelectBuilder::create() + ->select('id') + ->from('Post') + ->build(), + ) + ->build(); + + $this->query->composeSelect($select); + } + public function testWhereNotValue1() { $sql = $this->query->compose(Select::fromRaw([ diff --git a/tests/unit/Espo/ORM/Query/Part/JoinTest.php b/tests/unit/Espo/ORM/Query/Part/JoinTest.php index 71f558bfd3..99833e6748 100644 --- a/tests/unit/Espo/ORM/Query/Part/JoinTest.php +++ b/tests/unit/Espo/ORM/Query/Part/JoinTest.php @@ -29,8 +29,10 @@ namespace tests\unit\Espo\ORM\Query\Part; +use Espo\ORM\Query\Part\Expression; use Espo\ORM\Query\Part\Join; use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\SelectBuilder; class JoinTest extends \PHPUnit\Framework\TestCase { @@ -50,6 +52,8 @@ class JoinTest extends \PHPUnit\Framework\TestCase $this->assertTrue($join->isRelation()); $this->assertFalse($join->isTable()); + + $this->assertEquals(Join::TYPE_RELATION, $join->getType()); } public function testCreate2(): void @@ -68,6 +72,8 @@ class JoinTest extends \PHPUnit\Framework\TestCase $this->assertTrue($join->isTable()); $this->assertFalse($join->isRelation()); + + $this->assertEquals(Join::TYPE_TABLE, $join->getType()); } public function testCreate3(): void @@ -77,4 +83,18 @@ class JoinTest extends \PHPUnit\Framework\TestCase $this->assertEquals('Test', $join->getTarget()); $this->assertEquals('testAlias', $join->getAlias()); } + + public function testCreate4(): void + { + $join = Join::createWithSubQuery( + SelectBuilder::create() + ->select(Expression::value(true)) + ->build() + , + 'a' + ); + + $this->assertTrue($join->isSubQuery()); + $this->assertEquals(Join::TYPE_SUB_QUERY, $join->getType()); + } }