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..e3b4d95d0 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'; @@ -99,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, @@ -294,7 +298,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 +1184,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); + } }