Skip to content

Commit ef5d6e5

Browse files
authored
Merge pull request #998 from utopia-php/fix/create-index-unique-violation
fix(indexes): fail unique index creation over duplicate rows
2 parents 1c99c21 + fad0b6b commit ef5d6e5

9 files changed

Lines changed: 105 additions & 21 deletions

File tree

‎src/Database/Adapter/MariaDB.php‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Utopia\Database\Exception as DatabaseException;
1010
use Utopia\Database\Exception\Character as CharacterException;
1111
use Utopia\Database\Exception\Duplicate as DuplicateException;
12+
use Utopia\Database\Exception\Index as IndexException;
1213
use Utopia\Database\Exception\Limit as LimitException;
1314
use Utopia\Database\Exception\NotFound as NotFoundException;
1415
use Utopia\Database\Exception\Operator as OperatorException;
@@ -779,6 +780,12 @@ public function createIndex(string $collection, string $id, string $type, array
779780
->prepare($sql)
780781
->execute();
781782
} catch (PDOException $e) {
783+
// Existing rows violate the new unique index. Classified here because
784+
// processException() can't parse the key from a localized message.
785+
if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) {
786+
throw new UniqueException('Unique index violation', $e->getCode(), $e);
787+
}
788+
782789
throw $this->processException($e);
783790
}
784791
}
@@ -1893,6 +1900,11 @@ protected function processException(PDOException $e): \Exception
18931900
return new DuplicateException('Index already exists', $e->getCode(), $e);
18941901
}
18951902

1903+
// Index key too long
1904+
if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1071) {
1905+
return new IndexException('Index key length exceeds the maximum', $e->getCode(), $e);
1906+
}
1907+
18961908
// Duplicate row
18971909
if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) {
18981910
$key = $this->getViolatedKey($e->getMessage());

‎src/Database/Adapter/Memory.php‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,10 +1005,8 @@ public function createIndex(string $collection, string $id, string $type, array
10051005
$hashTable = [];
10061006
if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) {
10071007
// MariaDB rejects CREATE UNIQUE INDEX with errno 1062 when existing
1008-
// rows contain duplicates; Database::createIndex catches the resulting
1009-
// DuplicateException and treats it as an "orphan index" (the metadata
1010-
// is registered but the physical index is absent). Mirror that contract:
1011-
// throw DuplicateException so callers see identical end-state behavior.
1008+
// rows contain duplicates, which surfaces as UniqueException. Mirror
1009+
// that contract so Database::createIndex fails the same way.
10121010
// Build the hash table while we scan so we can reuse it for fast
10131011
// probes after the index lands — no second pass over the rows.
10141012
foreach ($this->data[$key]['documents'] as $docKey => $row) {
@@ -1026,7 +1024,8 @@ public function createIndex(string $collection, string $id, string $type, array
10261024
}
10271025
$hash = \serialize($signature);
10281026
if (isset($hashTable[$hash])) {
1029-
throw new DuplicateException('Cannot create unique index: existing rows already contain duplicate values');
1027+
// Under shared tables the duplicates may be another tenant's rows
1028+
throw new UniqueException('Unique index violation');
10301029
}
10311030
$hashTable[$hash] = $docKey;
10321031
}

‎src/Database/Adapter/Mongo.php‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,7 @@ public function createCollection(string $name, array $attributes = [], array $in
616616
$partialFilter = [];
617617
foreach ($attributes as $attr) {
618618
// Find the matching attribute in collectionAttributes to get its type
619-
$attrType = 'string'; // Default fallback
619+
$attrType = $this->getMongoTypeCode(null);
620620
foreach ($collectionAttributes as $collectionAttr) {
621621
if ($collectionAttr->getId() === $attr) {
622622
$attrType = $this->getMongoTypeCode($collectionAttr->getAttribute('type'));
@@ -1027,6 +1027,9 @@ public function createIndex(string $collection, string $id, string $type, array
10271027
$indexes['key']['_tenant'] = $this->getOrder(Database::ORDER_ASC);
10281028
}
10291029

1030+
// Types are keyed by attribute name, which the loop below replaces with the internal key
1031+
$bsonTypes = \array_map(fn (string $attribute) => $this->getMongoTypeCode($indexAttributeTypes[$attribute] ?? null), $attributes);
1032+
10301033
foreach ($attributes as $i => $attribute) {
10311034

10321035
if (isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === Database::VAR_OBJECT) {
@@ -1088,9 +1091,7 @@ public function createIndex(string $collection, string $id, string $type, array
10881091
if (in_array($type, [Database::INDEX_UNIQUE, Database::INDEX_KEY])) {
10891092
$partialFilter = [];
10901093
foreach ($attributes as $i => $attr) {
1091-
$attrType = $indexAttributeTypes[$i] ?? Database::VAR_STRING; // Default to string if type not provided
1092-
$attrType = $this->getMongoTypeCode($attrType);
1093-
$partialFilter[$attr] = ['$exists' => true, '$type' => $attrType];
1094+
$partialFilter[$attr] = ['$exists' => true, '$type' => $bsonTypes[$i]];
10941095
}
10951096
if (!empty($partialFilter)) {
10961097
$indexes['partialFilterExpression'] = $partialFilter;
@@ -1146,6 +1147,11 @@ public function createIndex(string $collection, string $id, string $type, array
11461147

11471148
return $result;
11481149
} catch (\Exception $e) {
1150+
// Existing documents violate the new unique index, whatever index the message names
1151+
if ($e->getCode() === 11000 || $e->getCode() === 11001) {
1152+
throw new UniqueException('Unique index violation', $e->getCode(), $e);
1153+
}
1154+
11491155
throw $this->processException($e);
11501156
}
11511157
}
@@ -2670,24 +2676,30 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
26702676
/**
26712677
* Converts Appwrite database type to MongoDB BSON type code.
26722678
*
2673-
* @param string $appwriteType
2674-
* @return string
2679+
* Numbers use the 'number' alias: an integer is stored as int32 or int64
2680+
* depending on its value, and a float attribute can hold an integer.
2681+
* An unknown type (schemaless, internal attributes) matches every stored
2682+
* value type except null.
2683+
*
2684+
* @param string|null $appwriteType
2685+
* @return string|array<string>
26752686
*/
2676-
private function getMongoTypeCode(string $appwriteType): string
2687+
private function getMongoTypeCode(?string $appwriteType): string|array
26772688
{
26782689
return match ($appwriteType) {
26792690
Database::VAR_STRING => 'string',
26802691
Database::VAR_VARCHAR => 'string',
26812692
Database::VAR_TEXT => 'string',
26822693
Database::VAR_MEDIUMTEXT => 'string',
26832694
Database::VAR_LONGTEXT => 'string',
2684-
Database::VAR_INTEGER => 'int',
2685-
Database::VAR_BIGINT => 'long',
2686-
Database::VAR_FLOAT => 'double',
2695+
Database::VAR_INTEGER => 'number',
2696+
Database::VAR_BIGINT => 'number',
2697+
Database::VAR_FLOAT => 'number',
26872698
Database::VAR_BOOLEAN => 'bool',
26882699
Database::VAR_DATETIME => 'date',
26892700
Database::VAR_ID => 'string',
26902701
Database::VAR_UUID7 => 'string',
2702+
null => ['string', 'number', 'bool', 'date', 'object'],
26912703
default => 'string'
26922704
};
26932705
}

‎src/Database/Adapter/Postgres.php‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,12 @@ public function createIndex(string $collection, string $id, string $type, array
925925
try {
926926
return $this->getPDO()->prepare($sql)->execute();
927927
} catch (PDOException $e) {
928+
// Existing rows violate the new unique index. Classified here because an
929+
// expression key (nested object path) has no columns for processException() to parse.
930+
if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
931+
throw new UniqueException('Unique index violation', $e->getCode(), $e);
932+
}
933+
928934
throw $this->processException($e);
929935
}
930936
}
@@ -2198,6 +2204,11 @@ protected function processException(PDOException $e): \Exception
21982204
return new LimitException('Datetime field overflow', $e->getCode(), $e);
21992205
}
22002206

2207+
// Index row too large
2208+
if ($e->getCode() === '54000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7 && \str_contains($e->getMessage(), 'index row')) {
2209+
return new LimitException('Index row size exceeds the maximum', $e->getCode(), $e);
2210+
}
2211+
22012212
// Unknown table
22022213
if ($e->getCode() === '42P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
22032214
return new NotFoundException('Collection not found', $e->getCode(), $e);

‎src/Database/Adapter/Redis.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2728,7 +2728,7 @@ public function createIndex(string $collection, string $id, string $type, array
27282728
}
27292729
$hash = \serialize($signature);
27302730
if (isset($seen[$hash])) {
2731-
throw new DuplicateException('Cannot create unique index: existing rows already contain duplicate values');
2731+
throw new UniqueException('Cannot create unique index: existing rows already contain duplicate values');
27322732
}
27332733
$seen[$hash] = true;
27342734
}

‎src/Database/Adapter/SQLite.php‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -765,9 +765,13 @@ public function createIndex(string $collection, string $id, string $type, array
765765

766766
$sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql);
767767

768-
return $this->getPDO()
769-
->prepare($sql)
770-
->execute();
768+
try {
769+
return $this->getPDO()
770+
->prepare($sql)
771+
->execute();
772+
} catch (PDOException $e) {
773+
throw $this->processException($e);
774+
}
771775
}
772776

773777
/**

‎src/Database/Database.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Utopia\Database\Exception\Structure as StructureException;
2323
use Utopia\Database\Exception\Timeout as TimeoutException;
2424
use Utopia\Database\Exception\Type as TypeException;
25+
use Utopia\Database\Exception\Unique as UniqueException;
2526
use Utopia\Database\Helpers\ID;
2627
use Utopia\Database\Helpers\Permission;
2728
use Utopia\Database\Helpers\Role;
@@ -4830,6 +4831,9 @@ public function createIndex(string $collection, string $id, string $type, array
48304831
if (!$created) {
48314832
throw new DatabaseException('Failed to create index');
48324833
}
4834+
} catch (UniqueException $e) {
4835+
// Existing rows violate the unique constraint, so no index was built.
4836+
throw $e;
48334837
} catch (DuplicateException) {
48344838
// Metadata check (lines above) already verified index is absent
48354839
// from metadata. A DuplicateException from the adapter means the

‎tests/e2e/Adapter/MemoryTest.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Utopia\Database\Document;
1111
use Utopia\Database\Exception\Duplicate as DuplicateException;
1212
use Utopia\Database\Exception\NotFound as NotFoundException;
13+
use Utopia\Database\Exception\Unique as UniqueException;
1314
use Utopia\Database\Helpers\Permission;
1415
use Utopia\Database\Helpers\Role;
1516
use Utopia\Database\Query;
@@ -282,7 +283,7 @@ public function testArrayAttributeRoundTrip(): void
282283

283284
/**
284285
* Regression: CREATE UNIQUE INDEX on a collection that already contains
285-
* duplicate values must surface DuplicateException at the adapter layer
286+
* duplicate values must surface UniqueException at the adapter layer
286287
* (matches MariaDB errno 1062).
287288
*/
288289
public function testCreateUniqueIndexRejectsExistingDuplicates(): void
@@ -299,7 +300,7 @@ public function testCreateUniqueIndexRejectsExistingDuplicates(): void
299300
new Document(['$id' => 'b', 'addr' => 'dup@example.com', '$permissions' => []])
300301
);
301302

302-
$this->expectException(DuplicateException::class);
303+
$this->expectException(UniqueException::class);
303304
$adapter->createIndex('emails', 'unique_addr', Database::INDEX_UNIQUE, ['addr'], [], []);
304305
}
305306

‎tests/e2e/Adapter/Scopes/IndexTests.php‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Utopia\Database\Exception\Duplicate as DuplicateException;
1111
use Utopia\Database\Exception\Limit as LimitException;
1212
use Utopia\Database\Exception\Query as QueryException;
13+
use Utopia\Database\Exception\Unique as UniqueException;
1314
use Utopia\Database\Helpers\ID;
1415
use Utopia\Database\Helpers\Permission;
1516
use Utopia\Database\Helpers\Role;
@@ -511,6 +512,46 @@ public function testCreateIndexOnSequence(): void
511512
$database->deleteCollection(__FUNCTION__);
512513
}
513514

515+
public function testCreateUniqueIndexOverDuplicates(): void
516+
{
517+
/** @var Database $database */
518+
$database = $this->getDatabase();
519+
520+
if (!$database->getAdapter()->getSupportForUniqueIndex()) {
521+
$this->expectNotToPerformAssertions();
522+
return;
523+
}
524+
525+
$database->createCollection(__FUNCTION__);
526+
$database->createAttribute(__FUNCTION__, 'name', Database::VAR_STRING, 128, false);
527+
$database->createAttribute(__FUNCTION__, 'age', Database::VAR_INTEGER, 0, false);
528+
529+
foreach (['first', 'second'] as $id) {
530+
$database->createDocument(__FUNCTION__, new Document([
531+
'$id' => $id,
532+
'$permissions' => [
533+
Permission::read(Role::any()),
534+
],
535+
'name' => 'chester',
536+
'age' => 7,
537+
]));
538+
}
539+
540+
foreach (['name', 'age'] as $attribute) {
541+
try {
542+
$database->createIndex(__FUNCTION__, "unique_{$attribute}", Database::INDEX_UNIQUE, [$attribute]);
543+
$this->fail('Failed to throw exception');
544+
} catch (Exception $e) {
545+
$this->assertInstanceOf(UniqueException::class, $e);
546+
}
547+
548+
$indexes = $database->getCollection(__FUNCTION__)->getAttribute('indexes');
549+
$this->assertNotContains("unique_{$attribute}", \array_map(fn ($index) => $index->getId(), $indexes));
550+
}
551+
552+
$database->deleteCollection(__FUNCTION__);
553+
}
554+
514555
public function testIndexLengthZero(): void
515556
{
516557
/** @var Database $database */

0 commit comments

Comments
 (0)