Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/Database/Adapter/MariaDB.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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);
}
Expand DownExpand Up@@ -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}`";
Expand Down
9 changes: 5 additions & 4 deletions src/Database/Adapter/Memory.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -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');
}
}
}
Expand Down
21 changes: 18 additions & 3 deletions src/Database/Adapter/Mongo.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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);
}
Expand DownExpand Up@@ -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 "";
Expand Down
30 changes: 27 additions & 3 deletions src/Database/Adapter/Postgres.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand DownExpand Up@@ -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<string>|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
Expand Down
3 changes: 2 additions & 1 deletion src/Database/Adapter/Redis.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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');
}
}
}
Expand Down
33 changes: 31 additions & 2 deletions src/Database/Adapter/SQLite.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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);
}
Expand All@@ -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<string>|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;
Expand Down
7 changes: 7 additions & 0 deletions src/Database/Exception/Unique.php
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
<?php

namespace Utopia\Database\Exception;

class Unique extends Duplicate
{
}
36 changes: 33 additions & 3 deletions tests/e2e/Adapter/Scopes/DocumentTests.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,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;
Expand DownExpand Up@@ -5795,11 +5796,12 @@ public function testUniqueIndexDuplicate(): void
$this->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
Expand DownExpand Up@@ -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',
Expand All@@ -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
*/
Expand DownExpand Up@@ -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);
}
}

Expand Down
Loading
Loading