Skip to content

Commit 60e1d32

Browse files
authored
Merge pull request #776 from utopia-php/query-exists
2 parents c8c1b2f + aa28027 commit 60e1d32

File tree

6 files changed

+276
-3
lines changed

6 files changed

+276
-3
lines changed

src/Database/Adapter/Mongo.php

Lines changed: 9 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,10 @@ 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+
foreach ($query->getValues() as $attribute) {
2442+
$filter['$or'][] = [$attribute => [$operator => $value]];
2443+
}
24372444
} else {
24382445
$filter[$attribute][$operator] = $value;
24392446
}
@@ -2472,6 +2479,8 @@ protected function getQueryOperator(string $operator): string
24722479
Query::TYPE_NOT_ENDS_WITH => '$regex',
24732480
Query::TYPE_OR => '$or',
24742481
Query::TYPE_AND => '$and',
2482+
Query::TYPE_EXISTS,
2483+
Query::TYPE_NOT_EXISTS => '$exists',
24752484
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),
24762485
};
24772486
}

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: 29 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';
@@ -99,6 +101,8 @@ class Query
99101
self::TYPE_VECTOR_DOT,
100102
self::TYPE_VECTOR_COSINE,
101103
self::TYPE_VECTOR_EUCLIDEAN,
104+
self::TYPE_EXISTS,
105+
self::TYPE_NOT_EXISTS,
102106
self::TYPE_SELECT,
103107
self::TYPE_ORDER_DESC,
104108
self::TYPE_ORDER_ASC,
@@ -294,7 +298,9 @@ public static function isMethod(string $value): bool
294298
self::TYPE_SELECT,
295299
self::TYPE_VECTOR_DOT,
296300
self::TYPE_VECTOR_COSINE,
297-
self::TYPE_VECTOR_EUCLIDEAN => true,
301+
self::TYPE_VECTOR_EUCLIDEAN,
302+
self::TYPE_EXISTS,
303+
self::TYPE_NOT_EXISTS => true,
298304
default => false,
299305
};
300306
}
@@ -1178,4 +1184,26 @@ public static function vectorEuclidean(string $attribute, array $vector): self
11781184
{
11791185
return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]);
11801186
}
1187+
1188+
/**
1189+
* Helper method to create Query with exists method
1190+
*
1191+
* @param array<string> $attributes
1192+
* @return Query
1193+
*/
1194+
public static function exists(array $attributes): self
1195+
{
1196+
return new self(self::TYPE_EXISTS, '', $attributes);
1197+
}
1198+
1199+
/**
1200+
* Helper method to create Query with notExists method
1201+
*
1202+
* @param string|int|float|bool|array<mixed,mixed> $attribute
1203+
* @return Query
1204+
*/
1205+
public static function notExists(string|int|float|bool|array $attribute): self
1206+
{
1207+
return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]);
1208+
}
11811209
}

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;
@@ -306,6 +312,8 @@ public function isValid($value): bool
306312
case Query::TYPE_EQUAL:
307313
case Query::TYPE_CONTAINS:
308314
case Query::TYPE_NOT_CONTAINS:
315+
case Query::TYPE_EXISTS:
316+
case Query::TYPE_NOT_EXISTS:
309317
if ($this->isEmpty($value->getValues())) {
310318
$this->message = \ucfirst($method) . ' queries require at least one value.';
311319
return false;

tests/e2e/Adapter/Scopes/SchemalessTests.php

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,4 +1155,227 @@ 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+
// Multiple attributes in a single exists query (OR semantics)
1219+
$documents = $database->find($colName, [
1220+
Query::exists(['optionalField', 'name']),
1221+
]);
1222+
// All documents have "name", some also have "optionalField"
1223+
$this->assertEquals(5, count($documents));
1224+
1225+
// Multiple attributes where only one exists on some documents
1226+
$documents = $database->find($colName, [
1227+
Query::exists(['optionalField', 'nonExistentField']),
1228+
]);
1229+
// Only documents where optionalField exists should be returned
1230+
$this->assertEquals(3, count($documents)); // doc1, doc2, doc4
1231+
1232+
// Multiple attributes where none exist should return empty
1233+
$documents = $database->find($colName, [
1234+
Query::exists(['nonExistentField', 'alsoMissing']),
1235+
]);
1236+
$this->assertEquals(0, count($documents));
1237+
1238+
// Multiple attributes including one present on all docs still returns all (OR)
1239+
$documents = $database->find($colName, [
1240+
Query::exists(['name', 'nonExistentField', 'alsoMissing']),
1241+
]);
1242+
$this->assertEquals(5, count($documents));
1243+
1244+
// Multiple exists queries (AND semantics)
1245+
$documents = $database->find($colName, [
1246+
Query::exists(['optionalField']),
1247+
Query::exists(['name']),
1248+
]);
1249+
// Documents must have both attributes
1250+
$this->assertEquals(3, count($documents)); // doc1, doc2, doc4
1251+
1252+
// Nested OR with exists (optionalField OR nonExistentField) AND name
1253+
$documents = $database->find($colName, [
1254+
Query::and([
1255+
Query::or([
1256+
Query::exists(['optionalField']),
1257+
Query::exists(['nonExistentField']),
1258+
]),
1259+
Query::exists(['name']),
1260+
]),
1261+
]);
1262+
$this->assertEquals(3, count($documents)); // doc1, doc2, doc4
1263+
1264+
// Nested OR with only missing attributes should yield empty
1265+
$documents = $database->find($colName, [
1266+
Query::or([
1267+
Query::exists(['nonExistentField']),
1268+
Query::exists(['alsoMissing']),
1269+
]),
1270+
]);
1271+
$this->assertEquals(0, count($documents));
1272+
1273+
$database->deleteCollection($colName);
1274+
}
1275+
1276+
public function testSchemalessNotExists(): void
1277+
{
1278+
/** @var Database $database */
1279+
$database = static::getDatabase();
1280+
1281+
if ($database->getAdapter()->getSupportForAttributes()) {
1282+
$this->expectNotToPerformAssertions();
1283+
return;
1284+
}
1285+
1286+
$colName = uniqid('schemaless_not_exists');
1287+
$database->createCollection($colName);
1288+
1289+
$permissions = [
1290+
Permission::read(Role::any()),
1291+
Permission::write(Role::any()),
1292+
Permission::update(Role::any()),
1293+
Permission::delete(Role::any())
1294+
];
1295+
1296+
// Create documents with and without the 'optionalField' attribute
1297+
$docs = [
1298+
new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']),
1299+
new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']),
1300+
new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField
1301+
new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null
1302+
new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField
1303+
];
1304+
$this->assertEquals(5, $database->createDocuments($colName, $docs));
1305+
1306+
// Test notExists - should return documents where optionalField does not exist
1307+
$documents = $database->find($colName, [
1308+
Query::notExists('optionalField'),
1309+
]);
1310+
1311+
$this->assertEquals(2, count($documents)); // doc3, doc5
1312+
$ids = array_map(fn ($doc) => $doc->getId(), $documents);
1313+
$this->assertContains('doc3', $ids);
1314+
$this->assertContains('doc5', $ids);
1315+
1316+
// Verify that doc4 is NOT included (it exists even though null)
1317+
$this->assertNotContains('doc4', $ids);
1318+
1319+
// Test notExists with another attribute
1320+
$documents = $database->find($colName, [
1321+
Query::notExists('name'),
1322+
]);
1323+
$this->assertEquals(0, count($documents)); // All documents have 'name'
1324+
1325+
// Test notExists with non-existent attribute
1326+
$documents = $database->find($colName, [
1327+
Query::notExists('nonExistentField'),
1328+
]);
1329+
$this->assertEquals(5, count($documents)); // All documents don't have this field
1330+
1331+
// Multiple attributes in a single notExists query (OR semantics) - both missing
1332+
$documents = $database->find($colName, [
1333+
Query::notExists(['nonExistentField', 'alsoMissing']),
1334+
]);
1335+
$this->assertEquals(5, count($documents));
1336+
1337+
// Multiple attributes (OR) where only some documents miss one of them
1338+
$documents = $database->find($colName, [
1339+
Query::notExists(['name', 'optionalField']),
1340+
]);
1341+
$this->assertEquals(2, count($documents)); // doc3, doc5
1342+
1343+
// Multiple notExists queries (AND semantics) - must miss both
1344+
$documents = $database->find($colName, [
1345+
Query::notExists(['optionalField']),
1346+
Query::notExists(['nonExistentField']),
1347+
]);
1348+
$this->assertEquals(2, count($documents)); // doc3, doc5
1349+
1350+
// Test combination of exists and notExists
1351+
$documents = $database->find($colName, [
1352+
Query::exists(['name']),
1353+
Query::notExists('optionalField'),
1354+
]);
1355+
$this->assertEquals(2, count($documents)); // doc3, doc5
1356+
1357+
// Nested OR/AND with notExists: (notExists optionalField OR notExists nonExistent) AND name
1358+
$documents = $database->find($colName, [
1359+
Query::and([
1360+
Query::or([
1361+
Query::notExists(['optionalField']),
1362+
Query::notExists(['nonExistentField']),
1363+
]),
1364+
Query::exists(['name']),
1365+
]),
1366+
]);
1367+
// notExists(nonExistentField) matches all docs, so OR is always true; AND with name returns all
1368+
$this->assertEquals(5, count($documents)); // all docs match due to nonExistentField
1369+
1370+
// Nested OR with notExists where all attributes exist => empty
1371+
$documents = $database->find($colName, [
1372+
Query::or([
1373+
Query::notExists(['name']),
1374+
Query::notExists(['optionalField']),
1375+
]),
1376+
]);
1377+
$this->assertEquals(2, count($documents)); // only ones missing optionalField (doc3, doc5)
1378+
1379+
$database->deleteCollection($colName);
1380+
}
11581381
}

0 commit comments

Comments
 (0)