From 4cce34bf28513337d410259c738bf7eef2f29831 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Oct 2025 12:02:49 +1300 Subject: [PATCH 1/7] Allow filtering --- src/Database/Database.php | 141 ++++++++++ src/Database/Validator/Query/Filter.php | 12 +- src/Database/Validator/Query/Order.php | 11 + .../e2e/Adapter/Scopes/RelationshipTests.php | 247 ++++++++++++++++++ 4 files changed, 406 insertions(+), 5 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index f9d90ddc2..3326f7c7b 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6769,6 +6769,9 @@ public function find(string $collection, array $queries = [], string $forPermiss $selections = $this->validateSelections($collection, $selects); $nestedSelections = $this->processRelationshipQueries($relationships, $queries); + // Convert relationship filter queries to SQL-level subqueries + $queries = $this->convertRelationshipFiltersToSubqueries($collection, $relationships, $queries); + $getResults = fn () => $this->adapter->find( $collection, $queries, @@ -7642,6 +7645,144 @@ private function processRelationshipQueries( return $nestedSelections; } + /** + * Convert relationship filter queries to SQL-safe subqueries. + * Queries like Query::equal('author.name', ['Alice']) are converted to + * Query::equal('author', []) + * + * @param Document $collection + * @param array $relationships + * @param array $queries + * @return array + */ + private function convertRelationshipFiltersToSubqueries( + Document $collection, + array $relationships, + array $queries + ): array { + $additionalQueries = []; + + foreach ($queries as $index => $query) { + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + + // Skip non-filter queries and non-relationship queries + if ($method === Query::TYPE_SELECT || $method === Query::TYPE_ORDER_ASC || $method === Query::TYPE_ORDER_DESC) { + continue; + } + + if (!\str_contains($attribute, '.')) { + continue; + } + + // Parse the relationship path + $parts = \explode('.', $attribute); + $relationshipKey = \array_shift($parts); + $nestedField = \implode('.', $parts); + + // Find the relationship + $relationship = \array_values(\array_filter( + $relationships, + fn (Document $rel) => $rel->getAttribute('key') === $relationshipKey + ))[0] ?? null; + + if (!$relationship) { + continue; + } + + $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; + $relationType = $relationship->getAttribute('options')['relationType']; + $side = $relationship->getAttribute('options')['side']; + + // Build query for the related collection + $relatedQuery = new Query($method, $nestedField, $query->getValues()); + + try { + // For virtual parent relationships (where parent doesn't store child IDs), + // we need to find which parents have matching children + // - ONE_TO_MANY from parent side: parent doesn't store children + // - MANY_TO_ONE from child side: the "one" side doesn't store "many" IDs + // - MANY_TO_MANY: both sides are virtual, stored in junction table + $needsParentResolution = ( + ($relationType === self::RELATION_ONE_TO_MANY && $side === self::RELATION_SIDE_PARENT) || + ($relationType === self::RELATION_MANY_TO_ONE && $side === self::RELATION_SIDE_CHILD) || + ($relationType === self::RELATION_MANY_TO_MANY) + ); + + if ($needsParentResolution) { + $matchingDocs = $this->silent(fn () => $this->find( + $relatedCollection, + [$relatedQuery, Query::limit(PHP_INT_MAX)], + self::PERMISSION_READ + )); + } else { + $matchingDocs = $this->silent(fn () => $this->find( + $relatedCollection, + [$relatedQuery, Query::select(['$id']), Query::limit(PHP_INT_MAX)], + self::PERMISSION_READ + )); + } + + $matchingIds = \array_map(fn($doc) => $doc->getId(), $matchingDocs); + + // Use the same parent resolution logic as above + if ($needsParentResolution) { + // Need to find which parents have these children + $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; + + $parentIds = []; + foreach ($matchingDocs as $doc) { + $parentId = $doc->getAttribute($twoWayKey); + + // Handle MANY_TO_MANY: twoWayKey returns an array + if (\is_array($parentId)) { + foreach ($parentId as $id) { + if ($id instanceof Document) { + $id = $id->getId(); + } + if ($id && !\in_array($id, $parentIds)) { + $parentIds[] = $id; + } + } + } else { + // Handle ONE_TO_MANY/MANY_TO_ONE: single value + if ($parentId instanceof Document) { + $parentId = $parentId->getId(); + } + if ($parentId && !\in_array($parentId, $parentIds)) { + $parentIds[] = $parentId; + } + } + } + + // Add filter on current collection's $id + if (!empty($parentIds)) { + $additionalQueries[] = Query::equal('$id', $parentIds); + } else { + $additionalQueries[] = Query::equal('$id', ['__impossible__']); + } + } else { + // For other types, filter by the relationship field + if (!empty($matchingIds)) { + $additionalQueries[] = Query::equal($relationshipKey, $matchingIds); + } else { + $additionalQueries[] = Query::equal($relationshipKey, ['__impossible__']); + } + } + + // Remove the original relationship query + unset($queries[$index]); + } catch (\Exception $e) { + // If subquery fails, add impossible filter + $additionalQueries[] = Query::equal('$id', ['__impossible__']); + unset($queries[$index]); + } + } + + // Merge additional queries + return \array_merge(\array_values($queries), $additionalQueries); + } + /** * Encode spatial data from array format to WKT (Well-Known Text) format * diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 9c60f551c..5761aab0c 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -59,11 +59,6 @@ protected function isValidAttribute(string $attribute): bool // For relationships, just validate the top level. // will validate each nested level during the recursive calls. $attribute = \explode('.', $attribute)[0]; - - if (isset($this->schema[$attribute])) { - $this->message = 'Cannot query nested attribute on: ' . $attribute; - return false; - } } // Search for attribute in schema @@ -87,6 +82,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s return false; } + $originalAttribute = $attribute; // isset check if for special symbols "." in the attribute name if (\str_contains($attribute, '.') && !isset($this->schema[$attribute])) { // For relationships, just validate the top level. @@ -96,6 +92,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s $attributeSchema = $this->schema[$attribute]; + // Skip value validation for nested relationship queries (e.g., author.age) + // The values will be validated when querying the related collection + if ($attributeSchema['type'] === Database::VAR_RELATIONSHIP && $originalAttribute !== $attribute) { + return true; + } + if (count($values) > $this->maxValuesCount) { $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; return false; diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index 9cc520b4f..9487f55d1 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -28,6 +28,17 @@ public function __construct(array $attributes = []) */ protected function isValidAttribute(string $attribute): bool { + if (\str_contains($attribute, '.')) { + // Check for special symbol `.` + if (isset($this->schema[$attribute])) { + return true; + } + + // For relationships, just validate the top level. + // Will validate each nested level during the recursive calls. + $attribute = \explode('.', $attribute)[0]; + } + // Search for attribute in schema if (!isset($this->schema[$attribute])) { $this->message = 'Attribute not found in schema: ' . $attribute; diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 1319d622e..9cca1a7e5 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -3008,4 +3008,251 @@ public function testNestedDocumentCreationWithDepthHandling(): void $database->deleteCollection('product_depth_test'); $database->deleteCollection('store_depth_test'); } + + /** + * Test filtering by relationship fields using dot-path notation + */ + public function testRelationshipFiltering(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create Author -> Posts relationship + $database->createCollection('authors_filter'); + $database->createCollection('posts_filter'); + + $database->createAttribute('authors_filter', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('authors_filter', 'age', Database::VAR_INTEGER, 0, true); + $database->createAttribute('posts_filter', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('posts_filter', 'published', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'authors_filter', + relatedCollection: 'posts_filter', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'posts', + twoWayKey: 'author' + ); + + // Create test data + $author1 = $database->createDocument('authors_filter', new Document([ + '$id' => 'author1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Alice', + 'age' => 30, + ])); + + $author2 = $database->createDocument('authors_filter', new Document([ + '$id' => 'author2', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Bob', + 'age' => 25, + ])); + + // Create posts + $database->createDocument('posts_filter', new Document([ + '$id' => 'post1', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Post 1', + 'published' => true, + 'author' => 'author1', + ])); + + $database->createDocument('posts_filter', new Document([ + '$id' => 'post2', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Post 2', + 'published' => true, + 'author' => 'author1', + ])); + + $database->createDocument('posts_filter', new Document([ + '$id' => 'post3', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Bob Post', + 'published' => true, + 'author' => 'author2', + ])); + + // Test: Filter posts by author name + $posts = $database->find('posts_filter', [ + Query::equal('author.name', ['Alice']), + ]); + $this->assertCount(2, $posts); + $this->assertEquals('post1', $posts[0]->getId()); + $this->assertEquals('post2', $posts[1]->getId()); + + // Test: Filter posts by author age + $posts = $database->find('posts_filter', [ + Query::lessThan('author.age', 30), + ]); + $this->assertCount(1, $posts); + $this->assertEquals('post3', $posts[0]->getId()); + + // Test: Filter authors by their posts' published status + $authors = $database->find('authors_filter', [ + Query::equal('posts.published', [true]), + ]); + $this->assertCount(2, $authors); // Both authors have published posts + + // Clean up ONE_TO_MANY test + $database->deleteCollection('authors_filter'); + $database->deleteCollection('posts_filter'); + + // ==================== Test ONE_TO_ONE relationships ==================== + $database->createCollection('users_oto'); + $database->createCollection('profiles_oto'); + + $database->createAttribute('users_oto', 'username', Database::VAR_STRING, 255, true); + $database->createAttribute('profiles_oto', 'bio', Database::VAR_STRING, 255, true); + + // ONE_TO_ONE with twoWay=true + $database->createRelationship( + collection: 'users_oto', + relatedCollection: 'profiles_oto', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'profile', + twoWayKey: 'user' + ); + + $user1 = $database->createDocument('users_oto', new Document([ + '$id' => 'user1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'username' => 'alice', + ])); + + $profile1 = $database->createDocument('profiles_oto', new Document([ + '$id' => 'profile1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'bio' => 'Software Engineer', + 'user' => 'user1', + ])); + + // Test: Filter profiles by user username + $profiles = $database->find('profiles_oto', [ + Query::equal('user.username', ['alice']), + ]); + $this->assertCount(1, $profiles); + $this->assertEquals('profile1', $profiles[0]->getId()); + + // Test: Filter users by profile bio + $users = $database->find('users_oto', [ + Query::equal('profile.bio', ['Software Engineer']), + ]); + $this->assertCount(1, $users); + $this->assertEquals('user1', $users[0]->getId()); + + // Clean up ONE_TO_ONE test + $database->deleteCollection('users_oto'); + $database->deleteCollection('profiles_oto'); + + // ==================== Test MANY_TO_ONE relationships ==================== + $database->createCollection('comments_mto'); + $database->createCollection('users_mto'); + + $database->createAttribute('comments_mto', 'content', Database::VAR_STRING, 255, true); + $database->createAttribute('users_mto', 'name', Database::VAR_STRING, 255, true); + + // MANY_TO_ONE with twoWay=true + $database->createRelationship( + collection: 'comments_mto', + relatedCollection: 'users_mto', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'commenter', + twoWayKey: 'comments' + ); + + $userA = $database->createDocument('users_mto', new Document([ + '$id' => 'userA', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Alice', + ])); + + $comment1 = $database->createDocument('comments_mto', new Document([ + '$id' => 'comment1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'content' => 'Great post!', + 'commenter' => 'userA', + ])); + + $comment2 = $database->createDocument('comments_mto', new Document([ + '$id' => 'comment2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'content' => 'Nice work!', + 'commenter' => 'userA', + ])); + + // Test: Filter comments by commenter name + $comments = $database->find('comments_mto', [ + Query::equal('commenter.name', ['Alice']), + ]); + $this->assertCount(2, $comments); + + // Test: Filter users by their comments' content + $users = $database->find('users_mto', [ + Query::equal('comments.content', ['Great post!']), + ]); + $this->assertCount(1, $users); + $this->assertEquals('userA', $users[0]->getId()); + + // Clean up MANY_TO_ONE test + $database->deleteCollection('comments_mto'); + $database->deleteCollection('users_mto'); + + // ==================== Test MANY_TO_MANY relationships ==================== + $database->createCollection('students_mtm'); + $database->createCollection('courses_mtm'); + + $database->createAttribute('students_mtm', 'studentName', Database::VAR_STRING, 255, true); + $database->createAttribute('courses_mtm', 'courseName', Database::VAR_STRING, 255, true); + + // MANY_TO_MANY + $database->createRelationship( + collection: 'students_mtm', + relatedCollection: 'courses_mtm', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'enrolledCourses', + twoWayKey: 'students' + ); + + $student1 = $database->createDocument('students_mtm', new Document([ + '$id' => 'student1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'studentName' => 'John', + ])); + + $course1 = $database->createDocument('courses_mtm', new Document([ + '$id' => 'course1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'courseName' => 'Physics', + 'students' => ['student1'], + ])); + + // Test: Filter students by enrolled course name + $students = $database->find('students_mtm', [ + Query::equal('enrolledCourses.courseName', ['Physics']), + ]); + $this->assertCount(1, $students); + $this->assertEquals('student1', $students[0]->getId()); + + // Test: Filter courses by student name + $courses = $database->find('courses_mtm', [ + Query::equal('students.studentName', ['John']), + ]); + $this->assertCount(1, $courses); + $this->assertEquals('course1', $courses[0]->getId()); + + // Clean up MANY_TO_MANY test + $database->deleteCollection('students_mtm'); + $database->deleteCollection('courses_mtm'); + } } From c2e69fcbf5fe71bb5c87dc4a993e1bc52f20106a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Oct 2025 12:49:37 +1300 Subject: [PATCH 2/7] Group by relation --- src/Database/Database.php | 54 ++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 3326f7c7b..3e7ff2bf9 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7661,7 +7661,10 @@ private function convertRelationshipFiltersToSubqueries( array $queries ): array { $additionalQueries = []; + $groupedQueries = []; + $indicesToRemove = []; + // Group queries by relationship key foreach ($queries as $index => $query) { $method = $query->getMethod(); $attribute = $query->getAttribute(); @@ -7690,12 +7693,40 @@ private function convertRelationshipFiltersToSubqueries( continue; } + // Group queries by relationship key + if (!isset($groupedQueries[$relationshipKey])) { + $groupedQueries[$relationshipKey] = [ + 'relationship' => $relationship, + 'queries' => [], + 'indices' => [] + ]; + } + + $groupedQueries[$relationshipKey]['queries'][] = [ + 'method' => $method, + 'field' => $nestedField, + 'values' => $query->getValues() + ]; + $groupedQueries[$relationshipKey]['indices'][] = $index; + } + + // Process each relationship group + foreach ($groupedQueries as $relationshipKey => $group) { + $relationship = $group['relationship']; + $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; $relationType = $relationship->getAttribute('options')['relationType']; $side = $relationship->getAttribute('options')['side']; - // Build query for the related collection - $relatedQuery = new Query($method, $nestedField, $query->getValues()); + // Build combined queries for the related collection + $relatedQueries = []; + foreach ($group['queries'] as $queryData) { + $relatedQueries[] = new Query( + $queryData['method'], + $queryData['field'], + $queryData['values'] + ); + } try { // For virtual parent relationships (where parent doesn't store child IDs), @@ -7712,13 +7743,13 @@ private function convertRelationshipFiltersToSubqueries( if ($needsParentResolution) { $matchingDocs = $this->silent(fn () => $this->find( $relatedCollection, - [$relatedQuery, Query::limit(PHP_INT_MAX)], + \array_merge($relatedQueries, [Query::limit(PHP_INT_MAX)]), self::PERMISSION_READ )); } else { $matchingDocs = $this->silent(fn () => $this->find( $relatedCollection, - [$relatedQuery, Query::select(['$id']), Query::limit(PHP_INT_MAX)], + \array_merge($relatedQueries, [Query::select(['$id']), Query::limit(PHP_INT_MAX)]), self::PERMISSION_READ )); } @@ -7770,15 +7801,24 @@ private function convertRelationshipFiltersToSubqueries( } } - // Remove the original relationship query - unset($queries[$index]); + // Remove all original relationship queries for this group + foreach ($group['indices'] as $index) { + $indicesToRemove[] = $index; + } } catch (\Exception $e) { // If subquery fails, add impossible filter $additionalQueries[] = Query::equal('$id', ['__impossible__']); - unset($queries[$index]); + foreach ($group['indices'] as $index) { + $indicesToRemove[] = $index; + } } } + // Remove the original queries + foreach ($indicesToRemove as $index) { + unset($queries[$index]); + } + // Merge additional queries return \array_merge(\array_values($queries), $additionalQueries); } From 84e5cd6dc3ba5a9b51fba5e93b035712be33201c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Oct 2025 22:54:49 +1300 Subject: [PATCH 3/7] Format --- bin/tasks/relationships.php | 2 +- src/Database/Database.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/tasks/relationships.php b/bin/tasks/relationships.php index 3f1a9e8bb..595d01531 100644 --- a/bin/tasks/relationships.php +++ b/bin/tasks/relationships.php @@ -356,7 +356,7 @@ function benchmarkSingle(Database $database): array foreach ($collections as $collection) { // Fetch one document ID to use (skip relationships to avoid infinite recursion) - $docs = $database->skipRelationships(fn() => $database->findOne($collection)); + $docs = $database->skipRelationships(fn () => $database->findOne($collection)); $id = $docs->getId(); $start = microtime(true); diff --git a/src/Database/Database.php b/src/Database/Database.php index e34dc27f0..f51e156fb 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7783,7 +7783,7 @@ private function convertRelationshipFiltersToSubqueries( )); } - $matchingIds = \array_map(fn($doc) => $doc->getId(), $matchingDocs); + $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); // Use the same parent resolution logic as above if ($needsParentResolution) { From 7a77fecc88c547a683af351fc842ae46e3a3c804 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Oct 2025 23:21:10 +1300 Subject: [PATCH 4/7] Fix limits --- src/Database/Database.php | 21 +- .../e2e/Adapter/Scopes/RelationshipTests.php | 502 ++++++++++++++++++ 2 files changed, 522 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index f51e156fb..0529f207b 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3799,6 +3799,7 @@ private function populateOneToOneRelationshipsBatch(array $documents, Document $ foreach (array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal('$id', $chunk), + Query::limit(PHP_INT_MAX), ...$queries ]); array_push($relatedDocuments, ...$chunkDocs); @@ -3892,6 +3893,7 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document foreach (array_chunk($parentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX), ...$otherQueries ]); array_push($relatedDocuments, ...$chunkDocs); @@ -3989,6 +3991,7 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document foreach (array_chunk($childIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX), ...$otherQueries ]); array_push($relatedDocuments, ...$chunkDocs); @@ -4111,7 +4114,8 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document // Process in chunks to avoid exceeding query value limits foreach (array_chunk($documentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkJunctions = $this->skipRelationships(fn () => $this->find($junction, [ - Query::equal($twoWayKey, $chunk) + Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX) ])); array_push($junctions, ...$chunkJunctions); } @@ -4144,6 +4148,7 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document foreach (array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal('$id', $chunk), + Query::limit(PHP_INT_MAX), ...$queries ]); array_push($foundRelated, ...$chunkDocs); @@ -7689,6 +7694,20 @@ private function convertRelationshipFiltersToSubqueries( array $relationships, array $queries ): array { + // Early return if no dot-path queries exist + $hasDotPath = false; + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (\str_contains($attr, '.')) { + $hasDotPath = true; + break; + } + } + + if (!$hasDotPath) { + return $queries; + } + $additionalQueries = []; $groupedQueries = []; $indicesToRemove = []; diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 9cca1a7e5..5aed4e8cb 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -3255,4 +3255,506 @@ public function testRelationshipFiltering(): void $database->deleteCollection('students_mtm'); $database->deleteCollection('courses_mtm'); } + + /** + * Comprehensive test for all query types on relationships + */ + public function testRelationshipQueryTypes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup test collections + $database->createCollection('products_qt'); + $database->createCollection('vendors_qt'); + + $database->createAttribute('products_qt', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('products_qt', 'price', Database::VAR_FLOAT, 0, true); + $database->createAttribute('vendors_qt', 'company', Database::VAR_STRING, 255, true); + $database->createAttribute('vendors_qt', 'rating', Database::VAR_FLOAT, 0, true); + $database->createAttribute('vendors_qt', 'email', Database::VAR_STRING, 255, true); + $database->createAttribute('vendors_qt', 'verified', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'products_qt', + relatedCollection: 'vendors_qt', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'vendor', + twoWayKey: 'products' + ); + + // Create test vendors + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'Acme Corp', + 'rating' => 4.5, + 'email' => 'sales@acme.com', + 'verified' => true, + ])); + + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'TechSupply Inc', + 'rating' => 3.8, + 'email' => 'info@techsupply.com', + 'verified' => true, + ])); + + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'Budget Vendors', + 'rating' => 2.5, + 'email' => 'contact@budget.com', + 'verified' => false, + ])); + + // Create test products + $database->createDocument('products_qt', new Document([ + '$id' => 'product1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget A', + 'price' => 19.99, + 'vendor' => 'vendor1', + ])); + + $database->createDocument('products_qt', new Document([ + '$id' => 'product2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget B', + 'price' => 29.99, + 'vendor' => 'vendor2', + ])); + + $database->createDocument('products_qt', new Document([ + '$id' => 'product3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget C', + 'price' => 9.99, + 'vendor' => 'vendor3', + ])); + + // Test: Query::equal() + $products = $database->find('products_qt', [ + Query::equal('vendor.company', ['Acme Corp']) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::notEqual() + $products = $database->find('products_qt', [ + Query::notEqual('vendor.company', ['Budget Vendors']) + ]); + $this->assertCount(2, $products); + + // Test: Query::lessThan() + $products = $database->find('products_qt', [ + Query::lessThan('vendor.rating', 4.0) + ]); + $this->assertCount(2, $products); // vendor2 (3.8) and vendor3 (2.5) + + // Test: Query::lessThanEqual() + $products = $database->find('products_qt', [ + Query::lessThanEqual('vendor.rating', 3.8) + ]); + $this->assertCount(2, $products); + + // Test: Query::greaterThan() + $products = $database->find('products_qt', [ + Query::greaterThan('vendor.rating', 4.0) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::greaterThanEqual() + $products = $database->find('products_qt', [ + Query::greaterThanEqual('vendor.rating', 3.8) + ]); + $this->assertCount(2, $products); // vendor1 (4.5) and vendor2 (3.8) + + // Test: Query::startsWith() + $products = $database->find('products_qt', [ + Query::startsWith('vendor.email', 'sales@') + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::endsWith() + $products = $database->find('products_qt', [ + Query::endsWith('vendor.email', '.com') + ]); + $this->assertCount(3, $products); + + // Test: Query::contains() + $products = $database->find('products_qt', [ + Query::contains('vendor.company', ['Corp']) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Boolean query + $products = $database->find('products_qt', [ + Query::equal('vendor.verified', [true]) + ]); + $this->assertCount(2, $products); // vendor1 and vendor2 are verified + + $products = $database->find('products_qt', [ + Query::equal('vendor.verified', [false]) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product3', $products[0]->getId()); + + // Test: Multiple conditions on same relationship (query grouping optimization) + $products = $database->find('products_qt', [ + Query::greaterThan('vendor.rating', 3.0), + Query::equal('vendor.verified', [true]), + Query::startsWith('vendor.company', 'Acme') + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Clean up + $database->deleteCollection('products_qt'); + $database->deleteCollection('vendors_qt'); + } + + /** + * Test edge cases and error scenarios for relationship queries + */ + public function testRelationshipQueryEdgeCases(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup test collections + $database->createCollection('orders_edge'); + $database->createCollection('customers_edge'); + + $database->createAttribute('orders_edge', 'orderNumber', Database::VAR_STRING, 255, true); + $database->createAttribute('orders_edge', 'total', Database::VAR_FLOAT, 0, true); + $database->createAttribute('customers_edge', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('customers_edge', 'age', Database::VAR_INTEGER, 0, true); + + $database->createRelationship( + collection: 'orders_edge', + relatedCollection: 'customers_edge', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'customer', + twoWayKey: 'orders' + ); + + // Create customer + $database->createDocument('customers_edge', new Document([ + '$id' => 'customer1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'John Doe', + 'age' => 30, + ])); + + // Create order + $database->createDocument('orders_edge', new Document([ + '$id' => 'order1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'orderNumber' => 'ORD001', + 'total' => 100.00, + 'customer' => 'customer1', + ])); + + // Edge Case 1: Query with no matching results + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['Jane Doe']) + ]); + $this->assertCount(0, $orders); + + // Edge Case 2: Query with impossible condition (combines to empty set) + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::equal('customer.age', [25]) // John is 30, not 25 + ]); + $this->assertCount(0, $orders); + + // Edge Case 3: Query on non-existent relationship field + try { + $orders = $database->find('orders_edge', [ + Query::equal('nonexistent.field', ['value']) + ]); + // Should return empty or throw - either is acceptable + $this->assertCount(0, $orders); + } catch (\Exception $e) { + // Expected - non-existent relationship + $this->assertTrue(true); + } + + // Edge Case 4: Empty array values (should throw exception) + try { + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', []) + ]); + $this->fail('Expected exception for empty array values'); + } catch (\Exception $e) { + // Expected - empty array values are invalid + $this->assertStringContainsString('at least one value', $e->getMessage()); + } + + // Edge Case 5: Null or missing relationship + $database->createDocument('orders_edge', new Document([ + '$id' => 'order2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'orderNumber' => 'ORD002', + 'total' => 50.00, + // No customer relationship + ])); + + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']) + ]); + $this->assertCount(1, $orders); // Only order1 has a customer + + // Edge Case 6: Combining relationship query with regular query + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::greaterThan('total', 75.00) + ]); + $this->assertCount(1, $orders); + $this->assertEquals('order1', $orders[0]->getId()); + + // Edge Case 7: Query with limit and offset + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::limit(1), + Query::offset(0) + ]); + $this->assertCount(1, $orders); + + // Clean up + $database->deleteCollection('orders_edge'); + $database->deleteCollection('customers_edge'); + } + + /** + * Test relationship queries from parent side with virtual fields + */ + public function testRelationshipQueryParentSide(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup ONE_TO_MANY relationship + $database->createCollection('teams_parent'); + $database->createCollection('members_parent'); + + $database->createAttribute('teams_parent', 'teamName', Database::VAR_STRING, 255, true); + $database->createAttribute('teams_parent', 'active', Database::VAR_BOOLEAN, 0, true); + $database->createAttribute('members_parent', 'memberName', Database::VAR_STRING, 255, true); + $database->createAttribute('members_parent', 'role', Database::VAR_STRING, 255, true); + $database->createAttribute('members_parent', 'senior', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'teams_parent', + relatedCollection: 'members_parent', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'members', + twoWayKey: 'team' + ); + + // Create teams + $database->createDocument('teams_parent', new Document([ + '$id' => 'team1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'teamName' => 'Engineering', + 'active' => true, + ])); + + $database->createDocument('teams_parent', new Document([ + '$id' => 'team2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'teamName' => 'Sales', + 'active' => true, + ])); + + // Create members + $database->createDocument('members_parent', new Document([ + '$id' => 'member1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Alice', + 'role' => 'Engineer', + 'senior' => true, + 'team' => 'team1', + ])); + + $database->createDocument('members_parent', new Document([ + '$id' => 'member2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Bob', + 'role' => 'Manager', + 'senior' => false, + 'team' => 'team2', + ])); + + $database->createDocument('members_parent', new Document([ + '$id' => 'member3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Charlie', + 'role' => 'Engineer', + 'senior' => true, + 'team' => 'team1', + ])); + + // Test: Find teams that have senior engineers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Engineer']), + Query::equal('members.senior', [true]) + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team1', $teams[0]->getId()); + + // Test: Find teams with managers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Manager']) + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team2', $teams[0]->getId()); + + // Test: Find teams with members named 'Alice' + $teams = $database->find('teams_parent', [ + Query::startsWith('members.memberName', 'A') + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team1', $teams[0]->getId()); + + // Test: No teams with junior managers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Manager']), + Query::equal('members.senior', [true]) + ]); + $this->assertCount(0, $teams); + + // Clean up + $database->deleteCollection('teams_parent'); + $database->deleteCollection('members_parent'); + } + + /** + * Test MANY_TO_MANY relationships with complex queries + */ + public function testRelationshipManyToManyComplex(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup MANY_TO_MANY + $database->createCollection('developers_mtm'); + $database->createCollection('projects_mtm'); + + $database->createAttribute('developers_mtm', 'devName', Database::VAR_STRING, 255, true); + $database->createAttribute('developers_mtm', 'experience', Database::VAR_INTEGER, 0, true); + $database->createAttribute('projects_mtm', 'projectName', Database::VAR_STRING, 255, true); + $database->createAttribute('projects_mtm', 'budget', Database::VAR_FLOAT, 0, true); + $database->createAttribute('projects_mtm', 'priority', Database::VAR_STRING, 50, true); + + $database->createRelationship( + collection: 'developers_mtm', + relatedCollection: 'projects_mtm', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'assignedProjects', + twoWayKey: 'assignedDevelopers' + ); + + // Create developers + $dev1 = $database->createDocument('developers_mtm', new Document([ + '$id' => 'dev1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'devName' => 'Senior Dev', + 'experience' => 10, + ])); + + $dev2 = $database->createDocument('developers_mtm', new Document([ + '$id' => 'dev2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'devName' => 'Junior Dev', + 'experience' => 2, + ])); + + // Create projects + $project1 = $database->createDocument('projects_mtm', new Document([ + '$id' => 'proj1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'projectName' => 'High Priority Project', + 'budget' => 100000.00, + 'priority' => 'high', + 'assignedDevelopers' => ['dev1', 'dev2'], + ])); + + $project2 = $database->createDocument('projects_mtm', new Document([ + '$id' => 'proj2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'projectName' => 'Low Priority Project', + 'budget' => 25000.00, + 'priority' => 'low', + 'assignedDevelopers' => ['dev2'], + ])); + + // Test: Find developers on high priority projects + $developers = $database->find('developers_mtm', [ + Query::equal('assignedProjects.priority', ['high']) + ]); + $this->assertCount(2, $developers); // Both assigned to proj1 + + // Test: Find developers on high budget projects + $developers = $database->find('developers_mtm', [ + Query::greaterThan('assignedProjects.budget', 50000.00) + ]); + $this->assertCount(2, $developers); + + // Test: Find projects with experienced developers + $projects = $database->find('projects_mtm', [ + Query::greaterThanEqual('assignedDevelopers.experience', 10) + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj1', $projects[0]->getId()); + + // Test: Find projects with junior developers + $projects = $database->find('projects_mtm', [ + Query::lessThan('assignedDevelopers.experience', 5) + ]); + $this->assertCount(2, $projects); // Both projects have dev2 + + // Test: Combined queries + $projects = $database->find('projects_mtm', [ + Query::equal('assignedDevelopers.devName', ['Junior Dev']), + Query::equal('priority', ['low']) + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj2', $projects[0]->getId()); + + // Clean up + $database->deleteCollection('developers_mtm'); + $database->deleteCollection('projects_mtm'); + } } From e94083c8a057b50f667ace297f696c18d0e709e3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 3 Oct 2025 00:07:31 +1300 Subject: [PATCH 5/7] Fix static calls --- src/Database/Database.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 0529f207b..983acabe3 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4050,7 +4050,7 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu } // Always preserve internal attributes - $internalKeys = array_map(fn ($attr) => $attr['$id'], self::getInternalAttributes()); + $internalKeys = array_map(fn ($attr) => $attr['$id'], $this->getInternalAttributes()); $fieldsToKeep = array_merge($fieldsToKeep, $internalKeys); // Early return if wildcard selector present @@ -7383,7 +7383,7 @@ private function validateSelections(Document $collection, array $queries): array // Allow querying internal attributes $keys = \array_map( fn ($attribute) => $attribute['$id'], - self::getInternalAttributes() + $this->getInternalAttributes() ); foreach ($collection->getAttribute('attributes', []) as $attribute) { From 37b5daf08403b668cf739c9f593703ce03710044 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 3 Oct 2025 01:15:22 +1300 Subject: [PATCH 6/7] Cleanup --- src/Database/Database.php | 261 +++++++++++++++++--------------------- 1 file changed, 119 insertions(+), 142 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 983acabe3..f124d6eea 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3541,7 +3541,7 @@ public function getDocument(string $collection, string $id, array $queries = [], // Skip relationship population if we're in batch mode (relationships will be populated later) if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack, $nestedSelections)); + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $nestedSelections)); $document = $documents[0]; } @@ -3566,24 +3566,25 @@ public function getDocument(string $collection, string $id, array $queries = [], } /** - * Populate relationships for an array of documents (TRUE breadth-first approach) - * Completely separates fetching from relationship population for massive performance gains + * Populate relationships for an array of documents with breadth-first traversal * * @param array $documents * @param Document $collection * @param int $relationshipFetchDepth - * @param array $relationshipFetchStack * @param array> $selects * @return array * @throws DatabaseException */ - private function populateDocumentsRelationships(array $documents, Document $collection, int $relationshipFetchDepth = 0, array $relationshipFetchStack = [], array $selects = []): array - { - // Enable batch mode to prevent nested relationship population during fetches + private function populateDocumentsRelationships( + array $documents, + Document $collection, + int $relationshipFetchDepth = 0, + array $selects = [] + ): array { + // Prevent nested relationship population during fetches $this->inBatchRelationshipPopulation = true; try { - // Queue of work items: [documents, collectionDoc, depth, selects, skipKey, hasExplicitSelects] $queue = [ [ 'documents' => $documents, @@ -3597,11 +3598,9 @@ private function populateDocumentsRelationships(array $documents, Document $coll $currentDepth = $relationshipFetchDepth; - // Process queue level by level (breadth-first) while (!empty($queue) && $currentDepth < self::RELATION_MAX_DEPTH) { $nextQueue = []; - // Process ALL items at the current depth foreach ($queue as $item) { $docs = $item['documents']; $coll = $item['collection']; @@ -3613,7 +3612,6 @@ private function populateDocumentsRelationships(array $documents, Document $coll continue; } - // Get all relationship attributes for this collection $attributes = $coll->getAttribute('attributes', []); $relationships = []; @@ -3627,20 +3625,16 @@ private function populateDocumentsRelationships(array $documents, Document $coll // Include relationship if: // 1. No explicit selects (fetch all) OR // 2. Relationship is explicitly selected - if (!$parentHasExplicitSelects || array_key_exists($attribute['key'], $sels)) { + if (!$parentHasExplicitSelects || \array_key_exists($attribute['key'], $sels)) { $relationships[] = $attribute; } } } - // Process each relationship type foreach ($relationships as $relationship) { $key = $relationship['key']; - $relationType = $relationship['options']['relationType']; $queries = $sels[$key] ?? []; $relationship->setAttribute('collection', $coll->getId()); - - // Check if we're at max depth BEFORE populating $isAtMaxDepth = ($currentDepth + 1) >= self::RELATION_MAX_DEPTH; // If we're at max depth, remove this relationship from source documents and skip @@ -3651,7 +3645,6 @@ private function populateDocumentsRelationships(array $documents, Document $coll continue; } - // Fetch and populate this relationship $relatedDocs = $this->populateSingleRelationshipBatch( $docs, $relationship, @@ -3662,10 +3655,12 @@ private function populateDocumentsRelationships(array $documents, Document $coll $twoWay = $relationship['options']['twoWay']; $twoWayKey = $relationship['options']['twoWayKey']; - // Queue if: (1) no explicit selects (fetch all recursively), OR - // (2) explicit nested selects for this relationship (isset($sels[$key])) + // Queue if: + // 1. No explicit selects (fetch all recursively), OR + // 2. Explicit nested selects for this relationship $hasNestedSelectsForThisRel = isset($sels[$key]); - $shouldQueue = !empty($relatedDocs) && ($hasNestedSelectsForThisRel || !$parentHasExplicitSelects); + $shouldQueue = !empty($relatedDocs) && + ($hasNestedSelectsForThisRel || !$parentHasExplicitSelects); if ($shouldQueue) { $relatedCollectionId = $relationship['options']['relatedCollection']; @@ -3673,12 +3668,11 @@ private function populateDocumentsRelationships(array $documents, Document $coll if (!$relatedCollection->isEmpty()) { // Get nested selections for this relationship - // $sels[$key] is an array of Query objects $relationshipQueries = $hasNestedSelectsForThisRel ? $sels[$key] : []; // Extract nested selections for the related collection $relatedCollectionRelationships = $relatedCollection->getAttribute('attributes', []); - $relatedCollectionRelationships = array_filter( + $relatedCollectionRelationships = \array_filter( $relatedCollectionRelationships, fn ($attr) => $attr['type'] === Database::VAR_RELATIONSHIP ); @@ -3701,7 +3695,7 @@ private function populateDocumentsRelationships(array $documents, Document $coll } // Remove back-references for two-way relationships - // Back-references are ALWAYS removed to prevent circular references + // Back-references are always removed to prevent circular references if ($twoWay && !empty($relatedDocs)) { foreach ($relatedDocs as $relatedDoc) { $relatedDoc->removeAttribute($twoWayKey); @@ -3710,12 +3704,10 @@ private function populateDocumentsRelationships(array $documents, Document $coll } } - // Move to next depth $queue = $nextQueue; $currentDepth++; } } finally { - // Always disable batch mode when done $this->inBatchRelationshipPopulation = false; } @@ -3732,23 +3724,18 @@ private function populateDocumentsRelationships(array $documents, Document $coll * @return array * @throws DatabaseException */ - private function populateSingleRelationshipBatch(array $documents, Document $relationship, array $queries): array - { - $key = $relationship['key']; - $relationType = $relationship['options']['relationType']; - - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - return $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries); - case Database::RELATION_ONE_TO_MANY: - return $this->populateOneToManyRelationshipsBatch($documents, $relationship, $queries); - case Database::RELATION_MANY_TO_ONE: - return $this->populateManyToOneRelationshipsBatch($documents, $relationship, $queries); - case Database::RELATION_MANY_TO_MANY: - return $this->populateManyToManyRelationshipsBatch($documents, $relationship, $queries); - default: - return []; - } + private function populateSingleRelationshipBatch( + array $documents, + Document $relationship, + array $queries + ): array { + return match ($relationship['options']['relationType']) { + Database::RELATION_ONE_TO_ONE => $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_ONE_TO_MANY => $this->populateOneToManyRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_MANY_TO_ONE => $this->populateManyToOneRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_MANY_TO_MANY => $this->populateManyToManyRelationshipsBatch($documents, $relationship, $queries), + default => [], + }; } /** @@ -3766,14 +3753,13 @@ private function populateOneToOneRelationshipsBatch(array $documents, Document $ $key = $relationship['key']; $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - // Collect all related document IDs $relatedIds = []; $documentsByRelatedId = []; foreach ($documents as $document) { $value = $document->getAttribute($key); if (!\is_null($value)) { - // Skip if value is already a Document object (already populated) + // Skip if value is already populated if ($value instanceof Document) { continue; } @@ -3791,18 +3777,17 @@ private function populateOneToOneRelationshipsBatch(array $documents, Document $ return []; } - // Fetch all related documents, chunking to stay within query limits - $uniqueRelatedIds = array_unique($relatedIds); + $uniqueRelatedIds = \array_unique($relatedIds); $relatedDocuments = []; // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal('$id', $chunk), Query::limit(PHP_INT_MAX), ...$queries ]); - array_push($relatedDocuments, ...$chunkDocs); + \array_push($relatedDocuments, ...$chunkDocs); } // Index related documents by ID for quick lookup @@ -3839,8 +3824,11 @@ private function populateOneToOneRelationshipsBatch(array $documents, Document $ * @return array * @throws DatabaseException */ - private function populateOneToManyRelationshipsBatch(array $documents, Document $relationship, array $queries): array - { + private function populateOneToManyRelationshipsBatch( + array $documents, + Document $relationship, + array $queries, + ): array { $key = $relationship['key']; $twoWay = $relationship['options']['twoWay']; $twoWayKey = $relationship['options']['twoWayKey']; @@ -3859,15 +3847,13 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document } // Parent side - fetch multiple related documents - // Collect all parent document IDs $parentIds = []; foreach ($documents as $document) { $parentId = $document->getId(); $parentIds[] = $parentId; } - // Remove duplicates - $parentIds = array_unique($parentIds); + $parentIds = \array_unique($parentIds); if (empty($parentIds)) { return []; @@ -3885,18 +3871,15 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document } } - // Fetch all related documents for all parents, chunking to stay within query limits - // Don't apply selects yet - we need the back-reference for grouping $relatedDocuments = []; - // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($parentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($parentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal($twoWayKey, $chunk), Query::limit(PHP_INT_MAX), ...$otherQueries ]); - array_push($relatedDocuments, ...$chunkDocs); + \array_push($relatedDocuments, ...$chunkDocs); } // Group related documents by parent ID @@ -3905,17 +3888,19 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document $parentId = $related->getAttribute($twoWayKey); if (!\is_null($parentId)) { // Handle case where parentId might be a Document object instead of string - $parentKey = $parentId instanceof Document ? $parentId->getId() : $parentId; + $parentKey = $parentId instanceof Document + ? $parentId->getId() + : $parentId; + if (!isset($relatedByParentId[$parentKey])) { $relatedByParentId[$parentKey] = []; } - // Note: We don't remove the back-reference here because documents may be reused across fetches - // Cycles are prevented by depth limiting in the breadth-first traversal + // We don't remove the back-reference here because documents may be reused across fetches + // Cycles are prevented by depth limiting in breadth-first traversal $relatedByParentId[$parentKey][] = $related; } } - // Apply select filters to related documents if specified $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); // Assign related documents to their parent documents @@ -3937,8 +3922,11 @@ private function populateOneToManyRelationshipsBatch(array $documents, Document * @return array * @throws DatabaseException */ - private function populateManyToOneRelationshipsBatch(array $documents, Document $relationship, array $queries): array - { + private function populateManyToOneRelationshipsBatch( + array $documents, + Document $relationship, + array $queries, + ): array { $key = $relationship['key']; $twoWay = $relationship['options']['twoWay']; $twoWayKey = $relationship['options']['twoWayKey']; @@ -3958,21 +3946,18 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document return []; } - // Collect all child document IDs $childIds = []; foreach ($documents as $document) { $childId = $document->getId(); $childIds[] = $childId; } - // Remove duplicates $childIds = array_unique($childIds); if (empty($childIds)) { return []; } - // Separate select queries from other queries $selectQueries = []; $otherQueries = []; foreach ($queries as $query) { @@ -3983,18 +3968,15 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document } } - // Fetch all related documents for all children, chunking to stay within query limits - // Don't apply selects yet - we need the back-reference for grouping $relatedDocuments = []; - // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($childIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($childIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal($twoWayKey, $chunk), Query::limit(PHP_INT_MAX), ...$otherQueries ]); - array_push($relatedDocuments, ...$chunkDocs); + \array_push($relatedDocuments, ...$chunkDocs); } // Group related documents by child ID @@ -4003,20 +3985,21 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document $childId = $related->getAttribute($twoWayKey); if (!\is_null($childId)) { // Handle case where childId might be a Document object instead of string - $childKey = $childId instanceof Document ? $childId->getId() : $childId; + $childKey = $childId instanceof Document + ? $childId->getId() + : $childId; + if (!isset($relatedByChildId[$childKey])) { $relatedByChildId[$childKey] = []; } - // Note: We don't remove the back-reference here because documents may be reused across fetches - // Cycles are prevented by depth limiting in the breadth-first traversal + // We don't remove the back-reference here because documents may be reused across fetches + // Cycles are prevented by depth limiting in breadth-first traversal $relatedByChildId[$childKey][] = $related; } } - // Apply select filters to related documents if specified $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); - // Assign related documents to their child documents foreach ($documents as $document) { $childId = $document->getId(); $document->setAttribute($key, $relatedByChildId[$childId] ?? []); @@ -4025,51 +4008,6 @@ private function populateManyToOneRelationshipsBatch(array $documents, Document return $relatedDocuments; } - /** - * Apply select filters to documents after fetching - * - * Filters document attributes based on select queries while preserving internal attributes. - * This is used in batch relationship population to apply selects after grouping. - * - * @param array $documents Documents to filter - * @param array $selectQueries Select query objects - * @return void - */ - private function applySelectFiltersToDocuments(array $documents, array $selectQueries): void - { - if (empty($selectQueries)) { - return; - } - - // Collect all fields to keep from select queries - $fieldsToKeep = []; - foreach ($selectQueries as $selectQuery) { - foreach ($selectQuery->getValues() as $value) { - $fieldsToKeep[] = $value; - } - } - - // Always preserve internal attributes - $internalKeys = array_map(fn ($attr) => $attr['$id'], $this->getInternalAttributes()); - $fieldsToKeep = array_merge($fieldsToKeep, $internalKeys); - - // Early return if wildcard selector present - if (in_array('*', $fieldsToKeep)) { - return; - } - - // Filter each document to only include selected fields - foreach ($documents as $doc) { - $allKeys = array_keys($doc->getArrayCopy()); - foreach ($allKeys as $attrKey) { - // Keep if: explicitly selected OR is internal attribute - if (!in_array($attrKey, $fieldsToKeep) && !str_starts_with($attrKey, '$')) { - $doc->removeAttribute($attrKey); - } - } - } - } - /** * Populate many-to-many relationships in batch * @@ -4079,8 +4017,11 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu * @return array * @throws DatabaseException */ - private function populateManyToManyRelationshipsBatch(array $documents, Document $relationship, array $queries): array - { + private function populateManyToManyRelationshipsBatch( + array $documents, + Document $relationship, + array $queries + ): array { $key = $relationship['key']; $twoWay = $relationship['options']['twoWay']; $twoWayKey = $relationship['options']['twoWayKey']; @@ -4092,14 +4033,12 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document return []; } - // Collect all document IDs $documentIds = []; foreach ($documents as $document) { $documentId = $document->getId(); $documentIds[] = $documentId; } - // Remove duplicates $documentIds = array_unique($documentIds); if (empty($documentIds)) { @@ -4108,19 +4047,16 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - // Fetch all junction records for all documents, chunking to stay within query limits $junctions = []; - // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($documentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($documentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkJunctions = $this->skipRelationships(fn () => $this->find($junction, [ Query::equal($twoWayKey, $chunk), Query::limit(PHP_INT_MAX) ])); - array_push($junctions, ...$chunkJunctions); + \array_push($junctions, ...$chunkJunctions); } - // Collect all related IDs from junctions $relatedIds = []; $junctionsByDocumentId = []; @@ -4137,26 +4073,23 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document } } - // Fetch all related documents, chunking to stay within query limits $related = []; $allRelatedDocs = []; if (!empty($relatedIds)) { $uniqueRelatedIds = array_unique($relatedIds); $foundRelated = []; - // Process in chunks to avoid exceeding query value limits - foreach (array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + foreach (\array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { $chunkDocs = $this->find($relatedCollection->getId(), [ Query::equal('$id', $chunk), Query::limit(PHP_INT_MAX), ...$queries ]); - array_push($foundRelated, ...$chunkDocs); + \array_push($foundRelated, ...$chunkDocs); } $allRelatedDocs = $foundRelated; - // Index related documents by ID for quick lookup $relatedById = []; foreach ($foundRelated as $doc) { $relatedById[$doc->getId()] = $doc; @@ -4174,7 +4107,6 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document } } - // Assign related documents to their parent documents foreach ($documents as $document) { $documentId = $document->getId(); $document->setAttribute($key, $related[$documentId] ?? []); @@ -4183,6 +4115,51 @@ private function populateManyToManyRelationshipsBatch(array $documents, Document return $allRelatedDocs; } + /** + * Apply select filters to documents after fetching + * + * Filters document attributes based on select queries while preserving internal attributes. + * This is used in batch relationship population to apply selects after grouping. + * + * @param array $documents Documents to filter + * @param array $selectQueries Select query objects + * @return void + */ + private function applySelectFiltersToDocuments(array $documents, array $selectQueries): void + { + if (empty($selectQueries)) { + return; + } + + // Collect all fields to keep from select queries + $fieldsToKeep = []; + foreach ($selectQueries as $selectQuery) { + foreach ($selectQuery->getValues() as $value) { + $fieldsToKeep[] = $value; + } + } + + // Always preserve internal attributes + $internalKeys = \array_map(fn ($attr) => $attr['$id'], $this->getInternalAttributes()); + + \array_push($fieldsToKeep, ...$internalKeys); + + // Early return if wildcard selector present + if (in_array('*', $fieldsToKeep)) { + return; + } + + foreach ($documents as $doc) { + $allKeys = array_keys($doc->getArrayCopy()); + foreach ($allKeys as $attrKey) { + // Keep if: explicitly selected OR is internal attribute + if (!in_array($attrKey, $fieldsToKeep) && !str_starts_with($attrKey, '$')) { + $doc->removeAttribute($attrKey); + } + } + } + } + /** * Create Document * @@ -4279,7 +4256,7 @@ public function createDocument(string $collection, Document $document): Document if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { // Use the write stack depth for proper MAX_DEPTH enforcement during creation $fetchDepth = count($this->relationshipWriteStack); - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $fetchDepth, $this->relationshipFetchStack)); + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $fetchDepth)); $document = $documents[0]; } @@ -4382,7 +4359,7 @@ public function createDocuments( // Use batch relationship population for better performance if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack)); + $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); } foreach ($batch as $document) { @@ -4934,7 +4911,7 @@ public function updateDocument(string $collection, string $id, Document $documen } if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack)); + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth)); $document = $documents[0]; } @@ -5807,7 +5784,7 @@ public function upsertDocumentsWithIncrease( // Use batch relationship population for better performance if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack)); + $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); } foreach ($batch as $index => $doc) { @@ -6825,7 +6802,7 @@ public function find(string $collection, array $queries = [], string $forPermiss if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { if (count($results) > 0) { // Always use batch processing for all cases (single and multiple documents, nested or top-level) - $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $this->relationshipFetchStack, $nestedSelections)); + $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $nestedSelections)); } } From e78f6d9b118fd78b8feccc1ef8226a7fdc322778 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 3 Oct 2025 01:42:57 +1300 Subject: [PATCH 7/7] Short circuit query mismatch --- src/Database/Database.php | 112 ++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 59 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index f124d6eea..d081ddf68 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4127,7 +4127,7 @@ private function populateManyToManyRelationshipsBatch( */ private function applySelectFiltersToDocuments(array $documents, array $selectQueries): void { - if (empty($selectQueries)) { + if (empty($selectQueries) || empty($documents)) { return; } @@ -4135,25 +4135,26 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu $fieldsToKeep = []; foreach ($selectQueries as $selectQuery) { foreach ($selectQuery->getValues() as $value) { - $fieldsToKeep[] = $value; + $fieldsToKeep[$value] = true; } } - // Always preserve internal attributes - $internalKeys = \array_map(fn ($attr) => $attr['$id'], $this->getInternalAttributes()); - - \array_push($fieldsToKeep, ...$internalKeys); - // Early return if wildcard selector present - if (in_array('*', $fieldsToKeep)) { + if (isset($fieldsToKeep['*'])) { return; } + // Always preserve internal attributes (use hashmap for O(1) lookup) + $internalKeys = \array_map(fn ($attr) => $attr['$id'], $this->getInternalAttributes()); + foreach ($internalKeys as $key) { + $fieldsToKeep[$key] = true; + } + foreach ($documents as $doc) { - $allKeys = array_keys($doc->getArrayCopy()); + $allKeys = \array_keys($doc->getArrayCopy()); foreach ($allKeys as $attrKey) { - // Keep if: explicitly selected OR is internal attribute - if (!in_array($attrKey, $fieldsToKeep) && !str_starts_with($attrKey, '$')) { + // Keep if: explicitly selected OR is internal attribute ($ prefix) + if (!isset($fieldsToKeep[$attrKey]) && !\str_starts_with($attrKey, '$')) { $doc->removeAttribute($attrKey); } } @@ -4357,7 +4358,6 @@ public function createDocuments( $batch = $this->adapter->getSequences($collection->getId(), $batch); - // Use batch relationship population for better performance if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); } @@ -5782,7 +5782,6 @@ public function upsertDocumentsWithIncrease( } } - // Use batch relationship population for better performance if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); } @@ -6781,27 +6780,31 @@ public function find(string $collection, array $queries = [], string $forPermiss $nestedSelections = $this->processRelationshipQueries($relationships, $queries); // Convert relationship filter queries to SQL-level subqueries - $queries = $this->convertRelationshipFiltersToSubqueries($collection, $relationships, $queries); + $queriesOrNull = $this->convertRelationshipFiltersToSubqueries($relationships, $queries); - $getResults = fn () => $this->adapter->find( - $collection, - $queries, - $limit ?? 25, - $offset ?? 0, - $orderAttributes, - $orderTypes, - $cursor, - $cursorDirection, - $forPermission - ); + // If conversion returns null, it means no documents can match (relationship filter found no matches) + if ($queriesOrNull === null) { + $results = []; + } else { + $queries = $queriesOrNull; - $results = $skipAuth ? Authorization::skip($getResults) : $getResults(); + $getResults = fn () => $this->adapter->find( + $collection, + $queries, + $limit ?? 25, + $offset ?? 0, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $forPermission + ); + + $results = $skipAuth ? Authorization::skip($getResults) : $getResults(); + } - // Skip relationship population if we're in batch mode (relationships will be populated later) - // Use batch relationship population for better performance at all levels if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { if (count($results) > 0) { - // Always use batch processing for all cases (single and multiple documents, nested or top-level) $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $nestedSelections)); } } @@ -7661,16 +7664,14 @@ private function processRelationshipQueries( * Queries like Query::equal('author.name', ['Alice']) are converted to * Query::equal('author', []) * - * @param Document $collection * @param array $relationships * @param array $queries - * @return array + * @return array|null Returns null if relationship filters cannot match any documents */ private function convertRelationshipFiltersToSubqueries( - Document $collection, array $relationships, - array $queries - ): array { + array $queries, + ): ?array { // Early return if no dot-path queries exist $hasDotPath = false; foreach ($queries as $query) { @@ -7685,20 +7686,20 @@ private function convertRelationshipFiltersToSubqueries( return $queries; } + $relationshipsByKey = []; + foreach ($relationships as $relationship) { + $relationshipsByKey[$relationship->getAttribute('key')] = $relationship; + } + $additionalQueries = []; $groupedQueries = []; $indicesToRemove = []; // Group queries by relationship key - foreach ($queries as $index => $query) { + foreach (Query::groupByType($queries)['filters'] as $index => $query) { $method = $query->getMethod(); $attribute = $query->getAttribute(); - // Skip non-filter queries and non-relationship queries - if ($method === Query::TYPE_SELECT || $method === Query::TYPE_ORDER_ASC || $method === Query::TYPE_ORDER_DESC) { - continue; - } - if (!\str_contains($attribute, '.')) { continue; } @@ -7707,12 +7708,7 @@ private function convertRelationshipFiltersToSubqueries( $parts = \explode('.', $attribute); $relationshipKey = \array_shift($parts); $nestedField = \implode('.', $parts); - - // Find the relationship - $relationship = \array_values(\array_filter( - $relationships, - fn (Document $rel) => $rel->getAttribute('key') === $relationshipKey - ))[0] ?? null; + $relationship = $relationshipsByKey[$relationshipKey] ?? null; if (!$relationship) { continue; @@ -7732,13 +7728,13 @@ private function convertRelationshipFiltersToSubqueries( 'field' => $nestedField, 'values' => $query->getValues() ]; + $groupedQueries[$relationshipKey]['indices'][] = $index; } // Process each relationship group foreach ($groupedQueries as $relationshipKey => $group) { $relationship = $group['relationship']; - $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; $relationType = $relationship->getAttribute('options')['relationType']; $side = $relationship->getAttribute('options')['side']; @@ -7768,20 +7764,22 @@ private function convertRelationshipFiltersToSubqueries( if ($needsParentResolution) { $matchingDocs = $this->silent(fn () => $this->find( $relatedCollection, - \array_merge($relatedQueries, [Query::limit(PHP_INT_MAX)]), - self::PERMISSION_READ + \array_merge($relatedQueries, [ + Query::limit(PHP_INT_MAX), + ]) )); } else { $matchingDocs = $this->silent(fn () => $this->find( $relatedCollection, - \array_merge($relatedQueries, [Query::select(['$id']), Query::limit(PHP_INT_MAX)]), - self::PERMISSION_READ + \array_merge($relatedQueries, [ + Query::select(['$id']), + Query::limit(PHP_INT_MAX), + ]) )); } $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); - // Use the same parent resolution logic as above if ($needsParentResolution) { // Need to find which parents have these children $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; @@ -7815,14 +7813,14 @@ private function convertRelationshipFiltersToSubqueries( if (!empty($parentIds)) { $additionalQueries[] = Query::equal('$id', $parentIds); } else { - $additionalQueries[] = Query::equal('$id', ['__impossible__']); + return null; } } else { // For other types, filter by the relationship field if (!empty($matchingIds)) { $additionalQueries[] = Query::equal($relationshipKey, $matchingIds); } else { - $additionalQueries[] = Query::equal($relationshipKey, ['__impossible__']); + return null; } } @@ -7831,11 +7829,7 @@ private function convertRelationshipFiltersToSubqueries( $indicesToRemove[] = $index; } } catch (\Exception $e) { - // If subquery fails, add impossible filter - $additionalQueries[] = Query::equal('$id', ['__impossible__']); - foreach ($group['indices'] as $index) { - $indicesToRemove[] = $index; - } + return null; } }