Skip to content
Open
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
9 changes: 8 additions & 1 deletion src/Database/Adapter/MariaDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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\Query;

Expand Down Expand Up @@ -816,6 +817,7 @@ public function deleteIndex(string $collection, string $id): bool
* @return Document
* @throws Exception
* @throws PDOException
* @throws UniqueException
* @throws DuplicateException
* @throws \Throwable
Comment thread
fogelito marked this conversation as resolved.
*/
Expand Down Expand Up @@ -942,6 +944,7 @@ public function createDocument(Document $collection, Document $document): Docume
* @return Document
* @throws Exception
* @throws PDOException
* @throws UniqueException
* @throws DuplicateException
* @throws \Throwable
*/
Expand Down Expand Up @@ -1795,7 +1798,11 @@ protected function processException(PDOException $e): \Exception

// Duplicate row
if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) {
return new DuplicateException('Document already exists', $e->getCode(), $e);
if (str_contains($e->getMessage(), "for key '_uid'")) {
return new DuplicateException('Document already exists', $e->getCode(), $e);
}

return new UniqueException('Unique index violation', $e->getCode(), $e);
}

// Data is too big for column resize
Expand Down
12 changes: 11 additions & 1 deletion src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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\Query;

Expand Down Expand Up @@ -1916,7 +1917,16 @@ protected function processException(PDOException $e): \Exception

// Duplicate row
if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
return new DuplicateException('Document already exists', $e->getCode(), $e);
if (preg_match('/Key \(([^)]+)\)=\(.+\) already exists/', $e->getMessage(), $matches)) {
$columns = array_map('trim', explode(',', $matches[1]));
sort($columns);
$target = $this->sharedTables ? ['_tenant', '_uid'] : ['_uid'];
if ($columns == $target) {
return new DuplicateException('Document already exists', $e->getCode(), $e);
}
}

return new UniqueException('Unique index violation', $e->getCode(), $e);
}
Comment on lines 1919 to 1930
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Regex miss silently downgrades _uid duplicate to UniqueException

If preg_match fails to match (e.g. the PDO error message omits the DETAIL line, or the Postgres locale formats it differently), execution falls through to return new UniqueException(...). Before this PR every 23505 returned DuplicateException; now an unmatched primary-key duplicate silently becomes a UniqueException, breaking callers that check $e instanceof DuplicateException && !($e instanceof UniqueException). A safe fallback would return DuplicateException when the regex cannot resolve the column list.


// Data is too big for column resize
Expand Down
35 changes: 20 additions & 15 deletions src/Database/Adapter/SQLite.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception as DatabaseException;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Exception\NotFound as NotFoundException;
use Utopia\Database\Exception\Timeout as TimeoutException;
use Utopia\Database\Exception\Transaction as TransactionException;
use Utopia\Database\Exception\Unique as UniqueException;
use Utopia\Database\Helpers\ID;

/**
Expand Down Expand Up @@ -517,7 +517,7 @@ public function deleteIndex(string $collection, string $id): bool
* @return Document
* @throws Exception
* @throws PDOException
* @throws Duplicate
* @throws UniqueException
*/
public function createDocument(Document $collection, Document $document): Document
{
Expand Down Expand Up @@ -619,10 +619,7 @@ public function createDocument(Document $collection, Document $document): Docume
$stmtPermissions->execute();
}
} catch (PDOException $e) {
throw match ($e->getCode()) {
"1062", "23000" => new Duplicate('Duplicated document: ' . $e->getMessage()),
default => $e,
};
throw $this->processException($e);
}


Expand All @@ -639,7 +636,7 @@ public function createDocument(Document $collection, Document $document): Docume
* @return Document
* @throws Exception
* @throws PDOException
* @throws Duplicate
* @throws UniqueException
*/
public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document
{
Expand Down Expand Up @@ -841,11 +838,7 @@ public function updateDocument(Document $collection, string $id, Document $docum
$stmtAddPermissions->execute();
}
} catch (PDOException $e) {
throw match ($e->getCode()) {
'1062',
'23000' => new Duplicate('Duplicated document: ' . $e->getMessage()),
default => $e,
};
throw $this->processException($e);
}

return $document;
Expand Down Expand Up @@ -1248,9 +1241,21 @@ protected function processException(PDOException $e): \Exception
return new TimeoutException('Query timed out', $e->getCode(), $e);
}

// Duplicate
if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1) {
return new DuplicateException('Document already exists', $e->getCode(), $e);
// Duplicate row
if ($e->getCode() === '23000' && ($e->errorInfo[1] ?? null) === 19) {
$msg = $e->errorInfo[2] ?? $e->getMessage();

// Match all table.column pairs (handles commas & spaces)
if (preg_match_all('/\b([^.]+)\.([^\s,]+)/', $msg, $matches, PREG_SET_ORDER)) {
$columns = array_map(fn ($m) => $m[2], $matches);
sort($columns);

if ($columns === ['_tenant', '_uid'] || in_array('_uid', $columns)) {
return new DuplicateException('Document already exists', $e->getCode(), $e);
}
Comment on lines +1253 to +1255
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 in_array check is too broad — user composite indexes on $id misclassified

in_array('_uid', $columns) will match any composite unique index that happens to include the $id/_uid field (e.g. a user-created index on ['$id', 'email']). When that constraint fires, this path returns DuplicateException instead of UniqueException, breaking the distinction the PR is introducing. The Postgres adapter avoids this by using an exact column-list comparison — SQLite should match that approach.

Suggested change
if ($columns === ['_tenant', '_uid'] || in_array('_uid', $columns)) {
return new DuplicateException('Document already exists', $e->getCode(), $e);
}
if ($columns === ['_tenant', '_uid'] || $columns === ['_uid']) {
return new DuplicateException('Document already exists', $e->getCode(), $e);
}

}

return new UniqueException('Unique index violation', $e->getCode(), $e);
}

return $e;
Expand Down
9 changes: 9 additions & 0 deletions src/Database/Exception/Unique.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Utopia\Database\Exception;

use Utopia\Database\Exception;

class Unique extends Exception
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 UniqueException must extend DuplicateException for the tests to pass

testUniqueIndexDuplicate and testUniqueIndexDuplicateUpdate both assert assertInstanceOf(DuplicateException::class, $e) on the same exception that must also satisfy assertInstanceOf(UniqueException::class, $e). The only way both assertions pass on a single thrown UniqueException is if Unique extends Duplicate. With the current class Unique extends Exception, the assertInstanceOf(DuplicateException::class, $e) check will fail at runtime.

It also preserves backward compatibility: existing catch blocks for DuplicateException will continue to catch unique-constraint violations, which is the expected behaviour the PR description refers to when it says "Public API signatures unchanged."

Suggested change
class Unique extends Exception
class Unique extends Duplicate

with use Utopia\Database\Exception\Duplicate; replacing the base Exception import.

{
}
5 changes: 5 additions & 0 deletions tests/e2e/Adapter/Scopes/DocumentTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Utopia\Database\Exception\Limit as LimitException;
use Utopia\Database\Exception\Structure as StructureException;
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 Down Expand Up @@ -4762,6 +4763,7 @@ public function testUniqueIndexDuplicate(): void
$this->fail('Failed to throw exception');
} catch (Throwable $e) {
$this->assertInstanceOf(DuplicateException::class, $e);
$this->assertInstanceOf(UniqueException::class, $e);
}
}
/**
Expand Down Expand Up @@ -4804,6 +4806,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 Expand Up @@ -5316,6 +5319,7 @@ public function testExceptionDuplicate(Document $document): void
$this->fail('Failed to throw exception');
} catch (Throwable $e) {
$this->assertInstanceOf(DuplicateException::class, $e);
$this->assertNotInstanceOf(UniqueException::class, $e);
}
}

Expand All @@ -5339,6 +5343,7 @@ public function testExceptionCaseInsensitiveDuplicate(Document $document): Docum
$this->fail('Failed to throw exception');
} catch (Throwable $e) {
$this->assertInstanceOf(DuplicateException::class, $e);
$this->assertNotInstanceOf(UniqueException::class, $e);
}

return $document;
Expand Down