Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
94c7012
* added object attribute support in mongodb
ArnabChatterjee20k Jan 5, 2026
3b5360b
added elemMatch
ArnabChatterjee20k Jan 5, 2026
7a26698
refactor: improve elemMatch handling and clean up code style
ArnabChatterjee20k Jan 6, 2026
c2e1670
refactor: streamline elemMatch validation and update schemaless tests
ArnabChatterjee20k Jan 6, 2026
e4f00f3
refactor: update ObjectAttributeTests to check for attribute support …
ArnabChatterjee20k Jan 6, 2026
b397af6
feat: add support for object (JSON) indexes across database adapters
ArnabChatterjee20k Jan 7, 2026
3f85b56
updated tests for elemMatch
ArnabChatterjee20k Jan 7, 2026
c579ec5
refactor: enhance object query validation and add corresponding tests
ArnabChatterjee20k Jan 7, 2026
3c04a4b
linting
ArnabChatterjee20k Jan 7, 2026
1214e56
refactor: improve validation logic for object query values to handle …
ArnabChatterjee20k Jan 7, 2026
9534938
Merge remote-tracking branch 'upstream/3.x' into mongo-object
ArnabChatterjee20k Jan 7, 2026
d511a60
refactor: enhance validation for object attribute query values to dis…
ArnabChatterjee20k Jan 7, 2026
473f3c4
Update src/Database/Adapter/Mongo.php
ArnabChatterjee20k Jan 8, 2026
6d6b975
Merge remote-tracking branch 'upstream/3.x' into mongo-object
ArnabChatterjee20k Jan 8, 2026
d52f10e
refactor: rename getSupportForIndexObject to getSupportForObjectIndex…
ArnabChatterjee20k Jan 8, 2026
93ea96f
linting
ArnabChatterjee20k Jan 8, 2026
6cfac6f
linting
ArnabChatterjee20k Jan 8, 2026
838068b
updated index validator
ArnabChatterjee20k Jan 8, 2026
2aadc20
updated database index validator
ArnabChatterjee20k Jan 8, 2026
1556e6e
test: add schemaless nested object attribute queries
ArnabChatterjee20k Jan 9, 2026
887775d
fix: adjust condition for schemaless object attribute support in tests
ArnabChatterjee20k Jan 9, 2026
6de1a2d
test: enhance schemaless nested object attribute queries with additio…
ArnabChatterjee20k Jan 9, 2026
85be9a8
updated tests
ArnabChatterjee20k Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 117 additions & 6 deletions src/Database/Adapter/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +43,8 @@ class Mongo extends Adapter
'$regex',
'$not',
'$nor',
'$elemMatch',
'$exists'
];

protected Client $client;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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());
Expand All @@ -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;
}
Expand All @@ -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));
Comment thread
ArnabChatterjee20k marked this conversation as resolved.
Outdated
}

if (is_array($value)) {
return array_map(
fn ($v) => $this->convertStdClassToArray($v),
$value
);
}

return $value;
}

/**
* Returns the document after casting to
* @param Document $collection
Expand Down Expand Up @@ -1318,6 +1351,10 @@ public function castingBefore(Document $collection, Document $document): Documen
$node = new UTCDateTime(new \DateTime($node));
}
break;
case Database::VAR_OBJECT:
$node = json_decode($node);
$node = $this->convertStdClassToArray($node);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it redundant? Since we already have an array from json_decode

break;
default:
break;
}
Expand Down Expand Up @@ -1591,7 +1628,6 @@ public function upsertDocuments(Document $collection, string $attribute, array $
$operations,
options: $options
);

} catch (MongoException $e) {
throw $this->processException($e);
}
Expand Down Expand Up @@ -1976,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
Expand All @@ -1998,7 +2034,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 {
Expand Down Expand Up @@ -2334,6 +2369,15 @@ 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) {
$filters[$separator][] = [
$query->getAttribute() => [
'$elemMatch' => $this->buildFilters($query->getValues(), $separator)
]
];
continue;
}

$operator = $this->getQueryOperator($query->getMethod());

$filters[$separator][] = $this->buildFilters($query->getValues(), $operator);
Expand Down Expand Up @@ -2382,6 +2426,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;
Expand Down Expand Up @@ -2441,6 +2489,68 @@ 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
*
Expand Down Expand Up @@ -2472,6 +2582,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),
};
}
Expand Down Expand Up @@ -2792,7 +2903,7 @@ public function getSupportForBatchCreateAttributes(): bool

public function getSupportForObject(): bool
{
return false;
return true;
}

/**
Expand Down
50 changes: 47 additions & 3 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
return;
}
// can be non string in case of mongodb as it stores the value as object
if (!is_string($value)) {
return $value;
}
Expand Down Expand Up @@ -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<mixed> $values
* @return bool
*/
private function isCompatibleObjectValue(array $values): bool
Comment thread
abnegate marked this conversation as resolved.
{
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
{
/**
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 15 additions & 1 deletion src/Database/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -109,6 +109,7 @@ class Query
self::TYPE_CURSOR_BEFORE,
self::TYPE_AND,
self::TYPE_OR,
self::TYPE_ELEM_MATCH,
];

public const VECTOR_TYPES = [
Expand All @@ -120,12 +121,14 @@ class Query
protected const LOGICAL_TYPES = [
self::TYPE_AND,
self::TYPE_OR,
self::TYPE_ELEM_MATCH,
];

protected string $method = '';
protected string $attribute = '';
protected string $attributeType = '';
protected bool $onArray = false;
protected bool $isObjectAttribute = false;

/**
* @var array<mixed>
Expand Down Expand Up @@ -291,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,
Expand Down Expand Up @@ -1178,4 +1182,14 @@ public static function vectorEuclidean(string $attribute, array $vector): self
{
return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]);
}

/**
* @param string $attribute
* @param array<Query> $queries
* @return Query
*/
public static function elemMatch(string $attribute, array $queries): self
{
return new self(self::TYPE_ELEM_MATCH, $attribute, $queries);
}
}
1 change: 1 addition & 0 deletions src/Database/Validator/Queries.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading