From e10f6820d8626ac705325cdfef43c452f4e830e0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 12 Aug 2026 19:20:08 +0530 Subject: [PATCH 1/2] Add unique constraint exception --- src/Database/Adapter/MariaDB.php | 3 ++- src/Database/Adapter/Memory.php | 9 +++++---- src/Database/Adapter/Mongo.php | 3 ++- src/Database/Adapter/Postgres.php | 3 ++- src/Database/Adapter/Redis.php | 3 ++- src/Database/Adapter/SQLite.php | 3 ++- src/Database/Exception/Unique.php | 7 +++++++ tests/e2e/Adapter/Scopes/DocumentTests.php | 12 +++++++++--- 8 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 src/Database/Exception/Unique.php diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 3b275b0650..ca6351b09b 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; @@ -1890,7 +1891,7 @@ protected function processException(PDOException $e): \Exception 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); + return new UniqueException('Unique index violation', $e->getCode(), $e); } return new DuplicateException('Document already exists', $e->getCode(), $e); } 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..3e1cfd837f 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; @@ -4012,7 +4013,7 @@ protected function processException(\Throwable $e): \Throwable 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); + return new UniqueException('Unique index violation', $e->getCode(), $e); } return new DuplicateException('Document already exists', $e->getCode(), $e); } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index cf3321bcd0..2df1aac2aa 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; @@ -2161,7 +2162,7 @@ protected function processException(PDOException $e): \Exception 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); + return new UniqueException('Unique index violation', $e->getCode(), $e); } return new DuplicateException('Document already exists', $e->getCode(), $e); } 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..4a09f47b5c 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; @@ -1892,7 +1893,7 @@ protected function processException(PDOException $e): \Exception stripos($message, 'duplicate') !== false ) { if (!\str_contains($message, '_uid')) { - return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e); + return new UniqueException('Unique index violation', $e->getCode(), $e); } return new DuplicateException('Document already exists', $e->getCode(), $e); } 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,13 @@ 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()); } $database->deleteCollection('duplicateMessages'); } + /** * @depends testUniqueIndexDuplicate */ @@ -5895,6 +5900,7 @@ public function testUniqueIndexDuplicateUpdate(): void $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertInstanceOf(DuplicateException::class, $e); + $this->assertInstanceOf(UniqueException::class, $e); } } From 23eaafb68f74df2cfc954c9c2195f5a34880745b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 12:30:12 +1200 Subject: [PATCH 2/2] (fix): classify unique violations by violated key, not message substring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching the whole driver message for `_uid` misreads any conflict whose index name or conflicting value happens to contain that substring, so a plain unique-index violation was reported as a document-ID duplicate. Each adapter now parses the key the driver actually names — MariaDB/MySQL the key name, Postgres the DETAIL column list, SQLite the failed column list, Mongo the index name — and falls back to DuplicateException when the message cannot be parsed, preserving pre-existing behaviour rather than silently downgrading a document-ID duplicate. Co-Authored-By: Claude Opus 5 --- src/Database/Adapter/MariaDB.php | 20 ++- src/Database/Adapter/Mongo.php | 18 +- src/Database/Adapter/Postgres.php | 27 ++- src/Database/Adapter/SQLite.php | 30 +++- tests/e2e/Adapter/Scopes/DocumentTests.php | 24 +++ tests/unit/UniqueViolationTest.php | 184 +++++++++++++++++++++ 6 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 tests/unit/UniqueViolationTest.php diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index ca6351b09b..5ad0710b60 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1886,11 +1886,11 @@ 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')) { + 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); @@ -1937,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/Mongo.php b/src/Database/Adapter/Mongo.php index 3e1cfd837f..e26e14a55d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4011,8 +4011,8 @@ protected function processException(\Throwable $e): \Throwable // Duplicate key error if ($e->getCode() === 11000 || $e->getCode() === 11001) { - $message = $e->getMessage(); - if (!\str_contains($message, '_uid')) { + $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); @@ -4052,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 2df1aac2aa..438c2878ac 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2160,8 +2160,8 @@ 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')) { + $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); @@ -2201,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/SQLite.php b/src/Database/Adapter/SQLite.php index 4a09f47b5c..2f70bf04cb 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1892,7 +1892,8 @@ protected function processException(PDOException $e): \Exception stripos($message, 'unique') !== false || stripos($message, 'duplicate') !== false ) { - if (!\str_contains($message, '_uid')) { + $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); @@ -1907,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/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 8e444fa77a..2c5a65d131 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -5857,6 +5857,30 @@ public function testDuplicateExceptionMessages(): void $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'); } 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); + } +}