Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -919,7 +919,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 @@ -2427,7 +2433,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 @@ -2508,7 +2514,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 @@ -883,15 +883,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 @@ -1745,9 +1749,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 @@ -1757,7 +1766,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 @@ -2829,4 +2838,30 @@ protected function getSQLTable(string $name): string

return "{$this->quote($this->getDatabase())}.{$this->quote($table)}";
}
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;
}
}
27 changes: 24 additions & 3 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,7 @@ public function createCollection(string $id, array $attributes = [], array $inde
$this->adapter->getSupportForIndex(),
$this->adapter->getSupportForUniqueIndex(),
$this->adapter->getSupportForFulltextIndex(),
$this->adapter->getSupportForObject()
);
foreach ($indexes as $index) {
if (!$validator->isValid($index)) {
Expand Down Expand Up @@ -2798,6 +2799,7 @@ public function updateAttribute(string $collection, string $id, ?string $type =
$this->adapter->getSupportForIndex(),
$this->adapter->getSupportForUniqueIndex(),
$this->adapter->getSupportForFulltextIndex(),
$this->adapter->getSupportForObject()
);

foreach ($indexes as $index) {
Expand Down Expand Up @@ -3639,14 +3641,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 @@ -3693,6 +3704,7 @@ public function createIndex(string $collection, string $id, string $type, array
$this->adapter->getSupportForIndex(),
$this->adapter->getSupportForUniqueIndex(),
$this->adapter->getSupportForFulltextIndex(),
$this->adapter->getSupportForObject()
);
if (!$validator->isValid($index)) {
throw new IndexException($validator->getDescription());
Expand Down Expand Up @@ -8135,11 +8147,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 @@ -54,6 +55,7 @@ public function __construct(
protected bool $supportForKeyIndexes = true,
protected bool $supportForUniqueIndexes = true,
protected bool $supportForFulltextIndexes = true,
protected bool $supportForObjects = false
) {
foreach ($attributes as $attribute) {
$key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id')));
Expand Down Expand Up @@ -166,6 +168,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 @@ -235,8 +251,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 @@ -374,6 +401,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 @@ -719,6 +749,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 All @@ -729,4 +767,14 @@ public function checkObjectIndexes(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 @@ -169,13 +172,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