diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 3b275b0650..5ad0710b60 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -15,6 +15,7 @@ use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Truncate as TruncateException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Helpers\ID; use Utopia\Database\Operator; use Utopia\Database\Query; @@ -1885,12 +1886,12 @@ protected function processException(PDOException $e): \Exception // Duplicate row if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) { - $message = $e->getMessage(); - if (\str_contains($message, '_index1')) { + $key = $this->getViolatedKey($e->getMessage()); + if ($key === '_index1') { return new DuplicateException('Duplicate permissions for document', $e->getCode(), $e); } - if (!\str_contains($message, '_uid')) { - return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e); + if ($key !== null && $key !== '_uid' && $key !== 'PRIMARY') { + return new UniqueException('Unique index violation', $e->getCode(), $e); } return new DuplicateException('Document already exists', $e->getCode(), $e); } @@ -1936,6 +1937,20 @@ protected function processException(PDOException $e): \Exception return $e; } + /** + * Extract the index name from a duplicate entry error, e.g. + * "Duplicate entry 'x' for key 'movies._uid'" resolves to "_uid". + * Returns null when the message cannot be parsed. + */ + protected function getViolatedKey(string $message): ?string + { + if (\preg_match("/for key '(?:[^'.]*\.)?([^']+)'/", $message, $matches) === 1) { + return $matches[1]; + } + + return null; + } + protected function quote(string $string): string { return "`{$string}`"; diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 66762a4584..5e126a7177 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -11,6 +11,7 @@ use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Operator as OperatorException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Operator; use Utopia\Database\Query; @@ -1443,11 +1444,11 @@ public function updateDocuments(Document $collection, Document $updates, array $ } } if (! $existingIsSelf) { - throw new DuplicateException('Document with the requested unique attributes already exists'); + throw new UniqueException('Unique index violation'); } } if (isset($pendingByIndex[$indexId][$hash]) && $pendingByIndex[$indexId][$hash] !== $docKey) { - throw new DuplicateException('Document with the requested unique attributes already exists'); + throw new UniqueException('Unique index violation'); } $pendingByIndex[$indexId][$hash] = $docKey; } @@ -2439,7 +2440,7 @@ protected function probeUniqueHash(string $key, string $indexId, ?string $newHas { if ($newHash !== null && isset($this->uniqueIndexHashes[$key][$indexId][$newHash]) && $this->uniqueIndexHashes[$key][$indexId][$newHash] !== $docKey) { - throw new DuplicateException('Document with the requested unique attributes already exists'); + throw new UniqueException('Unique index violation'); } $previousValueAtNew = $newHash !== null ? ($this->uniqueIndexHashes[$key][$indexId][$newHash] ?? null) : null; @@ -3424,7 +3425,7 @@ protected function checkUniqueSignatures(string $key, array $newSignatures, stri foreach ($newSignatures as $indexId => $hash) { $existing = $this->uniqueIndexHashes[$key][$indexId][$hash] ?? null; if ($existing !== null && $existing !== $docKey) { - throw new DuplicateException('Document with the requested unique attributes already exists'); + throw new UniqueException('Unique index violation'); } } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 0ce0ece451..e26e14a55d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -22,6 +22,7 @@ use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Type as TypeException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Operator; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -4010,9 +4011,9 @@ protected function processException(\Throwable $e): \Throwable // Duplicate key error if ($e->getCode() === 11000 || $e->getCode() === 11001) { - $message = $e->getMessage(); - if (!\str_contains($message, '_uid')) { - return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e); + $index = $this->getViolatedIndex($e->getMessage()); + if ($index !== null && $index !== '_uid' && $index !== '_id_') { + return new UniqueException('Unique index violation', $e->getCode(), $e); } return new DuplicateException('Document already exists', $e->getCode(), $e); } @@ -4051,6 +4052,20 @@ protected function processException(\Throwable $e): \Throwable return $e; } + /** + * Extract the index name from a duplicate key error, e.g. + * "E11000 duplicate key error collection: db.movies index: _uid dup key: { _uid: \"movie\" }" + * resolves to "_uid". Returns null when the message cannot be parsed. + */ + protected function getViolatedIndex(string $message): ?string + { + if (\preg_match('/index:\s*(\S+)\s+dup key/', $message, $matches) !== 1) { + return null; + } + + return $matches[1]; + } + protected function quote(string $string): string { return ""; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index cf3321bcd0..438c2878ac 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -15,6 +15,7 @@ use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Truncate as TruncateException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Helpers\ID; use Utopia\Database\Operator; use Utopia\Database\Query; @@ -2159,9 +2160,9 @@ protected function processException(PDOException $e): \Exception // Duplicate row if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - $message = $e->getMessage(); - if (!\str_contains($message, '_uid')) { - return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e); + $columns = $this->getViolatedColumns($e->getMessage()); + if ($columns !== null && $columns !== ['_uid'] && $columns !== ['_tenant', '_uid']) { + return new UniqueException('Unique index violation', $e->getCode(), $e); } return new DuplicateException('Document already exists', $e->getCode(), $e); } @@ -2200,6 +2201,29 @@ protected function processException(PDOException $e): \Exception return $e; } + /** + * Extract the violated columns from a unique violation error, e.g. + * "DETAIL: Key (_uid, _tenant)=(movie, 1) already exists." resolves to + * ['_tenant', '_uid']. Returns null when the message cannot be parsed. + * + * @return array|null + */ + protected function getViolatedColumns(string $message): ?array + { + if (\preg_match('/Key \(([^)]+)\)=/', $message, $matches) !== 1) { + return null; + } + + $columns = \array_map( + fn (string $column) => \trim($column, " \t\"'"), + \explode(',', $matches[1]) + ); + + \sort($columns); + + return $columns; + } + /** * @param string $string * @return string diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 02bae86f71..81f3350634 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -16,6 +16,7 @@ use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Transaction as TransactionException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Operator; @@ -1777,7 +1778,7 @@ private function enforceUniqueIndexes(RedisClient $client, string $collection, D \array_unshift($signature, $tenant); } if (\serialize($signature) === $newHash) { - throw new DuplicateException('Document with the requested unique attributes already exists'); + throw new UniqueException('Unique index violation'); } } } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 875d6f9ef5..2f70bf04cb 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -15,6 +15,7 @@ use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Truncate as TruncateException; +use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Helpers\ID; use Utopia\Database\Operator; use Utopia\Database\Query; @@ -1891,8 +1892,9 @@ protected function processException(PDOException $e): \Exception stripos($message, 'unique') !== false || stripos($message, 'duplicate') !== false ) { - if (!\str_contains($message, '_uid')) { - return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e); + $columns = $this->getViolatedColumns($message); + if ($columns !== null && $columns !== ['_uid'] && $columns !== ['_tenant', '_uid']) { + return new UniqueException('Unique index violation', $e->getCode(), $e); } return new DuplicateException('Document already exists', $e->getCode(), $e); } @@ -1906,6 +1908,33 @@ protected function processException(PDOException $e): \Exception return $e; } + /** + * Extract the violated columns from a constraint error, e.g. + * "UNIQUE constraint failed: movies._tenant, movies._uid" resolves to + * ['_tenant', '_uid']. Returns null when the message cannot be parsed. + * + * @return array|null + */ + protected function getViolatedColumns(string $message): ?array + { + if (\preg_match('/UNIQUE constraint failed:\s*(.+)/', $message, $matches) !== 1) { + return null; + } + + $columns = \array_map(function (string $column): string { + $separator = \strrpos($column, '.'); + if ($separator !== false) { + $column = \substr($column, $separator + 1); + } + + return \trim($column, " \t`\""); + }, \explode(',', $matches[1])); + + \sort($columns); + + return $columns; + } + public function getSupportForSpatialIndexOrder(): bool { return false; diff --git a/src/Database/Exception/Unique.php b/src/Database/Exception/Unique.php new file mode 100644 index 0000000000..d8e2fe5014 --- /dev/null +++ b/src/Database/Exception/Unique.php @@ -0,0 +1,7 @@ +fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertInstanceOf(DuplicateException::class, $e); + $this->assertInstanceOf(UniqueException::class, $e); } } /** - * Test that DuplicateException messages differentiate between + * Test that duplicate exceptions differentiate between * document ID duplicates and unique index violations. */ public function testDuplicateExceptionMessages(): void @@ -5836,10 +5838,11 @@ public function testDuplicateExceptionMessages(): void ])); $this->fail('Expected DuplicateException for duplicate document ID'); } catch (DuplicateException $e) { + $this->assertNotInstanceOf(UniqueException::class, $e); $this->assertStringContainsString('Document already exists', $e->getMessage()); } - // Test 2: Unique index violation should mention "unique attributes" + // Test 2: Unique index violation should use UniqueException try { $database->createDocument('duplicateMessages', new Document([ '$id' => 'dup_msg_2', @@ -5850,11 +5853,37 @@ public function testDuplicateExceptionMessages(): void ])); $this->fail('Expected DuplicateException for unique index violation'); } catch (DuplicateException $e) { - $this->assertStringContainsString('unique attributes', $e->getMessage()); + $this->assertInstanceOf(UniqueException::class, $e); + $this->assertStringContainsString('Unique index violation', $e->getMessage()); + } + + // Test 3: A conflicting value containing "_uid" must not be mistaken + // for a document identifier conflict + $database->createDocument('duplicateMessages', new Document([ + '$id' => 'dup_msg_3', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'email' => 'prefix_uid_suffix@example.com', + ])); + + try { + $database->createDocument('duplicateMessages', new Document([ + '$id' => 'dup_msg_4', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'email' => 'prefix_uid_suffix@example.com', + ])); + $this->fail('Expected DuplicateException for unique index violation'); + } catch (DuplicateException $e) { + $this->assertInstanceOf(UniqueException::class, $e); + $this->assertStringContainsString('Unique index violation', $e->getMessage()); } $database->deleteCollection('duplicateMessages'); } + /** * @depends testUniqueIndexDuplicate */ @@ -5895,6 +5924,7 @@ public function testUniqueIndexDuplicateUpdate(): void $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertInstanceOf(DuplicateException::class, $e); + $this->assertInstanceOf(UniqueException::class, $e); } } diff --git a/tests/unit/UniqueViolationTest.php b/tests/unit/UniqueViolationTest.php new file mode 100644 index 0000000000..8d7c8950c1 --- /dev/null +++ b/tests/unit/UniqueViolationTest.php @@ -0,0 +1,184 @@ +assertDuplicate(MySQL::class, $this->mysqlException( + "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'movie-1' for key 'movies._uid'" + )); + } + + public function testMySQLPrimaryKeyConflictIsDuplicate(): void + { + $this->assertDuplicate(MySQL::class, $this->mysqlException( + "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '5' for key 'PRIMARY'" + )); + } + + public function testMySQLUniqueIndexConflictWithUidInValueIsUnique(): void + { + $this->assertUnique(MySQL::class, $this->mysqlException( + "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'prefix_uid_suffix' for key 'slug'" + )); + } + + public function testMySQLUniqueIndexConflictWithUidInIndexNameIsUnique(): void + { + $this->assertUnique(MySQL::class, $this->mysqlException( + "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'a' for key 'movies.slug_uid_index'" + )); + } + + public function testMySQLUnparsableMessageIsDuplicate(): void + { + $this->assertDuplicate(MySQL::class, $this->mysqlException( + 'SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry' + )); + } + + public function testPostgresDocumentIdConflictIsDuplicate(): void + { + $this->assertDuplicate(Postgres::class, $this->postgresException( + 'SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "ns_1_movies_uid"' + . "\nDETAIL: Key (_uid, _tenant)=(movie-1, 1) already exists." + )); + } + + public function testPostgresUniqueIndexConflictWithUidInValueIsUnique(): void + { + $this->assertUnique(Postgres::class, $this->postgresException( + 'SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "ns_1_movies_slug"' + . "\nDETAIL: Key (slug)=(prefix_uid_suffix) already exists." + )); + } + + public function testPostgresCompositeIndexOnDocumentIdIsUnique(): void + { + $this->assertUnique(Postgres::class, $this->postgresException( + 'SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "ns_1_movies_pair"' + . "\nDETAIL: Key (_uid, email)=(movie-1, a@b.co) already exists." + )); + } + + public function testPostgresMissingDetailIsDuplicate(): void + { + $this->assertDuplicate(Postgres::class, $this->postgresException( + 'SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "ns_1_movies_uid"' + )); + } + + public function testSQLiteDocumentIdConflictIsDuplicate(): void + { + $this->assertDuplicate(SQLite::class, $this->sqliteException( + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: ns_movies._tenant, ns_movies._uid' + )); + } + + public function testSQLiteCompositeIndexOnDocumentIdIsUnique(): void + { + $this->assertUnique(SQLite::class, $this->sqliteException( + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: ns_movies._uid, ns_movies.email' + )); + } + + public function testSQLiteUniqueIndexConflictIsUnique(): void + { + $this->assertUnique(SQLite::class, $this->sqliteException( + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: ns_movies.slug' + )); + } + + public function testMongoDocumentIdConflictIsDuplicate(): void + { + $this->assertDuplicate(Mongo::class, new Exception( + 'E11000 duplicate key error collection: db.ns_movies index: _uid dup key: { _uid: "movie-1" }', + 11000 + )); + } + + public function testMongoUniqueIndexConflictWithUidInValueIsUnique(): void + { + $this->assertUnique(Mongo::class, new Exception( + 'E11000 duplicate key error collection: db.ns_movies index: slug dup key: { slug: "prefix_uid_suffix" }', + 11000 + )); + } + + public function testMongoUnparsableMessageIsDuplicate(): void + { + $this->assertDuplicate(Mongo::class, new Exception('E11000 duplicate key error', 11000)); + } + + private function mysqlException(string $message): PDOException + { + $exception = new PDOException($message); + (new ReflectionProperty(Exception::class, 'code'))->setValue($exception, '23000'); + $exception->errorInfo = ['23000', 1062, $message]; + + return $exception; + } + + private function postgresException(string $message): PDOException + { + $exception = new PDOException($message); + (new ReflectionProperty(Exception::class, 'code'))->setValue($exception, '23505'); + $exception->errorInfo = ['23505', 7, $message]; + + return $exception; + } + + private function sqliteException(string $message): PDOException + { + $exception = new PDOException($message); + (new ReflectionProperty(Exception::class, 'code'))->setValue($exception, 'HY000'); + $exception->errorInfo = ['HY000', 19, $message]; + + return $exception; + } + + /** + * @param class-string $adapter + */ + private function assertDuplicate(string $adapter, Throwable $exception): void + { + $processed = $this->process($adapter, $exception); + + $this->assertInstanceOf(DuplicateException::class, $processed); + $this->assertNotInstanceOf(UniqueException::class, $processed); + } + + /** + * @param class-string $adapter + */ + private function assertUnique(string $adapter, Throwable $exception): void + { + $this->assertInstanceOf(UniqueException::class, $this->process($adapter, $exception)); + } + + /** + * @param class-string $adapter + */ + private function process(string $adapter, Throwable $exception): Throwable + { + $class = new ReflectionClass($adapter); + $method = $class->getMethod('processException'); + + return $method->invoke($class->newInstanceWithoutConstructor(), $exception); + } +}