Skip to content

Commit 19305fc

Browse files
authored
Merge pull request #818 from utopia-php/fix-m2m-api-resolution
Fix M2M relationship queries via API
2 parents e3305f6 + 4fc76ee commit 19305fc

File tree

2 files changed

+80
-14
lines changed

2 files changed

+80
-14
lines changed

src/Database/Database.php

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7864,7 +7864,7 @@ public function find(string $collection, array $queries = [], string $forPermiss
78647864
$nestedSelections = $this->processRelationshipQueries($relationships, $queries);
78657865

78667866
// Convert relationship filter queries to SQL-level subqueries
7867-
$queriesOrNull = $this->convertRelationshipQueries($relationships, $queries);
7867+
$queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection);
78687868

78697869
// If conversion returns null, it means no documents can match (relationship filter found no matches)
78707870
if ($queriesOrNull === null) {
@@ -8064,7 +8064,7 @@ public function count(string $collection, array $queries = [], ?int $max = null)
80648064
$queries = Query::groupByType($queries)['filters'];
80658065
$queries = $this->convertQueries($collection, $queries);
80668066

8067-
$queriesOrNull = $this->convertRelationshipQueries($relationships, $queries);
8067+
$queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection);
80688068

80698069
if ($queriesOrNull === null) {
80708070
return 0;
@@ -8130,7 +8130,7 @@ public function sum(string $collection, string $attribute, array $queries = [],
81308130
);
81318131

81328132
$queries = $this->convertQueries($collection, $queries);
8133-
$queriesOrNull = $this->convertRelationshipQueries($relationships, $queries);
8133+
$queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection);
81348134

81358135
// If conversion returns null, it means no documents can match (relationship filter found no matches)
81368136
if ($queriesOrNull === null) {
@@ -9084,6 +9084,7 @@ private function processNestedRelationshipPath(string $startCollection, array $q
90849084
private function convertRelationshipQueries(
90859085
array $relationships,
90869086
array $queries,
9087+
?Document $collection = null,
90879088
): ?array {
90889089
// Early return if no relationship queries exist
90899090
$hasRelationshipQuery = false;
@@ -9134,7 +9135,7 @@ private function convertRelationshipQueries(
91349135
$resolvedAttribute = '$id';
91359136
foreach ($query->getValues() as $value) {
91369137
$relatedQuery = Query::equal($nestedAttribute, [$value]);
9137-
$result = $this->resolveRelationshipGroupToIds($relationship, [$relatedQuery]);
9138+
$result = $this->resolveRelationshipGroupToIds($relationship, [$relatedQuery], $collection);
91389139

91399140
if ($result === null) {
91409141
return null;
@@ -9220,7 +9221,7 @@ private function convertRelationshipQueries(
92209221
}
92219222

92229223
try {
9223-
$result = $this->resolveRelationshipGroupToIds($relationship, $relatedQueries);
9224+
$result = $this->resolveRelationshipGroupToIds($relationship, $relatedQueries, $collection);
92249225

92259226
if ($result === null) {
92269227
return null;
@@ -9252,11 +9253,13 @@ private function convertRelationshipQueries(
92529253
*
92539254
* @param Document $relationship
92549255
* @param array<Query> $relatedQueries Queries on the related collection
9256+
* @param Document|null $collection The parent collection document (needed for junction table lookups)
92559257
* @return array{attribute: string, ids: string[]}|null
92569258
*/
92579259
private function resolveRelationshipGroupToIds(
92589260
Document $relationship,
92599261
array $relatedQueries,
9262+
?Document $collection = null,
92609263
): ?array {
92619264
$relatedCollection = $relationship->getAttribute('options')['relatedCollection'];
92629265
$relationType = $relationship->getAttribute('options')['relationType'];
@@ -9294,24 +9297,52 @@ private function resolveRelationshipGroupToIds(
92949297
($relationType === self::RELATION_MANY_TO_MANY)
92959298
);
92969299

9297-
if ($needsParentResolution) {
9298-
$matchingDocs = $this->silent(fn () => $this->find(
9300+
if ($relationType === self::RELATION_MANY_TO_MANY && $needsParentResolution && $collection !== null) {
9301+
// For many-to-many, query the junction table directly instead of relying
9302+
// on relationship population (which fails when resolveRelationships is false,
9303+
// e.g. when the outer find() is wrapped in skipRelationships()).
9304+
$matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find(
92999305
$relatedCollection,
93009306
\array_merge($relatedQueries, [
9307+
Query::select(['$id']),
93019308
Query::limit(PHP_INT_MAX),
93029309
])
9303-
));
9304-
} else {
9305-
$matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find(
9310+
)));
9311+
9312+
$matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs);
9313+
9314+
if (empty($matchingIds)) {
9315+
return null;
9316+
}
9317+
9318+
$twoWayKey = $relationship->getAttribute('options')['twoWayKey'];
9319+
$relatedCollectionDoc = $this->silent(fn () => $this->getCollection($relatedCollection));
9320+
$junction = $this->getJunctionCollection($collection, $relatedCollectionDoc, $side);
9321+
9322+
$junctionDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find($junction, [
9323+
Query::equal($relationshipKey, $matchingIds),
9324+
Query::limit(PHP_INT_MAX),
9325+
])));
9326+
9327+
$parentIds = [];
9328+
foreach ($junctionDocs as $jDoc) {
9329+
$pId = $jDoc->getAttribute($twoWayKey);
9330+
if ($pId && !\in_array($pId, $parentIds)) {
9331+
$parentIds[] = $pId;
9332+
}
9333+
}
9334+
9335+
return empty($parentIds) ? null : ['attribute' => '$id', 'ids' => $parentIds];
9336+
} elseif ($needsParentResolution) {
9337+
// For one-to-many/many-to-one parent resolution, we need relationship
9338+
// population to read the twoWayKey attribute from the related documents.
9339+
$matchingDocs = $this->silent(fn () => $this->find(
93069340
$relatedCollection,
93079341
\array_merge($relatedQueries, [
9308-
Query::select(['$id']),
93099342
Query::limit(PHP_INT_MAX),
93109343
])
9311-
)));
9312-
}
9344+
));
93139345

9314-
if ($needsParentResolution) {
93159346
$twoWayKey = $relationship->getAttribute('options')['twoWayKey'];
93169347
$parentIds = [];
93179348

@@ -9339,6 +9370,14 @@ private function resolveRelationshipGroupToIds(
93399370

93409371
return empty($parentIds) ? null : ['attribute' => '$id', 'ids' => $parentIds];
93419372
} else {
9373+
$matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find(
9374+
$relatedCollection,
9375+
\array_merge($relatedQueries, [
9376+
Query::select(['$id']),
9377+
Query::limit(PHP_INT_MAX),
9378+
])
9379+
)));
9380+
93429381
$matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs);
93439382
return empty($matchingIds) ? null : ['attribute' => $relationshipKey, 'ids' => $matchingIds];
93449383
}

tests/e2e/Adapter/Scopes/RelationshipTests.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3533,6 +3533,33 @@ public function testQueryByRelationshipId(): void
35333533
$this->assertStringContainsString('Query::containsAll()', $e->getMessage());
35343534
}
35353535

3536+
// Test M2M relationship query inside skipRelationships context
3537+
// This simulates Appwrite's XList.php which wraps find() in skipRelationships()
3538+
// when no select queries are provided
3539+
$projects = $database->skipRelationships(fn () => $database->find('projectsMtmId', [
3540+
Query::equal('developers.$id', ['dev1']),
3541+
]));
3542+
$this->assertCount(2, $projects);
3543+
3544+
$projects = $database->skipRelationships(fn () => $database->find('projectsMtmId', [
3545+
Query::equal('developers.$id', ['dev2']),
3546+
]));
3547+
$this->assertCount(1, $projects);
3548+
$this->assertEquals('project1', $projects[0]->getId());
3549+
3550+
// Also test inverse direction inside skipRelationships
3551+
$developers = $database->skipRelationships(fn () => $database->find('developersMtmId', [
3552+
Query::equal('projects.$id', ['project1']),
3553+
]));
3554+
$this->assertCount(2, $developers);
3555+
3556+
// Test containsAll inside skipRelationships
3557+
$projects = $database->skipRelationships(fn () => $database->find('projectsMtmId', [
3558+
Query::containsAll('developers.$id', ['dev1', 'dev2']),
3559+
]));
3560+
$this->assertCount(1, $projects);
3561+
$this->assertEquals('project1', $projects[0]->getId());
3562+
35363563
// Clean up MANY_TO_MANY test
35373564
$database->deleteCollection('developersMtmId');
35383565
$database->deleteCollection('projectsMtmId');

0 commit comments

Comments
 (0)