Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3eea2d5
feat: add support for nested object attribute indexing and validation
ArnabChatterjee20k Jan 14, 2026
44f6190
added dot notation based object attribute support
ArnabChatterjee20k Jan 14, 2026
f461bb4
reverted base tests
ArnabChatterjee20k Jan 14, 2026
4c6bb26
added nested index creation for mongodb
ArnabChatterjee20k Jan 14, 2026
ef0edaa
feat: enhance support for nested object attributes in Postgres adapte…
ArnabChatterjee20k Jan 15, 2026
17c6359
feat: improve handling of nested object attributes and validation in …
ArnabChatterjee20k Jan 15, 2026
b2a3fb5
fix: update error message for invalid nested object index attributes
ArnabChatterjee20k Jan 15, 2026
807b8d2
fix: enhance validation for JSON keys and update method return types …
ArnabChatterjee20k Jan 15, 2026
8870d73
fix: replace strpos with str_contains for better readability in Mongo…
ArnabChatterjee20k Jan 15, 2026
dedaad4
removed redundant code
ArnabChatterjee20k Jan 15, 2026
1a424ef
added tests
ArnabChatterjee20k Jan 15, 2026
af7e92c
updated tests
ArnabChatterjee20k Jan 15, 2026
9ce30c1
fixed tests and evaluation
ArnabChatterjee20k Jan 15, 2026
c04975b
updated unit test
ArnabChatterjee20k Jan 15, 2026
69489c3
Merge remote-tracking branch 'origin/main' into nested-object-index
ArnabChatterjee20k Feb 10, 2026
1a2c1bd
linting
ArnabChatterjee20k Feb 10, 2026
798f49e
removed unused var
ArnabChatterjee20k Feb 10, 2026
535c8c5
Merge remote-tracking branch 'origin/main' into nested-object-index
ArnabChatterjee20k Feb 12, 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
14 changes: 11 additions & 3 deletions src/Database/Adapter/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,13 @@ public function createIndex(string $collection, string $id, string $type, array

foreach ($attributes as $i => $attribute) {

$attributes[$i] = $this->filter($this->getInternalKeyForAttribute($attribute));
if (isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === Database::VAR_OBJECT) {
$dottedAttributes = \explode('.', $attribute);
$expandedAttributes = array_map(fn ($attr) => $this->filter($attr), $dottedAttributes);
$attributes[$i] = implode('.', $expandedAttributes);
} else {
$attributes[$i] = $this->filter($this->getInternalKeyForAttribute($attribute));
}

$orderType = $this->getOrder($this->filter($orders[$i] ?? Database::ORDER_ASC));
$indexes['key'][$attributes[$i]] = $orderType;
Expand Down Expand Up @@ -2499,7 +2505,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() && !\str_contains($attribute, '.') && in_array($query->getMethod(), [Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])) {
$this->handleObjectFilters($query, $filter);
return $filter;
}
Expand Down Expand Up @@ -2580,7 +2586,9 @@ private function handleObjectFilters(Query $query, array &$filter): void
$flattendQuery = $this->flattenWithDotNotation(is_string($attribute) ? $attribute : '', $value);
$flattenedObjectKey = array_key_first($flattendQuery);
$queryValue = $flattendQuery[$flattenedObjectKey];
$flattenedObjectKey = $query->getAttribute() . '.' . array_key_first($flattendQuery);
$queryAttribute = $query->getAttribute();
$flattenedQueryField = array_key_first($flattendQuery);
$flattenedObjectKey = $flattenedQueryField === '' ? $queryAttribute : $queryAttribute . '.' . array_key_first($flattendQuery);
switch ($query->getMethod()) {

case Query::TYPE_CONTAINS:
Expand Down
59 changes: 47 additions & 12 deletions src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -886,15 +886,19 @@ public function createIndex(string $collection, string $id, string $type, array

foreach ($attributes as $i => $attr) {
$order = empty($orders[$i]) || Database::INDEX_FULLTEXT === $type ? '' : $orders[$i];

$attr = match ($attr) {
'$id' => '_uid',
'$createdAt' => '_createdAt',
'$updatedAt' => '_updatedAt',
default => $this->filter($attr),
};

$attributes[$i] = "\"{$attr}\" {$order}";
$isNestedPath = isset($indexAttributeTypes[$attr]) && \str_contains($attr, '.') && $indexAttributeTypes[$attr] === Database::VAR_OBJECT;
if ($isNestedPath) {
$attributes[$i] = $this->buildJsonbPath($attr, true) . ($order ? " {$order}" : '');
} else {
$attr = match ($attr) {
'$id' => '_uid',
'$createdAt' => '_createdAt',
'$updatedAt' => '_updatedAt',
default => $this->filter($attr),
};

$attributes[$i] = "\"{$attr}\" {$order}";
}
}

$sqlType = match ($type) {
Expand Down Expand Up @@ -1748,9 +1752,14 @@ protected function handleObjectQueries(Query $query, array &$binds, string $attr
protected function getSQLCondition(Query $query, array &$binds): string
{
$query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute()));
$isNestedObjectAttribute = $query->isObjectAttribute() && \str_contains($query->getAttribute(), '.');
if ($isNestedObjectAttribute) {
$attribute = $this->buildJsonbPath($query->getAttribute());
} else {
$attribute = $this->filter($query->getAttribute());
$attribute = $this->quote($attribute);
}

$attribute = $this->filter($query->getAttribute());
$attribute = $this->quote($attribute);
$alias = $this->quote(Query::DEFAULT_ALIAS);
$placeholder = ID::unique();

Expand All @@ -1760,7 +1769,7 @@ protected function getSQLCondition(Query $query, array &$binds): string
return $this->handleSpatialQueries($query, $binds, $attribute, $alias, $placeholder);
}

if ($query->isObjectAttribute()) {
if ($query->isObjectAttribute() && !$isNestedObjectAttribute) {
return $this->handleObjectQueries($query, $binds, $attribute, $alias, $placeholder);
}

Expand Down Expand Up @@ -2850,4 +2859,30 @@ public function getSupportForTTLIndexes(): bool
{
return false;
}
protected function buildJsonbPath(string $path, bool $asText = false): string
{
$parts = \explode('.', $path);

foreach ($parts as $part) {
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $part)) {
throw new DatabaseException('Invalid JSON key ' . $part);
}
}
if (\count($parts) === 1) {
$column = $this->filter($parts[0]);
return $this->quote($column);
}

$baseColumn = $this->quote($this->filter(\array_shift($parts)));
$lastKey = \array_pop($parts);

$chain = $baseColumn;
foreach ($parts as $key) {
$chain .= "->'{$key}'";
}

$result = "{$chain}->>'{$lastKey}'";

return $asText ? "(({$result})::text)" : $result;
}
}
33 changes: 27 additions & 6 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -1734,7 +1734,8 @@ public function createCollection(string $id, array $attributes = [], array $inde
$this->adapter->getSupportForIndex(),
$this->adapter->getSupportForUniqueIndex(),
$this->adapter->getSupportForFulltextIndex(),
$this->adapter->getSupportForTTLIndexes()
$this->adapter->getSupportForTTLIndexes(),
$this->adapter->getSupportForObject()
);
foreach ($indexes as $index) {
if (!$validator->isValid($index)) {
Expand Down Expand Up @@ -2900,7 +2901,8 @@ public function updateAttribute(string $collection, string $id, ?string $type =
$this->adapter->getSupportForIndex(),
$this->adapter->getSupportForUniqueIndex(),
$this->adapter->getSupportForFulltextIndex(),
$this->adapter->getSupportForTTLIndexes()
$this->adapter->getSupportForTTLIndexes(),
$this->adapter->getSupportForObject()
);

foreach ($indexes as $index) {
Expand Down Expand Up @@ -4053,14 +4055,23 @@ public function createIndex(string $collection, string $id, string $type, array
$collectionAttributes = $collection->getAttribute('attributes', []);
$indexAttributesWithTypes = [];
foreach ($attributes as $i => $attr) {
// Support nested paths on object attributes using dot notation:
// attribute.key.nestedKey -> base attribute "attribute"
$baseAttr = $attr;
if (\str_contains($attr, '.')) {
$baseAttr = \explode('.', $attr, 2)[0] ?? $attr;
}

foreach ($collectionAttributes as $collectionAttribute) {
if ($collectionAttribute->getAttribute('key') === $attr) {
$indexAttributesWithTypes[$attr] = $collectionAttribute->getAttribute('type');
if ($collectionAttribute->getAttribute('key') === $baseAttr) {

$attributeType = $collectionAttribute->getAttribute('type');
$indexAttributesWithTypes[$attr] = $attributeType;

/**
* mysql does not save length in collection when length = attributes size
*/
if ($collectionAttribute->getAttribute('type') === Database::VAR_STRING) {
if ($attributeType === self::VAR_STRING) {
if (!empty($lengths[$i]) && $lengths[$i] === $collectionAttribute->getAttribute('size') && $this->adapter->getMaxIndexLength() > 0) {
$lengths[$i] = null;
}
Expand Down Expand Up @@ -4108,7 +4119,8 @@ public function createIndex(string $collection, string $id, string $type, array
$this->adapter->getSupportForIndex(),
$this->adapter->getSupportForUniqueIndex(),
$this->adapter->getSupportForFulltextIndex(),
$this->adapter->getSupportForTTLIndexes()
$this->adapter->getSupportForTTLIndexes(),
$this->adapter->getSupportForObject()
);
if (!$validator->isValid($index)) {
throw new IndexException($validator->getDescription());
Expand Down Expand Up @@ -8642,11 +8654,20 @@ public function convertQuery(Document $collection, Query $query): Query
$attributes[] = new Document($attribute);
}

$queryAttribute = $query->getAttribute();
$isNestedQueryAttribute = $this->getAdapter()->getSupportForAttributes() && $this->getAdapter()->getSupportForObject() && \str_contains($queryAttribute, '.');

$attribute = new Document();

foreach ($attributes as $attr) {
if ($attr->getId() === $query->getAttribute()) {
$attribute = $attr;
} elseif ($isNestedQueryAttribute) {
// nested object query
$baseAttribute = \explode('.', $queryAttribute, 2)[0];
if ($baseAttribute === $attr->getId() && $attr->getAttribute('type') === Database::VAR_OBJECT) {
$query->setAttributeType(Database::VAR_OBJECT);
}
}
}

Expand Down
50 changes: 49 additions & 1 deletion src/Database/Validator/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class Index extends Validator
* @param bool $supportForKeyIndexes
* @param bool $supportForUniqueIndexes
* @param bool $supportForFulltextIndexes
* @param bool $supportForObjects
* @throws DatabaseException
*/
public function __construct(
Expand All @@ -55,6 +56,7 @@ public function __construct(
protected bool $supportForUniqueIndexes = true,
protected bool $supportForFulltextIndexes = true,
protected bool $supportForTTLIndexes = false,
protected bool $supportForObjects = false
) {
foreach ($attributes as $attribute) {
$key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id')));
Expand Down Expand Up @@ -170,6 +172,20 @@ public function isValid($value): bool
public function checkValidIndex(Document $index): bool
{
$type = $index->getAttribute('type');
if ($this->supportForObjects) {
// getting dotted attributes not present in schema
$dottedAttributes = array_filter($index->getAttribute('attributes'), fn ($attr) => !isset($this->attributes[\strtolower($attr)]) && $this->isDottedAttribute($attr));
if (\count($dottedAttributes)) {
foreach ($dottedAttributes as $attribute) {
$baseAttribute = $this->getBaseAttributeFromDottedAttribute($attribute);
if (isset($this->attributes[\strtolower($baseAttribute)]) && $this->attributes[\strtolower($baseAttribute)]->getAttribute('type') != Database::VAR_OBJECT) {
$this->message = 'Index attribute "' . $attribute . '" is only supported on object attributes';
return false;
};
}
}
}

switch ($type) {
case Database::INDEX_KEY:
if (!$this->supportForKeyIndexes) {
Expand Down Expand Up @@ -246,8 +262,19 @@ public function checkValidIndex(Document $index): bool
*/
public function checkValidAttributes(Document $index): bool
{
if (!$this->supportForAttributes) {
return true;
}
foreach ($index->getAttribute('attributes', []) as $attribute) {
if ($this->supportForAttributes && !isset($this->attributes[\strtolower($attribute)])) {
// attribute is part of the attributes
// or object indexes supported and its a dotted attribute with base present in the attributes
if (!isset($this->attributes[\strtolower($attribute)])) {
if ($this->supportForObjects) {
$baseAttribute = $this->getBaseAttributeFromDottedAttribute($attribute);
if (isset($this->attributes[\strtolower($baseAttribute)])) {
continue;
}
}
$this->message = 'Invalid index attribute "' . $attribute . '" not found';
return false;
}
Expand Down Expand Up @@ -399,6 +426,9 @@ public function checkIndexLengths(Document $index): bool
return false;
}
foreach ($attributes as $attributePosition => $attributeName) {
if ($this->supportForObjects && !isset($this->attributes[\strtolower($attributeName)])) {
$attributeName = $this->getBaseAttributeFromDottedAttribute($attributeName);
}
$attribute = $this->attributes[\strtolower($attributeName)];

switch ($attribute->getAttribute('type')) {
Expand Down Expand Up @@ -756,6 +786,14 @@ public function checkObjectIndexes(Document $index): bool
}

$attributeName = $attributes[0] ?? '';

// Object indexes are only allowed on the top-level object attribute,
// not on nested paths like "data.key.nestedKey".
if (\strpos($attributeName, '.') !== false) {
$this->message = 'Object index can only be created on a top-level object attribute';
return false;
}

$attribute = $this->attributes[\strtolower($attributeName)] ?? new Document();
$attributeType = $attribute->getAttribute('type', '');

Expand Down Expand Up @@ -812,4 +850,14 @@ public function checkTTLIndexes(Document $index): bool

return true;
}

private function isDottedAttribute(string $attribute): bool
{
return \str_contains($attribute, '.');
}

private function getBaseAttributeFromDottedAttribute(string $attribute): string
{
return $this->isDottedAttribute($attribute) ? \explode('.', $attribute, 2)[0] ?? '' : $attribute;
}
}
14 changes: 12 additions & 2 deletions src/Database/Validator/Query/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s

$originalAttribute = $attribute;
// isset check if for special symbols "." in the attribute name
// same for nested path on object
if (\str_contains($attribute, '.') && !isset($this->schema[$attribute])) {
// For relationships, just validate the top level.
// Utopia will validate each nested level during the recursive calls.
Expand Down Expand Up @@ -126,6 +127,8 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s

$attributeType = $attributeSchema['type'];

$isDottedOnObject = \str_contains($originalAttribute, '.') && $attributeType === Database::VAR_OBJECT;

// If the query method is spatial-only, the attribute must be a spatial type
$query = new Query($method);
if ($query->isSpatialQuery() && !in_array($attributeType, Database::SPATIAL_TYPES, true)) {
Expand Down Expand Up @@ -178,13 +181,20 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s
break;

case Database::VAR_OBJECT:
if (\in_array($method, [Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS], true)
// For dotted attributes on objects, validate as string (path queries)
if ($isDottedOnObject) {
$validator = new Text(0, 0);
continue 2;
}

// object containment queries on the base object attribute
elseif (\in_array($method, [Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS], true)
&& !$this->isValidObjectQueryValues($value)) {
$this->message = 'Invalid object query structure for attribute "' . $attribute . '"';
return false;
}
continue 2;

continue 2;
case Database::VAR_POINT:
case Database::VAR_LINESTRING:
case Database::VAR_POLYGON:
Expand Down
Loading