Skip to content

Commit ccb69f0

Browse files
Merge remote-tracking branch 'upstream/3.x' into query-regex
2 parents 57507d6 + 60e1d32 commit ccb69f0

6 files changed

Lines changed: 276 additions & 3 deletions

File tree

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
}
@@ -2473,6 +2480,8 @@ protected function getQueryOperator(string $operator): string
24732480
Query::TYPE_REGEX => '$regex',
24742481
Query::TYPE_OR => '$or',
24752482
Query::TYPE_AND => '$and',
2483+
Query::TYPE_EXISTS,
2484+
Query::TYPE_NOT_EXISTS => '$exists',
24762485
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),
24772486
};
24782487
}

src/Database/Adapter/SQL.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1800,6 +1800,9 @@ protected function getSQLOperator(string $method): string
18001800
case Query::TYPE_VECTOR_COSINE:
18011801
case Query::TYPE_VECTOR_EUCLIDEAN:
18021802
throw new DatabaseException('Vector queries are not supported by this database');
1803+
case Query::TYPE_EXISTS:
1804+
case Query::TYPE_NOT_EXISTS:
1805+
throw new DatabaseException('Exists queries are not supported by this database');
18031806
default:
18041807
throw new DatabaseException('Unknown method: ' . $method);
18051808
}

src/Database/Query.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ class Query
2727
public const TYPE_ENDS_WITH = 'endsWith';
2828
public const TYPE_NOT_ENDS_WITH = 'notEndsWith';
2929
public const TYPE_REGEX = 'regex';
30+
public const TYPE_EXISTS = 'exists';
31+
public const TYPE_NOT_EXISTS = 'notExists';
3032

3133
// Spatial methods
3234
public const TYPE_CROSSES = 'crosses';
@@ -100,6 +102,8 @@ class Query
100102
self::TYPE_VECTOR_DOT,
101103
self::TYPE_VECTOR_COSINE,
102104
self::TYPE_VECTOR_EUCLIDEAN,
105+
self::TYPE_EXISTS,
106+
self::TYPE_NOT_EXISTS,
103107
self::TYPE_SELECT,
104108
self::TYPE_ORDER_DESC,
105109
self::TYPE_ORDER_ASC,
@@ -296,7 +300,9 @@ public static function isMethod(string $value): bool
296300
self::TYPE_SELECT,
297301
self::TYPE_VECTOR_DOT,
298302
self::TYPE_VECTOR_COSINE,
299-
self::TYPE_VECTOR_EUCLIDEAN => true,
303+
self::TYPE_VECTOR_EUCLIDEAN,
304+
self::TYPE_EXISTS,
305+
self::TYPE_NOT_EXISTS => true,
300306
default => false,
301307
};
302308
}
@@ -1192,4 +1198,26 @@ public static function regex(string $attribute, string $pattern): self
11921198
{
11931199
return new self(self::TYPE_REGEX, $attribute, [$pattern]);
11941200
}
1201+
1202+
/**
1203+
* Helper method to create Query with exists method
1204+
*
1205+
* @param array<string> $attributes
1206+
* @return Query
1207+
*/
1208+
public static function exists(array $attributes): self
1209+
{
1210+
return new self(self::TYPE_EXISTS, '', $attributes);
1211+
}
1212+
1213+
/**
1214+
* Helper method to create Query with notExists method
1215+
*
1216+
* @param string|int|float|bool|array<mixed,mixed> $attribute
1217+
* @return Query
1218+
*/
1219+
public static function notExists(string|int|float|bool|array $attribute): self
1220+
{
1221+
return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]);
1222+
}
11951223
}

src/Database/Validator/Queries.php

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

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)