Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
14 changes: 7 additions & 7 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 64 additions & 18 deletions src/Database/Adapter/SQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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];
Expand All @@ -3847,6 +3860,39 @@ private function remapJoinQueries(array &$queries): array
return $joinTablePrefixes;
}

/**
* @return list<mixed>
*/
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<BaseQuery> $queries
*/
Expand Down
28 changes: 16 additions & 12 deletions src/Database/Validator/IndexedQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -64,7 +65,7 @@ public function __construct(array $attributes = [], array $indexes = [], array $
/**
* Count vector queries across entire query tree
*
* @param array<Query> $queries
* @param array<BaseQuery> $queries
*/
private function countVectorQueries(array $queries): int
{
Expand All @@ -75,8 +76,10 @@ private function countVectorQueries(array $queries): int
$count++;
}

if ($query->isNested()) {
/** @var array<Query> $nestedValues */
if ($query->isNestedJoin()) {
$count += $this->countVectorQueries($query->getJoinOnQueries());
} elseif ($query->isNested()) {
/** @var array<BaseQuery> $nestedValues */
$nestedValues = $query->getValues();
$count += $this->countVectorQueries($nestedValues);
}
Expand All @@ -86,7 +89,7 @@ private function countVectorQueries(array $queries): int
}

/**
* @param array<Query> $queries
* @param array<BaseQuery> $queries
* @return array<string, true>
*/
private function joinAliases(array $queries): array
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -147,7 +147,7 @@ public function isValid($value): bool
}

/**
* @param array<Query> $queries
* @param array<BaseQuery> $queries
* @param array<string, true> $joinAliases
*/
private function validateSearchIndexes(array $queries, array $joinAliases): bool
Expand Down Expand Up @@ -181,8 +181,12 @@ private function validateSearchIndexes(array $queries, array $joinAliases): bool
}
}

if ($query->isNested() && $query->getMethod() !== Method::Having) {
/** @var array<Query> $nested */
if ($query->isNestedJoin()) {
if (! $this->validateSearchIndexes($query->getJoinOnQueries(), $joinAliases)) {
return false;
}
} elseif ($query->isNested() && $query->getMethod() !== Method::Having) {
/** @var array<BaseQuery> $nested */
$nested = $query->getValues();
if (! $this->validateSearchIndexes($nested, $joinAliases)) {
return false;
Expand Down
26 changes: 19 additions & 7 deletions src/Database/Validator/Queries.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand All @@ -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 !== []) {
Expand All @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

$method = $query->getMethod();

// Route every aggregate method through the single source of truth
Expand Down
33 changes: 33 additions & 0 deletions src/Database/Validator/Query/Join.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
59 changes: 59 additions & 0 deletions tests/e2e/Adapter/Scopes/JoinTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading
Loading