From 4fc76ee45b1f935ef6de35044e464b9f7294dcc5 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 18 Feb 2026 23:17:41 +1300 Subject: [PATCH] Fix M2M relationship queries returning 0 results inside skipRelationships context When Appwrite wraps find() in skipRelationships() (for requests without select queries), resolveRelationships is set to false for the entire call chain. The inner find() in resolveRelationshipGroupToIds relied on relationship population to read the twoWayKey attribute for M2M parent resolution - but with resolveRelationships=false, the twoWayKey was never populated, causing 0 results. Fix: For M2M, query the junction table directly (same pattern used by processNestedRelationshipPath) instead of relying on relationship population. This works regardless of the resolveRelationships flag. Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 67 +++++++++++++++---- .../e2e/Adapter/Scopes/RelationshipTests.php | 27 ++++++++ 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 5b16e4547..c9e3f263f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7864,7 +7864,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $nestedSelections = $this->processRelationshipQueries($relationships, $queries); // Convert relationship filter queries to SQL-level subqueries - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries); + $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); // If conversion returns null, it means no documents can match (relationship filter found no matches) if ($queriesOrNull === null) { @@ -8064,7 +8064,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $queries = Query::groupByType($queries)['filters']; $queries = $this->convertQueries($collection, $queries); - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries); + $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); if ($queriesOrNull === null) { return 0; @@ -8130,7 +8130,7 @@ public function sum(string $collection, string $attribute, array $queries = [], ); $queries = $this->convertQueries($collection, $queries); - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries); + $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); // If conversion returns null, it means no documents can match (relationship filter found no matches) if ($queriesOrNull === null) { @@ -9084,6 +9084,7 @@ private function processNestedRelationshipPath(string $startCollection, array $q private function convertRelationshipQueries( array $relationships, array $queries, + ?Document $collection = null, ): ?array { // Early return if no relationship queries exist $hasRelationshipQuery = false; @@ -9134,7 +9135,7 @@ private function convertRelationshipQueries( $resolvedAttribute = '$id'; foreach ($query->getValues() as $value) { $relatedQuery = Query::equal($nestedAttribute, [$value]); - $result = $this->resolveRelationshipGroupToIds($relationship, [$relatedQuery]); + $result = $this->resolveRelationshipGroupToIds($relationship, [$relatedQuery], $collection); if ($result === null) { return null; @@ -9220,7 +9221,7 @@ private function convertRelationshipQueries( } try { - $result = $this->resolveRelationshipGroupToIds($relationship, $relatedQueries); + $result = $this->resolveRelationshipGroupToIds($relationship, $relatedQueries, $collection); if ($result === null) { return null; @@ -9252,11 +9253,13 @@ private function convertRelationshipQueries( * * @param Document $relationship * @param array $relatedQueries Queries on the related collection + * @param Document|null $collection The parent collection document (needed for junction table lookups) * @return array{attribute: string, ids: string[]}|null */ private function resolveRelationshipGroupToIds( Document $relationship, array $relatedQueries, + ?Document $collection = null, ): ?array { $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; $relationType = $relationship->getAttribute('options')['relationType']; @@ -9294,24 +9297,52 @@ private function resolveRelationshipGroupToIds( ($relationType === self::RELATION_MANY_TO_MANY) ); - if ($needsParentResolution) { - $matchingDocs = $this->silent(fn () => $this->find( + if ($relationType === self::RELATION_MANY_TO_MANY && $needsParentResolution && $collection !== null) { + // For many-to-many, query the junction table directly instead of relying + // on relationship population (which fails when resolveRelationships is false, + // e.g. when the outer find() is wrapped in skipRelationships()). + $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( $relatedCollection, \array_merge($relatedQueries, [ + Query::select(['$id']), Query::limit(PHP_INT_MAX), ]) - )); - } else { - $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( + ))); + + $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); + + if (empty($matchingIds)) { + return null; + } + + $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; + $relatedCollectionDoc = $this->silent(fn () => $this->getCollection($relatedCollection)); + $junction = $this->getJunctionCollection($collection, $relatedCollectionDoc, $side); + + $junctionDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find($junction, [ + Query::equal($relationshipKey, $matchingIds), + Query::limit(PHP_INT_MAX), + ]))); + + $parentIds = []; + foreach ($junctionDocs as $jDoc) { + $pId = $jDoc->getAttribute($twoWayKey); + if ($pId && !\in_array($pId, $parentIds)) { + $parentIds[] = $pId; + } + } + + return empty($parentIds) ? null : ['attribute' => '$id', 'ids' => $parentIds]; + } elseif ($needsParentResolution) { + // For one-to-many/many-to-one parent resolution, we need relationship + // population to read the twoWayKey attribute from the related documents. + $matchingDocs = $this->silent(fn () => $this->find( $relatedCollection, \array_merge($relatedQueries, [ - Query::select(['$id']), Query::limit(PHP_INT_MAX), ]) - ))); - } + )); - if ($needsParentResolution) { $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; $parentIds = []; @@ -9339,6 +9370,14 @@ private function resolveRelationshipGroupToIds( return empty($parentIds) ? null : ['attribute' => '$id', 'ids' => $parentIds]; } else { + $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( + $relatedCollection, + \array_merge($relatedQueries, [ + Query::select(['$id']), + Query::limit(PHP_INT_MAX), + ]) + ))); + $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); return empty($matchingIds) ? null : ['attribute' => $relationshipKey, 'ids' => $matchingIds]; } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index e0a39c049..9182b8b8b 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -3533,6 +3533,33 @@ public function testQueryByRelationshipId(): void $this->assertStringContainsString('Query::containsAll()', $e->getMessage()); } + // Test M2M relationship query inside skipRelationships context + // This simulates Appwrite's XList.php which wraps find() in skipRelationships() + // when no select queries are provided + $projects = $database->skipRelationships(fn () => $database->find('projectsMtmId', [ + Query::equal('developers.$id', ['dev1']), + ])); + $this->assertCount(2, $projects); + + $projects = $database->skipRelationships(fn () => $database->find('projectsMtmId', [ + Query::equal('developers.$id', ['dev2']), + ])); + $this->assertCount(1, $projects); + $this->assertEquals('project1', $projects[0]->getId()); + + // Also test inverse direction inside skipRelationships + $developers = $database->skipRelationships(fn () => $database->find('developersMtmId', [ + Query::equal('projects.$id', ['project1']), + ])); + $this->assertCount(2, $developers); + + // Test containsAll inside skipRelationships + $projects = $database->skipRelationships(fn () => $database->find('projectsMtmId', [ + Query::containsAll('developers.$id', ['dev1', 'dev2']), + ])); + $this->assertCount(1, $projects); + $this->assertEquals('project1', $projects[0]->getId()); + // Clean up MANY_TO_MANY test $database->deleteCollection('developersMtmId'); $database->deleteCollection('projectsMtmId');