diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 6d2aac8ef..d478e4a8d 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -9,6 +9,7 @@ use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Character as CharacterException; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Operator as OperatorException; @@ -779,6 +780,12 @@ public function createIndex(string $collection, string $id, string $type, array ->prepare($sql) ->execute(); } catch (PDOException $e) { + // Existing rows violate the new unique index. Classified here because + // processException() can't parse the key from a localized message. + if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) { + throw new UniqueException('Unique index violation', $e->getCode(), $e); + } + throw $this->processException($e); } } @@ -1893,6 +1900,11 @@ protected function processException(PDOException $e): \Exception return new DuplicateException('Index already exists', $e->getCode(), $e); } + // Index key too long + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1071) { + return new IndexException('Index key length exceeds the maximum', $e->getCode(), $e); + } + // Duplicate row if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) { $key = $this->getViolatedKey($e->getMessage()); diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 5e126a717..c1a374dec 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -1005,10 +1005,8 @@ public function createIndex(string $collection, string $id, string $type, array $hashTable = []; if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) { // MariaDB rejects CREATE UNIQUE INDEX with errno 1062 when existing - // rows contain duplicates; Database::createIndex catches the resulting - // DuplicateException and treats it as an "orphan index" (the metadata - // is registered but the physical index is absent). Mirror that contract: - // throw DuplicateException so callers see identical end-state behavior. + // rows contain duplicates, which surfaces as UniqueException. Mirror + // that contract so Database::createIndex fails the same way. // Build the hash table while we scan so we can reuse it for fast // probes after the index lands — no second pass over the rows. foreach ($this->data[$key]['documents'] as $docKey => $row) { @@ -1026,7 +1024,8 @@ public function createIndex(string $collection, string $id, string $type, array } $hash = \serialize($signature); if (isset($hashTable[$hash])) { - throw new DuplicateException('Cannot create unique index: existing rows already contain duplicate values'); + // Under shared tables the duplicates may be another tenant's rows + throw new UniqueException('Unique index violation'); } $hashTable[$hash] = $docKey; } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 760e9e79c..fdc09ed68 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -616,7 +616,7 @@ public function createCollection(string $name, array $attributes = [], array $in $partialFilter = []; foreach ($attributes as $attr) { // Find the matching attribute in collectionAttributes to get its type - $attrType = 'string'; // Default fallback + $attrType = $this->getMongoTypeCode(null); foreach ($collectionAttributes as $collectionAttr) { if ($collectionAttr->getId() === $attr) { $attrType = $this->getMongoTypeCode($collectionAttr->getAttribute('type')); @@ -1027,6 +1027,9 @@ public function createIndex(string $collection, string $id, string $type, array $indexes['key']['_tenant'] = $this->getOrder(Database::ORDER_ASC); } + // Types are keyed by attribute name, which the loop below replaces with the internal key + $bsonTypes = \array_map(fn (string $attribute) => $this->getMongoTypeCode($indexAttributeTypes[$attribute] ?? null), $attributes); + foreach ($attributes as $i => $attribute) { if (isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === Database::VAR_OBJECT) { @@ -1088,9 +1091,7 @@ public function createIndex(string $collection, string $id, string $type, array if (in_array($type, [Database::INDEX_UNIQUE, Database::INDEX_KEY])) { $partialFilter = []; foreach ($attributes as $i => $attr) { - $attrType = $indexAttributeTypes[$i] ?? Database::VAR_STRING; // Default to string if type not provided - $attrType = $this->getMongoTypeCode($attrType); - $partialFilter[$attr] = ['$exists' => true, '$type' => $attrType]; + $partialFilter[$attr] = ['$exists' => true, '$type' => $bsonTypes[$i]]; } if (!empty($partialFilter)) { $indexes['partialFilterExpression'] = $partialFilter; @@ -1146,6 +1147,11 @@ public function createIndex(string $collection, string $id, string $type, array return $result; } catch (\Exception $e) { + // Existing documents violate the new unique index, whatever index the message names + if ($e->getCode() === 11000 || $e->getCode() === 11001) { + throw new UniqueException('Unique index violation', $e->getCode(), $e); + } + throw $this->processException($e); } } @@ -2670,10 +2676,15 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 /** * Converts Appwrite database type to MongoDB BSON type code. * - * @param string $appwriteType - * @return string + * Numbers use the 'number' alias: an integer is stored as int32 or int64 + * depending on its value, and a float attribute can hold an integer. + * An unknown type (schemaless, internal attributes) matches every stored + * value type except null. + * + * @param string|null $appwriteType + * @return string|array */ - private function getMongoTypeCode(string $appwriteType): string + private function getMongoTypeCode(?string $appwriteType): string|array { return match ($appwriteType) { Database::VAR_STRING => 'string', @@ -2681,13 +2692,14 @@ private function getMongoTypeCode(string $appwriteType): string Database::VAR_TEXT => 'string', Database::VAR_MEDIUMTEXT => 'string', Database::VAR_LONGTEXT => 'string', - Database::VAR_INTEGER => 'int', - Database::VAR_BIGINT => 'long', - Database::VAR_FLOAT => 'double', + Database::VAR_INTEGER => 'number', + Database::VAR_BIGINT => 'number', + Database::VAR_FLOAT => 'number', Database::VAR_BOOLEAN => 'bool', Database::VAR_DATETIME => 'date', Database::VAR_ID => 'string', Database::VAR_UUID7 => 'string', + null => ['string', 'number', 'bool', 'date', 'object'], default => 'string' }; } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 3004e9780..2a84e8522 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -925,6 +925,12 @@ public function createIndex(string $collection, string $id, string $type, array try { return $this->getPDO()->prepare($sql)->execute(); } catch (PDOException $e) { + // Existing rows violate the new unique index. Classified here because an + // expression key (nested object path) has no columns for processException() to parse. + if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + throw new UniqueException('Unique index violation', $e->getCode(), $e); + } + throw $this->processException($e); } } @@ -2198,6 +2204,11 @@ protected function processException(PDOException $e): \Exception return new LimitException('Datetime field overflow', $e->getCode(), $e); } + // Index row too large + if ($e->getCode() === '54000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7 && \str_contains($e->getMessage(), 'index row')) { + return new LimitException('Index row size exceeds the maximum', $e->getCode(), $e); + } + // Unknown table if ($e->getCode() === '42P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { return new NotFoundException('Collection not found', $e->getCode(), $e); diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 81f335063..5d61cf304 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -2728,7 +2728,7 @@ public function createIndex(string $collection, string $id, string $type, array } $hash = \serialize($signature); if (isset($seen[$hash])) { - throw new DuplicateException('Cannot create unique index: existing rows already contain duplicate values'); + throw new UniqueException('Cannot create unique index: existing rows already contain duplicate values'); } $seen[$hash] = true; } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 3880aec16..8e1e35829 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -765,9 +765,13 @@ public function createIndex(string $collection, string $id, string $type, array $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); - return $this->getPDO() - ->prepare($sql) - ->execute(); + try { + return $this->getPDO() + ->prepare($sql) + ->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } } /** diff --git a/src/Database/Database.php b/src/Database/Database.php index 6c2c3f488..2b7ac0467 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -22,6 +22,7 @@ use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Type as TypeException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -4830,6 +4831,9 @@ public function createIndex(string $collection, string $id, string $type, array if (!$created) { throw new DatabaseException('Failed to create index'); } + } catch (UniqueException $e) { + // Existing rows violate the unique constraint, so no index was built. + throw $e; } catch (DuplicateException) { // Metadata check (lines above) already verified index is absent // from metadata. A DuplicateException from the adapter means the diff --git a/tests/e2e/Adapter/MemoryTest.php b/tests/e2e/Adapter/MemoryTest.php index fcb36e1f6..ec0140ce8 100644 --- a/tests/e2e/Adapter/MemoryTest.php +++ b/tests/e2e/Adapter/MemoryTest.php @@ -10,6 +10,7 @@ use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; @@ -282,7 +283,7 @@ public function testArrayAttributeRoundTrip(): void /** * Regression: CREATE UNIQUE INDEX on a collection that already contains - * duplicate values must surface DuplicateException at the adapter layer + * duplicate values must surface UniqueException at the adapter layer * (matches MariaDB errno 1062). */ public function testCreateUniqueIndexRejectsExistingDuplicates(): void @@ -299,7 +300,7 @@ public function testCreateUniqueIndexRejectsExistingDuplicates(): void new Document(['$id' => 'b', 'addr' => 'dup@example.com', '$permissions' => []]) ); - $this->expectException(DuplicateException::class); + $this->expectException(UniqueException::class); $adapter->createIndex('emails', 'unique_addr', Database::INDEX_UNIQUE, ['addr'], [], []); } diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index 13dd31d15..9d6666e52 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -10,6 +10,7 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -511,6 +512,46 @@ public function testCreateIndexOnSequence(): void $database->deleteCollection(__FUNCTION__); } + public function testCreateUniqueIndexOverDuplicates(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForUniqueIndex()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection(__FUNCTION__); + $database->createAttribute(__FUNCTION__, 'name', Database::VAR_STRING, 128, false); + $database->createAttribute(__FUNCTION__, 'age', Database::VAR_INTEGER, 0, false); + + foreach (['first', 'second'] as $id) { + $database->createDocument(__FUNCTION__, new Document([ + '$id' => $id, + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'chester', + 'age' => 7, + ])); + } + + foreach (['name', 'age'] as $attribute) { + try { + $database->createIndex(__FUNCTION__, "unique_{$attribute}", Database::INDEX_UNIQUE, [$attribute]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(UniqueException::class, $e); + } + + $indexes = $database->getCollection(__FUNCTION__)->getAttribute('indexes'); + $this->assertNotContains("unique_{$attribute}", \array_map(fn ($index) => $index->getId(), $indexes)); + } + + $database->deleteCollection(__FUNCTION__); + } + public function testIndexLengthZero(): void { /** @var Database $database */