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
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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".
Comment thread
abnegate marked this conversation as resolved.
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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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