From 94c7012bae2a54f54a2a9834679693b1d6a2e2b9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 5 Jan 2026 18:44:33 +0530 Subject: [PATCH 01/21] * added object attribute support in mongodb * support for object contains , not contains, equals, not equals --- src/Database/Adapter/Mongo.php | 108 +++++++++++++++++- src/Database/Database.php | 48 +++++++- src/Database/Query.php | 13 ++- .../Adapter/Scopes/ObjectAttributeTests.php | 72 ++++++++---- 4 files changed, 209 insertions(+), 32 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 009ad1f7c..c10cbb78c 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -5,6 +5,7 @@ use Exception; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use stdClass; use Utopia\Database\Adapter; use Utopia\Database\Change; use Utopia\Database\Database; @@ -42,6 +43,8 @@ class Mongo extends Adapter '$regex', '$not', '$nor', + '$elemMatch', + '$exists' ]; protected Client $client; @@ -414,7 +417,6 @@ public function createCollection(string $name, array $attributes = [], array $in try { $options = $this->getTransactionOptions(); $this->getClient()->createCollection($id, $options); - } catch (MongoException $e) { $e = $this->processException($e); if ($e instanceof DuplicateException) { @@ -1231,7 +1233,7 @@ public function castingAfter(Document $collection, Document $document): Document case Database::VAR_INTEGER: $node = (int)$node; break; - case Database::VAR_DATETIME : + case Database::VAR_DATETIME: if ($node instanceof UTCDateTime) { // Handle UTCDateTime objects $node = DateTime::format($node->toDateTime()); @@ -1257,6 +1259,12 @@ public function castingAfter(Document $collection, Document $document): Document } } break; + case Database::VAR_OBJECT: + // Convert stdClass objects to arrays for object attributes + if (is_object($node) && get_class($node) === stdClass::class) { + $node = $this->convertStdClassToArray($node); + } + break; default: break; } @@ -1265,9 +1273,34 @@ public function castingAfter(Document $collection, Document $document): Document $document->setAttribute($key, ($array) ? $value : $value[0]); } + if (!$this->getSupportForAttributes()) { + /** @var Document $doc */ + foreach ($document->getArrayCopy() as $key => $value) { + // mongodb results out a stdclass for objects + if (is_object($value) && get_class($value) === stdClass::class) { + $document->setAttribute($key, $this->convertStdClassToArray($value)); + } + } + } return $document; } + private function convertStdClassToArray(mixed $value) + { + if (is_object($value) && get_class($value) === stdClass::class) { + return array_map(fn ($v) => $this->convertStdClassToArray($v), get_object_vars($value)); + } + + if (is_array($value)) { + return array_map( + fn ($v) => $this->convertStdClassToArray($v), + $value + ); + } + + return $value; + } + /** * Returns the document after casting to * @param Document $collection @@ -1318,6 +1351,9 @@ public function castingBefore(Document $collection, Document $document): Documen $node = new UTCDateTime(new \DateTime($node)); } break; + case Database::VAR_OBJECT: + $node = json_decode($node); + break; default: break; } @@ -1591,7 +1627,6 @@ public function upsertDocuments(Document $collection, string $attribute, array $ $operations, options: $options ); - } catch (MongoException $e) { throw $this->processException($e); } @@ -1998,7 +2033,6 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $cursorId = (int)($moreResponse->cursor->id ?? 0); } - } catch (MongoException $e) { throw $this->processException($e); } finally { @@ -2382,6 +2416,10 @@ protected function buildFilter(Query $query): array }; $filter = []; + if($query->isObjectAttribute() && in_array($query->getMethod(),[Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])){ + $this->handleObjectFilters($query, $filter); + return $filter; + } if ($operator == '$eq' && \is_array($value)) { $filter[$attribute]['$in'] = $value; @@ -2441,6 +2479,66 @@ protected function buildFilter(Query $query): array return $filter; } + private function handleObjectFilters(Query $query, array &$filter){ + $conditions = []; + $isNot = in_array($query->getMethod(), [Query::TYPE_NOT_CONTAINS,Query::TYPE_NOT_EQUAL]); + $values = $query->getValues(); + foreach ($values as $attribute => $value) { + $flattendQuery = $this->flattenWithDotNotation(is_string($attribute)?$attribute:'', $value); + $flattenedObjectKey = array_key_first($flattendQuery); + $queryValue = $flattendQuery[$flattenedObjectKey]; + $flattenedObjectKey = $query->getAttribute() . '.' . array_key_first($flattendQuery); + switch ($query->getMethod()) { + + case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_CONTAINS: { + $arrayValue = \is_array($queryValue) ? $queryValue : [$queryValue]; + $operator = $isNot ? '$nin' : '$in'; + $conditions[] = [ $flattenedObjectKey => [ $operator => $arrayValue] ]; + break; + } + + case Query::TYPE_EQUAL: + case Query::TYPE_NOT_EQUAL: { + if (\is_array($queryValue)) { + $operator = $isNot ? '$nin' : '$in'; + $conditions[] = [ $flattenedObjectKey => [ $operator => $queryValue] ]; + } else { + $operator = $isNot ? '$ne' : '$eq'; + $conditions[] = [ $flattenedObjectKey => [ $operator => $queryValue] ]; + } + + break; + } + } + } + + $logicalOperator = $isNot? '$and' : '$or'; + if (count($conditions) && isset($filter[$logicalOperator])) { + $filter[$logicalOperator] = array_merge($filter[$logicalOperator], $conditions); + } else { + $filter[$logicalOperator] = $conditions; + } + } + + // TODO: check the condition for the multiple keys inside a query validator + // example -> [a=>[1,b=>[212]]] shouldn't be allowed + // allowed -> [a=>[1,2],b=>[212]] + // should be disallowed -> $data = ['name' => 'doc','role' => ['name'=>['test1','test2'],'ex'=>['new'=>'test1']]]; + private function flattenWithDotNotation(string $key, mixed $value, string $prefix=''):array{ + $result = []; + $currentPref = $prefix === '' ? $key :$prefix.'.'.$key; + if(is_array($value) && !array_is_list($value)){ + $nextKey = array_key_first($value); + $result += $this->flattenWithDotNotation($nextKey,$value[$nextKey],$currentPref); + } + // at the leaf node + else{ + $result[$currentPref] = $value; + } + return $result; + } + /** * Get Query Operator * @@ -2792,7 +2890,7 @@ public function getSupportForBatchCreateAttributes(): bool public function getSupportForObject(): bool { - return false; + return true; } /** diff --git a/src/Database/Database.php b/src/Database/Database.php index d5595df38..1f7f1e9e1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -650,13 +650,14 @@ function (mixed $value) { return \json_encode($value); }, /** - * @param string|null $value + * @param mixed $value * @return array|null */ - function (?string $value) { + function (mixed $value) { if (is_null($value)) { return null; } + // can be non string in case of mongodb as it stores the value as object if (!is_string($value)) { return $value; } @@ -8116,6 +8117,43 @@ public function convertQueries(Document $collection, array $queries): array * @throws QueryException * @throws \Utopia\Database\Exception */ + /** + * Check if values are compatible with object attribute type (hashmap/multi-dimensional array) + * + * @param array $values + * @return bool + */ + private function isCompatibleObjectValue(array $values): bool + { + if (empty($values)) { + return false; + } + + foreach ($values as $value) { + if (!\is_array($value)) { + return false; + } + + // Check associative array (hashmap) or nested structure + if (empty($value)) { + continue; + } + + // simple indexed array => not an object + if (\array_keys($value) === \range(0, \count($value) - 1)) { + return false; + } + + foreach ($value as $nestedValue) { + if (\is_array($nestedValue)) { + continue; + } + } + } + + return true; + } + public function convertQuery(Document $collection, Query $query): Query { /** @@ -8152,6 +8190,12 @@ public function convertQuery(Document $collection, Query $query): Query } $query->setValues($values); } + } elseif (!$this->adapter->getSupportForAttributes()) { + $values = $query->getValues(); + // setting attribute type to properly apply filters in the adapter level + if ($this->adapter->getSupportForObject() && $this->isCompatibleObjectValue($values)) { + $query->setAttributeType(Database::VAR_OBJECT); + } } return $query; diff --git a/src/Database/Query.php b/src/Database/Query.php index 60ec1d712..4dc5ee634 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -62,7 +62,7 @@ class Query // Logical methods public const TYPE_AND = 'and'; public const TYPE_OR = 'or'; - + public const TYPE_ELEM_MATCH = 'elemMatch'; public const DEFAULT_ALIAS = 'main'; public const TYPES = [ @@ -109,6 +109,7 @@ class Query self::TYPE_CURSOR_BEFORE, self::TYPE_AND, self::TYPE_OR, + self::TYPE_ELEM_MATCH, ]; public const VECTOR_TYPES = [ @@ -126,6 +127,7 @@ class Query protected string $attribute = ''; protected string $attributeType = ''; protected bool $onArray = false; + protected bool $isObjectAttribute = false; /** * @var array @@ -1178,4 +1180,13 @@ public static function vectorEuclidean(string $attribute, array $vector): self { return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); } + + /** + * @param array $queries + * @return Query + */ + public static function elemMatch(array $queries): self + { + return new self(self::TYPE_ELEM_MATCH, '', $queries); + } } diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 2e9dc78f7..003eec9b5 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -15,6 +15,30 @@ trait ObjectAttributeTests { + /** + * Helper function to create an attribute if adapter supports attributes, + * otherwise returns true to allow tests to continue + * + * @param Database $database + * @param string $collectionId + * @param string $attributeId + * @param string $type + * @param int $size + * @param bool $required + * @param mixed $default + * @return bool + */ + private function createAttribute(Database $database, string $collectionId, string $attributeId, string $type, int $size, bool $required, $default = null): bool + { + if (!$database->getAdapter()->getSupportForAttributes()) { + return true; + } + + $result = $database->createAttribute($collectionId, $attributeId, $type, $size, $required, $default); + $this->assertEquals(true, $result); + return $result; + } + public function testObjectAttribute(): void { /** @var Database $database */ @@ -29,12 +53,12 @@ public function testObjectAttribute(): void $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::VAR_OBJECT, 0, false)); + $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); // Test 1: Create and read document with object attribute $doc1 = $database->createDocument($collectionId, new Document([ '$id' => 'doc1', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'meta' => [ 'age' => 25, 'skills' => ['react', 'node'], @@ -81,7 +105,7 @@ public function testObjectAttribute(): void // Test 5: Create another document with different values $doc2 = $database->createDocument($collectionId, new Document([ '$id' => 'doc2', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'meta' => [ 'age' => 30, 'skills' => ['python', 'java'], @@ -163,7 +187,7 @@ public function testObjectAttribute(): void try { // test -> not equal allows one value only $results = $database->find($collectionId, [ - Query::notEqual('meta', [['age' => 26],['age' => 27]]) + Query::notEqual('meta', [['age' => 26], ['age' => 27]]) ]); $this->fail('No query thrown'); } catch (Exception $e) { @@ -441,7 +465,7 @@ public function testObjectAttribute(): void // Test 28: Test equal query with complete object match $doc11 = $database->createDocument($collectionId, new Document([ '$id' => 'doc11', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'meta' => [ 'config' => [ 'theme' => 'dark', @@ -557,15 +581,15 @@ public function testObjectAttributeGinIndex(): void $database = static::getDatabase(); // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { - $this->markTestSkipped('Adapter does not support object attributes'); - } + // if (!$database->getAdapter()->getSupportForObject()) { + // } + $this->markTestSkipped('Adapter does not support object attributes'); $collectionId = ID::unique(); $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'data', Database::VAR_OBJECT, 0, false)); + $this->createAttribute($database, $collectionId, 'data', Database::VAR_OBJECT, 0, false); // Test 1: Create Object index on object attribute $ginIndex = $database->createIndex($collectionId, 'idx_data_gin', Database::INDEX_OBJECT, ['data']); @@ -620,7 +644,7 @@ public function testObjectAttributeGinIndex(): void $this->assertEquals('gin2', $results[0]->getId()); // Test 6: Try to create Object index on non-object attribute (should fail) - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, false); + $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); $exceptionThrown = false; try { @@ -633,7 +657,7 @@ public function testObjectAttributeGinIndex(): void $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index on non-object attribute'); // Test 7: Try to create Object index on multiple attributes (should fail) - $database->createAttribute($collectionId, 'metadata', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'metadata', Database::VAR_OBJECT, 0, false); $exceptionThrown = false; try { @@ -666,7 +690,7 @@ public function testObjectAttributeInvalidCases(): void $database = static::getDatabase(); // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { + if (!$database->getAdapter()->getSupportForObject() || !$database->getAdapter()->getSupportForAttributes()) { $this->markTestSkipped('Adapter does not support object attributes'); } @@ -674,7 +698,7 @@ public function testObjectAttributeInvalidCases(): void $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::VAR_OBJECT, 0, false)); + $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); // Test 1: Try to create document with string instead of object (should fail) $exceptionThrown = false; @@ -841,11 +865,11 @@ public function testObjectAttributeInvalidCases(): void // Test 16: with multiple json $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; - $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings)); + $this->createAttribute($database, $collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings); $database->createDocument($collectionId, new Document(['$permissions' => [Permission::read(Role::any())]])); - $database->createDocument($collectionId, new Document(['settings' => ['config' => ['theme' => 'dark', 'lang' => 'en']],'$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($collectionId, new Document(['settings' => ['config' => ['theme' => 'dark', 'lang' => 'en']], '$permissions' => [Permission::read(Role::any())]])); $results = $database->find($collectionId, [ - Query::equal('settings', [['config' => ['theme' => 'light']],['config' => ['theme' => 'dark']]]) + Query::equal('settings', [['config' => ['theme' => 'light']], ['config' => ['theme' => 'dark']]]) ]); $this->assertCount(2, $results); @@ -865,7 +889,7 @@ public function testObjectAttributeDefaults(): void $database = static::getDatabase(); // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { + if (!$database->getAdapter()->getSupportForObject() || !$database->getAdapter()->getSupportForAttributes()) { $this->markTestSkipped('Adapter does not support object attributes'); } @@ -873,20 +897,20 @@ public function testObjectAttributeDefaults(): void $database->createCollection($collectionId); // 1) Default empty object - $this->assertEquals(true, $database->createAttribute($collectionId, 'metaDefaultEmpty', Database::VAR_OBJECT, 0, false, [])); + $this->createAttribute($database, $collectionId, 'metaDefaultEmpty', Database::VAR_OBJECT, 0, false, []); // 2) Default nested object $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; - $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings)); + $this->createAttribute($database, $collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings); // 3) Required without default (should fail when missing) - $this->assertEquals(true, $database->createAttribute($collectionId, 'profile', Database::VAR_OBJECT, 0, true, null)); + $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, true, null); // 4) Required with default (should auto-populate) - $this->assertEquals(true, $database->createAttribute($collectionId, 'profile2', Database::VAR_OBJECT, 0, false, ['name' => 'anon'])); + $this->createAttribute($database, $collectionId, 'profile2', Database::VAR_OBJECT, 0, false, ['name' => 'anon']); // 5) Explicit null default - $this->assertEquals(true, $database->createAttribute($collectionId, 'misc', Database::VAR_OBJECT, 0, false, null)); + $this->createAttribute($database, $collectionId, 'misc', Database::VAR_OBJECT, 0, false, null); // Create document missing all above attributes $exceptionThrown = false; @@ -954,8 +978,8 @@ public function testMetadataWithVector(): void $database->createCollection($collectionId); // Attributes: 3D vector and nested metadata object - $database->createAttribute($collectionId, 'embedding', Database::VAR_VECTOR, 3, true); - $database->createAttribute($collectionId, 'metadata', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'embedding', Database::VAR_VECTOR, 3, true); + $this->createAttribute($database, $collectionId, 'metadata', Database::VAR_OBJECT, 0, false); // Seed documents $docA = $database->createDocument($collectionId, new Document([ From 3b5360b9287faea576625c51a44ed3ccf92d39ed Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 5 Jan 2026 19:41:47 +0530 Subject: [PATCH 02/21] added elemMatch --- src/Database/Adapter/Mongo.php | 34 ++- src/Database/Query.php | 7 +- src/Database/Validator/Queries.php | 1 + src/Database/Validator/Query/Filter.php | 50 +++++ tests/e2e/Adapter/Scopes/SchemalessTests.php | 215 ++++++++++++++++++- 5 files changed, 302 insertions(+), 5 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index c10cbb78c..322a3bb81 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1353,6 +1353,7 @@ public function castingBefore(Document $collection, Document $document): Documen break; case Database::VAR_OBJECT: $node = json_decode($node); + $node = $this->convertStdClassToArray($node); break; default: break; @@ -2011,7 +2012,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 // Process first batch foreach ($results as $result) { $record = $this->replaceChars('_', '$', (array)$result); - $found[] = new Document($record); + $found[] = new Document($this->convertStdClassToArray($record)); } // Get cursor ID for subsequent batches @@ -2367,7 +2368,35 @@ protected function buildFilters(array $queries, string $separator = '$and'): arr foreach ($queries as $query) { /* @var $query Query */ - if ($query->isNested()) { + if ($query->getMethod() === Query::TYPE_ELEM_MATCH) { + // Handle elemMatch specially - it needs attribute and wraps nested queries + $attribute = $query->getAttribute(); + if ($attribute === '$id') { + $attribute = '_uid'; + } elseif ($attribute === '$sequence') { + $attribute = '_id'; + } elseif ($attribute === '$createdAt') { + $attribute = '_createdAt'; + } elseif ($attribute === '$updatedAt') { + $attribute = '_updatedAt'; + } + + // Process each nested query individually and merge conditions + $conditions = []; + foreach ($query->getValues() as $nestedQuery) { + /* @var $nestedQuery Query */ + // Build filter for each nested query + $nestedFilter = $this->buildFilter($nestedQuery); + // Merge the conditions (nestedFilter is like ['sku' => ['$eq' => 'ABC']]) + $conditions = array_merge($conditions, $nestedFilter); + } + + $filters[$separator][] = [ + $attribute => [ + '$elemMatch' => $conditions + ] + ]; + } elseif ($query->isNested()) { $operator = $this->getQueryOperator($query->getMethod()); $filters[$separator][] = $this->buildFilters($query->getValues(), $operator); @@ -2570,6 +2599,7 @@ protected function getQueryOperator(string $operator): string Query::TYPE_NOT_ENDS_WITH => '$regex', Query::TYPE_OR => '$or', Query::TYPE_AND => '$and', + Query::TYPE_ELEM_MATCH => '$elemMatch', 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/Query.php b/src/Database/Query.php index 4dc5ee634..323e5b3e7 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -121,6 +121,7 @@ class Query protected const LOGICAL_TYPES = [ self::TYPE_AND, self::TYPE_OR, + self::TYPE_ELEM_MATCH, ]; protected string $method = ''; @@ -293,6 +294,7 @@ public static function isMethod(string $value): bool self::TYPE_NOT_TOUCHES, self::TYPE_OR, self::TYPE_AND, + self::TYPE_ELEM_MATCH, self::TYPE_SELECT, self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, @@ -1182,11 +1184,12 @@ public static function vectorEuclidean(string $attribute, array $vector): self } /** + * @param string $attribute * @param array $queries * @return Query */ - public static function elemMatch(array $queries): self + public static function elemMatch(string $attribute, array $queries): self { - return new self(self::TYPE_ELEM_MATCH, '', $queries); + return new self(self::TYPE_ELEM_MATCH, $attribute, $queries); } } diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 8066228e3..1b2a6ed40 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -107,6 +107,7 @@ public function isValid($value): bool Query::TYPE_NOT_CONTAINS, Query::TYPE_AND, Query::TYPE_OR, + Query::TYPE_ELEM_MATCH, Query::TYPE_CROSSES, Query::TYPE_NOT_CROSSES, Query::TYPE_DISTANCE_EQUAL, diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 11053f14c..8b6f424ae 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -396,6 +396,56 @@ public function isValid($value): bool return true; + case Query::TYPE_ELEM_MATCH: + // Validate that the attribute (array field) exists + if (!$this->isValidAttribute($attribute)) { + return false; + } + + // For schemaless mode, allow elemMatch on any attribute + if (!$this->supportForAttributes) { + // Validate nested queries are filter queries + $filters = Query::groupByType($value->getValues())['filters']; + if (count($value->getValues()) !== count($filters)) { + $this->message = 'elemMatch queries can only contain filter queries'; + return false; + } + if (count($filters) < 1) { + $this->message = 'elemMatch queries require at least one query'; + return false; + } + return true; + } + + // For schema mode, validate that the attribute is an array + if (!isset($this->schema[$attribute])) { + $this->message = 'Attribute not found in schema: ' . $attribute; + return false; + } + + $attributeSchema = $this->schema[$attribute]; + $isArray = $attributeSchema['array'] ?? false; + + if (!$isArray) { + $this->message = 'elemMatch can only be used on array attributes: ' . $attribute; + return false; + } + + // Validate nested queries are filter queries + $filters = Query::groupByType($value->getValues())['filters']; + if (count($value->getValues()) !== count($filters)) { + $this->message = 'elemMatch queries can only contain filter queries'; + return false; + } + if (count($filters) < 1) { + $this->message = 'elemMatch queries require at least one query'; + return false; + } + + // Note: We don't validate the nested query attributes against the schema + // because they are attributes of objects within the array, not top-level attributes + return true; + default: // Handle spatial query types and any other query types if ($value->isSpatialQuery()) { diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index ee0985682..52f27f548 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -14,7 +14,7 @@ use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; - +use Utopia\Database\Helpers\ID; trait SchemalessTests { public function testSchemalessDocumentOperation(): void @@ -1155,4 +1155,217 @@ public function testSchemalessDates(): void $database->deleteCollection($col); } + + /** + * Test elemMatch query functionality + * + * @throws Exception + */ + public function testElemMatch(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create array attribute for items + $database->createAttribute($collectionId, 'items', Database::VAR_OBJECT, 0, false, null, true); + + // Create documents with array of objects + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'order1', + '$permissions' => [Permission::read(Role::any())], + 'items' => [ + ['sku' => 'ABC', 'qty' => 5, 'price' => 10.50], + ['sku' => 'XYZ', 'qty' => 2, 'price' => 20.00], + ] + ])); + + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'order2', + '$permissions' => [Permission::read(Role::any())], + 'items' => [ + ['sku' => 'ABC', 'qty' => 1, 'price' => 10.50], + ['sku' => 'DEF', 'qty' => 10, 'price' => 15.00], + ] + ])); + + $doc3 = $database->createDocument($collectionId, new Document([ + '$id' => 'order3', + '$permissions' => [Permission::read(Role::any())], + 'items' => [ + ['sku' => 'XYZ', 'qty' => 3, 'price' => 20.00], + ] + ])); + + // Test 1: elemMatch with equal and greaterThan - should match doc1 + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + Query::greaterThan('qty', 1), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('order1', $results[0]->getId()); + + // Test 2: elemMatch with equal and greaterThan - should not match doc2 (qty is 1, not > 1) + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + Query::greaterThan('qty', 1), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('order1', $results[0]->getId()); + + // Test 3: elemMatch with equal only - should match doc1 and doc2 + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + ]) + ]); + $this->assertCount(2, $results); + $ids = array_map(fn($doc) => $doc->getId(), $results); + $this->assertContains('order1', $ids); + $this->assertContains('order2', $ids); + + // Elematch means at least ONE element in the array matches this condition. + // that means array having two docs qty -> 1 and qty -> 10 , it will be returned as it has atleast one doc with qty > 10 + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::greaterThan('qty', 1), + ]) + ]); + $this->assertCount(3, $results); + $ids = array_map(fn($doc) => $doc->getId(), $results); + $this->assertContains('order1', $ids); + $this->assertContains('order2', $ids); + $this->assertContains('order3', $ids); + + // Test 5: elemMatch with multiple conditions - should match doc2 + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['DEF']), + Query::greaterThan('qty', 5), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('order2', $results[0]->getId()); + + // Test 6: elemMatch with lessThan + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + Query::lessThan('qty', 3), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('order2', $results[0]->getId()); + + // Test 7: elemMatch with equal and greaterThanEqual + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + Query::greaterThanEqual('qty', 1), + ]) + ]); + $this->assertCount(2, $results); + + // Test 8: elemMatch with no matching conditions + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['NONEXISTENT']), + ]) + ]); + $this->assertCount(0, $results); + + // Test 9: elemMatch with price condition + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['XYZ']), + Query::equal('price', [20.00]), + ]) + ]); + $this->assertCount(2, $results); + $ids = array_map(fn($doc) => $doc->getId(), $results); + $this->assertContains('order1', $ids); + $this->assertContains('order3', $ids); + + // Test 10: elemMatch with notEqual + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::notEqual('sku', ['ABC']), + Query::greaterThan('qty', 2), + ]) + ]); + // order 1 has elements where sku == "ABC", qty: 5 => !=ABC fails and sku = XYZ ,qty: 2 => >2 fails + $this->assertCount(2, $results); + $ids = array_map(fn($doc) => $doc->getId(), $results); + $this->assertContains('order2', $ids); + $this->assertContains('order3', $ids); + + // Clean up + $database->deleteCollection($collectionId); + } + + /** + * Test elemMatch with complex nested conditions + * + * @throws Exception + */ + public function testElemMatchComplex(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create array attribute + $database->createAttribute($collectionId, 'products', Database::VAR_OBJECT, 0, false, null, true); + + // Create documents with complex nested structures + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'store1', + '$permissions' => [Permission::read(Role::any())], + 'products' => [ + ['name' => 'Widget', 'stock' => 100, 'category' => 'A', 'active' => true], + ['name' => 'Gadget', 'stock' => 50, 'category' => 'B', 'active' => false], + ] + ])); + + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'store2', + '$permissions' => [Permission::read(Role::any())], + 'products' => [ + ['name' => 'Widget', 'stock' => 200, 'category' => 'A', 'active' => true], + ['name' => 'Thing', 'stock' => 25, 'category' => 'C', 'active' => true], + ] + ])); + + // Test: elemMatch with multiple conditions including boolean + $results = $database->find($collectionId, [ + Query::elemMatch('products', [ + Query::equal('name', ['Widget']), + Query::greaterThan('stock', 50), + Query::equal('category', ['A']), + Query::equal('active', [true]), + ]) + ]); + $this->assertCount(2, $results); + + // Test: elemMatch with between + $results = $database->find($collectionId, [ + Query::elemMatch('products', [ + Query::equal('category', ['A']), + Query::between('stock', 75, 150), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('store1', $results[0]->getId()); + + // Clean up + $database->deleteCollection($collectionId); + } } From 7a26698617618c8d42a167475f48db577bec5f84 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 6 Jan 2026 16:31:25 +0530 Subject: [PATCH 03/21] refactor: improve elemMatch handling and clean up code style --- src/Database/Adapter/Mongo.php | 61 +++++++------------- src/Database/Database.php | 2 +- tests/e2e/Adapter/Scopes/SchemalessTests.php | 11 ++-- 3 files changed, 29 insertions(+), 45 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 322a3bb81..3c453e4be 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2368,35 +2368,16 @@ protected function buildFilters(array $queries, string $separator = '$and'): arr foreach ($queries as $query) { /* @var $query Query */ - if ($query->getMethod() === Query::TYPE_ELEM_MATCH) { - // Handle elemMatch specially - it needs attribute and wraps nested queries - $attribute = $query->getAttribute(); - if ($attribute === '$id') { - $attribute = '_uid'; - } elseif ($attribute === '$sequence') { - $attribute = '_id'; - } elseif ($attribute === '$createdAt') { - $attribute = '_createdAt'; - } elseif ($attribute === '$updatedAt') { - $attribute = '_updatedAt'; - } - - // Process each nested query individually and merge conditions - $conditions = []; - foreach ($query->getValues() as $nestedQuery) { - /* @var $nestedQuery Query */ - // Build filter for each nested query - $nestedFilter = $this->buildFilter($nestedQuery); - // Merge the conditions (nestedFilter is like ['sku' => ['$eq' => 'ABC']]) - $conditions = array_merge($conditions, $nestedFilter); + if ($query->isNested()) { + if ($query->getMethod() === Query::TYPE_ELEM_MATCH) { + $filters[$separator][] = [ + $query->getAttribute() => [ + '$elemMatch' => $this->buildFilters($query->getValues(), $separator) + ] + ]; + continue; } - $filters[$separator][] = [ - $attribute => [ - '$elemMatch' => $conditions - ] - ]; - } elseif ($query->isNested()) { $operator = $this->getQueryOperator($query->getMethod()); $filters[$separator][] = $this->buildFilters($query->getValues(), $operator); @@ -2445,7 +2426,7 @@ protected function buildFilter(Query $query): array }; $filter = []; - if($query->isObjectAttribute() && in_array($query->getMethod(),[Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])){ + if ($query->isObjectAttribute() && in_array($query->getMethod(), [Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])) { $this->handleObjectFilters($query, $filter); return $filter; } @@ -2508,12 +2489,13 @@ protected function buildFilter(Query $query): array return $filter; } - private function handleObjectFilters(Query $query, array &$filter){ + private function handleObjectFilters(Query $query, array &$filter) + { $conditions = []; $isNot = in_array($query->getMethod(), [Query::TYPE_NOT_CONTAINS,Query::TYPE_NOT_EQUAL]); $values = $query->getValues(); foreach ($values as $attribute => $value) { - $flattendQuery = $this->flattenWithDotNotation(is_string($attribute)?$attribute:'', $value); + $flattendQuery = $this->flattenWithDotNotation(is_string($attribute) ? $attribute : '', $value); $flattenedObjectKey = array_key_first($flattendQuery); $queryValue = $flattendQuery[$flattenedObjectKey]; $flattenedObjectKey = $query->getAttribute() . '.' . array_key_first($flattendQuery); @@ -2526,7 +2508,7 @@ private function handleObjectFilters(Query $query, array &$filter){ $conditions[] = [ $flattenedObjectKey => [ $operator => $arrayValue] ]; break; } - + case Query::TYPE_EQUAL: case Query::TYPE_NOT_EQUAL: { if (\is_array($queryValue)) { @@ -2536,13 +2518,13 @@ private function handleObjectFilters(Query $query, array &$filter){ $operator = $isNot ? '$ne' : '$eq'; $conditions[] = [ $flattenedObjectKey => [ $operator => $queryValue] ]; } - + break; } } } - $logicalOperator = $isNot? '$and' : '$or'; + $logicalOperator = $isNot ? '$and' : '$or'; if (count($conditions) && isset($filter[$logicalOperator])) { $filter[$logicalOperator] = array_merge($filter[$logicalOperator], $conditions); } else { @@ -2554,15 +2536,16 @@ private function handleObjectFilters(Query $query, array &$filter){ // example -> [a=>[1,b=>[212]]] shouldn't be allowed // allowed -> [a=>[1,2],b=>[212]] // should be disallowed -> $data = ['name' => 'doc','role' => ['name'=>['test1','test2'],'ex'=>['new'=>'test1']]]; - private function flattenWithDotNotation(string $key, mixed $value, string $prefix=''):array{ + private function flattenWithDotNotation(string $key, mixed $value, string $prefix = ''): array + { $result = []; - $currentPref = $prefix === '' ? $key :$prefix.'.'.$key; - if(is_array($value) && !array_is_list($value)){ + $currentPref = $prefix === '' ? $key : $prefix.'.'.$key; + if (is_array($value) && !array_is_list($value)) { $nextKey = array_key_first($value); - $result += $this->flattenWithDotNotation($nextKey,$value[$nextKey],$currentPref); - } + $result += $this->flattenWithDotNotation($nextKey, $value[$nextKey], $currentPref); + } // at the leaf node - else{ + else { $result[$currentPref] = $value; } return $result; diff --git a/src/Database/Database.php b/src/Database/Database.php index 1f7f1e9e1..51691bb0a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -655,7 +655,7 @@ function (mixed $value) { */ function (mixed $value) { if (is_null($value)) { - return null; + return; } // can be non string in case of mongodb as it stores the value as object if (!is_string($value)) { diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 52f27f548..057cbc288 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -10,11 +10,12 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Structure as StructureException; +use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; -use Utopia\Database\Helpers\ID; + trait SchemalessTests { public function testSchemalessDocumentOperation(): void @@ -1226,7 +1227,7 @@ public function testElemMatch(): void ]) ]); $this->assertCount(2, $results); - $ids = array_map(fn($doc) => $doc->getId(), $results); + $ids = array_map(fn ($doc) => $doc->getId(), $results); $this->assertContains('order1', $ids); $this->assertContains('order2', $ids); @@ -1238,7 +1239,7 @@ public function testElemMatch(): void ]) ]); $this->assertCount(3, $results); - $ids = array_map(fn($doc) => $doc->getId(), $results); + $ids = array_map(fn ($doc) => $doc->getId(), $results); $this->assertContains('order1', $ids); $this->assertContains('order2', $ids); $this->assertContains('order3', $ids); @@ -1288,7 +1289,7 @@ public function testElemMatch(): void ]) ]); $this->assertCount(2, $results); - $ids = array_map(fn($doc) => $doc->getId(), $results); + $ids = array_map(fn ($doc) => $doc->getId(), $results); $this->assertContains('order1', $ids); $this->assertContains('order3', $ids); @@ -1301,7 +1302,7 @@ public function testElemMatch(): void ]); // order 1 has elements where sku == "ABC", qty: 5 => !=ABC fails and sku = XYZ ,qty: 2 => >2 fails $this->assertCount(2, $results); - $ids = array_map(fn($doc) => $doc->getId(), $results); + $ids = array_map(fn ($doc) => $doc->getId(), $results); $this->assertContains('order2', $ids); $this->assertContains('order3', $ids); From c2e1670f18f2a6385b03d3d53a3a6e56a53951cd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 6 Jan 2026 17:15:16 +0530 Subject: [PATCH 04/21] refactor: streamline elemMatch validation and update schemaless tests --- src/Database/Validator/Query/Filter.php | 38 ++++---------------- tests/e2e/Adapter/Scopes/SchemalessTests.php | 16 ++++----- 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 8b6f424ae..d805e7172 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -397,53 +397,29 @@ public function isValid($value): bool return true; case Query::TYPE_ELEM_MATCH: - // Validate that the attribute (array field) exists - if (!$this->isValidAttribute($attribute)) { - return false; - } - - // For schemaless mode, allow elemMatch on any attribute - if (!$this->supportForAttributes) { - // Validate nested queries are filter queries - $filters = Query::groupByType($value->getValues())['filters']; - if (count($value->getValues()) !== count($filters)) { - $this->message = 'elemMatch queries can only contain filter queries'; - return false; - } - if (count($filters) < 1) { - $this->message = 'elemMatch queries require at least one query'; - return false; - } - return true; - } - - // For schema mode, validate that the attribute is an array - if (!isset($this->schema[$attribute])) { - $this->message = 'Attribute not found in schema: ' . $attribute; + // elemMatch is not supported when adapter supports attributes (schema mode) + if ($this->supportForAttributes) { + $this->message = 'elemMatch is not supported by the database'; return false; } - $attributeSchema = $this->schema[$attribute]; - $isArray = $attributeSchema['array'] ?? false; - - if (!$isArray) { - $this->message = 'elemMatch can only be used on array attributes: ' . $attribute; + // Validate that the attribute (array field) exists + if (!$this->isValidAttribute($attribute)) { return false; } + // For schemaless mode, allow elemMatch on any attribute // Validate nested queries are filter queries $filters = Query::groupByType($value->getValues())['filters']; if (count($value->getValues()) !== count($filters)) { $this->message = 'elemMatch queries can only contain filter queries'; return false; } + if (count($filters) < 1) { $this->message = 'elemMatch queries require at least one query'; return false; } - - // Note: We don't validate the nested query attributes against the schema - // because they are attributes of objects within the array, not top-level attributes return true; default: diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 057cbc288..4dfe98a9d 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1166,13 +1166,13 @@ public function testElemMatch(): void { /** @var Database $database */ $database = static::getDatabase(); - + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } $collectionId = ID::unique(); $database->createCollection($collectionId); - // Create array attribute for items - $database->createAttribute($collectionId, 'items', Database::VAR_OBJECT, 0, false, null, true); - // Create documents with array of objects $doc1 = $database->createDocument($collectionId, new Document([ '$id' => 'order1', @@ -1319,13 +1319,13 @@ public function testElemMatchComplex(): void { /** @var Database $database */ $database = static::getDatabase(); - + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } $collectionId = ID::unique(); $database->createCollection($collectionId); - // Create array attribute - $database->createAttribute($collectionId, 'products', Database::VAR_OBJECT, 0, false, null, true); - // Create documents with complex nested structures $doc1 = $database->createDocument($collectionId, new Document([ '$id' => 'store1', From e4f00f3ce317df76ac5b86d3ac085434b51ab03e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 6 Jan 2026 17:22:59 +0530 Subject: [PATCH 05/21] refactor: update ObjectAttributeTests to check for attribute support in adapter --- tests/e2e/Adapter/Base.php | 24 +++++++++---------- .../Adapter/Scopes/ObjectAttributeTests.php | 6 ++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index b6b585784..53fc6a23c 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -23,19 +23,19 @@ abstract class Base extends TestCase { - use CollectionTests; - use CustomDocumentTypeTests; - use DocumentTests; - use AttributeTests; - use IndexTests; - use OperatorTests; - use PermissionTests; - use RelationshipTests; - use SpatialTests; - use SchemalessTests; + // use CollectionTests; + // use CustomDocumentTypeTests; + // use DocumentTests; + // use AttributeTests; + // use IndexTests; + // use OperatorTests; + // use PermissionTests; + // use RelationshipTests; + // use SpatialTests; + // use SchemalessTests; use ObjectAttributeTests; - use VectorTests; - use GeneralTests; + // use VectorTests; + // use GeneralTests; protected static string $namespace; diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 003eec9b5..d43730ebb 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -581,9 +581,9 @@ public function testObjectAttributeGinIndex(): void $database = static::getDatabase(); // Skip test if adapter doesn't support JSONB - // if (!$database->getAdapter()->getSupportForObject()) { - // } - $this->markTestSkipped('Adapter does not support object attributes'); + if (!$database->getAdapter()->getSupportForObject() || !$database->getAdapter()->getSupportForAttributes()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } $collectionId = ID::unique(); $database->createCollection($collectionId); From b397af6e13a904a17804624dbadb20fd349efb64 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 12:59:09 +0530 Subject: [PATCH 06/21] feat: add support for object (JSON) indexes across database adapters --- src/Database/Adapter.php | 7 +++ src/Database/Adapter/MariaDB.php | 10 ++++ src/Database/Adapter/Mongo.php | 10 ++++ src/Database/Adapter/MySQL.php | 5 ++ src/Database/Adapter/Pool.php | 5 ++ src/Database/Adapter/Postgres.php | 10 ++++ src/Database/Adapter/SQLite.php | 10 ++++ src/Database/Database.php | 8 +-- tests/e2e/Adapter/Base.php | 24 ++++---- .../Adapter/Scopes/ObjectAttributeTests.php | 6 +- tests/e2e/Adapter/Scopes/SchemalessTests.php | 56 +++++++++++++++++++ 11 files changed, 132 insertions(+), 19 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 62a8eb7fe..6678445d8 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1079,6 +1079,13 @@ abstract public function getSupportForSpatialAttributes(): bool; */ abstract public function getSupportForObject(): bool; + /** + * Are object (JSON) indexes supported? + * + * @return bool + */ + abstract public function getSupportForIndexObject(): bool; + /** * Does the adapter support null values in spatial indexes? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 2876139f7..54285090a 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2140,6 +2140,16 @@ public function getSupportForObject(): bool return false; } + /** + * Are object (JSON) indexes supported? + * + * @return bool + */ + public function getSupportForIndexObject(): bool + { + return false; + } + /** * Get Support for Null Values in Spatial Indexes * diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 3c453e4be..59b69b6e4 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2906,6 +2906,16 @@ public function getSupportForObject(): bool return true; } + /** + * Are object (JSON) indexes supported? + * + * @return bool + */ + public function getSupportForIndexObject(): bool + { + return false; + } + /** * Get current attribute count from collection document * diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 2ff77e9a0..8ef735b6c 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -245,6 +245,11 @@ public function getSupportForSpatialAxisOrder(): bool return true; } + public function getSupportForIndexObject(): bool + { + return false; + } + /** * Get the spatial axis order specification string for MySQL * MySQL with SRID 4326 expects lat-long by default, but our data is in long-lat format diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 76c98e8b2..27e2fe7ec 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -590,6 +590,11 @@ public function getSupportForObject(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForIndexObject(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function castingBefore(Document $collection, Document $document): Document { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 86da09a58..70342c0d4 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2194,6 +2194,16 @@ public function getSupportForObject(): bool return true; } + /** + * Are object (JSONB) indexes supported? + * + * @return bool + */ + public function getSupportForIndexObject(): bool + { + return true; + } + /** * Does the adapter support null values in spatial indexes? * diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index a3d31db68..57bcc4c8e 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1013,6 +1013,16 @@ public function getSupportForObject(): bool return false; } + /** + * Are object (JSON) indexes supported? + * + * @return bool + */ + public function getSupportForIndexObject(): bool + { + return false; + } + public function getSupportForSpatialIndexNull(): bool { return false; // SQLite doesn't have native spatial support diff --git a/src/Database/Database.php b/src/Database/Database.php index 51691bb0a..97fb6ecf1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1641,7 +1641,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObject(), + $this->adapter->getSupportForIndexObject(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -2786,7 +2786,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObject() + $this->adapter->getSupportForIndexObject() ); foreach ($indexes as $index) { @@ -3661,7 +3661,7 @@ public function createIndex(string $collection, string $id, string $type, array break; case self::INDEX_OBJECT: - if (!$this->adapter->getSupportForObject()) { + if (!$this->adapter->getSupportForIndexObject()) { throw new DatabaseException('Object indexes are not supported'); } break; @@ -3722,7 +3722,7 @@ public function createIndex(string $collection, string $id, string $type, array $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObject(), + $this->adapter->getSupportForIndexObject() ); if (!$validator->isValid($index)) { throw new IndexException($validator->getDescription()); diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 53fc6a23c..b6b585784 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -23,19 +23,19 @@ abstract class Base extends TestCase { - // use CollectionTests; - // use CustomDocumentTypeTests; - // use DocumentTests; - // use AttributeTests; - // use IndexTests; - // use OperatorTests; - // use PermissionTests; - // use RelationshipTests; - // use SpatialTests; - // use SchemalessTests; + use CollectionTests; + use CustomDocumentTypeTests; + use DocumentTests; + use AttributeTests; + use IndexTests; + use OperatorTests; + use PermissionTests; + use RelationshipTests; + use SpatialTests; + use SchemalessTests; use ObjectAttributeTests; - // use VectorTests; - // use GeneralTests; + use VectorTests; + use GeneralTests; protected static string $namespace; diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index d43730ebb..3c9077e6f 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -580,9 +580,9 @@ public function testObjectAttributeGinIndex(): void /** @var Database $database */ $database = static::getDatabase(); - // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject() || !$database->getAdapter()->getSupportForAttributes()) { - $this->markTestSkipped('Adapter does not support object attributes'); + if (!$database->getAdapter()->getSupportForIndexObject()) { + $this->markTestSkipped('Adapter does not support object indexes'); + return; } $collectionId = ID::unique(); diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 4dfe98a9d..95ce55c2b 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -752,6 +752,62 @@ public function testSchemalessIndexDuplicatePrevention(): void $database->deleteCollection($col); } + public function testSchemalessObjectIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Only run for schemaless adapters that support object attributes + if ($database->getAdapter()->getSupportForAttributes() || !$database->getAdapter()->getSupportForObject()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_obj_idx'); + $database->createCollection($col); + + // Define object attributes in metadata + $database->createAttribute($col, 'meta', Database::VAR_OBJECT, 0, false); + $database->createAttribute($col, 'meta2', Database::VAR_OBJECT, 0, false); + + // Create regular key index on first object attribute + $this->assertTrue( + $database->createIndex( + $col, + 'idx_meta_key', + Database::INDEX_KEY, + ['meta'], + [0], + [Database::ORDER_ASC] + ) + ); + + // Create unique index on second object attribute + $this->assertTrue( + $database->createIndex( + $col, + 'idx_meta_unique', + Database::INDEX_UNIQUE, + ['meta2'], + [0], + [Database::ORDER_ASC] + ) + ); + + // Verify index metadata is stored on the collection + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + $ids = array_map(fn ($i) => $i['$id'], $indexes); + $this->assertContains('idx_meta_key', $ids); + $this->assertContains('idx_meta_unique', $ids); + + // Clean up indexes and collection + $this->assertTrue($database->deleteIndex($col, 'idx_meta_key')); + $this->assertTrue($database->deleteIndex($col, 'idx_meta_unique')); + $database->deleteCollection($col); + } + public function testSchemalessPermissions(): void { /** @var Database $database */ From 3f85b5672c1c48cc099b9d2b7f94cbbf1c97fbc1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 12:59:23 +0530 Subject: [PATCH 07/21] updated tests for elemMatch --- tests/e2e/Adapter/Scopes/SchemalessTests.php | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 95ce55c2b..e51b4545e 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1422,6 +1422,43 @@ public function testElemMatchComplex(): void $this->assertCount(1, $results); $this->assertEquals('store1', $results[0]->getId()); + // Test: elemMatch with OR grouping on name and stock threshold + $results = $database->find($collectionId, [ + Query::elemMatch('products', [ + Query::or([ + Query::equal('name', ['Widget']), + Query::equal('name', ['Thing']), + ]), + Query::greaterThanEqual('stock', 25), + ]) + ]); + // Both stores have at least one matching product: + // - store1: Widget (stock 100) + // - store2: Widget (stock 200) and Thing (stock 25) + $this->assertCount(2, $results); + + // Test: elemMatch with nested AND/OR conditions + $results = $database->find($collectionId, [ + Query::elemMatch('products', [ + Query::or([ + Query::and([ + Query::equal('name', ['Widget']), + Query::greaterThan('stock', 150), + ]), + Query::and([ + Query::equal('name', ['Thing']), + Query::greaterThan('stock', 20), + ]), + ]), + Query::equal('active', [true]), + ]) + ]); + // Only store2 matches: + // - Widget with stock 200 (>150) and active true + // - Thing with stock 25 (>20) and active true + $this->assertCount(1, $results); + $this->assertEquals('store2', $results[0]->getId()); + // Clean up $database->deleteCollection($collectionId); } From c579ec5c4ada9c5699698f5b384fd8f68c713259 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 13:15:42 +0530 Subject: [PATCH 08/21] refactor: enhance object query validation and add corresponding tests --- src/Database/Adapter/Mongo.php | 14 +++---- src/Database/Validator/Query/Filter.php | 56 ++++++++++++++++++++++++- tests/unit/Validator/QueriesTest.php | 42 ++++++++++++++++++- 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 59b69b6e4..c42f0863d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1274,7 +1274,6 @@ public function castingAfter(Document $collection, Document $document): Document } if (!$this->getSupportForAttributes()) { - /** @var Document $doc */ foreach ($document->getArrayCopy() as $key => $value) { // mongodb results out a stdclass for objects if (is_object($value) && get_class($value) === stdClass::class) { @@ -1285,7 +1284,7 @@ public function castingAfter(Document $collection, Document $document): Document return $document; } - private function convertStdClassToArray(mixed $value) + private function convertStdClassToArray(mixed $value): mixed { if (is_object($value) && get_class($value) === stdClass::class) { return array_map(fn ($v) => $this->convertStdClassToArray($v), get_object_vars($value)); @@ -2489,7 +2488,12 @@ protected function buildFilter(Query $query): array return $filter; } - private function handleObjectFilters(Query $query, array &$filter) + /** + * @param Query $query + * @param array $filter + * @return void + */ + private function handleObjectFilters(Query $query, array &$filter): void { $conditions = []; $isNot = in_array($query->getMethod(), [Query::TYPE_NOT_CONTAINS,Query::TYPE_NOT_EQUAL]); @@ -2532,10 +2536,6 @@ private function handleObjectFilters(Query $query, array &$filter) } } - // TODO: check the condition for the multiple keys inside a query validator - // example -> [a=>[1,b=>[212]]] shouldn't be allowed - // allowed -> [a=>[1,2],b=>[212]] - // should be disallowed -> $data = ['name' => 'doc','role' => ['name'=>['test1','test2'],'ex'=>['new'=>'test1']]]; private function flattenWithDotNotation(string $key, mixed $value, string $prefix = ''): array { $result = []; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index d805e7172..2ad2624e6 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -163,8 +163,11 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s break; case Database::VAR_OBJECT: - // value for object can be of any type as its a hashmap - // eg; ['key'=>value'] + if (\in_array($method, [Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS], true) + && !$this->isValidObjectQueryValues($values)) { + $this->message = 'Invalid object query structure for attribute "' . $attribute . '"'; + return false; + } continue 2; case Database::VAR_POINT: @@ -288,6 +291,55 @@ protected function isEmpty(array $values): bool return false; } + /** + * Validate object attribute query values. + * + * Disallows ambiguous nested structures like: + * ['a' => [1, 'b' => [212]]] + * ['role' => ['name' => [...], 'ex' => [...]]] + * + * but allows: + * ['a' => [1, 2], 'b' => [212]] + * + * @param array $values + * @return bool + */ + private function isValidObjectQueryValues(array $values): bool + { + $validateNode = function (mixed $node) use (&$validateNode): bool { + if (!\is_array($node)) { + return true; + } + + if (\array_is_list($node)) { + // Indexed array: validate each element + foreach ($node as $item) { + if (!$validateNode($item)) { + return false; + } + } + + return true; + } + + // Associative array (object-like). Only one key is allowed at each level. + if (\count($node) !== 1) { + return false; + } + + $firstKey = \array_key_first($node); + return $validateNode($node[$firstKey]); + }; + + foreach ($values as $value) { + if (!$validateNode($value)) { + return false; + } + } + + return true; + } + /** * Is valid. * diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 265e9cbd0..6f9facbb3 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -57,7 +57,13 @@ public function testValid(): void 'key' => 'name', 'type' => Database::VAR_STRING, 'array' => false, - ]) + ]), + new Document([ + '$id' => 'meta', + 'key' => 'meta', + 'type' => Database::VAR_OBJECT, + 'array' => false, + ]), ]; $validator = new Queries( @@ -75,5 +81,39 @@ public function testValid(): void $this->assertEquals(true, $validator->isValid([Query::limit(10)]), $validator->getDescription()); $this->assertEquals(true, $validator->isValid([Query::offset(10)]), $validator->getDescription()); $this->assertEquals(true, $validator->isValid([Query::orderAsc('name')]), $validator->getDescription()); + + // Object attribute query: allowed shape + $this->assertTrue( + $validator->isValid([ + Query::equal('meta', [ + ['a' => [1, 2]], + ['b' => [212]], + ]), + ]), + $validator->getDescription() + ); + + // Object attribute query: disallowed nested multiple keys in same level + $this->assertFalse( + $validator->isValid([ + Query::equal('meta', [ + ['a' => [1, 'b' => [212]]], + ]), + ]) + ); + + // Object attribute query: disallowed complex multi-key nested structure + $this->assertFalse( + $validator->isValid([ + Query::contains('meta', [ + [ + 'role' => [ + 'name' => ['test1', 'test2'], + 'ex' => ['new' => 'test1'], + ], + ], + ]), + ]) + ); } } From 3c04a4bfcd2589b0944c168c0cecb6e4dc9ad29f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 13:23:42 +0530 Subject: [PATCH 09/21] linting --- src/Database/Adapter/Mongo.php | 29 ++++++++++++++----- .../Adapter/Scopes/ObjectAttributeTests.php | 1 - 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index c42f0863d..613a40a2a 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2536,18 +2536,33 @@ private function handleObjectFilters(Query $query, array &$filter): void } } + /** + * Flatten a nested associative array into Mongo-style dot notation. + * + * @param string $key + * @param mixed $value + * @param string $prefix + * @return array + */ private function flattenWithDotNotation(string $key, mixed $value, string $prefix = ''): array { + /** @var array $result */ $result = []; - $currentPref = $prefix === '' ? $key : $prefix.'.'.$key; - if (is_array($value) && !array_is_list($value)) { - $nextKey = array_key_first($value); - $result += $this->flattenWithDotNotation($nextKey, $value[$nextKey], $currentPref); - } - // at the leaf node - else { + $currentPref = $prefix === '' ? $key : $prefix . '.' . $key; + + if (\is_array($value) && !\array_is_list($value)) { + $nextKey = \array_key_first($value); + if ($nextKey === null) { + return $result; + } + + $nextKeyString = (string) $nextKey; + $result += $this->flattenWithDotNotation($nextKeyString, $value[$nextKey], $currentPref); + } else { + // at the leaf node $result[$currentPref] = $value; } + return $result; } diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 3c9077e6f..f2cbadb75 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -582,7 +582,6 @@ public function testObjectAttributeGinIndex(): void if (!$database->getAdapter()->getSupportForIndexObject()) { $this->markTestSkipped('Adapter does not support object indexes'); - return; } $collectionId = ID::unique(); From 1214e5601ef000911dda4f6b038b882c66c10f75 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 19:46:40 +0530 Subject: [PATCH 10/21] refactor: improve validation logic for object query values to handle indexed and associative arrays --- src/Database/Validator/Query/Filter.php | 28 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 2ad2624e6..c8396b60e 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -306,15 +306,14 @@ protected function isEmpty(array $values): bool */ private function isValidObjectQueryValues(array $values): bool { - $validateNode = function (mixed $node) use (&$validateNode): bool { + $validateNode = function (mixed $node, bool $isInList = false) use (&$validateNode): bool { if (!\is_array($node)) { return true; } if (\array_is_list($node)) { - // Indexed array: validate each element foreach ($node as $item) { - if (!$validateNode($item)) { + if (!$validateNode($item, true)) { return false; } } @@ -322,17 +321,32 @@ private function isValidObjectQueryValues(array $values): bool return true; } - // Associative array (object-like). Only one key is allowed at each level. - if (\count($node) !== 1) { + if (!$isInList && \count($node) !== 1) { return false; } + if ($isInList) { + foreach ($node as $value) { + // When in a list context, values of associative arrays are also object structures, + // not navigation paths, so pass isInList=true for nested associative arrays + $valueIsInList = \is_array($value) && !\array_is_list($value); + if (!$validateNode($value, $valueIsInList)) { + return false; + } + } + return true; + } + $firstKey = \array_key_first($node); - return $validateNode($node[$firstKey]); + return $validateNode($node[$firstKey], false); }; + // Check if values is an indexed array (list) + // If so, its elements should be validated with isInList=true + $valuesIsIndexed = \array_is_list($values); + foreach ($values as $value) { - if (!$validateNode($value)) { + if (!$validateNode($value, $valuesIsIndexed)) { return false; } } From d511a60418824c54853cee2bd4976a0dcf6e9178 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 21:17:05 +0530 Subject: [PATCH 11/21] refactor: enhance validation for object attribute query values to disallow mixed lists and improve depth handling --- src/Database/Validator/Query/Filter.php | 65 ++++++++++++++----------- tests/unit/Validator/QueriesTest.php | 23 --------- 2 files changed, 36 insertions(+), 52 deletions(-) diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 3e4528a10..8925362ba 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -301,63 +301,70 @@ protected function isEmpty(array $values): bool * Validate object attribute query values. * * Disallows ambiguous nested structures like: - * ['a' => [1, 'b' => [212]]] - * ['role' => ['name' => [...], 'ex' => [...]]] + * ['a' => [1, 'b' => [212]]] // mixed list + * ['role' => ['name' => [...], 'ex' => [...]]] // multiple nested paths * * but allows: - * ['a' => [1, 2], 'b' => [212]] + * ['a' => [1, 2], 'b' => [212]] // multiple top-level paths + * ['projects' => [[...]]] // list of objects * * @param array $values * @return bool */ private function isValidObjectQueryValues(array $values): bool { - $validateNode = function (mixed $node, bool $isInList = false) use (&$validateNode): bool { + $validate = function (mixed $node, int $depth = 0, bool $inDataContext = false) use (&$validate): bool { if (!\is_array($node)) { return true; } if (\array_is_list($node)) { + // Check if list is mixed (has both assoc arrays and non-assoc items) + $hasAssoc = false; + $hasNonAssoc = false; + foreach ($node as $item) { - if (!$validateNode($item, true)) { - return false; + if (\is_array($item) && !\array_is_list($item)) { + $hasAssoc = true; + } else { + $hasNonAssoc = true; } } - return true; - } + // Mixed lists are invalid + if ($hasAssoc && $hasNonAssoc) { + return false; + } - if (!$isInList && \count($node) !== 1) { - return false; - } + // If list contains associative arrays, they're data objects + $enterDataContext = $hasAssoc; - if ($isInList) { - foreach ($node as $value) { - // When in a list context, values of associative arrays are also object structures, - // not navigation paths, so pass isInList=true for nested associative arrays - $valueIsInList = \is_array($value) && !\array_is_list($value); - if (!$validateNode($value, $valueIsInList)) { + foreach ($node as $item) { + if (!$validate($item, $depth + 1, $enterDataContext || $inDataContext)) { return false; } } return true; } - $firstKey = \array_key_first($node); - return $validateNode($node[$firstKey], false); - }; - - // Check if values is an indexed array (list) - // If so, its elements should be validated with isInList=true - $valuesIsIndexed = \array_is_list($values); - - foreach ($values as $value) { - if (!$validateNode($value, $valuesIsIndexed)) { + // Associative array + // If in data context, multiple keys are OK (it's an object) + // If depth > 0 and NOT in data context, only 1 key allowed (navigation) + if (!$inDataContext && $depth > 0 && \count($node) !== 1) { return false; } - } - return true; + // Validate all values + foreach ($node as $value) { + if (!$validate($value, $depth + 1, $inDataContext)) { + return false; + } + } + + return true; + }; + + return $validate($values, 0, false); } /** diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 6f9facbb3..7aaf0337a 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -92,28 +92,5 @@ public function testValid(): void ]), $validator->getDescription() ); - - // Object attribute query: disallowed nested multiple keys in same level - $this->assertFalse( - $validator->isValid([ - Query::equal('meta', [ - ['a' => [1, 'b' => [212]]], - ]), - ]) - ); - - // Object attribute query: disallowed complex multi-key nested structure - $this->assertFalse( - $validator->isValid([ - Query::contains('meta', [ - [ - 'role' => [ - 'name' => ['test1', 'test2'], - 'ex' => ['new' => 'test1'], - ], - ], - ]), - ]) - ); } } From 473f3c4cc8988016d3fa0529654c1b476ed6fc19 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k <83803257+ArnabChatterjee20k@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:27:46 +0530 Subject: [PATCH 12/21] Update src/Database/Adapter/Mongo.php Co-authored-by: Jake Barnby --- src/Database/Adapter/Mongo.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 58aa75c32..f76848340 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1288,7 +1288,7 @@ public function castingAfter(Document $collection, Document $document): Document private function convertStdClassToArray(mixed $value): mixed { if (is_object($value) && get_class($value) === stdClass::class) { - return array_map(fn ($v) => $this->convertStdClassToArray($v), get_object_vars($value)); + return array_map($this->convertStdClassToArray(...), get_object_vars($value)); } if (is_array($value)) { From d52f10e91a5113188f9d023829d2cefa7f9a65ef Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 8 Jan 2026 20:20:52 +0530 Subject: [PATCH 13/21] refactor: rename getSupportForIndexObject to getSupportForObjectIndexes and update related logic --- src/Database/Adapter.php | 2 +- src/Database/Adapter/MariaDB.php | 2 +- src/Database/Adapter/Mongo.php | 32 +++++---- src/Database/Adapter/MySQL.php | 2 +- src/Database/Adapter/Pool.php | 2 +- src/Database/Adapter/Postgres.php | 2 +- src/Database/Adapter/SQLite.php | 2 +- src/Database/Database.php | 13 ++-- src/Database/Validator/Query/Filter.php | 70 ++++++------------- .../Adapter/Scopes/ObjectAttributeTests.php | 2 +- tests/unit/Validator/QueriesTest.php | 23 ++++++ 11 files changed, 76 insertions(+), 76 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 0067abf1f..49a33e403 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1084,7 +1084,7 @@ abstract public function getSupportForObject(): bool; * * @return bool */ - abstract public function getSupportForIndexObject(): bool; + abstract public function getSupportForObjectIndexes(): bool; /** * Does the adapter support null values in spatial indexes? diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index dd7823a3f..b8110b039 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2145,7 +2145,7 @@ public function getSupportForObject(): bool * * @return bool */ - public function getSupportForIndexObject(): bool + public function getSupportForObjectIndexes(): bool { return false; } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 31a34c788..79be4ef30 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1353,7 +1353,6 @@ public function castingBefore(Document $collection, Document $document): Documen break; case Database::VAR_OBJECT: $node = json_decode($node); - $node = $this->convertStdClassToArray($node); break; default: break; @@ -2555,19 +2554,26 @@ private function flattenWithDotNotation(string $key, mixed $value, string $prefi { /** @var array $result */ $result = []; - $currentPref = $prefix === '' ? $key : $prefix . '.' . $key; - if (\is_array($value) && !\array_is_list($value)) { - $nextKey = \array_key_first($value); - if ($nextKey === null) { - return $result; - } + $stack = []; - $nextKeyString = (string) $nextKey; - $result += $this->flattenWithDotNotation($nextKeyString, $value[$nextKey], $currentPref); - } else { - // at the leaf node - $result[$currentPref] = $value; + $initialKey = $prefix === '' ? $key : $prefix . '.' . $key; + $stack[] = [$initialKey, $value]; + while (!empty($stack)) { + [$currentPath, $currentValue] = array_pop($stack); + if (is_array($currentValue) && !array_is_list($currentValue)) { + foreach ($currentValue as $nextKey => $nextValue) { + if ($nextKey === null) { + continue; + } + $nextKey = (string)$nextKey; + $nextPath = $currentPath === '' ? $nextKey : $currentPath . '.' . $nextKey; + $stack[] = [$nextPath, $nextValue]; + } + } else { + // leaf node + $result[$currentPath] = $currentValue; + } } return $result; @@ -2956,7 +2962,7 @@ public function getSupportForObject(): bool * * @return bool */ - public function getSupportForIndexObject(): bool + public function getSupportForObjectIndexes(): bool { return false; } diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index d955a72b7..308013738 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -253,7 +253,7 @@ public function getSupportForSpatialAxisOrder(): bool return true; } - public function getSupportForIndexObject(): bool + public function getSupportForObjectIndexes(): bool { return false; } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 0168d3f1d..d70a836ea 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -605,7 +605,7 @@ public function getSupportForObject(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - public function getSupportForIndexObject(): bool + public function getSupportForObjectIndexes(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 0e90c58cb..050180a0a 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2229,7 +2229,7 @@ public function getSupportForObject(): bool * * @return bool */ - public function getSupportForIndexObject(): bool + public function getSupportForObjectIndexes(): bool { return true; } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 82f65e32a..948070654 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1018,7 +1018,7 @@ public function getSupportForObject(): bool * * @return bool */ - public function getSupportForIndexObject(): bool + public function getSupportForObjectIndexes(): bool { return false; } diff --git a/src/Database/Database.php b/src/Database/Database.php index c9922d45c..3cbfe508a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1642,12 +1642,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForIndexObject(), - $this->adapter->getSupportForTrigramIndex(), - $this->adapter->getSupportForSpatialAttributes(), - $this->adapter->getSupportForIndex(), - $this->adapter->getSupportForUniqueIndex(), - $this->adapter->getSupportForFulltextIndex(), + $this->adapter->getSupportForObjectIndexes(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -2792,7 +2787,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForIndexObject(), + $this->adapter->getSupportForObjectIndexes(), $this->adapter->getSupportForTrigramIndex(), $this->adapter->getSupportForSpatialAttributes(), $this->adapter->getSupportForIndex(), @@ -3672,7 +3667,7 @@ public function createIndex(string $collection, string $id, string $type, array break; case self::INDEX_OBJECT: - if (!$this->adapter->getSupportForObject()) { + if (!$this->adapter->getSupportForObjectIndexes()) { throw new DatabaseException('Object indexes are not supported'); } break; @@ -3733,7 +3728,7 @@ public function createIndex(string $collection, string $id, string $type, array $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForIndexObject(), + $this->adapter->getSupportForObjectIndexes(), $this->adapter->getSupportForTrigramIndex(), $this->adapter->getSupportForSpatialAttributes(), $this->adapter->getSupportForIndex(), diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index a8ff6f09e..2e287a3a8 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -170,7 +170,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s case Database::VAR_OBJECT: if (\in_array($method, [Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS], true) - && !$this->isValidObjectQueryValues($values)) { + && !$this->isValidObjectQueryValues($value)) { $this->message = 'Invalid object query structure for attribute "' . $attribute . '"'; return false; } @@ -302,67 +302,43 @@ protected function isEmpty(array $values): bool * * Disallows ambiguous nested structures like: * ['a' => [1, 'b' => [212]]] // mixed list - * ['role' => ['name' => [...], 'ex' => [...]]] // multiple nested paths * * but allows: * ['a' => [1, 2], 'b' => [212]] // multiple top-level paths * ['projects' => [[...]]] // list of objects + * ['role' => ['name' => [...], 'ex' => [...]]] // multiple nested paths * - * @param array $values + * @param mixed $values * @return bool */ - private function isValidObjectQueryValues(array $values): bool + private function isValidObjectQueryValues(mixed $values): bool { - $validate = function (mixed $node, int $depth = 0, bool $inDataContext = false) use (&$validate): bool { - if (!\is_array($node)) { - return true; - } - - if (\array_is_list($node)) { - // Check if list is mixed (has both assoc arrays and non-assoc items) - $hasAssoc = false; - $hasNonAssoc = false; - - foreach ($node as $item) { - if (\is_array($item) && !\array_is_list($item)) { - $hasAssoc = true; - } else { - $hasNonAssoc = true; - } - } - - // Mixed lists are invalid - if ($hasAssoc && $hasNonAssoc) { - return false; - } + if (!is_array($values)) { + return true; + } - // If list contains associative arrays, they're data objects - $enterDataContext = $hasAssoc; + $hasInt = false; + $hasString = false; - foreach ($node as $item) { - if (!$validate($item, $depth + 1, $enterDataContext || $inDataContext)) { - return false; - } - } - return true; + foreach (array_keys($values) as $key) { + if (is_int($key)) { + $hasInt = true; + } else { + $hasString = true; } + } - // Associative array - // If in data context, multiple keys are OK (it's an object) - // If depth > 0 and NOT in data context, only 1 key allowed (navigation) - if (!$inDataContext && $depth > 0 && \count($node) !== 1) { - return false; - } + if ($hasInt && $hasString) { + return false; + } - // Validate all values - foreach ($node as $value) { - if (!$validate($value, $depth + 1, $inDataContext)) { - return false; - } + foreach ($values as $value) { + if (!$this->isValidObjectQueryValues($value)) { + return false; } + } - return true; - }; + return true; return $validate($values, 0, false); } diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index f2cbadb75..a37cfb451 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -580,7 +580,7 @@ public function testObjectAttributeGinIndex(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForIndexObject()) { + if (!$database->getAdapter()->getSupportForObjectIndexes()) { $this->markTestSkipped('Adapter does not support object indexes'); } diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 7aaf0337a..40e8d7671 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -92,5 +92,28 @@ public function testValid(): void ]), $validator->getDescription() ); + + // Object attribute query: disallowed nested multiple keys in same level + $this->assertFalse( + $validator->isValid([ + Query::equal('meta', [ + ['a' => [1, 'b' => [212]]], + ]), + ]) + ); + + // Object attribute query: disallowed complex multi-key nested structure + $this->assertTrue( + $validator->isValid([ + Query::contains('meta', [ + [ + 'role' => [ + 'name' => ['test1', 'test2'], + 'ex' => ['new' => 'test1'], + ], + ], + ]), + ]) + ); } } From 93ea96ff3a56b78e6f716b71d3ecfb477ee43346 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 8 Jan 2026 20:23:56 +0530 Subject: [PATCH 14/21] linting --- src/Database/Validator/Query/Filter.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 2e287a3a8..3ac36bc71 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -339,8 +339,6 @@ private function isValidObjectQueryValues(mixed $values): bool } return true; - - return $validate($values, 0, false); } /** From 6cfac6f1d0b41fe6f28c06bf09efe3580ade59b0 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 8 Jan 2026 20:27:26 +0530 Subject: [PATCH 15/21] linting --- src/Database/Adapter/Mongo.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 79be4ef30..fdcdd80c2 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2563,9 +2563,6 @@ private function flattenWithDotNotation(string $key, mixed $value, string $prefi [$currentPath, $currentValue] = array_pop($stack); if (is_array($currentValue) && !array_is_list($currentValue)) { foreach ($currentValue as $nextKey => $nextValue) { - if ($nextKey === null) { - continue; - } $nextKey = (string)$nextKey; $nextPath = $currentPath === '' ? $nextKey : $currentPath . '.' . $nextKey; $stack[] = [$nextPath, $nextValue]; From 838068be444aca73f6710a9f2b33d26cba57c951 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 8 Jan 2026 20:36:05 +0530 Subject: [PATCH 16/21] updated index validator --- src/Database/Database.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 3cbfe508a..3e9778bff 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1643,6 +1643,11 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), $this->adapter->getSupportForObjectIndexes(), + $this->adapter->getSupportForTrigramIndex(), + $this->adapter->getSupportForSpatialAttributes(), + $this->adapter->getSupportForIndex(), + $this->adapter->getSupportForUniqueIndex(), + $this->adapter->getSupportForFulltextIndex(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { From 2aadc201af23b6b48a6b7b4c5ecde3d5bc87cc5d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 8 Jan 2026 20:47:28 +0530 Subject: [PATCH 17/21] updated database index validator --- src/Database/Database.php | 46 --------------------------------------- 1 file changed, 46 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 3e9778bff..a2bc2da55 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3635,52 +3635,6 @@ public function createIndex(string $collection, string $id, string $type, array throw new LimitException('Index limit reached. Cannot create new index.'); } - switch ($type) { - case self::INDEX_KEY: - if (!$this->adapter->getSupportForIndex()) { - throw new DatabaseException('Key index is not supported'); - } - break; - - case self::INDEX_UNIQUE: - if (!$this->adapter->getSupportForUniqueIndex()) { - throw new DatabaseException('Unique index is not supported'); - } - break; - - case self::INDEX_FULLTEXT: - if (!$this->adapter->getSupportForFulltextIndex()) { - throw new DatabaseException('Fulltext index is not supported'); - } - break; - - case self::INDEX_SPATIAL: - if (!$this->adapter->getSupportForSpatialAttributes()) { - throw new DatabaseException('Spatial indexes are not supported'); - } - if (!empty($orders) && !$this->adapter->getSupportForSpatialIndexOrder()) { - throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); - } - break; - - case Database::INDEX_HNSW_EUCLIDEAN: - case Database::INDEX_HNSW_COSINE: - case Database::INDEX_HNSW_DOT: - if (!$this->adapter->getSupportForVectors()) { - throw new DatabaseException('Vector indexes are not supported'); - } - break; - - case self::INDEX_OBJECT: - if (!$this->adapter->getSupportForObjectIndexes()) { - throw new DatabaseException('Object indexes are not supported'); - } - break; - - default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT); - } - /** @var array $collectionAttributes */ $collectionAttributes = $collection->getAttribute('attributes', []); $indexAttributesWithTypes = []; From 1556e6e628c20fea3063086d3f32290b81870c6a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Jan 2026 13:33:23 +0530 Subject: [PATCH 18/21] test: add schemaless nested object attribute queries --- tests/e2e/Adapter/Scopes/SchemalessTests.php | 125 +++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index e2e45dcaa..f44ebe9c8 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1685,4 +1685,129 @@ public function testElemMatchComplex(): void // Clean up $database->deleteCollection($collectionId); } + + public function testSchemalessNestedObjectAttributeQueries(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Only run for schemaless adapters that support object attributes + if ($database->getAdapter()->getSupportForAttributes() || !$database->getAdapter()->getSupportForObject()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_nested_obj'); + $database->createCollection($col); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Documents with nested objects + $database->createDocument($col, new Document([ + '$id' => 'u1', + '$permissions' => $permissions, + 'profile' => [ + 'name' => 'Alice', + 'location' => [ + 'country' => 'US', + 'city' => 'New York', + 'coordinates' => [ + 'lat' => 40.7128, + 'lng' => -74.0060, + ], + ], + ], + ])); + + $database->createDocument($col, new Document([ + '$id' => 'u2', + '$permissions' => $permissions, + 'profile' => [ + 'name' => 'Bob', + 'location' => [ + 'country' => 'UK', + 'city' => 'London', + 'coordinates' => [ + 'lat' => 51.5074, + 'lng' => -0.1278, + ], + ], + ], + ])); + + // Document without full nesting + $database->createDocument($col, new Document([ + '$id' => 'u3', + '$permissions' => $permissions, + 'profile' => [ + 'name' => 'Charlie', + 'location' => [ + 'country' => 'US', + ], + ], + ])); + + // Query using Mongo-style dotted paths: attribute.key.key + $nycDocs = $database->find($col, [ + Query::equal('profile.location.city', ['New York']), + ]); + $this->assertCount(1, $nycDocs); + $this->assertEquals('u1', $nycDocs[0]->getId()); + + // Query on deeper nested numeric field + $northOf50 = $database->find($col, [ + Query::greaterThan('profile.location.coordinates.lat', 50), + ]); + $this->assertCount(1, $northOf50); + $this->assertEquals('u2', $northOf50[0]->getId()); + + // exists on nested key should match docs where the full path exists + $withCoordinates = $database->find($col, [ + Query::exists(['profile.location.coordinates.lng']), + ]); + $this->assertCount(2, $withCoordinates); + $ids = array_map(fn (Document $doc) => $doc->getId(), $withCoordinates); + $this->assertContains('u1', $ids); + $this->assertContains('u2', $ids); + $this->assertNotContains('u3', $ids); + + // Combination of filters on nested paths + $usWithCoords = $database->find($col, [ + Query::equal('profile.location.country', ['US']), + Query::exists(['profile.location.coordinates.lat']), + ]); + $this->assertCount(1, $usWithCoords); + $this->assertEquals('u1', $usWithCoords[0]->getId()); + + // contains on object attribute using nested structure: parent.key and [key => [key => 'value']] + $matchedByNestedContains = $database->find($col, [ + Query::contains('profile', [ + 'location' => [ + 'city' => 'London', + ], + ]), + ]); + $this->assertCount(1, $matchedByNestedContains); + $this->assertEquals('u2', $matchedByNestedContains[0]->getId()); + + // equal on object attribute using nested structure should behave similarly + $matchedByNestedEqual = $database->find($col, [ + Query::equal('profile', [ + 'location' => [ + 'country' => 'US', + ], + ]), + ]); + $this->assertCount(2, $matchedByNestedEqual); + $idsEqual = array_map(fn (Document $doc) => $doc->getId(), $matchedByNestedEqual); + $this->assertContains('u1', $idsEqual); + $this->assertContains('u3', $idsEqual); + + $database->deleteCollection($col); + } } From 887775da20bed0b63672235d37627c7fe63bd408 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Jan 2026 13:33:53 +0530 Subject: [PATCH 19/21] fix: adjust condition for schemaless object attribute support in tests --- tests/e2e/Adapter/Scopes/SchemalessTests.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index f44ebe9c8..9a464b778 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1691,8 +1691,9 @@ public function testSchemalessNestedObjectAttributeQueries(): void /** @var Database $database */ $database = static::getDatabase(); - // Only run for schemaless adapters that support object attributes - if ($database->getAdapter()->getSupportForAttributes() || !$database->getAdapter()->getSupportForObject()) { + /** @var Database $database */ + $database = static::getDatabase(); + if ($database->getAdapter()->getSupportForAttributes()) { $this->expectNotToPerformAssertions(); return; } From 6de1a2d9cb54bababe5f056359f5ab59e0df3669 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Jan 2026 13:36:52 +0530 Subject: [PATCH 20/21] test: enhance schemaless nested object attribute queries with additional session scenarios --- tests/e2e/Adapter/Scopes/SchemalessTests.php | 54 ++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 9a464b778..a5b449a70 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1723,6 +1723,22 @@ public function testSchemalessNestedObjectAttributeQueries(): void ], ], ], + 'sessions' => [ + [ + 'device' => [ + 'os' => 'ios', + 'version' => '17', + ], + 'active' => true, + ], + [ + 'device' => [ + 'os' => 'android', + 'version' => '14', + ], + 'active' => false, + ], + ], ])); $database->createDocument($col, new Document([ @@ -1739,6 +1755,15 @@ public function testSchemalessNestedObjectAttributeQueries(): void ], ], ], + 'sessions' => [ + [ + 'device' => [ + 'os' => 'android', + 'version' => '15', + ], + 'active' => true, + ], + ], ])); // Document without full nesting @@ -1751,6 +1776,7 @@ public function testSchemalessNestedObjectAttributeQueries(): void 'country' => 'US', ], ], + 'sessions' => [], ])); // Query using Mongo-style dotted paths: attribute.key.key @@ -1809,6 +1835,34 @@ public function testSchemalessNestedObjectAttributeQueries(): void $this->assertContains('u1', $idsEqual); $this->assertContains('u3', $idsEqual); + // elemMatch on array of nested objects (sessions.device.os, sessions.active) + $iosActive = $database->find($col, [ + Query::elemMatch('sessions', [ + Query::equal('device.os', ['ios']), + Query::equal('active', [true]), + ]), + ]); + $this->assertCount(1, $iosActive); + $this->assertEquals('u1', $iosActive[0]->getId()); + + // elemMatch where nested condition only matches u2 (android & active) + $androidActive = $database->find($col, [ + Query::elemMatch('sessions', [ + Query::equal('device.os', ['android']), + Query::equal('active', [true]), + ]), + ]); + $this->assertCount(1, $androidActive); + $this->assertEquals('u2', $androidActive[0]->getId()); + + // elemMatch with condition that should not match any document + $windowsSessions = $database->find($col, [ + Query::elemMatch('sessions', [ + Query::equal('device.os', ['windows']), + ]), + ]); + $this->assertCount(0, $windowsSessions); + $database->deleteCollection($col); } } From 85be9a823805d37567a4849b9bce20a5b866c51e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Jan 2026 13:41:46 +0530 Subject: [PATCH 21/21] updated tests --- tests/e2e/Adapter/Scopes/SchemalessTests.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index a5b449a70..856d08263 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1813,22 +1813,22 @@ public function testSchemalessNestedObjectAttributeQueries(): void // contains on object attribute using nested structure: parent.key and [key => [key => 'value']] $matchedByNestedContains = $database->find($col, [ - Query::contains('profile', [ + Query::contains('profile', [[ 'location' => [ 'city' => 'London', ], - ]), + ]]), ]); $this->assertCount(1, $matchedByNestedContains); $this->assertEquals('u2', $matchedByNestedContains[0]->getId()); // equal on object attribute using nested structure should behave similarly $matchedByNestedEqual = $database->find($col, [ - Query::equal('profile', [ + Query::equal('profile', [[ 'location' => [ 'country' => 'US', ], - ]), + ]]), ]); $this->assertCount(2, $matchedByNestedEqual); $idsEqual = array_map(fn (Document $doc) => $doc->getId(), $matchedByNestedEqual);