diff --git a/src/Query/Builder.php b/src/Query/Builder.php index eb2f8c9..8568955 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -659,15 +659,7 @@ private function buildJoinsClause(ParsedQuery $grouped, array &$joinFilterWhereC default => throw new UnsupportedException('Unsupported join method: ' . $joinQuery->getMethod()->value), }; $isCrossJoin = $joinType === JoinType::Cross || $joinType === JoinType::Natural; - - $joinValues = $joinQuery->getValues(); - if ($isCrossJoin) { - /** @var string $joinAlias */ - $joinAlias = $joinValues[0] ?? ''; - } else { - /** @var string $joinAlias */ - $joinAlias = $joinValues[3] ?? ''; - } + $joinAlias = $joinQuery->getJoinAlias(); $effectiveJoinTable = $joinAlias !== '' ? $joinAlias : $joinTable; foreach ($this->joinFilterHooks as $hook) { @@ -1301,6 +1293,7 @@ public function compileFilter(Query $query): string Method::Exists => $this->compileExists($query), Method::NotExists => $this->compileNotExists($query), Method::Raw => $this->compileRaw($query), + Method::On => $this->compileOn($query), default => throw new UnsupportedException('Unsupported filter type: ' . $method->value), }; } @@ -1483,6 +1476,15 @@ public function compileJoin(Query $query): string return $type . ' ' . $table; } + if ($query->isNestedJoin()) { + $alias = $query->getJoinAlias(); + if ($alias !== '') { + $table .= ' AS ' . $this->quote($alias); + } + + return $type . ' ' . $table . ' ON ' . \implode(' AND ', $this->compileJoinOn($query)); + } + if (empty($values)) { return $type . ' ' . $table; } @@ -1511,6 +1513,53 @@ public function compileJoin(Query $query): string return $type . ' ' . $table . ' ON ' . $left . ' ' . $operator . ' ' . $right; } + /** + * @return list + */ + private function compileJoinOn(Query $query): array + { + $onQueries = $query->getJoinOnQueries(); + if ($onQueries === []) { + throw new ValidationException('Join ON requires at least one condition'); + } + + $parts = []; + + foreach ($onQueries as $onQuery) { + $this->assertJoinOnPredicate($onQuery); + if ($onQuery->getMethod() === Method::On) { + $parts[] = $this->compileOn($onQuery); + continue; + } + + $parts[] = $this->compileFilter($onQuery); + } + + return $parts; + } + + private function compileOn(Query $query): string + { + $values = $query->getValues(); + /** @var string $leftCol */ + $leftCol = $values[0] ?? ''; + /** @var string $operator */ + $operator = $values[1] ?? '='; + /** @var string $rightCol */ + $rightCol = $values[2] ?? ''; + + if ($leftCol === '' || $rightCol === '') { + throw new ValidationException('Join ON requires left and right columns'); + } + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (! \in_array($operator, $allowedOperators, true)) { + throw new ValidationException('Invalid join operator: ' . $operator); + } + + return $this->resolveAndWrap($leftCol) . ' ' . $operator . ' ' . $this->resolveAndWrap($rightCol); + } + protected function compileJoinWithBuilder(Query $query, JoinBuilder $joinBuilder): string { $type = match ($query->getMethod()) { @@ -1524,16 +1573,7 @@ protected function compileJoinWithBuilder(Query $query, JoinBuilder $joinBuilder }; $table = $this->quote($query->getAttribute()); - $values = $query->getValues(); - - // Handle alias - if ($query->getMethod() === Method::CrossJoin || $query->getMethod() === Method::NaturalJoin) { - /** @var string $alias */ - $alias = $values[0] ?? ''; - } else { - /** @var string $alias */ - $alias = $values[3] ?? ''; - } + $alias = $query->getJoinAlias(); if ($alias !== '') { $table .= ' AS ' . $this->quote($alias); @@ -1905,6 +1945,8 @@ public function toAst(): Select { $grouped = Query::groupByType($this->pendingQueries); + $this->prepareAliasQualification($grouped); + $columns = $this->buildAstColumns($grouped); $from = $this->buildAstFrom(); $joins = $this->buildAstJoins($grouped); @@ -1977,6 +2019,10 @@ private function columnNameToAstExpression(string $col): Expression return new Column($parts[1], $parts[0]); } + if ($this->qualify && ! isset($this->aggregationAliases[$col])) { + return new Column($col, $this->alias); + } + return new Column($col); } @@ -2040,10 +2086,13 @@ private function buildAstJoins(ParsedQuery $grouped): array $isCrossOrNatural = $joinMethod === Method::CrossJoin || $joinMethod === Method::NaturalJoin; if ($isCrossOrNatural) { - /** @var string $joinAlias */ - $joinAlias = $values[0] ?? ''; + $joinAlias = $joinQuery->getJoinAlias(); $tableRef = new Table($table, $joinAlias !== '' ? $joinAlias : null); $joins[] = new AstJoinClause($type, $tableRef, null); + } elseif ($joinQuery->isNestedJoin()) { + $joinAlias = $joinQuery->getJoinAlias(); + $tableRef = new Table($table, $joinAlias !== '' ? $joinAlias : null); + $joins[] = new AstJoinClause($type, $tableRef, $this->nestedJoinOnToAst($joinQuery)); } else { /** @var string $leftCol */ $leftCol = $values[0] ?? ''; @@ -2051,8 +2100,7 @@ private function buildAstJoins(ParsedQuery $grouped): array $operator = $values[1] ?? '='; /** @var string $rightCol */ $rightCol = $values[2] ?? ''; - /** @var string $joinAlias */ - $joinAlias = $values[3] ?? ''; + $joinAlias = $joinQuery->getJoinAlias(); $tableRef = new Table($table, $joinAlias !== '' ? $joinAlias : null); @@ -2112,11 +2160,96 @@ private function queryToAstExpression(Query $query): Expression Method::NotEndsWith => new Binary(new Column($attr), 'NOT LIKE', new Literal('%' . $this->toScalar($values[0] ?? ''))), Method::And => $this->buildLogicalAstExpression($query, 'AND'), Method::Or => $this->buildLogicalAstExpression($query, 'OR'), + Method::On => $this->buildOnAstExpression($query), Method::Raw => new Raw($attr), default => new Raw($attr !== '' ? $attr : '1 = 1'), }; } + private function nestedJoinOnToAst(Query $joinQuery): Expression + { + $onQueries = $joinQuery->getJoinOnQueries(); + if ($onQueries === []) { + throw new ValidationException('Join ON requires at least one condition'); + } + + $exprs = []; + foreach ($onQueries as $onQuery) { + $this->assertJoinOnPredicate($onQuery); + $exprs[] = $this->queryToAstExpression($onQuery); + } + + return $this->combineAstExpressions($exprs, 'AND'); + } + + private function assertJoinOnPredicate(Query $query): void + { + $method = $query->getMethod(); + $allowed = match ($method) { + Method::On, + Method::Equal, + Method::NotEqual, + Method::GreaterThan, + Method::GreaterThanEqual, + Method::LessThan, + Method::LessThanEqual, + Method::Between, + Method::NotBetween, + Method::IsNull, + Method::IsNotNull, + Method::Contains, + Method::ContainsAny, + Method::NotContains, + Method::StartsWith, + Method::NotStartsWith, + Method::EndsWith, + Method::NotEndsWith, + Method::And, + Method::Or => true, + default => false, + }; + + if (! $allowed) { + throw new ValidationException('Unsupported join ON condition: ' . $method->value); + } + + if ($method !== Method::And && $method !== Method::Or) { + return; + } + + foreach ($query->getValues() as $child) { + if ($child instanceof Query) { + $this->assertJoinOnPredicate($child); + } + } + } + + private function buildOnAstExpression(Query $query): Expression + { + $values = $query->getValues(); + /** @var string $leftCol */ + $leftCol = $values[0] ?? ''; + /** @var string $operator */ + $operator = $values[1] ?? '='; + /** @var string $rightCol */ + $rightCol = $values[2] ?? ''; + + if ($leftCol === '' || $rightCol === '') { + throw new ValidationException('Join ON requires left and right columns'); + } + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (! \in_array($operator, $allowedOperators, true)) { + throw new ValidationException('Invalid join operator: ' . $operator); + } + + return new Binary( + $this->columnNameToAstExpression($leftCol), + $operator, + $this->columnNameToAstExpression($rightCol), + ); + } + private function toLiteral(mixed $value): Literal { if ($value === null || \is_string($value) || \is_int($value) || \is_float($value) || \is_bool($value)) { diff --git a/src/Query/Method.php b/src/Query/Method.php index c32801d..3b60e6f 100644 --- a/src/Query/Method.php +++ b/src/Query/Method.php @@ -96,6 +96,7 @@ enum Method: string case CrossJoin = 'crossJoin'; case FullOuterJoin = 'fullOuterJoin'; case NaturalJoin = 'naturalJoin'; + case On = 'on'; // Union case Union = 'union'; diff --git a/src/Query/Query.php b/src/Query/Query.php index 1c8fe54..6aab214 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -78,6 +78,59 @@ public function getValue(mixed $default = null): mixed return $this->values[0] ?? $default; } + public function isNestedJoin(): bool + { + if (! $this->method->isJoin()) { + return false; + } + + foreach ($this->values as $value) { + if ($value instanceof self) { + return true; + } + } + + return false; + } + + public function getJoinAlias(): string + { + if ($this->method === Method::CrossJoin || $this->method === Method::NaturalJoin) { + $alias = $this->values[0] ?? ''; + + return \is_string($alias) ? $alias : ''; + } + + if ($this->isNestedJoin()) { + $first = $this->values[0] ?? null; + + return \is_string($first) ? $first : ''; + } + + $alias = $this->values[3] ?? ''; + + return \is_string($alias) ? $alias : ''; + } + + /** + * @return list + */ + public function getJoinOnQueries(): array + { + if (! $this->isNestedJoin()) { + return []; + } + + $queries = []; + foreach ($this->values as $value) { + if ($value instanceof self) { + $queries[] = $value; + } + } + + return $queries; + } + /** * Sets method */ @@ -204,6 +257,13 @@ public static function parseQuery(array $query, bool $allowRaw = false): static /** @var array $value */ $values[$index] = static::parseQuery($value, $allowRaw); } + } elseif ($methodEnum->isJoin()) { + foreach ($values as $index => $value) { + if (\is_array($value) && isset($value['method']) && \is_string($value['method'])) { + /** @var array $value */ + $values[$index] = static::parseQuery($value, $allowRaw); + } + } } return new static($methodEnum, $attribute, $values); @@ -291,7 +351,7 @@ public function shape(): string $node = \array_pop($stack); $nodes[] = $node; - if (! \in_array($node->method, self::LOGICAL_TYPES, true)) { + if (! \in_array($node->method, self::LOGICAL_TYPES, true) && ! $node->isNestedJoin()) { continue; } foreach ($node->values as $child) { @@ -306,7 +366,7 @@ public function shape(): string foreach (\array_reverse($nodes) as $node) { $id = \spl_object_id($node); - if (! \in_array($node->method, self::LOGICAL_TYPES, true)) { + if (! \in_array($node->method, self::LOGICAL_TYPES, true) && ! $node->isNestedJoin()) { $shapes[$id] = $node->method->value.':'.$node->attribute; continue; @@ -338,16 +398,9 @@ public function toArray(): array $array['attribute'] = $this->attribute; } - if ($this->method->isNested()) { - foreach ($this->values as $index => $value) { - /** @var Query $value */ - $array['values'][$index] = $value->toArray(); - } - } else { - $array['values'] = []; - foreach ($this->values as $value) { - $array['values'][] = $value; - } + $array['values'] = []; + foreach ($this->values as $value) { + $array['values'][] = $value instanceof self ? $value->toArray() : $value; } return $array; @@ -1240,34 +1293,39 @@ public static function distinct(): static // Join factory methods - public static function join(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + /** + * Column-to-column join ON condition. + */ + public static function on(string $left, string $right, string $operator = '='): static { - $values = [$left, $operator, $right]; - if ($alias !== '') { - $values[] = $alias; - } - - return new static(Method::Join, $table, $values); + return new static(Method::On, '', [$left, $operator, $right]); } - public static function leftJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + /** + * @param string|list $leftOrAliasOrOn + * @param string|list $rightOrOn + */ + public static function join(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): static { - $values = [$left, $operator, $right]; - if ($alias !== '') { - $values[] = $alias; - } - - return new static(Method::LeftJoin, $table, $values); + return self::createJoin(Method::Join, $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); } - public static function rightJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + /** + * @param string|list $leftOrAliasOrOn + * @param string|list $rightOrOn + */ + public static function leftJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): static { - $values = [$left, $operator, $right]; - if ($alias !== '') { - $values[] = $alias; - } + return self::createJoin(Method::LeftJoin, $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); + } - return new static(Method::RightJoin, $table, $values); + /** + * @param string|list $leftOrAliasOrOn + * @param string|list $rightOrOn + */ + public static function rightJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): static + { + return self::createJoin(Method::RightJoin, $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); } public static function crossJoin(string $table, string $alias = ''): static @@ -1275,19 +1333,67 @@ public static function crossJoin(string $table, string $alias = ''): static return new static(Method::CrossJoin, $table, $alias !== '' ? [$alias] : []); } - public static function fullOuterJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + /** + * @param string|list $leftOrAliasOrOn + * @param string|list $rightOrOn + */ + public static function fullOuterJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): static + { + return self::createJoin(Method::FullOuterJoin, $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); + } + + public static function naturalJoin(string $table, string $alias = ''): static + { + return new static(Method::NaturalJoin, $table, $alias !== '' ? [$alias] : []); + } + + /** + * @param string|list $leftOrAliasOrOn + * @param string|list $rightOrOn + */ + private static function createJoin(Method $method, string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn, string $operator, string $alias): static { - $values = [$left, $operator, $right]; + if (\is_array($leftOrAliasOrOn)) { + return self::createNestedJoin($method, $table, '', $leftOrAliasOrOn); + } + + if (\is_array($rightOrOn)) { + return self::createNestedJoin($method, $table, $leftOrAliasOrOn, $rightOrOn); + } + + $values = [$leftOrAliasOrOn, $operator, $rightOrOn]; if ($alias !== '') { $values[] = $alias; } - return new static(Method::FullOuterJoin, $table, $values); + return new static($method, $table, $values); } - public static function naturalJoin(string $table, string $alias = ''): static + /** + * @param array $on + */ + private static function createNestedJoin(Method $method, string $table, string $alias, array $on): static { - return new static(Method::NaturalJoin, $table, $alias !== '' ? [$alias] : []); + if ($on === []) { + throw new ValidationException('Join ON requires at least one condition'); + } + + $values = []; + if ($alias !== '') { + $values[] = $alias; + } + + foreach ($on as $query) { + if (\is_string($query)) { + $query = static::parse($query); + } + if (! $query instanceof self) { + throw new ValidationException('Join ON conditions must be Query objects'); + } + $values[] = $query; + } + + return new static($method, $table, $values); } // Union factory methods diff --git a/tests/Query/API/HelperTest.php b/tests/Query/API/HelperTest.php index 21785d1..219f900 100644 --- a/tests/Query/API/HelperTest.php +++ b/tests/Query/API/HelperTest.php @@ -50,6 +50,7 @@ public function testIsMethodValid(): void $this->assertTrue(Query::isMethod('leftJoin')); $this->assertTrue(Query::isMethod('rightJoin')); $this->assertTrue(Query::isMethod('crossJoin')); + $this->assertTrue(Query::isMethod('on')); $this->assertTrue(Query::isMethod('union')); $this->assertTrue(Query::isMethod('unionAll')); $this->assertTrue(Query::isMethod('raw')); diff --git a/tests/Query/API/JoinTest.php b/tests/Query/API/JoinTest.php index bf601bb..098aecb 100644 --- a/tests/Query/API/JoinTest.php +++ b/tests/Query/API/JoinTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Builder\MySQL; +use Utopia\Query\Exception\ValidationException; use Utopia\Query\Method; use Utopia\Query\Query; @@ -54,6 +55,7 @@ public function testJoinMethodsAreJoin(): void $this->assertTrue(Method::CrossJoin->isJoin()); $this->assertTrue(Method::FullOuterJoin->isJoin()); $this->assertTrue(Method::NaturalJoin->isJoin()); + $this->assertFalse(Method::On->isJoin()); $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); $this->assertCount(6, $joinMethods); } @@ -141,5 +143,187 @@ public function testJoinIsNotNested(): void { $query = Query::join('t', 'a', 'b'); $this->assertFalse($query->isNested()); + $this->assertFalse($query->isNestedJoin()); + } + + public function testOn(): void + { + $query = Query::on('$id', 'customerId'); + $this->assertSame(Method::On, $query->getMethod()); + $this->assertSame('', $query->getAttribute()); + $this->assertSame(['$id', '=', 'customerId'], $query->getValues()); + } + + public function testOnWithOperator(): void + { + $query = Query::on('a.id', 'b.aid', '!='); + $this->assertSame(['a.id', '!=', 'b.aid'], $query->getValues()); + } + + public function testNestedLeftJoinWithoutAlias(): void + { + $query = Query::leftJoin('orders', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', ['paid']), + ]); + + $this->assertTrue($query->isNestedJoin()); + $this->assertSame('', $query->getJoinAlias()); + $on = $query->getJoinOnQueries(); + $this->assertCount(2, $on); + $this->assertSame(Method::On, $on[0]->getMethod()); + $this->assertSame(Method::Equal, $on[1]->getMethod()); + } + + public function testNestedLeftJoinWithAlias(): void + { + $query = Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', ['paid']), + ]); + + $this->assertSame(Method::LeftJoin, $query->getMethod()); + $this->assertSame('orders', $query->getAttribute()); + $this->assertTrue($query->isNestedJoin()); + $this->assertSame('ord', $query->getJoinAlias()); + $this->assertCount(2, $query->getJoinOnQueries()); + } + + public function testNestedJoinAndRightJoinAndFullOuterJoin(): void + { + $on = [Query::on('users.id', 'orders.user_id')]; + + $inner = Query::join('orders', 'ord', $on); + $this->assertSame(Method::Join, $inner->getMethod()); + $this->assertTrue($inner->isNestedJoin()); + $this->assertSame('ord', $inner->getJoinAlias()); + + $right = Query::rightJoin('orders', $on); + $this->assertSame(Method::RightJoin, $right->getMethod()); + $this->assertTrue($right->isNestedJoin()); + $this->assertSame('', $right->getJoinAlias()); + + $full = Query::fullOuterJoin('orders', 'ord', $on); + $this->assertSame(Method::FullOuterJoin, $full->getMethod()); + $this->assertTrue($full->isNestedJoin()); + $this->assertSame('ord', $full->getJoinAlias()); + } + + public function testNestedJoinEmptyOnThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Join ON requires at least one condition'); + Query::leftJoin('orders', []); + } + + public function testNestedJoinRejectsNonQueryOn(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Join ON conditions must be Query objects'); + /** @phpstan-ignore argument.type */ + Query::leftJoin('orders', [42]); + } + + public function testNestedJoinFromJsonString(): void + { + $query = Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId')->toString(), + ]); + + $this->assertTrue($query->isNestedJoin()); + $this->assertSame(Method::On, $query->getJoinOnQueries()[0]->getMethod()); + } + + public function testSimpleJoinStillUsesTriple(): void + { + $query = Query::leftJoin('orders', '$id', 'customerId', '=', 'ord'); + $this->assertFalse($query->isNestedJoin()); + $this->assertSame(['$id', '=', 'customerId', 'ord'], $query->getValues()); + $this->assertSame('ord', $query->getJoinAlias()); + } + + public function testNestedLeftJoinCompile(): void + { + $builder = new MySQL(); + $query = Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + ]); + + $this->assertSame( + 'LEFT JOIN `orders` AS `ord` ON `$id` = `customerId`', + $query->compile($builder), + ); + } + + public function testNestedLeftJoinCompileWithFilter(): void + { + $builder = new MySQL(); + $query = Query::leftJoin('orders', 'ord', [ + Query::on('users.id', 'orders.customer_id'), + Query::equal('ord.status', ['paid']), + ]); + + $this->assertSame( + 'LEFT JOIN `orders` AS `ord` ON `users`.`id` = `orders`.`customer_id` AND `ord`.`status` IN (?)', + $query->compile($builder), + ); + $this->assertSame(['paid'], $builder->getBindings()); + } + + public function testNestedJoinCompileWithoutAlias(): void + { + $builder = new MySQL(); + $query = Query::join('orders', [ + Query::on('users.id', 'orders.user_id'), + ]); + + $this->assertSame( + 'JOIN `orders` ON `users`.`id` = `orders`.`user_id`', + $query->compile($builder), + ); + } + + public function testOnCompileRequiresColumns(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Join ON requires left and right columns'); + Query::on('', 'customerId')->compile(new MySQL()); + } + + public function testOnCompileRejectsInvalidOperator(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid join operator: LIKE'); + Query::on('$id', 'customerId', 'LIKE')->compile(new MySQL()); + } + + public function testNestedJoinRejectsSearchOnCompile(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Unsupported join ON condition: search'); + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::search('ord.status', 'paid'), + ])->compile(new MySQL()); + } + + public function testNestedJoinRejectsRegexOnCompile(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Unsupported join ON condition: regex'); + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::regex('ord.status', 'paid'), + ])->compile(new MySQL()); + } + + public function testNestedJoinShapeIncludesOnQueries(): void + { + $query = Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', ['paid']), + ]); + + $this->assertSame('leftJoin:orders(equal:ord.status|on:)', $query->shape()); } } diff --git a/tests/Query/API/ParseTest.php b/tests/Query/API/ParseTest.php index 56c2723..b7554ff 100644 --- a/tests/Query/API/ParseTest.php +++ b/tests/Query/API/ParseTest.php @@ -246,6 +246,52 @@ public function testRoundTripJoin(): void $this->assertSame(['users.id', '=', 'orders.user_id'], $parsed->getValues()); } + public function testRoundTripNestedJoin(): void + { + $original = Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', ['paid']), + ]); + $parsed = Query::parse($original->toString()); + + $this->assertSame(Method::LeftJoin, $parsed->getMethod()); + $this->assertSame('orders', $parsed->getAttribute()); + $this->assertTrue($parsed->isNestedJoin()); + $this->assertSame('ord', $parsed->getJoinAlias()); + $on = $parsed->getJoinOnQueries(); + $this->assertCount(2, $on); + $this->assertSame(Method::On, $on[0]->getMethod()); + $this->assertSame(['$id', '=', 'customerId'], $on[0]->getValues()); + $this->assertSame(Method::Equal, $on[1]->getMethod()); + $this->assertSame('ord.status', $on[1]->getAttribute()); + $this->assertSame(['paid'], $on[1]->getValues()); + } + + public function testRoundTripOn(): void + { + $original = Query::on('$id', 'customerId', '!='); + $parsed = Query::parse($original->toString()); + $this->assertSame(Method::On, $parsed->getMethod()); + $this->assertSame(['$id', '!=', 'customerId'], $parsed->getValues()); + } + + public function testParseNestedJoinFromArray(): void + { + $parsed = Query::parseQuery([ + 'method' => 'leftJoin', + 'attribute' => 'orders', + 'values' => [ + 'ord', + ['method' => 'on', 'values' => ['$id', '=', 'customerId']], + ['method' => 'equal', 'attribute' => 'ord.status', 'values' => ['paid']], + ], + ]); + + $this->assertTrue($parsed->isNestedJoin()); + $this->assertSame('ord', $parsed->getJoinAlias()); + $this->assertCount(2, $parsed->getJoinOnQueries()); + } + public function testRoundTripCrossJoin(): void { $original = Query::crossJoin('colors'); diff --git a/tests/Query/AST/BuilderAstTest.php b/tests/Query/AST/BuilderAstTest.php index 958190c..3d4e70c 100644 --- a/tests/Query/AST/BuilderAstTest.php +++ b/tests/Query/AST/BuilderAstTest.php @@ -525,6 +525,73 @@ public function testToAstLeftJoin(): void $this->assertSame('LEFT JOIN', $ast->joins[0]->type); } + public function testToAstNestedJoinOn(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([ + Query::leftJoin('orders', 'ord', [ + Query::on('users.id', 'orders.user_id'), + Query::equal('ord.status', ['paid']), + ]), + ]); + + $ast = $builder->toAst(); + + $this->assertCount(1, $ast->joins); + $join = $ast->joins[0]; + $this->assertSame('LEFT JOIN', $join->type); + $this->assertInstanceOf(Table::class, $join->table); + $this->assertSame('orders', $join->table->name); + $this->assertSame('ord', $join->table->alias); + $this->assertInstanceOf(Binary::class, $join->condition); + $this->assertSame('AND', $join->condition->operator); + $this->assertInstanceOf(Binary::class, $join->condition->left); + $this->assertSame('=', $join->condition->left->operator); + $this->assertInstanceOf(Column::class, $join->condition->left->left); + $this->assertSame('id', $join->condition->left->left->name); + $this->assertSame('users', $join->condition->left->left->table); + } + + public function testToAstNestedJoinOnQualifiesUnqualifiedOperandsWithBaseAlias(): void + { + $builder = (new MySQL()) + ->from('users', 'u') + ->filter([ + Query::leftJoin('orders', 'ord', [ + Query::on('id', 'userId'), + ]), + ]); + + $ast = $builder->toAst(); + + $this->assertCount(1, $ast->joins); + $condition = $ast->joins[0]->condition; + $this->assertInstanceOf(Binary::class, $condition); + $this->assertInstanceOf(Column::class, $condition->left); + $this->assertSame('id', $condition->left->name); + $this->assertSame('u', $condition->left->table); + $this->assertInstanceOf(Column::class, $condition->right); + $this->assertSame('userId', $condition->right->name); + $this->assertSame('u', $condition->right->table); + } + + public function testToAstNestedJoinRejectsUnsupportedOnPredicate(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([ + Query::leftJoin('orders', 'ord', [ + Query::on('users.id', 'orders.user_id'), + Query::search('ord.status', 'paid'), + ]), + ]); + + $this->expectException(\Utopia\Query\Exception\ValidationException::class); + $this->expectExceptionMessage('Unsupported join ON condition: search'); + $builder->toAst(); + } + public function testToAstCrossJoin(): void { $builder = (new MySQL()) diff --git a/tests/Query/Builder/MySQLTest.php b/tests/Query/Builder/MySQLTest.php index 712ce16..b72f5a7 100644 --- a/tests/Query/Builder/MySQLTest.php +++ b/tests/Query/Builder/MySQLTest.php @@ -3196,6 +3196,56 @@ public function testCompileCrossJoinStandalone(): void $sql = $builder->compileJoin(Query::crossJoin('colors')); $this->assertSame('CROSS JOIN `colors`', $sql); } + + public function testCompileNestedJoinOn(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::leftJoin('orders', 'ord', [ + Query::on('users.id', 'orders.user_id'), + Query::equal('ord.status', ['paid']), + ])); + $this->assertSame( + 'LEFT JOIN `orders` AS `ord` ON `users`.`id` = `orders`.`user_id` AND `ord`.`status` IN (?)', + $sql, + ); + $this->assertSame(['paid'], $builder->getBindings()); + } + + public function testBuildNestedJoinOn(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::leftJoin('orders', 'ord', [ + Query::on('users.id', 'orders.user_id'), + Query::equal('ord.status', ['paid']), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `users` LEFT JOIN `orders` AS `ord` ON `users`.`id` = `orders`.`user_id` AND `ord`.`status` IN (?)', + $result->query, + ); + $this->assertSame(['paid'], $result->bindings); + } + + public function testBuildNestedJoinOnQualifiesUnqualifiedOperandsWithBaseAlias(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->filter([ + Query::leftJoin('orders', 'ord', [ + Query::on('id', 'userId'), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM `users` AS `u` LEFT JOIN `orders` AS `ord` ON `u`.`id` = `u`.`userId`', + $result->query, + ); + } // 6. Filter edge cases public function testEqualWithSingleValue(): void