Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
11 changes: 10 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
Comment thread
fogelito marked this conversation as resolved.
*/
Expand Down Expand Up @@ -1795,7 +1798,13 @@ 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 (preg_match("/for key '(?:[^.]+\.)?([^']+)'/", $e->getMessage(), $matches)) {
if ($matches[1] === '_uid') {
Comment thread
abnegate marked this conversation as resolved.
Outdated
return new DuplicateException('Document already exists', $e->getCode(), $e);
}
}

return new UniqueException('Document already exists', $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 @@ -1924,7 +1925,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('Document already exists', $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
29 changes: 14 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,15 @@ 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' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 19) {
if (preg_match('/UNIQUE constraint failed:\s+([^.]+)\.([^\s]+)/', $e->getMessage(), $m)) {
if ($m[2] === '_uid') {
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('Document already exists', $e->getCode(), $e);
}
Comment thread
fogelito marked this conversation as resolved.

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

namespace Utopia\Database\Exception;

class Unique extends Duplicate
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@copilot Change this to extend the root database exception instead of duplicate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done in a163eb3Unique now extends \Utopia\Database\Exception directly.

{
}
95 changes: 61 additions & 34 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 @@ -4729,36 +4730,41 @@ public function testWritePermissionsUpdateFailure(Document $document): Document
*/
public function testUniqueIndexDuplicate(): void
{
$this->expectException(DuplicateException::class);

/** @var Database $database */
$database = static::getDatabase();

$this->assertEquals(true, $database->createIndex('movies', 'uniqueIndex', Database::INDEX_UNIQUE, ['name'], [128], [Database::ORDER_ASC]));

$database->createDocument('movies', new Document([
'$permissions' => [
Permission::read(Role::any()),
Permission::read(Role::user('1')),
Permission::read(Role::user('2')),
Permission::create(Role::any()),
Permission::create(Role::user('1x')),
Permission::create(Role::user('2x')),
Permission::update(Role::any()),
Permission::update(Role::user('1x')),
Permission::update(Role::user('2x')),
Permission::delete(Role::any()),
Permission::delete(Role::user('1x')),
Permission::delete(Role::user('2x')),
],
'name' => 'Frozen',
'director' => 'Chris Buck & Jennifer Lee',
'year' => 2013,
'price' => 39.50,
'active' => true,
'genres' => ['animation', 'kids'],
'with-dash' => 'Works4'
]));
try {
$database->createDocument('movies', new Document([
'$permissions' => [
Permission::read(Role::any()),
Permission::read(Role::user('1')),
Permission::read(Role::user('2')),
Permission::create(Role::any()),
Permission::create(Role::user('1x')),
Permission::create(Role::user('2x')),
Permission::update(Role::any()),
Permission::update(Role::user('1x')),
Permission::update(Role::user('2x')),
Permission::delete(Role::any()),
Permission::delete(Role::user('1x')),
Permission::delete(Role::user('2x')),
],
'name' => 'Frozen',
'director' => 'Chris Buck & Jennifer Lee',
'year' => 2013,
'price' => 39.50,
'active' => true,
'genres' => ['animation', 'kids'],
'with-dash' => 'Works4'
]));

$this->fail('Failed to throw exception');
} catch (Throwable $e) {
$this->assertInstanceOf(UniqueException::class, $e);
$this->assertInstanceOf(DuplicateException::class, $e);
}
}
/**
* @depends testUniqueIndexDuplicate
Expand Down Expand Up @@ -4794,9 +4800,14 @@ public function testUniqueIndexDuplicateUpdate(): void
'with-dash' => 'Works4'
]));

$this->expectException(DuplicateException::class);
try {
$database->updateDocument('movies', $document->getId(), $document->setAttribute('name', 'Frozen'));

$database->updateDocument('movies', $document->getId(), $document->setAttribute('name', 'Frozen'));
$this->fail('Failed to throw exception');
} catch (Throwable $e) {
$this->assertInstanceOf(UniqueException::class, $e);
$this->assertInstanceOf(DuplicateException::class, $e);
}
Comment thread
fogelito marked this conversation as resolved.
}

public function propagateBulkDocuments(string $collection, int $amount = 10, bool $documentSecurity = false): void
Expand Down Expand Up @@ -5298,9 +5309,18 @@ public function testExceptionDuplicate(Document $document): void
$database = static::getDatabase();

$document->setAttribute('$id', 'duplicated');
$document->removeAttribute('$sequence');

$database->createDocument($document->getCollection(), $document);
$this->expectException(DuplicateException::class);
$database->createDocument($document->getCollection(), $document);
$document->removeAttribute('$sequence');

try {
$database->createDocument($document->getCollection(), $document);
$this->fail('Failed to throw exception');
} catch (Throwable $e) {
$this->assertInstanceOf(DuplicateException::class, $e);
$this->assertNotInstanceOf(UniqueException::class, $e);
}
Comment thread
fogelito marked this conversation as resolved.
}

/**
Expand All @@ -5312,13 +5332,20 @@ public function testExceptionCaseInsensitiveDuplicate(Document $document): Docum
$database = static::getDatabase();

$document->setAttribute('$id', 'caseSensitive');
$document->setAttribute('$sequence', '200');
$database->createDocument($document->getCollection(), $document);
$document->removeAttribute('$sequence');

$document->setAttribute('$id', 'CaseSensitive');

$this->expectException(DuplicateException::class);
$database->createDocument($document->getCollection(), $document);
$document->setAttribute('$id', 'caseSensitive'); // Revert this
$document->removeAttribute('$sequence');

try {
$database->createDocument($document->getCollection(), $document);
$this->fail('Failed to throw exception');
} catch (Throwable $e) {
var_dump($e);
$this->assertInstanceOf(DuplicateException::class, $e);
$this->assertNotInstanceOf(UniqueException::class, $e);
}

return $document;
}
Expand Down
Loading