Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Database/Adapter/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class Mongo extends Adapter
'$regex',
'$not',
'$nor',
'$exists',
];

protected Client $client;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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),
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/Database/Adapter/SQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
30 changes: 29 additions & 1 deletion src/Database/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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);
}
}
4 changes: 3 additions & 1 deletion src/Database/Validator/Queries.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 => '',
};

Expand Down
10 changes: 9 additions & 1 deletion src/Database/Validator/Query/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down
127 changes: 127 additions & 0 deletions tests/e2e/Adapter/Scopes/SchemalessTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}