Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 4 additions & 2 deletions src/Database/Adapter/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
11 changes: 10 additions & 1 deletion src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

/**
Expand Down Expand Up @@ -2229,6 +2233,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);
Expand Down
41 changes: 28 additions & 13 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -1793,16 +1793,27 @@ public function createCollection(string $id, array $attributes = [], array $inde
try {
$this->adapter->createCollection($id, $attributes, $indexes);
} 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()) {
// 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. Verify the table
// is actually present before writing tenant metadata.
if ($id !== self::METADATA && !$this->adapter->exists($this->adapter->getDatabase(), $id)) {
throw $e;
}
} 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);
}
Comment thread
abnegate marked this conversation as resolved.
$this->adapter->createCollection($id, $attributes, $indexes);
}

if ($id === self::METADATA) {
Expand All @@ -1812,10 +1823,14 @@ 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 (!$this->adapter->getSharedTables()) {
try {
$this->cleanupCollection($id);
} catch (\Throwable $e) {
Console::error("Failed to rollback collection '{$id}': " . $e->getMessage());
}
} else {
Console::warning("createCollection '{$id}' metadata failed in shared-tables mode; physical table retained. Metadata document must be re-created.");
}
throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e);
}
Expand Down
132 changes: 132 additions & 0 deletions tests/e2e/Adapter/Scopes/CollectionTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,138 @@ 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;
}

$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);
}

$database
->setSharedTables($sharedTables)
->setNamespace($namespace)
->setDatabase($schema)
->setTenant($originalTenant);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public function testSharedTablesMultiTenantCreate(): void
{
/** @var Database $database */
$database = $this->getDatabase();
$sharedTables = $database->getSharedTables();
$namespace = $database->getNamespace();
$schema = $database->getDatabase();
$originalTenant = $database->getTenant();
$createdDb = false;

$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
$database->setTenant($tenant1);
$database->create();
$database->setTenant($tenant2);
$database->create();
$this->assertTrue($database->exists());
} 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();
$createdDb = true;
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
$this->expectNotToPerformAssertions();
return;
}

$database
->setSharedTables($sharedTables)
->setNamespace($namespace)
->setDatabase($schema)
->setTenant($originalTenant);
}

public function testEvents(): void
{
$this->getDatabase()->getAuthorization()->skip(function () {
Expand Down
Loading