Skip to content

Commit 5e0008e

Browse files
Add support for exists and notExists query types in Mongo
1 parent c8c1b2f commit 5e0008e

6 files changed

Lines changed: 176 additions & 3 deletions

File tree

src/Database/Adapter/Mongo.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class Mongo extends Adapter
4242
'$regex',
4343
'$not',
4444
'$nor',
45+
'$exists',
4546
];
4647

4748
protected Client $client;
@@ -2373,6 +2374,8 @@ protected function buildFilter(Query $query): array
23732374
$value = match ($query->getMethod()) {
23742375
Query::TYPE_IS_NULL,
23752376
Query::TYPE_IS_NOT_NULL => null,
2377+
Query::TYPE_EXISTS => true,
2378+
Query::TYPE_NOT_EXISTS => false,
23762379
default => $this->getQueryValue(
23772380
$query->getMethod(),
23782381
count($query->getValues()) > 1
@@ -2434,6 +2437,8 @@ protected function buildFilter(Query $query): array
24342437
$filter[$attribute] = ['$not' => $this->createSafeRegex($value, '^%s')];
24352438
} elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) {
24362439
$filter[$attribute] = ['$not' => $this->createSafeRegex($value, '%s$')];
2440+
} elseif ($operator === '$exists') {
2441+
$filter[$attribute][$operator] = $value;
24372442
} else {
24382443
$filter[$attribute][$operator] = $value;
24392444
}
@@ -2472,6 +2477,8 @@ protected function getQueryOperator(string $operator): string
24722477
Query::TYPE_NOT_ENDS_WITH => '$regex',
24732478
Query::TYPE_OR => '$or',
24742479
Query::TYPE_AND => '$and',
2480+
Query::TYPE_EXISTS,
2481+
Query::TYPE_NOT_EXISTS => '$exists',
24752482
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),
24762483
};
24772484
}

src/Database/Adapter/SQL.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1798,6 +1798,9 @@ protected function getSQLOperator(string $method): string
17981798
case Query::TYPE_VECTOR_COSINE:
17991799
case Query::TYPE_VECTOR_EUCLIDEAN:
18001800
throw new DatabaseException('Vector queries are not supported by this database');
1801+
case Query::TYPE_EXISTS:
1802+
case Query::TYPE_NOT_EXISTS:
1803+
throw new DatabaseException('Exists queries are not supported by this database');
18011804
default:
18021805
throw new DatabaseException('Unknown method: ' . $method);
18031806
}

src/Database/Query.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class Query
2626
public const TYPE_NOT_STARTS_WITH = 'notStartsWith';
2727
public const TYPE_ENDS_WITH = 'endsWith';
2828
public const TYPE_NOT_ENDS_WITH = 'notEndsWith';
29+
public const TYPE_EXISTS = 'exists';
30+
public const TYPE_NOT_EXISTS = 'notExists';
2931

3032
// Spatial methods
3133
public const TYPE_CROSSES = 'crosses';
@@ -294,7 +296,9 @@ public static function isMethod(string $value): bool
294296
self::TYPE_SELECT,
295297
self::TYPE_VECTOR_DOT,
296298
self::TYPE_VECTOR_COSINE,
297-
self::TYPE_VECTOR_EUCLIDEAN => true,
299+
self::TYPE_VECTOR_EUCLIDEAN,
300+
self::TYPE_EXISTS,
301+
self::TYPE_NOT_EXISTS => true,
298302
default => false,
299303
};
300304
}
@@ -1178,4 +1182,26 @@ public static function vectorEuclidean(string $attribute, array $vector): self
11781182
{
11791183
return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]);
11801184
}
1185+
1186+
/**
1187+
* Helper method to create Query with exists method
1188+
*
1189+
* @param string $attribute
1190+
* @return Query
1191+
*/
1192+
public static function exists(string $attribute): self
1193+
{
1194+
return new self(self::TYPE_EXISTS, $attribute);
1195+
}
1196+
1197+
/**
1198+
* Helper method to create Query with notExists method
1199+
*
1200+
* @param string $attribute
1201+
* @return Query
1202+
*/
1203+
public static function notExists(string $attribute): self
1204+
{
1205+
return new self(self::TYPE_NOT_EXISTS, $attribute);
1206+
}
11811207
}

src/Database/Validator/Queries.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ public function isValid($value): bool
121121
Query::TYPE_NOT_TOUCHES,
122122
Query::TYPE_VECTOR_DOT,
123123
Query::TYPE_VECTOR_COSINE,
124-
Query::TYPE_VECTOR_EUCLIDEAN => Base::METHOD_TYPE_FILTER,
124+
Query::TYPE_VECTOR_EUCLIDEAN,
125+
Query::TYPE_EXISTS,
126+
Query::TYPE_NOT_EXISTS => Base::METHOD_TYPE_FILTER,
125127
default => '',
126128
};
127129

src/Database/Validator/Query/Filter.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s
9191
$attribute = \explode('.', $attribute)[0];
9292
}
9393

94+
// exists and notExists queries don't require values, just attribute validation
95+
if (in_array($method, [Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS])) {
96+
// Validate attribute (handles encrypted attributes, schemaless mode, etc.)
97+
return $this->isValidAttribute($attribute);
98+
}
99+
94100
if (!$this->supportForAttributes && !isset($this->schema[$attribute])) {
95101
// First check maxValuesCount guard for any IN-style value arrays
96102
if (count($values) > $this->maxValuesCount) {
@@ -250,7 +256,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s
250256

251257
if (
252258
$array &&
253-
!in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL])
259+
!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])
254260
) {
255261
$this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.';
256262
return false;
@@ -352,6 +358,8 @@ public function isValid($value): bool
352358

353359
case Query::TYPE_IS_NULL:
354360
case Query::TYPE_IS_NOT_NULL:
361+
case Query::TYPE_EXISTS:
362+
case Query::TYPE_NOT_EXISTS:
355363
return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method);
356364

357365
case Query::TYPE_VECTOR_DOT:

tests/e2e/Adapter/Scopes/SchemalessTests.php

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,4 +1155,131 @@ public function testSchemalessDates(): void
11551155

11561156
$database->deleteCollection($col);
11571157
}
1158+
1159+
public function testSchemalessExists(): void
1160+
{
1161+
/** @var Database $database */
1162+
$database = static::getDatabase();
1163+
1164+
if ($database->getAdapter()->getSupportForAttributes()) {
1165+
$this->expectNotToPerformAssertions();
1166+
return;
1167+
}
1168+
1169+
$colName = uniqid('schemaless_exists');
1170+
$database->createCollection($colName);
1171+
1172+
$permissions = [
1173+
Permission::read(Role::any()),
1174+
Permission::write(Role::any()),
1175+
Permission::update(Role::any()),
1176+
Permission::delete(Role::any())
1177+
];
1178+
1179+
// Create documents with and without the 'optionalField' attribute
1180+
$docs = [
1181+
new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']),
1182+
new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']),
1183+
new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField
1184+
new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null
1185+
new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField
1186+
];
1187+
$this->assertEquals(5, $database->createDocuments($colName, $docs));
1188+
1189+
// Test exists - should return documents where optionalField exists (even if null)
1190+
$documents = $database->find($colName, [
1191+
Query::exists('optionalField'),
1192+
]);
1193+
1194+
$this->assertEquals(3, count($documents)); // doc1, doc2, doc4
1195+
$ids = array_map(fn ($doc) => $doc->getId(), $documents);
1196+
$this->assertContains('doc1', $ids);
1197+
$this->assertContains('doc2', $ids);
1198+
$this->assertContains('doc4', $ids);
1199+
1200+
// Verify that doc4 is included even though optionalField is null
1201+
$doc4 = array_filter($documents, fn ($doc) => $doc->getId() === 'doc4');
1202+
$this->assertCount(1, $doc4);
1203+
$doc4Array = array_values($doc4);
1204+
$this->assertTrue(array_key_exists('optionalField', $doc4Array[0]->getAttributes()));
1205+
1206+
// Test exists with another attribute
1207+
$documents = $database->find($colName, [
1208+
Query::exists('name'),
1209+
]);
1210+
$this->assertEquals(5, count($documents)); // All documents have 'name'
1211+
1212+
// Test exists with non-existent attribute
1213+
$documents = $database->find($colName, [
1214+
Query::exists('nonExistentField'),
1215+
]);
1216+
$this->assertEquals(0, count($documents));
1217+
1218+
$database->deleteCollection($colName);
1219+
}
1220+
1221+
public function testSchemalessNotExists(): void
1222+
{
1223+
/** @var Database $database */
1224+
$database = static::getDatabase();
1225+
1226+
if ($database->getAdapter()->getSupportForAttributes()) {
1227+
$this->expectNotToPerformAssertions();
1228+
return;
1229+
}
1230+
1231+
$colName = uniqid('schemaless_not_exists');
1232+
$database->createCollection($colName);
1233+
1234+
$permissions = [
1235+
Permission::read(Role::any()),
1236+
Permission::write(Role::any()),
1237+
Permission::update(Role::any()),
1238+
Permission::delete(Role::any())
1239+
];
1240+
1241+
// Create documents with and without the 'optionalField' attribute
1242+
$docs = [
1243+
new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']),
1244+
new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']),
1245+
new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField
1246+
new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null
1247+
new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField
1248+
];
1249+
$this->assertEquals(5, $database->createDocuments($colName, $docs));
1250+
1251+
// Test notExists - should return documents where optionalField does not exist
1252+
$documents = $database->find($colName, [
1253+
Query::notExists('optionalField'),
1254+
]);
1255+
1256+
$this->assertEquals(2, count($documents)); // doc3, doc5
1257+
$ids = array_map(fn ($doc) => $doc->getId(), $documents);
1258+
$this->assertContains('doc3', $ids);
1259+
$this->assertContains('doc5', $ids);
1260+
1261+
// Verify that doc4 is NOT included (it exists even though null)
1262+
$this->assertNotContains('doc4', $ids);
1263+
1264+
// Test notExists with another attribute
1265+
$documents = $database->find($colName, [
1266+
Query::notExists('name'),
1267+
]);
1268+
$this->assertEquals(0, count($documents)); // All documents have 'name'
1269+
1270+
// Test notExists with non-existent attribute
1271+
$documents = $database->find($colName, [
1272+
Query::notExists('nonExistentField'),
1273+
]);
1274+
$this->assertEquals(5, count($documents)); // All documents don't have this field
1275+
1276+
// Test combination of exists and notExists
1277+
$documents = $database->find($colName, [
1278+
Query::exists('name'),
1279+
Query::notExists('optionalField'),
1280+
]);
1281+
$this->assertEquals(2, count($documents)); // doc3, doc5
1282+
1283+
$database->deleteCollection($colName);
1284+
}
11581285
}

0 commit comments

Comments
 (0)