diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 009ad1f7c..18554f87c 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,10 @@ 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') { + foreach ($query->getValues() as $attribute) { + $filter['$or'][] = [$attribute => [$operator => $value]]; + } } else { $filter[$attribute][$operator] = $value; } @@ -2472,6 +2479,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..e8ccdcaa3 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 array $attributes + * @return Query + */ + public static function exists(array $attributes): self + { + return new self(self::TYPE_EXISTS, '', $attributes); + } + + /** + * Helper method to create Query with notExists method + * + * @param string|int|float|bool|array $attribute + * @return Query + */ + public static function notExists(string|int|float|bool|array $attribute): self + { + return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$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..e62fc3913 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; @@ -306,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; diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index ee0985682..87c35af0e 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1155,4 +1155,227 @@ 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)); + + // 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)); + + $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 + + // 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::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); + } }