From 5e0008eb19b75de893aa8553bc96496b5feaf6c7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 17:42:55 +0530 Subject: [PATCH 1/4] Add support for exists and notExists query types in Mongo --- src/Database/Adapter/Mongo.php | 7 + src/Database/Adapter/SQL.php | 3 + src/Database/Query.php | 28 +++- src/Database/Validator/Queries.php | 4 +- src/Database/Validator/Query/Filter.php | 10 +- tests/e2e/Adapter/Scopes/SchemalessTests.php | 127 +++++++++++++++++++ 6 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 009ad1f7c..f8ff6edf0 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -42,6 +42,7 @@ class Mongo extends Adapter '$regex', '$not', '$nor', + '$exists', ]; protected Client $client; @@ -2373,6 +2374,8 @@ protected function buildFilter(Query $query): array $value = match ($query->getMethod()) { Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL => null, + Query::TYPE_EXISTS => true, + Query::TYPE_NOT_EXISTS => false, default => $this->getQueryValue( $query->getMethod(), count($query->getValues()) > 1 @@ -2434,6 +2437,8 @@ protected function buildFilter(Query $query): array $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '^%s')]; } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) { $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '%s$')]; + } elseif ($operator === '$exists') { + $filter[$attribute][$operator] = $value; } else { $filter[$attribute][$operator] = $value; } @@ -2472,6 +2477,8 @@ protected function getQueryOperator(string $operator): string Query::TYPE_NOT_ENDS_WITH => '$regex', Query::TYPE_OR => '$or', Query::TYPE_AND => '$and', + Query::TYPE_EXISTS, + Query::TYPE_NOT_EXISTS => '$exists', default => throw new DatabaseException('Unknown operator:' . $operator . '. Must be one of ' . Query::TYPE_EQUAL . ', ' . Query::TYPE_NOT_EQUAL . ', ' . Query::TYPE_LESSER . ', ' . Query::TYPE_LESSER_EQUAL . ', ' . Query::TYPE_GREATER . ', ' . Query::TYPE_GREATER_EQUAL . ', ' . Query::TYPE_IS_NULL . ', ' . Query::TYPE_IS_NOT_NULL . ', ' . Query::TYPE_BETWEEN . ', ' . Query::TYPE_NOT_BETWEEN . ', ' . Query::TYPE_STARTS_WITH . ', ' . Query::TYPE_NOT_STARTS_WITH . ', ' . Query::TYPE_ENDS_WITH . ', ' . Query::TYPE_NOT_ENDS_WITH . ', ' . Query::TYPE_CONTAINS . ', ' . Query::TYPE_NOT_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_NOT_SEARCH . ', ' . Query::TYPE_SELECT), }; } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 4bd0bb653..dfd1565ba 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1798,6 +1798,9 @@ protected function getSQLOperator(string $method): string case Query::TYPE_VECTOR_COSINE: case Query::TYPE_VECTOR_EUCLIDEAN: throw new DatabaseException('Vector queries are not supported by this database'); + case Query::TYPE_EXISTS: + case Query::TYPE_NOT_EXISTS: + throw new DatabaseException('Exists queries are not supported by this database'); default: throw new DatabaseException('Unknown method: ' . $method); } diff --git a/src/Database/Query.php b/src/Database/Query.php index 60ec1d712..d265d7bf3 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -26,6 +26,8 @@ class Query public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; public const TYPE_ENDS_WITH = 'endsWith'; public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + public const TYPE_EXISTS = 'exists'; + public const TYPE_NOT_EXISTS = 'notExists'; // Spatial methods public const TYPE_CROSSES = 'crosses'; @@ -294,7 +296,9 @@ public static function isMethod(string $value): bool self::TYPE_SELECT, self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN => true, + self::TYPE_VECTOR_EUCLIDEAN, + self::TYPE_EXISTS, + self::TYPE_NOT_EXISTS => true, default => false, }; } @@ -1178,4 +1182,26 @@ public static function vectorEuclidean(string $attribute, array $vector): self { return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); } + + /** + * Helper method to create Query with exists method + * + * @param string $attribute + * @return Query + */ + public static function exists(string $attribute): self + { + return new self(self::TYPE_EXISTS, $attribute); + } + + /** + * Helper method to create Query with notExists method + * + * @param string $attribute + * @return Query + */ + public static function notExists(string $attribute): self + { + return new self(self::TYPE_NOT_EXISTS, $attribute); + } } diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 8066228e3..22017692a 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -121,7 +121,9 @@ public function isValid($value): bool Query::TYPE_NOT_TOUCHES, Query::TYPE_VECTOR_DOT, Query::TYPE_VECTOR_COSINE, - Query::TYPE_VECTOR_EUCLIDEAN => Base::METHOD_TYPE_FILTER, + Query::TYPE_VECTOR_EUCLIDEAN, + Query::TYPE_EXISTS, + Query::TYPE_NOT_EXISTS => Base::METHOD_TYPE_FILTER, default => '', }; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 11053f14c..7d6fcd4d0 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -91,6 +91,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s $attribute = \explode('.', $attribute)[0]; } + // exists and notExists queries don't require values, just attribute validation + if (in_array($method, [Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS])) { + // Validate attribute (handles encrypted attributes, schemaless mode, etc.) + return $this->isValidAttribute($attribute); + } + if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { // First check maxValuesCount guard for any IN-style value arrays if (count($values) > $this->maxValuesCount) { @@ -250,7 +256,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s if ( $array && - !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) + !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS]) ) { $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; return false; @@ -352,6 +358,8 @@ public function isValid($value): bool case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: + case Query::TYPE_EXISTS: + case Query::TYPE_NOT_EXISTS: return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_VECTOR_DOT: diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index ee0985682..16bd434a6 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1155,4 +1155,131 @@ public function testSchemalessDates(): void $database->deleteCollection($col); } + + public function testSchemalessExists(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless_exists'); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Create documents with and without the 'optionalField' attribute + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField + new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null + new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField + ]; + $this->assertEquals(5, $database->createDocuments($colName, $docs)); + + // Test exists - should return documents where optionalField exists (even if null) + $documents = $database->find($colName, [ + Query::exists('optionalField'), + ]); + + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + $ids = array_map(fn ($doc) => $doc->getId(), $documents); + $this->assertContains('doc1', $ids); + $this->assertContains('doc2', $ids); + $this->assertContains('doc4', $ids); + + // Verify that doc4 is included even though optionalField is null + $doc4 = array_filter($documents, fn ($doc) => $doc->getId() === 'doc4'); + $this->assertCount(1, $doc4); + $doc4Array = array_values($doc4); + $this->assertTrue(array_key_exists('optionalField', $doc4Array[0]->getAttributes())); + + // Test exists with another attribute + $documents = $database->find($colName, [ + Query::exists('name'), + ]); + $this->assertEquals(5, count($documents)); // All documents have 'name' + + // Test exists with non-existent attribute + $documents = $database->find($colName, [ + Query::exists('nonExistentField'), + ]); + $this->assertEquals(0, count($documents)); + + $database->deleteCollection($colName); + } + + public function testSchemalessNotExists(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless_not_exists'); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Create documents with and without the 'optionalField' attribute + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField + new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null + new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField + ]; + $this->assertEquals(5, $database->createDocuments($colName, $docs)); + + // Test notExists - should return documents where optionalField does not exist + $documents = $database->find($colName, [ + Query::notExists('optionalField'), + ]); + + $this->assertEquals(2, count($documents)); // doc3, doc5 + $ids = array_map(fn ($doc) => $doc->getId(), $documents); + $this->assertContains('doc3', $ids); + $this->assertContains('doc5', $ids); + + // Verify that doc4 is NOT included (it exists even though null) + $this->assertNotContains('doc4', $ids); + + // Test notExists with another attribute + $documents = $database->find($colName, [ + Query::notExists('name'), + ]); + $this->assertEquals(0, count($documents)); // All documents have 'name' + + // Test notExists with non-existent attribute + $documents = $database->find($colName, [ + Query::notExists('nonExistentField'), + ]); + $this->assertEquals(5, count($documents)); // All documents don't have this field + + // Test combination of exists and notExists + $documents = $database->find($colName, [ + Query::exists('name'), + Query::notExists('optionalField'), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + + $database->deleteCollection($colName); + } } From 086052dfe12ca317e6f109258ee1b149bdc954eb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 18:29:55 +0530 Subject: [PATCH 2/4] added missing types to the query types --- src/Database/Query.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Database/Query.php b/src/Database/Query.php index d265d7bf3..e3b4d95d0 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -101,6 +101,8 @@ class Query self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, self::TYPE_VECTOR_EUCLIDEAN, + self::TYPE_EXISTS, + self::TYPE_NOT_EXISTS, self::TYPE_SELECT, self::TYPE_ORDER_DESC, self::TYPE_ORDER_ASC, From b73282899275bdc4c9d23fdb160507e7614a5b83 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 14:12:56 +0530 Subject: [PATCH 3/4] updated exists and not exists method --- src/Database/Adapter/Mongo.php | 4 +- src/Database/Query.php | 12 +-- src/Database/Validator/Query/Filter.php | 4 +- tests/e2e/Adapter/Scopes/SchemalessTests.php | 104 ++++++++++++++++++- 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index f8ff6edf0..18554f87c 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2438,7 +2438,9 @@ protected function buildFilter(Query $query): array } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) { $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '%s$')]; } elseif ($operator === '$exists') { - $filter[$attribute][$operator] = $value; + foreach ($query->getValues() as $attribute) { + $filter['$or'][] = [$attribute => [$operator => $value]]; + } } else { $filter[$attribute][$operator] = $value; } diff --git a/src/Database/Query.php b/src/Database/Query.php index e3b4d95d0..f5e8f6420 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -1188,22 +1188,22 @@ public static function vectorEuclidean(string $attribute, array $vector): self /** * Helper method to create Query with exists method * - * @param string $attribute + * @param array $attribute * @return Query */ - public static function exists(string $attribute): self + public static function exists(array $attributes): self { - return new self(self::TYPE_EXISTS, $attribute); + return new self(self::TYPE_EXISTS, '', $attributes); } /** * Helper method to create Query with notExists method * - * @param string $attribute + * @param string|int|float|bool|array $attribute * @return Query */ - public static function notExists(string $attribute): self + public static function notExists(string|int|float|bool|array $attribute): self { - return new self(self::TYPE_NOT_EXISTS, $attribute); + return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]); } } diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 7d6fcd4d0..e62fc3913 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -312,6 +312,8 @@ public function isValid($value): bool case Query::TYPE_EQUAL: case Query::TYPE_CONTAINS: case Query::TYPE_NOT_CONTAINS: + case Query::TYPE_EXISTS: + case Query::TYPE_NOT_EXISTS: if ($this->isEmpty($value->getValues())) { $this->message = \ucfirst($method) . ' queries require at least one value.'; return false; @@ -358,8 +360,6 @@ public function isValid($value): bool case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: - case Query::TYPE_EXISTS: - case Query::TYPE_NOT_EXISTS: return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_VECTOR_DOT: diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 16bd434a6..87c35af0e 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1188,7 +1188,7 @@ public function testSchemalessExists(): void // Test exists - should return documents where optionalField exists (even if null) $documents = $database->find($colName, [ - Query::exists('optionalField'), + Query::exists(['optionalField']), ]); $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 @@ -1205,13 +1205,68 @@ public function testSchemalessExists(): void // Test exists with another attribute $documents = $database->find($colName, [ - Query::exists('name'), + Query::exists(['name']), ]); $this->assertEquals(5, count($documents)); // All documents have 'name' // Test exists with non-existent attribute $documents = $database->find($colName, [ - Query::exists('nonExistentField'), + Query::exists(['nonExistentField']), + ]); + $this->assertEquals(0, count($documents)); + + // Multiple attributes in a single exists query (OR semantics) + $documents = $database->find($colName, [ + Query::exists(['optionalField', 'name']), + ]); + // All documents have "name", some also have "optionalField" + $this->assertEquals(5, count($documents)); + + // Multiple attributes where only one exists on some documents + $documents = $database->find($colName, [ + Query::exists(['optionalField', 'nonExistentField']), + ]); + // Only documents where optionalField exists should be returned + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Multiple attributes where none exist should return empty + $documents = $database->find($colName, [ + Query::exists(['nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(0, count($documents)); + + // Multiple attributes including one present on all docs still returns all (OR) + $documents = $database->find($colName, [ + Query::exists(['name', 'nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(5, count($documents)); + + // Multiple exists queries (AND semantics) + $documents = $database->find($colName, [ + Query::exists(['optionalField']), + Query::exists(['name']), + ]); + // Documents must have both attributes + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Nested OR with exists (optionalField OR nonExistentField) AND name + $documents = $database->find($colName, [ + Query::and([ + Query::or([ + Query::exists(['optionalField']), + Query::exists(['nonExistentField']), + ]), + Query::exists(['name']), + ]), + ]); + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Nested OR with only missing attributes should yield empty + $documents = $database->find($colName, [ + Query::or([ + Query::exists(['nonExistentField']), + Query::exists(['alsoMissing']), + ]), ]); $this->assertEquals(0, count($documents)); @@ -1273,13 +1328,54 @@ public function testSchemalessNotExists(): void ]); $this->assertEquals(5, count($documents)); // All documents don't have this field + // Multiple attributes in a single notExists query (OR semantics) - both missing + $documents = $database->find($colName, [ + Query::notExists(['nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(5, count($documents)); + + // Multiple attributes (OR) where only some documents miss one of them + $documents = $database->find($colName, [ + Query::notExists(['name', 'optionalField']), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + + // Multiple notExists queries (AND semantics) - must miss both + $documents = $database->find($colName, [ + Query::notExists(['optionalField']), + Query::notExists(['nonExistentField']), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + // Test combination of exists and notExists $documents = $database->find($colName, [ - Query::exists('name'), + Query::exists(['name']), Query::notExists('optionalField'), ]); $this->assertEquals(2, count($documents)); // doc3, doc5 + // Nested OR/AND with notExists: (notExists optionalField OR notExists nonExistent) AND name + $documents = $database->find($colName, [ + Query::and([ + Query::or([ + Query::notExists(['optionalField']), + Query::notExists(['nonExistentField']), + ]), + Query::exists(['name']), + ]), + ]); + // notExists(nonExistentField) matches all docs, so OR is always true; AND with name returns all + $this->assertEquals(5, count($documents)); // all docs match due to nonExistentField + + // Nested OR with notExists where all attributes exist => empty + $documents = $database->find($colName, [ + Query::or([ + Query::notExists(['name']), + Query::notExists(['optionalField']), + ]), + ]); + $this->assertEquals(2, count($documents)); // only ones missing optionalField (doc3, doc5) + $database->deleteCollection($colName); } } From aa280279f5dd734ea3185cdcaaffec654391f5b8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 14:18:44 +0530 Subject: [PATCH 4/4] linting --- src/Database/Query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index f5e8f6420..e8ccdcaa3 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -1188,7 +1188,7 @@ public static function vectorEuclidean(string $attribute, array $vector): self /** * Helper method to create Query with exists method * - * @param array $attribute + * @param array $attributes * @return Query */ public static function exists(array $attributes): self