diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index fdcdd80c2..12a19b2c9 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -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; @@ -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; } @@ -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: diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 050180a0a..0d9f88db3 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -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) { @@ -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(); @@ -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); } @@ -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; + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index a2bc2da55..fd1e72f5e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -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)) { @@ -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) { @@ -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; } @@ -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()); @@ -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); + } } } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index e2fc70a0b..6e84706b7 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -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( @@ -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'))); @@ -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) { @@ -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; } @@ -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')) { @@ -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', ''); @@ -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; + } } diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 3ac36bc71..a741aba3e 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -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. @@ -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)) { @@ -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: diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index a37cfb451..868283d3f 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -5,6 +5,7 @@ use Exception; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; @@ -1117,4 +1118,758 @@ public function testMetadataWithVector(): void // Cleanup $database->deleteCollection($collectionId); } + + public function testNestedObjectAttributeIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->markTestSkipped('Adapter does not support attributes (schemaful required for nested object attribute indexes)'); + } + + if (!$database->getAdapter()->getSupportForObjectIndexes()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Base attributes + $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); + + // 1) KEY index on a nested object path (dot notation) + + + // 2) UNIQUE index on a nested object path should enforce uniqueness on insert + $created = $database->createIndex($collectionId, 'idx_profile_email_unique', Database::INDEX_UNIQUE, ['profile.user.email']); + $this->assertTrue($created); + + $database->createDocument($collectionId, new Document([ + '$id' => 'nest1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'a@example.com', + 'info' => [ + 'country' => 'IN' + ] + ] + ] + ])); + + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'nest2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'a@example.com', // duplicate + 'info' => [ + 'country' => 'US' + ] + ] + ] + ])); + $this->fail('Expected Duplicate exception for UNIQUE index on nested object path'); + } catch (Exception $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + + // 3) INDEX_OBJECT must NOT be allowed on nested paths + try { + $database->createIndex($collectionId, 'idx_profile_nested_object', Database::INDEX_OBJECT, ['profile.user.email']); + } catch (Exception $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + // 4) Nested path indexes must only be allowed when base attribute is VAR_OBJECT + try { + $database->createIndex($collectionId, 'idx_name_nested', Database::INDEX_KEY, ['name.first']); + $this->fail('Expected Type exception for nested index on non-object base attribute'); + } catch (Exception $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + $database->deleteCollection($collectionId); + } + + public function testQueryNestedAttribute(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->markTestSkipped('Adapter does not support attributes (schemaful required for nested object attribute indexes)'); + } + + if (!$database->getAdapter()->getSupportForObjectIndexes()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Base attributes + $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); + + // Create index on nested email path + $created = $database->createIndex($collectionId, 'idx_profile_email', Database::INDEX_KEY, ['profile.user.email']); + $this->assertTrue($created); + + // Seed documents with different nested values + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'alice@example.com', + 'info' => [ + 'country' => 'IN', + 'city' => 'BLR' + ] + ] + ], + 'name' => 'Alice' + ]), + new Document([ + '$id' => 'd2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'bob@example.com', + 'info' => [ + 'country' => 'US', + 'city' => 'NYC' + ] + ] + ], + 'name' => 'Bob' + ]), + new Document([ + '$id' => 'd3', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'carol@test.org', + 'info' => [ + 'country' => 'CA', + 'city' => 'TOR' + ] + ] + ], + 'name' => 'Carol' + ]) + ]); + + // Equal on nested email + $results = $database->find($collectionId, [ + Query::equal('profile.user.email', ['bob@example.com']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('d2', $results[0]->getId()); + + // Starts with on nested email + $results = $database->find($collectionId, [ + Query::startsWith('profile.user.email', 'alice@') + ]); + $this->assertCount(1, $results); + $this->assertEquals('d1', $results[0]->getId()); + + // Ends with on nested email + $results = $database->find($collectionId, [ + Query::endsWith('profile.user.email', 'test.org') + ]); + $this->assertCount(1, $results); + $this->assertEquals('d3', $results[0]->getId()); + + // Contains on nested country (as text) + $results = $database->find($collectionId, [ + Query::contains('profile.user.info.country', ['US']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('d2', $results[0]->getId()); + + // AND: country IN + email suffix + $results = $database->find($collectionId, [ + Query::and([ + Query::equal('profile.user.info.country', ['IN']), + Query::endsWith('profile.user.email', 'example.com'), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('d1', $results[0]->getId()); + + // OR: match either country = CA or email starts with bob@ + $results = $database->find($collectionId, [ + Query::or([ + Query::equal('profile.user.info.country', ['CA']), + Query::startsWith('profile.user.email', 'bob@'), + ]) + ]); + $this->assertCount(2, $results); + $ids = \array_map(fn (Document $d) => $d->getId(), $results); + \sort($ids); + $this->assertEquals(['d2', 'd3'], $ids); + + // NOT: exclude emails ending with example.com + $results = $database->find($collectionId, [ + Query::notEndsWith('profile.user.email', 'example.com') + ]); + $this->assertCount(1, $results); + $this->assertEquals('d3', $results[0]->getId()); + + $database->deleteCollection($collectionId); + } + + public function testNestedObjectAttributeEdgeCases(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Base attributes + $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); + $this->createAttribute($database, $collectionId, 'age', Database::VAR_INTEGER, 0, false); + + // Edge Case 1: Deep nesting (5 levels deep) + $created = $database->createIndex($collectionId, 'idx_deep_nest', Database::INDEX_KEY, ['profile.level1.level2.level3.level4.value']); + $this->assertTrue($created); + + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'deep1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'value' => 'deep_value_1' + ] + ] + ] + ] + ] + ]), + new Document([ + '$id' => 'deep2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'value' => 'deep_value_2' + ] + ] + ] + ] + ] + ]) + ]); + + $results = $database->find($collectionId, [ + Query::equal('profile.level1.level2.level3.level4.value', ['deep_value_1']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('deep1', $results[0]->getId()); + + // Edge Case 2: Multiple nested indexes on same base attribute + $created = $database->createIndex($collectionId, 'idx_email', Database::INDEX_KEY, ['profile.user.email']); + $this->assertTrue($created); + $created = $database->createIndex($collectionId, 'idx_country', Database::INDEX_KEY, ['profile.user.info.country']); + $this->assertTrue($created); + $created = $database->createIndex($collectionId, 'idx_city', Database::INDEX_KEY, ['profile.user.info.city']); + $this->assertTrue($created); + + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'multi1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'multi1@test.com', + 'info' => [ + 'country' => 'US', + 'city' => 'NYC' + ] + ] + ] + ]), + new Document([ + '$id' => 'multi2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'multi2@test.com', + 'info' => [ + 'country' => 'CA', + 'city' => 'TOR' + ] + ] + ] + ]) + ]); + + // Query using first nested index + $results = $database->find($collectionId, [ + Query::equal('profile.user.email', ['multi1@test.com']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('multi1', $results[0]->getId()); + + // Query using second nested index + $results = $database->find($collectionId, [ + Query::equal('profile.user.info.country', ['US']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('multi1', $results[0]->getId()); + + // Query using third nested index + $results = $database->find($collectionId, [ + Query::equal('profile.user.info.city', ['TOR']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('multi2', $results[0]->getId()); + + // Edge Case 3: Null/missing nested values + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'null1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => null, // null value + 'info' => [ + 'country' => 'US' + ] + ] + ] + ]), + new Document([ + '$id' => 'null2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + // missing email key entirely + 'info' => [ + 'country' => 'CA' + ] + ] + ] + ]), + new Document([ + '$id' => 'null3', + '$permissions' => [Permission::read(Role::any())], + 'profile' => null // entire profile is null + ]) + ]); + + // Query for null email should not match null1 (null values typically don't match equal queries) + $results = $database->find($collectionId, [ + Query::equal('profile.user.email', ['non-existent@test.com']) + ]); + // Should not include null1, null2, or null3 + foreach ($results as $doc) { + $this->assertNotEquals('null1', $doc->getId()); + $this->assertNotEquals('null2', $doc->getId()); + $this->assertNotEquals('null3', $doc->getId()); + } + + // Edge Case 4: Mixed queries (nested + regular attributes) + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'mixed1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Alice', + 'age' => 25, + 'profile' => [ + 'user' => [ + 'email' => 'alice.mixed@test.com', + 'info' => [ + 'country' => 'US' + ] + ] + ] + ]), + new Document([ + '$id' => 'mixed2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Bob', + 'age' => 30, + 'profile' => [ + 'user' => [ + 'email' => 'bob.mixed@test.com', + 'info' => [ + 'country' => 'CA' + ] + ] + ] + ]) + ]); + + // Create indexes on regular attributes + $database->createIndex($collectionId, 'idx_name', Database::INDEX_KEY, ['name']); + $database->createIndex($collectionId, 'idx_age', Database::INDEX_KEY, ['age']); + + // Combined query: nested path + regular attribute + $results = $database->find($collectionId, [ + Query::equal('profile.user.info.country', ['US']), + Query::equal('name', ['Alice']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('mixed1', $results[0]->getId()); + + // Combined query: nested path + regular attribute with AND + $results = $database->find($collectionId, [ + Query::and([ + Query::equal('profile.user.email', ['bob.mixed@test.com']), + Query::equal('age', [30]) + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('mixed2', $results[0]->getId()); + + // Edge Case 5: Update operations affecting nested indexed paths + $updated = $database->updateDocument($collectionId, 'mixed1', new Document([ + '$id' => 'mixed1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Alice Updated', + 'age' => 26, + 'profile' => [ + 'user' => [ + 'email' => 'alice.updated@test.com', // changed email + 'info' => [ + 'country' => 'CA' // changed country + ] + ] + ] + ])); + + // Query with old email should not match + $results = $database->find($collectionId, [ + Query::equal('profile.user.email', ['alice.mixed@test.com']) + ]); + foreach ($results as $doc) { + $this->assertNotEquals('mixed1', $doc->getId()); + } + + // Query with new email should match + $results = $database->find($collectionId, [ + Query::equal('profile.user.email', ['alice.updated@test.com']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('mixed1', $results[0]->getId()); + + // Query with new country should match + $results = $database->find($collectionId, [ + Query::equal('profile.user.info.country', ['CA']) + ]); + $this->assertGreaterThanOrEqual(2, count($results)); // Should include mixed1 and mixed2 + + // Edge Case 6: Query on non-indexed nested path + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'noindex1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'noindex1@test.com', + 'info' => [ + 'country' => 'US', + 'phone' => '+1234567890' // no index on this path + ] + ] + ] + ]), + new Document([ + '$id' => 'noindex2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'noindex2@test.com', + 'info' => [ + 'country' => 'CA', + 'phone' => '+9876543210' // no index on this path + ] + ] + ] + ]) + ]); + + // Query on non-indexed nested path should still work + $results = $database->find($collectionId, [ + Query::equal('profile.user.info.phone', ['+1234567890']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('noindex1', $results[0]->getId()); + + // Edge Case 7: Complex query combinations with nested paths + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'complex1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'complex1@test.com', + 'info' => [ + 'country' => 'US', + 'city' => 'NYC', + 'zip' => '10001' + ] + ] + ] + ]), + new Document([ + '$id' => 'complex2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'complex2@test.com', + 'info' => [ + 'country' => 'US', + 'city' => 'LAX', + 'zip' => '90001' + ] + ] + ] + ]), + new Document([ + '$id' => 'complex3', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'complex3@test.com', + 'info' => [ + 'country' => 'CA', + 'city' => 'TOR', + 'zip' => 'M5H1A1' + ] + ] + ] + ]) + ]); + + // Complex AND with multiple nested paths + $results = $database->find($collectionId, [ + Query::and([ + Query::equal('profile.user.info.country', ['US']), + Query::equal('profile.user.info.city', ['NYC']) + ]) + ]); + + $this->assertCount(2, $results); + + // Complex OR with nested paths + $results = $database->find($collectionId, [ + Query::or([ + Query::equal('profile.user.info.city', ['NYC']), + Query::equal('profile.user.info.city', ['TOR']) + ]) + ]); + $this->assertCount(4, $results); + $ids = \array_map(fn (Document $d) => $d->getId(), $results); + \sort($ids); + $this->assertEquals(['complex1', 'complex3','multi1','multi2'], $ids); + + // Complex nested AND/OR combination + $results = $database->find($collectionId, [ + Query::and([ + Query::equal('profile.user.info.country', ['US']), + Query::or([ + Query::equal('profile.user.info.city', ['NYC']), + Query::equal('profile.user.info.city', ['LAX']) + ]) + ]) + ]); + $this->assertCount(3, $results); + $ids = \array_map(fn (Document $d) => $d->getId(), $results); + \sort($ids); + $this->assertEquals(['complex1', 'complex2', 'multi1'], $ids); + + // Edge Case 8: Order/limit/offset with nested queries + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'order1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'a@order.com', + 'info' => [ + 'country' => 'US' + ] + ] + ] + ]), + new Document([ + '$id' => 'order2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'b@order.com', + 'info' => [ + 'country' => 'US' + ] + ] + ] + ]), + new Document([ + '$id' => 'order3', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'c@order.com', + 'info' => [ + 'country' => 'US' + ] + ] + ] + ]) + ]); + + // Limit with nested query + $results = $database->find($collectionId, [ + Query::equal('profile.user.info.country', ['US']), + Query::limit(2) + ]); + $this->assertCount(2, $results); + + // Offset with nested query + $results = $database->find($collectionId, [ + Query::equal('profile.user.info.country', ['US']), + Query::offset(1), + Query::limit(1) + ]); + $this->assertCount(1, $results); + + // Edge Case 9: Empty strings in nested paths + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'empty1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => '', // empty string + 'info' => [ + 'country' => 'US' + ] + ] + ] + ]) + ]); + + // Query for empty string + $results = $database->find($collectionId, [ + Query::equal('profile.user.email', ['']) + ]); + $this->assertGreaterThanOrEqual(1, count($results)); + $found = false; + foreach ($results as $doc) { + if ($doc->getId() === 'empty1') { + $found = true; + break; + } + } + $this->assertTrue($found, 'Should find document with empty email'); + + // Edge Case 10: Index deletion and re-creation + $database->deleteIndex($collectionId, 'idx_email'); + + // Query should still work without index (just slower) + $results = $database->find($collectionId, [ + Query::equal('profile.user.email', ['alice.updated@test.com']) + ]); + $this->assertGreaterThanOrEqual(1, count($results)); + + // Re-create index + $created = $database->createIndex($collectionId, 'idx_email_recreated', Database::INDEX_KEY, ['profile.user.email']); + $this->assertTrue($created); + + // Query should still work with recreated index + $results = $database->find($collectionId, [ + Query::equal('profile.user.email', ['alice.updated@test.com']) + ]); + $this->assertGreaterThanOrEqual(1, count($results)); + + // Edge Case 11: UNIQUE index with updates (duplicate prevention) + if ($database->getAdapter()->getSupportForIdenticalIndexes()) { + $created = $database->createIndex($collectionId, 'idx_unique_email', Database::INDEX_UNIQUE, ['profile.user.email']); + $this->assertTrue($created); + + // Try to create duplicate + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'duplicate1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'alice.updated@test.com', // duplicate + 'info' => [ + 'country' => 'XX' + ] + ] + ] + ])); + $this->fail('Expected Duplicate exception for UNIQUE index'); + } catch (Exception $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + } + + // Edge Case 12: Query with startsWith/endsWith/contains on nested paths + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'text1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'text1@example.org', + 'info' => [ + 'country' => 'United States', + 'city' => 'New York City' + ] + ] + ] + ]), + new Document([ + '$id' => 'text2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'text2@test.com', + 'info' => [ + 'country' => 'United Kingdom', + 'city' => 'London' + ] + ] + ] + ]) + ]); + + // startsWith on nested path + $results = $database->find($collectionId, [ + Query::startsWith('profile.user.email', 'text1@') + ]); + $this->assertCount(1, $results); + $this->assertEquals('text1', $results[0]->getId()); + + // contains on nested path + $results = $database->find($collectionId, [ + Query::contains('profile.user.info.country', ['United']) + ]); + $this->assertGreaterThanOrEqual(2, count($results)); + + $database->deleteCollection($collectionId); + } } diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 856d08263..c644ae387 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1865,4 +1865,95 @@ public function testSchemalessNestedObjectAttributeQueries(): void $database->deleteCollection($col); } + + public function testSchemalessMongoDotNotationIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Only meaningful for schemaless adapters + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_mongo_dot_idx'); + $database->createCollection($col); + + // Define top-level object attribute (metadata only; schemaless adapter won't enforce) + $database->createAttribute($col, 'profile', Database::VAR_OBJECT, 0, false); + + // Seed documents + $database->createDocuments($col, [ + new Document([ + '$id' => 'u1', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'alice@example.com', + 'id' => 'alice' + ] + ] + ]), + new Document([ + '$id' => 'u2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'bob@example.com', + 'id' => 'bob' + ] + ] + ]), + ]); + + // Create KEY index on nested path + $this->assertTrue( + $database->createIndex( + $col, + 'idx_profile_user_email_key', + Database::INDEX_KEY, + ['profile.user.email'], + [0], + [Database::ORDER_ASC] + ) + ); + + // Create UNIQUE index on nested path and verify enforcement + $this->assertTrue( + $database->createIndex( + $col, + 'idx_profile_user_id_unique', + Database::INDEX_UNIQUE, + ['profile.user.id'], + [0], + [Database::ORDER_ASC] + ) + ); + + try { + $database->createDocument($col, new Document([ + '$id' => 'u3', + '$permissions' => [Permission::read(Role::any())], + 'profile' => [ + 'user' => [ + 'email' => 'eve@example.com', + 'id' => 'alice' // duplicate unique nested id + ] + ] + ])); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + + // Validate dot-notation querying works (and is the shape that can use indexes) + $results = $database->find($col, [ + Query::equal('profile.user.email', ['bob@example.com']) + ]); + $this->assertCount(1, $results); + $this->assertEquals('u2', $results[0]->getId()); + + $database->deleteCollection($col); + } } diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 5dfe80e4e..a6992a3d3 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -325,6 +325,119 @@ public function testObjectIndexValidation(): void $this->assertEquals('Object indexes are not supported', $validatorNoSupport->getDescription()); } + /** + * @throws Exception + */ + public function testNestedObjectPathIndexValidation(): void + { + $collection = new Document([ + '$id' => ID::custom('test'), + 'name' => 'test', + 'attributes' => [ + new Document([ + '$id' => ID::custom('data'), + 'type' => Database::VAR_OBJECT, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('metadata'), + 'type' => Database::VAR_OBJECT, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 255, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]) + ], + 'indexes' => [] + ]); + + // Validator with supportForObjectIndexes enabled + $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, true, true, true, true, supportForObjects:true); + + // InValid: INDEX_OBJECT on nested path (dot notation) + $validNestedObjectIndex = new Document([ + '$id' => ID::custom('idx_nested_object'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['data.key.nestedKey'], + 'lengths' => [], + 'orders' => [], + ]); + + $this->assertFalse($validator->isValid($validNestedObjectIndex)); + + // Valid: INDEX_UNIQUE on nested path (for Postgres/Mongo) + $validNestedUniqueIndex = new Document([ + '$id' => ID::custom('idx_nested_unique'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['data.key.nestedKey'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validNestedUniqueIndex)); + + // Valid: INDEX_KEY on nested path + $validNestedKeyIndex = new Document([ + '$id' => ID::custom('idx_nested_key'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['metadata.user.id'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validNestedKeyIndex)); + + // Invalid: Nested path on non-object attribute + $invalidNestedPath = new Document([ + '$id' => ID::custom('idx_invalid_nested'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['name.key'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidNestedPath)); + $this->assertStringContainsString('Index attribute "name.key" is only supported on object attributes', $validator->getDescription()); + + // Invalid: Nested path with non-existent base attribute + $invalidBaseAttribute = new Document([ + '$id' => ID::custom('idx_invalid_base'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['nonexistent.key'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidBaseAttribute)); + $this->assertStringContainsString('Invalid index attribute', $validator->getDescription()); + + // Valid: Multiple nested paths in same index + $validMultiNested = new Document([ + '$id' => ID::custom('idx_multi_nested'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['data.key1', 'data.key2'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validMultiNested)); + } + /** * @throws Exception */