Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions src/Database/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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?
*
Expand Down Expand Up @@ -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<Document>
* @throws DatabaseException
*/
abstract public function getSchemaIndexes(string $collection): array;

/**
* Get the expected column type for a given attribute type.
*
Expand Down
52 changes: 52 additions & 0 deletions src/Database/Adapter/MariaDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 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 All @@ -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;
}

Expand Down Expand Up @@ -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<int|string> $tenants
Expand Down
10 changes: 10 additions & 0 deletions src/Database/Adapter/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
16 changes: 15 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 @@ -2142,6 +2146,11 @@ public function getSupportForSchemaAttributes(): bool
return false;
}

public function getSupportForSchemaIndexes(): bool
{
return false;
}

public function getSupportForUpserts(): bool
{
return true;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/Database/Adapter/SQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '',
Expand Down
16 changes: 15 additions & 1 deletion src/Database/Adapter/SQLite.php
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,11 @@ public function getSupportForSchemaAttributes(): bool
return false;
}

public function getSupportForSchemaIndexes(): bool
{
return false;
}

/**
* Is upsert supported?
*
Expand Down Expand Up @@ -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
Expand All @@ -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) ||
Expand Down
98 changes: 76 additions & 22 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -9206,6 +9251,15 @@ public function getSchemaAttributes(string $collection): array
return $this->adapter->getSchemaAttributes($collection);
}

/**
* @param string $collection
* @return array<Document>
*/
public function getSchemaIndexes(string $collection): array
{
return $this->adapter->getSchemaIndexes($collection);
}

/**
* @param string $collectionId
* @param string|null $documentId
Expand Down
Loading
Loading