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');