Skip to content

Commit c1d1389

Browse files
authored
Merge pull request #942 from utopia-php/feat-join-on-queries
feat: nested join ON queries
2 parents 808f90b + 21ed654 commit c1d1389

11 files changed

Lines changed: 416 additions & 45 deletions

File tree

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"utopia-php/cache": "^4.0",
4545
"utopia-php/pools": "^2.0",
4646
"utopia-php/mongo": "^1.0",
47-
"utopia-php/query": "^0.4",
47+
"utopia-php/query": "^0.5",
4848
"utopia-php/async": "^0.1"
4949
},
5050
"require-dev": {

‎composer.lock‎

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/Database/Adapter/SQL.php‎

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3551,7 +3551,20 @@ private function remapDottedQueryAttributes(array $queries, array $joinTablePref
35513551
private function remapDottedQuery(BaseQuery $query, array $aliasSet, array $mainAttributes): void
35523552
{
35533553
$method = $query->getMethod();
3554-
if ($method->isJoin() || $method === Method::Select) {
3554+
if ($method === Method::Select) {
3555+
return;
3556+
}
3557+
3558+
if ($method->isJoin()) {
3559+
if ($query->isNestedJoin()) {
3560+
foreach ($query->getJoinOnQueries() as $onQuery) {
3561+
if ($onQuery->getMethod() === Method::On) {
3562+
continue;
3563+
}
3564+
$this->remapDottedQuery($onQuery, $aliasSet, $mainAttributes);
3565+
}
3566+
}
3567+
35553568
return;
35563569
}
35573570

@@ -3815,30 +3828,30 @@ private function remapJoinQueries(array &$queries): array
38153828
$resolvedTable = $this->getSQLTableRaw($this->filter($joinTable));
38163829
$query->setAttribute($resolvedTable);
38173830

3818-
$values = $query->getValues();
38193831
$method = $query->getMethod();
3820-
$aliasIndex = ($method === Method::CrossJoin || $method === Method::NaturalJoin) ? 0 : 3;
3821-
$joinAlias = $this->sanitizeJoinAlias(
3822-
\is_string($values[$aliasIndex] ?? null) ? $values[$aliasIndex] : ''
3823-
);
3832+
$joinAlias = $this->sanitizeJoinAlias($query->getJoinAlias());
38243833
if ($joinAlias === '') {
38253834
$joinAlias = 'j'.$joinIndex;
38263835
}
38273836
$joinIndex++;
38283837

3829-
if ($aliasIndex === 3 && \count($values) >= 3) {
3830-
$left = $values[0] ?? null;
3831-
$right = $values[2] ?? null;
3832-
if (! \is_string($left) || ! \is_string($right)) {
3833-
throw new QueryException('Join columns must be strings');
3838+
if ($method === Method::CrossJoin || $method === Method::NaturalJoin) {
3839+
$query->setValues([$joinAlias]);
3840+
} elseif ($query->isNestedJoin()) {
3841+
$query->setValues($this->remapNestedJoinValues($query, $alias, $joinAlias));
3842+
} else {
3843+
$values = $query->getValues();
3844+
if (\count($values) >= 3) {
3845+
$left = $values[0] ?? null;
3846+
$right = $values[2] ?? null;
3847+
if (! \is_string($left) || ! \is_string($right)) {
3848+
throw new QueryException('Join columns must be strings');
3849+
}
3850+
$values[0] = $this->qualifyJoinColumn($left, $alias);
3851+
$values[2] = $this->qualifyJoinColumn($right, $joinAlias);
3852+
$values[3] = $joinAlias;
3853+
$query->setValues($values);
38343854
}
3835-
$values[0] = $this->qualifyJoinColumn($left, $alias);
3836-
$values[2] = $this->qualifyJoinColumn($right, $joinAlias);
3837-
$values[3] = $joinAlias;
3838-
$query->setValues($values);
3839-
} elseif ($aliasIndex === 0) {
3840-
$values[0] = $joinAlias;
3841-
$query->setValues($values);
38423855
}
38433856

38443857
$joinTablePrefixes[] = ['table' => $joinTable, 'alias' => $joinAlias];
@@ -3847,6 +3860,39 @@ private function remapJoinQueries(array &$queries): array
38473860
return $joinTablePrefixes;
38483861
}
38493862

3863+
/**
3864+
* @return list<mixed>
3865+
*/
3866+
private function remapNestedJoinValues(BaseQuery $query, string $mainAlias, string $joinAlias): array
3867+
{
3868+
$values = [$joinAlias];
3869+
foreach ($query->getJoinOnQueries() as $onQuery) {
3870+
$values[] = $this->remapNestedJoinOnQuery($onQuery, $mainAlias, $joinAlias);
3871+
}
3872+
3873+
return $values;
3874+
}
3875+
3876+
private function remapNestedJoinOnQuery(BaseQuery $onQuery, string $mainAlias, string $joinAlias): BaseQuery
3877+
{
3878+
if ($onQuery->getMethod() !== Method::On) {
3879+
return $onQuery;
3880+
}
3881+
3882+
$values = $onQuery->getValues();
3883+
$left = $values[0] ?? null;
3884+
$right = $values[2] ?? null;
3885+
if (! \is_string($left) || $left === '' || ! \is_string($right) || $right === '') {
3886+
throw new QueryException('Join ON requires left and right columns');
3887+
}
3888+
3889+
$values[0] = $this->qualifyJoinColumn($left, $mainAlias);
3890+
$values[2] = $this->qualifyJoinColumn($right, $joinAlias);
3891+
$onQuery->setValues($values);
3892+
3893+
return $onQuery;
3894+
}
3895+
38503896
/**
38513897
* @param array<BaseQuery> $queries
38523898
*/

‎src/Database/Validator/IndexedQueries.php‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Utopia\Database\Query;
1111
use Utopia\Database\Validator\Query\Base;
1212
use Utopia\Query\Method;
13+
use Utopia\Query\Query as BaseQuery;
1314
use Utopia\Query\Schema\IndexType;
1415

1516
/**
@@ -64,7 +65,7 @@ public function __construct(array $attributes = [], array $indexes = [], array $
6465
/**
6566
* Count vector queries across entire query tree
6667
*
67-
* @param array<Query> $queries
68+
* @param array<BaseQuery> $queries
6869
*/
6970
private function countVectorQueries(array $queries): int
7071
{
@@ -75,8 +76,10 @@ private function countVectorQueries(array $queries): int
7576
$count++;
7677
}
7778

78-
if ($query->isNested()) {
79-
/** @var array<Query> $nestedValues */
79+
if ($query->isNestedJoin()) {
80+
$count += $this->countVectorQueries($query->getJoinOnQueries());
81+
} elseif ($query->isNested()) {
82+
/** @var array<BaseQuery> $nestedValues */
8083
$nestedValues = $query->getValues();
8184
$count += $this->countVectorQueries($nestedValues);
8285
}
@@ -86,7 +89,7 @@ private function countVectorQueries(array $queries): int
8689
}
8790

8891
/**
89-
* @param array<Query> $queries
92+
* @param array<BaseQuery> $queries
9093
* @return array<string, true>
9194
*/
9295
private function joinAliases(array $queries): array
@@ -98,11 +101,8 @@ private function joinAliases(array $queries): array
98101
continue;
99102
}
100103

101-
$method = $query->getMethod();
102-
$values = $query->getValues();
103-
$aliasIndex = ($method === Method::CrossJoin || $method === Method::NaturalJoin) ? 0 : 3;
104-
$alias = $values[$aliasIndex] ?? '';
105-
if (\is_string($alias) && $alias !== '') {
104+
$alias = $query->getJoinAlias();
105+
if ($alias !== '') {
106106
$aliases[$alias] = true;
107107
}
108108
}
@@ -147,7 +147,7 @@ public function isValid($value): bool
147147
}
148148

149149
/**
150-
* @param array<Query> $queries
150+
* @param array<BaseQuery> $queries
151151
* @param array<string, true> $joinAliases
152152
*/
153153
private function validateSearchIndexes(array $queries, array $joinAliases): bool
@@ -181,8 +181,12 @@ private function validateSearchIndexes(array $queries, array $joinAliases): bool
181181
}
182182
}
183183

184-
if ($query->isNested() && $query->getMethod() !== Method::Having) {
185-
/** @var array<Query> $nested */
184+
if ($query->isNestedJoin()) {
185+
if (! $this->validateSearchIndexes($query->getJoinOnQueries(), $joinAliases)) {
186+
return false;
187+
}
188+
} elseif ($query->isNested() && $query->getMethod() !== Method::Having) {
189+
/** @var array<BaseQuery> $nested */
186190
$nested = $query->getValues();
187191
if (! $this->validateSearchIndexes($nested, $joinAliases)) {
188192
return false;

‎src/Database/Validator/Queries.php‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,8 @@ public function isValid($value): bool
123123
if (! $query->getMethod()->isJoin()) {
124124
continue;
125125
}
126-
$values = $query->getValues();
127-
$method = $query->getMethod();
128-
$alias = match ($method) {
129-
Method::CrossJoin, Method::NaturalJoin => $values[0] ?? '',
130-
default => $values[3] ?? '',
131-
};
132-
if (\is_string($alias) && $alias !== '') {
126+
$alias = $query->getJoinAlias();
127+
if ($alias !== '') {
133128
$joinAliases[] = $alias;
134129
}
135130
}
@@ -145,6 +140,14 @@ public function isValid($value): bool
145140
}
146141
}
147142

143+
$hasFilterValidator = false;
144+
foreach ($this->validators as $validator) {
145+
if ($validator->getMethodType() === Base::METHOD_TYPE_FILTER) {
146+
$hasFilterValidator = true;
147+
break;
148+
}
149+
}
150+
148151
// Same pass: nested and/or children must keep the join aliases collected above.
149152
$pending = $parsedQueries;
150153
while ($pending !== []) {
@@ -170,6 +173,15 @@ public function isValid($value): bool
170173
}
171174
}
172175

176+
if ($hasFilterValidator && $query->getMethod()->isJoin() && $query->isNestedJoin()) {
177+
foreach ($query->getJoinOnQueries() as $onQuery) {
178+
if ($onQuery->getMethod() === Method::On) {
179+
continue;
180+
}
181+
$pending[] = $onQuery;
182+
}
183+
}
184+
173185
$method = $query->getMethod();
174186

175187
// Route every aggregate method through the single source of truth

‎src/Database/Validator/Query/Join.php‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,39 @@ public function isValid($value): bool
4747
return false;
4848
}
4949

50+
if (! $value->isNestedJoin()) {
51+
return true;
52+
}
53+
54+
$onQueries = $value->getJoinOnQueries();
55+
if ($onQueries === []) {
56+
$this->message = 'Join ON requires at least one condition';
57+
58+
return false;
59+
}
60+
61+
$allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>'];
62+
foreach ($onQueries as $onQuery) {
63+
if ($onQuery->getMethod() !== Method::On) {
64+
continue;
65+
}
66+
67+
$values = $onQuery->getValues();
68+
$left = $values[0] ?? '';
69+
$operator = $values[1] ?? '=';
70+
$right = $values[2] ?? '';
71+
if (! \is_string($left) || $left === '' || ! \is_string($right) || $right === '') {
72+
$this->message = 'Join ON requires left and right columns';
73+
74+
return false;
75+
}
76+
if (! \is_string($operator) || ! \in_array($operator, $allowedOperators, true)) {
77+
$this->message = 'Invalid join operator: '.(\is_string($operator) ? $operator : \gettype($operator));
78+
79+
return false;
80+
}
81+
}
82+
5083
return true;
5184
}
5285
}

‎tests/e2e/Adapter/Scopes/JoinTests.php‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5667,4 +5667,63 @@ private function aliasedScores(array $documents): array
56675667

56685668
return $scores;
56695669
}
5670+
5671+
public function testLeftJoinOnFilterKeepsUnmatchedMainRows(): void
5672+
{
5673+
$database = static::getDatabase();
5674+
if (! $database->getAdapter()->supports(Capability::Joins)) {
5675+
$this->expectNotToPerformAssertions();
5676+
return;
5677+
}
5678+
5679+
$pCol = 'ljon_p';
5680+
$rCol = 'ljon_r';
5681+
$cols = [$pCol, $rCol];
5682+
$this->cleanupAggCollections($database, $cols);
5683+
5684+
$database->createCollection($pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())]);
5685+
$database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true));
5686+
5687+
$database->createCollection($rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())]);
5688+
$database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true));
5689+
$database->createAttribute($rCol, Attribute::integer(key: 'score', required: true));
5690+
5691+
foreach (['p1' => 'Alpha', 'p2' => 'Beta', 'p3' => 'Gamma'] as $id => $name) {
5692+
$database->createDocument($pCol, new Document([
5693+
'$id' => $id,
5694+
'name' => $name,
5695+
'$permissions' => [Permission::read(Role::any())],
5696+
]));
5697+
}
5698+
5699+
foreach ([
5700+
['prod_uid' => 'p1', 'score' => 5],
5701+
['prod_uid' => 'p2', 'score' => 2],
5702+
] as $review) {
5703+
$database->createDocument($rCol, new Document(array_merge($review, [
5704+
'$permissions' => [Permission::read(Role::any())],
5705+
])));
5706+
}
5707+
5708+
$results = $database->find($pCol, [
5709+
Query::leftJoin($rCol, 'rev', [
5710+
Query::on('$id', 'prod_uid'),
5711+
Query::greaterThanEqual('rev.score', 4),
5712+
]),
5713+
Query::select(['name', 'rev.score']),
5714+
]);
5715+
5716+
$this->assertCount(3, $results);
5717+
$mapped = [];
5718+
foreach ($results as $doc) {
5719+
$name = $doc->getAttribute('name');
5720+
$this->assertIsString($name);
5721+
$mapped[$name] = $doc->getAttribute('rev.score');
5722+
}
5723+
$this->assertEquals(5, $mapped['Alpha']);
5724+
$this->assertTrue($mapped['Beta'] === null || $mapped['Beta'] === '');
5725+
$this->assertTrue($mapped['Gamma'] === null || $mapped['Gamma'] === '');
5726+
5727+
$this->cleanupAggCollections($database, $cols);
5728+
}
56705729
}

0 commit comments

Comments
 (0)