From 0360c2245c30bdac5fa816076f288a5f292add65 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 10 Nov 2026 14:16:28 +0200 Subject: [PATCH 1/2] PR --- src/Database/Adapter/MariaDB.php | 38 ++++++++++++++++++ .../Scopes/SchemaReconciliationTests.php | 39 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 7b86d6d00..dfec68965 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -472,6 +472,44 @@ public function updateAttribute(string $collection, string $key, Attribute $attr } } + /** + * Dropping a column drops an index only when no other column is left in it. Under shared + * tables every key and unique index leads with the tenant column, so an index whose last + * attribute is the dropped column survives on the tenant column alone, keeps its name, and + * refuses the next index created under that name. Every index left on the tenant column + * alone is dropped with the column: no index the library creates is ever only that column. + * + * @throws DatabaseException + * @throws Exception + * @throws PDOException + */ + #[\Override] + public function deleteAttribute(string $collection, string $key): bool + { + $missing = null; + try { + $deleted = parent::deleteAttribute($collection, $key); + } catch (NotFoundException $error) { + // A retry after the column was dropped still clears what the first attempt left behind. + $missing = $error; + $deleted = false; + } + + if ($this->sharedTables) { + foreach ($this->getSchemaIndexes($collection) as $index) { + if (\array_map(\strtolower(...), $index->columns) === [\strtolower(Storage::TENANT)]) { + $this->deleteIndex($collection, $index->name); + } + } + } + + if ($missing !== null) { + throw $missing; + } + + return $deleted; + } + /** * @param array $indexAttributeTypes * @param array $collation diff --git a/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php b/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php index e497aedf4..87c22bd43 100644 --- a/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php +++ b/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php @@ -4,6 +4,8 @@ use Throwable; use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\Mongo; +use Utopia\Database\Adapter\Postgres; use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Collection; @@ -230,6 +232,43 @@ public function testAnOrphanIndexOfTheRequestedDefinitionIsReused(): void } } + public function testDeletingAnAttributeDropsItsOnlyIndexFromTheSchema(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + // Mongo unsets the field and keeps its indexes. + if (! $adapter->supports(Capability::SchemaIntrospection) || $adapter instanceof Mongo) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'reconcileDroppedAttributeIndex'; + $this->createReconciledCollection($database, $collection, [ + Attribute::string(key: 'status', size: 255), + Attribute::string(key: 'name', size: 255), + ]); + + try { + $database->createIndex($collection, Index::key(key: 'status_index', attributes: ['status'])); + $database->createIndex($collection, Index::key(key: 'status_name', attributes: ['status', 'name'])); + + $database->deleteAttribute($collection, 'status'); + + $this->assertFalse($this->hasSchemaIndex($database, $collection, 'status_index')); + // Postgres drops every index on a dropped column, composite ones included. + if (! $adapter instanceof Postgres) { + $this->assertTrue($this->hasSchemaIndex($database, $collection, 'status_name')); + } + + $database->createAttribute($collection, Attribute::string(key: 'status', size: 255)); + $this->assertSame('status_index', $database->createIndex($collection, Index::key(key: 'status_index', attributes: ['status']))->key); + } finally { + $database->deleteCollection($collection); + } + } + /** * @param list $attributes */ From f8013fa1af30f659d190191b42233574abc47dcd Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 10 Nov 2026 14:51:08 +0200 Subject: [PATCH 2/2] Move out from the adapter --- src/Database/Adapter/MariaDB.php | 38 ------------------------------- src/Database/Trait/Attributes.php | 11 +++++++++ 2 files changed, 11 insertions(+), 38 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index dfec68965..7b86d6d00 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -472,44 +472,6 @@ public function updateAttribute(string $collection, string $key, Attribute $attr } } - /** - * Dropping a column drops an index only when no other column is left in it. Under shared - * tables every key and unique index leads with the tenant column, so an index whose last - * attribute is the dropped column survives on the tenant column alone, keeps its name, and - * refuses the next index created under that name. Every index left on the tenant column - * alone is dropped with the column: no index the library creates is ever only that column. - * - * @throws DatabaseException - * @throws Exception - * @throws PDOException - */ - #[\Override] - public function deleteAttribute(string $collection, string $key): bool - { - $missing = null; - try { - $deleted = parent::deleteAttribute($collection, $key); - } catch (NotFoundException $error) { - // A retry after the column was dropped still clears what the first attempt left behind. - $missing = $error; - $deleted = false; - } - - if ($this->sharedTables) { - foreach ($this->getSchemaIndexes($collection) as $index) { - if (\array_map(\strtolower(...), $index->columns) === [\strtolower(Storage::TENANT)]) { - $this->deleteIndex($collection, $index->name); - } - } - } - - if ($missing !== null) { - throw $missing; - } - - return $deleted; - } - /** * @param array $indexAttributeTypes * @param array $collation diff --git a/src/Database/Trait/Attributes.php b/src/Database/Trait/Attributes.php index cf5cd6d2a..1300dd4a3 100644 --- a/src/Database/Trait/Attributes.php +++ b/src/Database/Trait/Attributes.php @@ -378,6 +378,17 @@ public function deleteAttribute(string $collection, string $key): void $deletedInSchema = $this->deleteAttributeFromSchema($definition->getId(), $key); + // An engine drops an index with its column only when no other column is left in it, and under + // shared tables every index also holds the tenant column. An index whose last attribute this was + // would survive on the tenant column alone and refuse the next index created under its name. + if ($this->adapter->hasSharedTables()) { + foreach ($indexes as $index) { + if ($index->attributes === [$key]) { + $this->deleteIndexFromSchema($definition->getId(), $index->key); + } + } + } + $this->updateMetadata( collection: $definition, rollbackOperation: fn () => $this->adapter->createAttribute($definition->getId(), $attribute),