diff --git a/composer.json b/composer.json index cb4fa6a0a..afb05405c 100755 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "utopia-php/cache": "^4.0", "utopia-php/pools": "^2.0", "utopia-php/mongo": "^1.0", - "utopia-php/query": "^0.4", + "utopia-php/query": "^0.5", "utopia-php/async": "^0.1" }, "require-dev": { diff --git a/composer.lock b/composer.lock index cc66748bb..f559e8cb4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6c32de508f93aa0e9269bdd86779b3ae", + "content-hash": "9ff3e93246620585fdcbcb7e0ae72315", "packages": [ { "name": "brick/math", @@ -2492,16 +2492,16 @@ }, { "name": "utopia-php/query", - "version": "0.4.0", + "version": "0.5.0", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "c334515035a2ab0aa49176eeca96de3e1c096e22" + "reference": "802821c6fb0470410e1f0e65561cc16224c343c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/c334515035a2ab0aa49176eeca96de3e1c096e22", - "reference": "c334515035a2ab0aa49176eeca96de3e1c096e22", + "url": "https://api.github.com/repos/utopia-php/query/zipball/802821c6fb0470410e1f0e65561cc16224c343c0", + "reference": "802821c6fb0470410e1f0e65561cc16224c343c0", "shasum": "" }, "require": { @@ -2535,9 +2535,9 @@ ], "support": { "issues": "https://github.com/utopia-php/query/issues", - "source": "https://github.com/utopia-php/query/tree/0.4.0" + "source": "https://github.com/utopia-php/query/tree/0.5.0" }, - "time": "2026-08-14T01:42:36+00:00" + "time": "2026-08-21T06:16:10+00:00" }, { "name": "utopia-php/telemetry", diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 0f89484e6..77fb4ff84 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -3551,7 +3551,20 @@ private function remapDottedQueryAttributes(array $queries, array $joinTablePref private function remapDottedQuery(BaseQuery $query, array $aliasSet, array $mainAttributes): void { $method = $query->getMethod(); - if ($method->isJoin() || $method === Method::Select) { + if ($method === Method::Select) { + return; + } + + if ($method->isJoin()) { + if ($query->isNestedJoin()) { + foreach ($query->getJoinOnQueries() as $onQuery) { + if ($onQuery->getMethod() === Method::On) { + continue; + } + $this->remapDottedQuery($onQuery, $aliasSet, $mainAttributes); + } + } + return; } @@ -3815,30 +3828,30 @@ private function remapJoinQueries(array &$queries): array $resolvedTable = $this->getSQLTableRaw($this->filter($joinTable)); $query->setAttribute($resolvedTable); - $values = $query->getValues(); $method = $query->getMethod(); - $aliasIndex = ($method === Method::CrossJoin || $method === Method::NaturalJoin) ? 0 : 3; - $joinAlias = $this->sanitizeJoinAlias( - \is_string($values[$aliasIndex] ?? null) ? $values[$aliasIndex] : '' - ); + $joinAlias = $this->sanitizeJoinAlias($query->getJoinAlias()); if ($joinAlias === '') { $joinAlias = 'j'.$joinIndex; } $joinIndex++; - if ($aliasIndex === 3 && \count($values) >= 3) { - $left = $values[0] ?? null; - $right = $values[2] ?? null; - if (! \is_string($left) || ! \is_string($right)) { - throw new QueryException('Join columns must be strings'); + if ($method === Method::CrossJoin || $method === Method::NaturalJoin) { + $query->setValues([$joinAlias]); + } elseif ($query->isNestedJoin()) { + $query->setValues($this->remapNestedJoinValues($query, $alias, $joinAlias)); + } else { + $values = $query->getValues(); + if (\count($values) >= 3) { + $left = $values[0] ?? null; + $right = $values[2] ?? null; + if (! \is_string($left) || ! \is_string($right)) { + throw new QueryException('Join columns must be strings'); + } + $values[0] = $this->qualifyJoinColumn($left, $alias); + $values[2] = $this->qualifyJoinColumn($right, $joinAlias); + $values[3] = $joinAlias; + $query->setValues($values); } - $values[0] = $this->qualifyJoinColumn($left, $alias); - $values[2] = $this->qualifyJoinColumn($right, $joinAlias); - $values[3] = $joinAlias; - $query->setValues($values); - } elseif ($aliasIndex === 0) { - $values[0] = $joinAlias; - $query->setValues($values); } $joinTablePrefixes[] = ['table' => $joinTable, 'alias' => $joinAlias]; @@ -3847,6 +3860,39 @@ private function remapJoinQueries(array &$queries): array return $joinTablePrefixes; } + /** + * @return list + */ + private function remapNestedJoinValues(BaseQuery $query, string $mainAlias, string $joinAlias): array + { + $values = [$joinAlias]; + foreach ($query->getJoinOnQueries() as $onQuery) { + $values[] = $this->remapNestedJoinOnQuery($onQuery, $mainAlias, $joinAlias); + } + + return $values; + } + + private function remapNestedJoinOnQuery(BaseQuery $onQuery, string $mainAlias, string $joinAlias): BaseQuery + { + if ($onQuery->getMethod() !== Method::On) { + return $onQuery; + } + + $values = $onQuery->getValues(); + $left = $values[0] ?? null; + $right = $values[2] ?? null; + if (! \is_string($left) || $left === '' || ! \is_string($right) || $right === '') { + throw new QueryException('Join ON requires left and right columns'); + } + + $values[0] = $this->qualifyJoinColumn($left, $mainAlias); + $values[2] = $this->qualifyJoinColumn($right, $joinAlias); + $onQuery->setValues($values); + + return $onQuery; + } + /** * @param array $queries */ diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php index 0ba94ea48..04bc79dcd 100644 --- a/src/Database/Validator/IndexedQueries.php +++ b/src/Database/Validator/IndexedQueries.php @@ -10,6 +10,7 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Query\Base; use Utopia\Query\Method; +use Utopia\Query\Query as BaseQuery; use Utopia\Query\Schema\IndexType; /** @@ -64,7 +65,7 @@ public function __construct(array $attributes = [], array $indexes = [], array $ /** * Count vector queries across entire query tree * - * @param array $queries + * @param array $queries */ private function countVectorQueries(array $queries): int { @@ -75,8 +76,10 @@ private function countVectorQueries(array $queries): int $count++; } - if ($query->isNested()) { - /** @var array $nestedValues */ + if ($query->isNestedJoin()) { + $count += $this->countVectorQueries($query->getJoinOnQueries()); + } elseif ($query->isNested()) { + /** @var array $nestedValues */ $nestedValues = $query->getValues(); $count += $this->countVectorQueries($nestedValues); } @@ -86,7 +89,7 @@ private function countVectorQueries(array $queries): int } /** - * @param array $queries + * @param array $queries * @return array */ private function joinAliases(array $queries): array @@ -98,11 +101,8 @@ private function joinAliases(array $queries): array continue; } - $method = $query->getMethod(); - $values = $query->getValues(); - $aliasIndex = ($method === Method::CrossJoin || $method === Method::NaturalJoin) ? 0 : 3; - $alias = $values[$aliasIndex] ?? ''; - if (\is_string($alias) && $alias !== '') { + $alias = $query->getJoinAlias(); + if ($alias !== '') { $aliases[$alias] = true; } } @@ -147,7 +147,7 @@ public function isValid($value): bool } /** - * @param array $queries + * @param array $queries * @param array $joinAliases */ private function validateSearchIndexes(array $queries, array $joinAliases): bool @@ -181,8 +181,12 @@ private function validateSearchIndexes(array $queries, array $joinAliases): bool } } - if ($query->isNested() && $query->getMethod() !== Method::Having) { - /** @var array $nested */ + if ($query->isNestedJoin()) { + if (! $this->validateSearchIndexes($query->getJoinOnQueries(), $joinAliases)) { + return false; + } + } elseif ($query->isNested() && $query->getMethod() !== Method::Having) { + /** @var array $nested */ $nested = $query->getValues(); if (! $this->validateSearchIndexes($nested, $joinAliases)) { return false; diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 9e6c8e2cd..1100b7ddf 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -123,13 +123,8 @@ public function isValid($value): bool if (! $query->getMethod()->isJoin()) { continue; } - $values = $query->getValues(); - $method = $query->getMethod(); - $alias = match ($method) { - Method::CrossJoin, Method::NaturalJoin => $values[0] ?? '', - default => $values[3] ?? '', - }; - if (\is_string($alias) && $alias !== '') { + $alias = $query->getJoinAlias(); + if ($alias !== '') { $joinAliases[] = $alias; } } @@ -145,6 +140,14 @@ public function isValid($value): bool } } + $hasFilterValidator = false; + foreach ($this->validators as $validator) { + if ($validator->getMethodType() === Base::METHOD_TYPE_FILTER) { + $hasFilterValidator = true; + break; + } + } + // Same pass: nested and/or children must keep the join aliases collected above. $pending = $parsedQueries; while ($pending !== []) { @@ -170,6 +173,15 @@ public function isValid($value): bool } } + if ($hasFilterValidator && $query->getMethod()->isJoin() && $query->isNestedJoin()) { + foreach ($query->getJoinOnQueries() as $onQuery) { + if ($onQuery->getMethod() === Method::On) { + continue; + } + $pending[] = $onQuery; + } + } + $method = $query->getMethod(); // Route every aggregate method through the single source of truth diff --git a/src/Database/Validator/Query/Join.php b/src/Database/Validator/Query/Join.php index a89b7cd1c..4b0d22d2e 100644 --- a/src/Database/Validator/Query/Join.php +++ b/src/Database/Validator/Query/Join.php @@ -47,6 +47,39 @@ public function isValid($value): bool return false; } + if (! $value->isNestedJoin()) { + return true; + } + + $onQueries = $value->getJoinOnQueries(); + if ($onQueries === []) { + $this->message = 'Join ON requires at least one condition'; + + return false; + } + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + foreach ($onQueries as $onQuery) { + if ($onQuery->getMethod() !== Method::On) { + continue; + } + + $values = $onQuery->getValues(); + $left = $values[0] ?? ''; + $operator = $values[1] ?? '='; + $right = $values[2] ?? ''; + if (! \is_string($left) || $left === '' || ! \is_string($right) || $right === '') { + $this->message = 'Join ON requires left and right columns'; + + return false; + } + if (! \is_string($operator) || ! \in_array($operator, $allowedOperators, true)) { + $this->message = 'Invalid join operator: '.(\is_string($operator) ? $operator : \gettype($operator)); + + return false; + } + } + return true; } } diff --git a/tests/e2e/Adapter/Scopes/JoinTests.php b/tests/e2e/Adapter/Scopes/JoinTests.php index 57d28556d..6e91289b0 100644 --- a/tests/e2e/Adapter/Scopes/JoinTests.php +++ b/tests/e2e/Adapter/Scopes/JoinTests.php @@ -5666,4 +5666,63 @@ private function aliasedScores(array $documents): array return $scores; } + + public function testLeftJoinOnFilterKeepsUnmatchedMainRows(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljon_p'; + $rCol = 'ljon_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection($pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())]); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection($rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())]); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + foreach (['p1' => 'Alpha', 'p2' => 'Beta', 'p3' => 'Gamma'] as $id => $name) { + $database->createDocument($pCol, new Document([ + '$id' => $id, + 'name' => $name, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + foreach ([ + ['prod_uid' => 'p1', 'score' => 5], + ['prod_uid' => 'p2', 'score' => 2], + ] as $review) { + $database->createDocument($rCol, new Document(array_merge($review, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->find($pCol, [ + Query::leftJoin($rCol, 'rev', [ + Query::on('$id', 'prod_uid'), + Query::greaterThanEqual('rev.score', 4), + ]), + Query::select(['name', 'rev.score']), + ]); + + $this->assertCount(3, $results); + $mapped = []; + foreach ($results as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $mapped[$name] = $doc->getAttribute('rev.score'); + } + $this->assertEquals(5, $mapped['Alpha']); + $this->assertTrue($mapped['Beta'] === null || $mapped['Beta'] === ''); + $this->assertTrue($mapped['Gamma'] === null || $mapped['Gamma'] === ''); + + $this->cleanupAggCollections($database, $cols); + } } diff --git a/tests/unit/SQLFindTest.php b/tests/unit/SQLFindTest.php index 27ec17510..a29a20e52 100644 --- a/tests/unit/SQLFindTest.php +++ b/tests/unit/SQLFindTest.php @@ -101,6 +101,23 @@ public function testJoinWithoutSelectProjectsQualifiedStars(): void $this->assertStringContainsString('LEFT JOIN', $sql); } + public function testNestedJoinOnCompilesPredicatesOntoJoin(): void + { + $sql = $this->captureFindSql([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', ['paid']), + ]), + ]); + + $this->assertQualifiedJoinStars($sql, joinAlias: 'ord'); + $this->assertStringContainsString('LEFT JOIN', $sql); + $this->assertStringContainsString('AS `ord`', $sql); + $this->assertMatchesRegularExpression('/ON\s+`table_main`\.`_uid`\s*=\s*`ord`\.`customerId`/i', $sql); + $this->assertStringContainsString('`ord`.`status`', $sql); + $this->assertDoesNotMatchRegularExpression('/WHERE[\s\S]*`ord`\.`status`/i', $sql); + } + public function testEmulatesFullOuterJoinWithOuterLimit(): void { $sql = $this->captureFindSql( diff --git a/tests/unit/Validator/DocumentQueriesTest.php b/tests/unit/Validator/DocumentQueriesTest.php index f71bdf543..edbfb7f7b 100644 --- a/tests/unit/Validator/DocumentQueriesTest.php +++ b/tests/unit/Validator/DocumentQueriesTest.php @@ -178,6 +178,53 @@ public function testNaturalJoinIsInvalid(): void $this->assertStringContainsString('Natural joins are not supported', $validator->getDescription()); } + public function testNestedJoinOnIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(true, $validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + ]), + ]), $validator->getDescription()); + } + + public function testNestedJoinOnWithFilterIsValidWithoutFilterValidator(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(true, $validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', ['paid']), + ]), + ]), $validator->getDescription()); + } + + public function testNestedJoinOnRequiresColumns(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(false, $validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('', 'customerId'), + ]), + ])); + $this->assertStringContainsString('Join ON requires left and right columns', $validator->getDescription()); + } + + public function testSelectWithNestedJoinAliasIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(true, $validator->isValid([ + Query::select(['ord.amount']), + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + ]), + ]), $validator->getDescription()); + } + /** * @return array */ diff --git a/tests/unit/Validator/IndexedQueriesTest.php b/tests/unit/Validator/IndexedQueriesTest.php index 7c8c0316c..05fbaacb4 100644 --- a/tests/unit/Validator/IndexedQueriesTest.php +++ b/tests/unit/Validator/IndexedQueriesTest.php @@ -203,6 +203,108 @@ public function test_join_side_search_skips_main_fulltext_index(): void ); } + public function testNestedJoinOnSearchRequiresFulltextIndex(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new IndexedQueries( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('meta', 'meta', [ + Query::on('$id', 'mainId'), + Query::search('name', 'needle'), + ]), + ])); + $this->assertSame( + 'Searching by attribute "name" requires a fulltext index.', + $validator->getDescription() + ); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('meta', 'meta', [ + Query::on('$id', 'mainId'), + Query::search('meta.body', 'needle'), + ]), + ]), $validator->getDescription()); + } + + public function testNestedJoinOnVectorCountsTowardLimit(): void + { + $attributes = [ + new Document([ + '$id' => 'embedding', + 'key' => 'embedding', + 'type' => ColumnType::Vector->value, + 'size' => 3, + 'array' => false, + ]), + ]; + + $validator = new IndexedQueries( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + + $this->assertFalse($validator->isValid([ + Query::vectorDot('embedding', [0.1, 0.2, 0.3]), + Query::leftJoin('meta', 'meta', [ + Query::on('$id', 'mainId'), + Query::vectorCosine('embedding', [0.3, 0.4, 0.5]), + ]), + ])); + $this->assertSame( + 'Cannot use multiple vector queries in a single request', + $validator->getDescription() + ); + } + + public function testNestedJoinOnSingleVectorIsValid(): void + { + $attributes = [ + new Document([ + '$id' => 'embedding', + 'key' => 'embedding', + 'type' => ColumnType::Vector->value, + 'size' => 3, + 'array' => false, + ]), + ]; + + $validator = new IndexedQueries( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('meta', 'meta', [ + Query::on('$id', 'mainId'), + Query::vectorCosine('embedding', [0.3, 0.4, 0.5]), + ]), + ]), $validator->getDescription()); + } + public function test_two_attributes_fulltext(): void { $attributes = [ diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 7c5e55136..2e57a6b5c 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -248,6 +248,57 @@ public function test_filter_before_join_accepts_dotted_alias(): void ]), $validator->getDescription()); } + public function testNestedJoinAliasIsCollected(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Queries([ + new Select($attributes), + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::select(['ord.amount']), + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customer_uid'), + Query::equal('ord.status', ['paid']), + ]), + ]), $validator->getDescription()); + } + + public function testNestedJoinOnFilterUnknownAliasIsInvalid(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Queries([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customer_uid'), + Query::equal('missing.status', ['paid']), + ]), + ])); + $this->assertStringContainsString('Attribute not found in schema', $validator->getDescription()); + } + public function test_order_before_join_accepts_dotted_alias(): void { $attributes = [