diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 4e25c8f81..a0c1c238a 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -958,6 +958,13 @@ abstract public function getSupportForAttributes(): bool; */ abstract public function getSupportForSchemaAttributes(): bool; + /** + * Are schema indexes supported? + * + * @return bool + */ + abstract public function getSupportForSchemaIndexes(): bool; + /** * Is index supported? * @@ -1365,6 +1372,17 @@ abstract public function getInternalIndexesKeys(): array; */ abstract public function getSchemaAttributes(string $collection): array; + /** + * Get Schema Indexes + * + * Returns physical index definitions from the database schema. + * + * @param string $collection + * @return array + * @throws DatabaseException + */ + abstract public function getSchemaIndexes(string $collection): array; + /** * Get the expected column type for a given attribute type. * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 1bd8797c9..223f91e71 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1845,6 +1845,58 @@ public function getSupportForSchemaAttributes(): bool return true; } + public function getSupportForSchemaIndexes(): bool + { + return true; + } + + public function getSchemaIndexes(string $collection): array + { + $schema = $this->getDatabase(); + $collection = $this->getNamespace() . '_' . $this->filter($collection); + + try { + $stmt = $this->getPDO()->prepare(' + SELECT + INDEX_NAME as indexName, + COLUMN_NAME as columnName, + NON_UNIQUE as nonUnique, + SEQ_IN_INDEX as seqInIndex, + INDEX_TYPE as indexType, + SUB_PART as subPart + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table + ORDER BY INDEX_NAME, SEQ_IN_INDEX + '); + $stmt->bindParam(':schema', $schema); + $stmt->bindParam(':table', $collection); + $stmt->execute(); + $rows = $stmt->fetchAll(); + $stmt->closeCursor(); + + $grouped = []; + foreach ($rows as $row) { + $name = $row['indexName']; + if (!isset($grouped[$name])) { + $grouped[$name] = [ + '$id' => $name, + 'indexName' => $name, + 'indexType' => $row['indexType'], + 'nonUnique' => (int)$row['nonUnique'], + 'columns' => [], + 'lengths' => [], + ]; + } + $grouped[$name]['columns'][] = $row['columnName']; + $grouped[$name]['lengths'][] = $row['subPart'] !== null ? (int)$row['subPart'] : null; + } + + return \array_map(fn ($idx) => new Document($idx), \array_values($grouped)); + } catch (PDOException $e) { + throw new DatabaseException('Failed to get schema indexes', $e->getCode(), $e); + } + } + /** * Set max execution time * @param int $milliseconds diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 789aa691a..a61a59c3a 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -414,8 +414,10 @@ public function createCollection(string $name, array $attributes = [], array $in { $id = $this->getNamespace() . '_' . $this->filter($name); - // For metadata collections outside transactions, check if exists first - if (!$this->inTransaction && $name === Database::METADATA && $this->exists($this->getNamespace(), $name)) { + // In shared-tables mode or for metadata, the physical collection may + // already exist for another tenant. Return early to avoid a + // "Collection Exists" exception from the client. + if (!$this->inTransaction && ($this->getSharedTables() || $name === Database::METADATA) && $this->exists($this->getNamespace(), $name)) { return true; } @@ -428,6 +430,16 @@ public function createCollection(string $name, array $attributes = [], array $in if ($e instanceof DuplicateException) { return true; } + // Client throws code-0 "Collection Exists" when its pre-check + // finds the collection. In shared-tables/metadata context this + // is a no-op; otherwise re-throw as DuplicateException so + // Database::createCollection() can run orphan reconciliation. + if ($e->getCode() === 0 && stripos($e->getMessage(), 'Collection Exists') !== false) { + if ($this->getSharedTables() || $name === Database::METADATA) { + return true; + } + throw new DuplicateException('Collection already exists', $e->getCode(), $e); + } throw $e; } @@ -3599,6 +3611,16 @@ public function getSchemaAttributes(string $collection): array return []; } + public function getSupportForSchemaIndexes(): bool + { + return false; + } + + public function getSchemaIndexes(string $collection): array + { + return []; + } + /** * @param string $collection * @param array $tenants diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 3128d97ed..e89be89ac 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -563,6 +563,16 @@ public function getSchemaAttributes(string $collection): array return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForSchemaIndexes(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSchemaIndexes(string $collection): array + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getTenantQuery(string $collection, string $alias = ''): string { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 2af11aea3..8dcf72025 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -439,7 +439,11 @@ public function deleteCollection(string $id): bool $sql = "DROP TABLE {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')}"; $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); - return $this->getPDO()->prepare($sql)->execute(); + try { + return $this->getPDO()->prepare($sql)->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } } /** @@ -2142,6 +2146,11 @@ public function getSupportForSchemaAttributes(): bool return false; } + public function getSupportForSchemaIndexes(): bool + { + return false; + } + public function getSupportForUpserts(): bool { return true; @@ -2229,6 +2238,11 @@ protected function processException(PDOException $e): \Exception return new LimitException('Datetime field overflow', $e->getCode(), $e); } + // Unknown table + if ($e->getCode() === '42P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new NotFoundException('Collection not found', $e->getCode(), $e); + } + // Unknown column if ($e->getCode() === "42703" && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { return new NotFoundException('Attribute not found', $e->getCode(), $e); diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index fb949dfa4..8f0bd2db2 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2328,6 +2328,16 @@ public function getSchemaAttributes(string $collection): array return []; } + public function getSchemaIndexes(string $collection): array + { + return []; + } + + public function getSupportForSchemaIndexes(): bool + { + return false; + } + public function getTenantQuery( string $collection, string $alias = '', diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 12f2406f4..3c25987eb 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -974,6 +974,11 @@ public function getSupportForSchemaAttributes(): bool return false; } + public function getSupportForSchemaIndexes(): bool + { + return false; + } + /** * Is upsert supported? * @@ -1312,6 +1317,16 @@ protected function processException(PDOException $e): \Exception return new TimeoutException('Query timed out', $e->getCode(), $e); } + // Table/index already exists (SQLITE_ERROR with "already exists" message) + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'already exists') !== false) { + return new DuplicateException('Collection already exists', $e->getCode(), $e); + } + + // Table not found (SQLITE_ERROR with "no such table" message) + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'no such table') !== false) { + return new NotFoundException('Collection not found', $e->getCode(), $e); + } + // Duplicate - SQLite uses various error codes for constraint violations: // - Error code 19 is SQLITE_CONSTRAINT (includes UNIQUE violations) // - Error code 1 is also used for some duplicate cases @@ -1320,7 +1335,6 @@ protected function processException(PDOException $e): \Exception ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1 || $e->errorInfo[1] === 19)) || $e->getCode() === '23000' ) { - // Check if it's actually a duplicate/unique constraint violation $message = $e->getMessage(); if ( (isset($e->errorInfo[1]) && $e->errorInfo[1] === 19) || diff --git a/src/Database/Database.php b/src/Database/Database.php index 72480760e..ffb0ff4da 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1790,19 +1790,31 @@ public function createCollection(string $id, array $attributes = [], array $inde } } + $createdPhysicalTable = false; + try { $this->adapter->createCollection($id, $attributes, $indexes); + $createdPhysicalTable = true; } catch (DuplicateException $e) { - // Metadata check (above) already verified collection is absent - // from metadata. A DuplicateException from the adapter means the - // collection exists only in physical schema — an orphan from a prior - // partial failure. Drop and recreate to ensure schema matches. - try { - $this->adapter->deleteCollection($id); - } catch (NotFoundException) { - // Already removed by a concurrent reconciler. + if ($this->adapter->getSharedTables() + && ($id === self::METADATA || $this->adapter->exists($this->adapter->getDatabase(), $id))) { + // In shared-tables mode the physical table is reused across + // tenants. A DuplicateException simply means the table already + // exists for another tenant — not an orphan. + } else { + // Metadata check (above) already verified collection is absent + // from metadata. A DuplicateException from the adapter means + // the collection exists only in physical schema — an orphan + // from a prior partial failure. Drop and recreate to ensure + // schema matches. + try { + $this->adapter->deleteCollection($id); + } catch (NotFoundException) { + // Already removed by a concurrent reconciler. + } + $this->adapter->createCollection($id, $attributes, $indexes); + $createdPhysicalTable = true; } - $this->adapter->createCollection($id, $attributes, $indexes); } if ($id === self::METADATA) { @@ -1812,10 +1824,12 @@ public function createCollection(string $id, array $attributes = [], array $inde try { $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); } catch (\Throwable $e) { - try { - $this->cleanupCollection($id); - } catch (\Throwable $e) { - Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); + if ($createdPhysicalTable) { + try { + $this->cleanupCollection($id); + } catch (\Throwable $e) { + Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); + } } throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); } @@ -4560,18 +4574,49 @@ public function createIndex(string $collection, string $id, string $type, array } $created = false; + $existsInSchema = false; - try { - $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes, [], $ttl); + if ($this->adapter->getSupportForSchemaIndexes() + && !($this->adapter->getSharedTables() && $this->isMigrating())) { + $schemaIndexes = $this->getSchemaIndexes($collection->getId()); + $filteredId = $this->adapter->filter($id); - if (!$created) { - throw new DatabaseException('Failed to create index'); + foreach ($schemaIndexes as $schemaIndex) { + if (\strtolower($schemaIndex->getId()) === \strtolower($filteredId)) { + $schemaColumns = $schemaIndex->getAttribute('columns', []); + $schemaLengths = $schemaIndex->getAttribute('lengths', []); + + $filteredAttributes = \array_map(fn ($a) => $this->adapter->filter($a), $attributes); + $match = ($schemaColumns === $filteredAttributes && $schemaLengths === $lengths); + + if ($match) { + $existsInSchema = true; + } else { + // Orphan index with wrong definition — drop so it + // gets recreated with the correct shape. + try { + $this->adapter->deleteIndex($collection->getId(), $id); + } catch (NotFoundException) { + } + } + break; + } + } + } + + if (!$existsInSchema) { + try { + $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes, [], $ttl); + + if (!$created) { + throw new DatabaseException('Failed to create index'); + } + } catch (DuplicateException) { + // Metadata check (lines above) already verified index is absent + // from metadata. A DuplicateException from the adapter means the + // index exists only in physical schema — an orphan from a prior + // partial failure. Skip creation and proceed to metadata update. } - } catch (DuplicateException $e) { - // Metadata check (lines above) already verified index is absent - // from metadata. A DuplicateException from the adapter means the - // index exists only in physical schema — an orphan from a prior - // partial failure. Skip creation and proceed to metadata update. } $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); @@ -9206,6 +9251,15 @@ public function getSchemaAttributes(string $collection): array return $this->adapter->getSchemaAttributes($collection); } + /** + * @param string $collection + * @return array + */ + public function getSchemaIndexes(string $collection): array + { + return $this->adapter->getSchemaIndexes($collection); + } + /** * @param string $collectionId * @param string|null $documentId diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index ccf884f5c..f2487c197 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1326,6 +1326,142 @@ public function testSharedTablesDuplicates(): void ->setDatabase($schema); } + public function testSharedTablesMultiTenantCreateCollection(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $sharedTables = $database->getSharedTables(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $originalTenant = $database->getTenant(); + $createdDb = false; + + if ($sharedTables) { + // Already in shared-tables mode (SharedTables/* test classes) + } elseif ($database->getAdapter()->getSupportForSchemas()) { + $dbName = 'stMultiTenant'; + if ($database->exists($dbName)) { + $database->setDatabase($dbName)->delete(); + } + $database + ->setDatabase($dbName) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(10) + ->create(); + $createdDb = true; + } else { + $this->expectNotToPerformAssertions(); + return; + } + + try { + $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 10 : 'tenant_10'; + $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 20 : 'tenant_20'; + $colName = 'multiTenantCol'; + + $database->setTenant($tenant1); + + $database->createCollection($colName, [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + ]), + ]); + + $col1 = $database->getCollection($colName); + $this->assertFalse($col1->isEmpty()); + $this->assertEquals(1, \count($col1->getAttribute('attributes'))); + + $database->setTenant($tenant2); + + $database->createCollection($colName, [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + ]), + ]); + + $col2 = $database->getCollection($colName); + $this->assertFalse($col2->isEmpty()); + $this->assertEquals(1, \count($col2->getAttribute('attributes'))); + + $database->setTenant($tenant1); + $col1Again = $database->getCollection($colName); + $this->assertFalse($col1Again->isEmpty()); + + if ($createdDb) { + $database->delete(); + } else { + $database->setTenant($tenant1); + $database->deleteCollection($colName); + $database->setTenant($tenant2); + $database->deleteCollection($colName); + } + } finally { + $database + ->setSharedTables($sharedTables) + ->setNamespace($namespace) + ->setDatabase($schema) + ->setTenant($originalTenant); + } + } + + public function testSharedTablesMultiTenantCreate(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $sharedTables = $database->getSharedTables(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $originalTenant = $database->getTenant(); + + try { + $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 100 : 'tenant_100'; + $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 200 : 'tenant_200'; + + if ($sharedTables) { + // Already in shared-tables mode; create() should be idempotent. + // No assertion on exists() since SQLite always returns false for + // database-level exists. The test verifies create() doesn't throw. + $database->setTenant($tenant1); + $database->create(); + $database->setTenant($tenant2); + $database->create(); + $this->assertTrue(true); + } elseif ($database->getAdapter()->getSupportForSchemas()) { + $dbName = 'stMultiCreate'; + if ($database->exists($dbName)) { + $database->setDatabase($dbName)->delete(); + } + $database + ->setDatabase($dbName) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant($tenant1) + ->create(); + $this->assertTrue($database->exists($dbName)); + $database->setTenant($tenant2); + $database->create(); + $this->assertTrue($database->exists($dbName)); + $database->delete(); + } else { + $this->expectNotToPerformAssertions(); + return; + } + } finally { + $database + ->setSharedTables($sharedTables) + ->setNamespace($namespace) + ->setDatabase($schema) + ->setTenant($originalTenant); + } + } + public function testEvents(): void { $this->getDatabase()->getAuthorization()->skip(function () {