diff --git a/src/Database/Database.php b/src/Database/Database.php index c343191b5..9f2973bcd 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3,6 +3,7 @@ namespace Utopia\Database; use Exception; +use Swoole\Coroutine; use Throwable; use Utopia\Cache\Cache; use Utopia\CLI\Console; @@ -1374,7 +1375,11 @@ public function create(?string $database = null): bool $this->silent(fn () => $this->createCollection(self::METADATA, $attributes)); - $this->trigger(self::EVENT_DATABASE_CREATE, $database); + try { + $this->trigger(self::EVENT_DATABASE_CREATE, $database); + } catch (\Throwable $e) { + // Ignore + } return true; } @@ -1404,7 +1409,11 @@ public function list(): array { $databases = $this->adapter->list(); - $this->trigger(self::EVENT_DATABASE_LIST, $databases); + try { + $this->trigger(self::EVENT_DATABASE_LIST, $databases); + } catch (\Throwable $e) { + // Ignore + } return $databases; } @@ -1422,10 +1431,14 @@ public function delete(?string $database = null): bool $deleted = $this->adapter->delete($database); - $this->trigger(self::EVENT_DATABASE_DELETE, [ - 'name' => $database, - 'deleted' => $deleted - ]); + try { + $this->trigger(self::EVENT_DATABASE_DELETE, [ + 'name' => $database, + 'deleted' => $deleted + ]); + } catch (\Throwable $e) { + // Ignore + } $this->cache->flush(); @@ -1565,8 +1578,11 @@ public function createCollection(string $id, array $attributes = [], array $inde } } + $created = false; + try { $this->adapter->createCollection($id, $attributes, $indexes); + $created = true; } catch (DuplicateException $e) { // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { @@ -1578,9 +1594,24 @@ public function createCollection(string $id, array $attributes = [], array $inde return new Document(self::COLLECTION); } - $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); + try { + $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); + } catch (\Throwable $e) { + if ($created) { + 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); + } - $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); + try { + $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); + } catch (\Throwable $e) { + // Ignore + } return $createdCollection; } @@ -1624,7 +1655,11 @@ public function updateCollection(string $id, array $permissions, bool $documentS $collection = $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - $this->trigger(self::EVENT_COLLECTION_UPDATE, $collection); + try { + $this->trigger(self::EVENT_COLLECTION_UPDATE, $collection); + } catch (\Throwable $e) { + // Ignore + } return $collection; } @@ -1650,7 +1685,11 @@ public function getCollection(string $id): Document return new Document(); } - $this->trigger(self::EVENT_COLLECTION_READ, $collection); + try { + $this->trigger(self::EVENT_COLLECTION_READ, $collection); + } catch (\Throwable $e) { + // Ignore + } return $collection; } @@ -1671,7 +1710,11 @@ public function listCollections(int $limit = 25, int $offset = 0): array Query::offset($offset) ])); - $this->trigger(self::EVENT_COLLECTION_LIST, $result); + try { + $this->trigger(self::EVENT_COLLECTION_LIST, $result); + } catch (\Throwable $e) { + // Ignore + } return $result; } @@ -1781,7 +1824,11 @@ public function deleteCollection(string $id): bool } if ($deleted) { - $this->trigger(self::EVENT_COLLECTION_DELETE, $collection); + try { + $this->trigger(self::EVENT_COLLECTION_DELETE, $collection); + } catch (\Throwable $e) { + // Ignore + } } $this->purgeCachedCollection($id); @@ -1843,11 +1890,7 @@ public function createAttribute(string $collection, string $id, string $type, in $filters ); - $collection->setAttribute( - 'attributes', - $attribute, - Document::SET_TYPE_APPEND - ); + $created = false; try { $created = $this->adapter->createAttribute($collection->getId(), $id, $type, $size, $signed, $array, $required); @@ -1862,14 +1905,32 @@ public function createAttribute(string $collection, string $id, string $type, in } } - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedDocument(self::METADATA, $collection->getId()); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->cleanupAttribute($collection->getId(), $id), + shouldRollback: $created, + operationDescription: "attribute creation '{$id}'" + ); - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); + + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection->getId(), + '$collection' => self::METADATA + ])); + } catch (\Throwable $e) { + // Ignore + } + + try { + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); + } catch (\Throwable $e) { + // Ignore + } return true; } @@ -1947,15 +2008,11 @@ public function createAttributes(string $collection, array $attributes): bool $attribute['filters'] ); - $collection->setAttribute( - 'attributes', - $attributeDocument, - Document::SET_TYPE_APPEND - ); - $attributeDocuments[] = $attributeDocument; } + $created = false; + try { $created = $this->adapter->createAttributes($collection->getId(), $attributes); @@ -1970,14 +2027,35 @@ public function createAttributes(string $collection, array $attributes): bool } } - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + foreach ($attributeDocuments as $attributeDocument) { + $collection->setAttribute('attributes', $attributeDocument, Document::SET_TYPE_APPEND); } - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedDocument(self::METADATA, $collection->getId()); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->cleanupAttributes($collection->getId(), $attributeDocuments), + shouldRollback: $created, + operationDescription: 'attributes creation', + rollbackReturnsErrors: true + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attributeDocuments); + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection->getId(), + '$collection' => self::METADATA + ])); + } catch (\Throwable $e) { + // Ignore + } + + try { + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attributeDocuments); + } catch (\Throwable $e) { + // Ignore + } return true; } @@ -2262,12 +2340,14 @@ protected function updateIndexMeta(string $collection, string $id, callable $upd // Execute update from callback $updateCallback($indexes[$index], $collection, $index); - // Save $collection->setAttribute('indexes', $indexes); - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $indexes[$index]); + $this->updateMetadata( + collection: $collection, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "index metadata update '{$id}'" + ); return $indexes[$index]; } @@ -2301,12 +2381,20 @@ protected function updateAttributeMeta(string $collection, string $id, callable // Execute update from callback $updateCallback($attributes[$index], $collection, $index); - // Save $collection->setAttribute('attributes', $attributes); - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + $this->updateMetadata( + collection: $collection, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "attribute metadata update '{$id}'" + ); - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attributes[$index]); + try { + $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attributes[$index]); + } catch (\Throwable $e) { + // Ignore + } return $attributes[$index]; } @@ -2426,255 +2514,312 @@ public function updateAttributeDefault(string $collection, string $id, mixed $de */ public function updateAttribute(string $collection, string $id, ?string $type = null, ?int $size = null, ?bool $required = null, mixed $default = null, ?bool $signed = null, ?bool $array = null, ?string $format = null, ?array $formatOptions = null, ?array $filters = null, ?string $newKey = null): Document { - return $this->updateAttributeMeta($collection, $id, function ($attribute, $collectionDoc, $attributeIndex) use ($collection, $id, $type, $size, $required, $default, $signed, $array, $format, $formatOptions, $filters, $newKey) { + $collectionDoc = $this->silent(fn () => $this->getCollection($collection)); - // Store original indexes before any modifications (deep copy preserving Document objects) - $originalIndexes = []; - foreach ($collectionDoc->getAttribute('indexes', []) as $index) { - $originalIndexes[] = clone $index; - } + if ($collectionDoc->getId() === self::METADATA) { + throw new DatabaseException('Cannot update metadata attributes'); + } - $altering = !\is_null($type) - || !\is_null($size) - || !\is_null($signed) - || !\is_null($array) - || !\is_null($newKey); - $type ??= $attribute->getAttribute('type'); - $size ??= $attribute->getAttribute('size'); - $signed ??= $attribute->getAttribute('signed'); - $required ??= $attribute->getAttribute('required'); - $default ??= $attribute->getAttribute('default'); - $array ??= $attribute->getAttribute('array'); - $format ??= $attribute->getAttribute('format'); - $formatOptions ??= $attribute->getAttribute('formatOptions'); - $filters ??= $attribute->getAttribute('filters'); + $attributes = $collectionDoc->getAttribute('attributes', []); + $attributeIndex = \array_search($id, \array_map(fn ($attribute) => $attribute['$id'], $attributes)); - if ($required === true && !\is_null($default)) { - $default = null; - } + if ($attributeIndex === false) { + throw new NotFoundException('Attribute not found'); + } - // we need to alter table attribute type to NOT NULL/NULL for change in required - if (!$this->adapter->getSupportForSpatialIndexNull() && in_array($type, Database::SPATIAL_TYPES)) { - $altering = true; - } + $attribute = $attributes[$attributeIndex]; - switch ($type) { - case self::VAR_STRING: - if (empty($size)) { - throw new DatabaseException('Size length is required'); - } + $originalType = $attribute->getAttribute('type'); + $originalSize = $attribute->getAttribute('size'); + $originalSigned = $attribute->getAttribute('signed'); + $originalArray = $attribute->getAttribute('array'); + $originalRequired = $attribute->getAttribute('required'); + $originalKey = $attribute->getAttribute('key'); - if ($size > $this->adapter->getLimitForString()) { - throw new DatabaseException('Max size allowed for string is: ' . number_format($this->adapter->getLimitForString())); - } - break; + $originalIndexes = []; + foreach ($collectionDoc->getAttribute('indexes', []) as $index) { + $originalIndexes[] = clone $index; + } - case self::VAR_INTEGER: - $limit = ($signed) ? $this->adapter->getLimitForInt() / 2 : $this->adapter->getLimitForInt(); - if ($size > $limit) { - throw new DatabaseException('Max size allowed for int is: ' . number_format($limit)); - } - break; - case self::VAR_FLOAT: - case self::VAR_BOOLEAN: - case self::VAR_DATETIME: - if (!empty($size)) { - throw new DatabaseException('Size must be empty'); - } - break; + $altering = !\is_null($type) + || !\is_null($size) + || !\is_null($signed) + || !\is_null($array) + || !\is_null($newKey); + $type ??= $attribute->getAttribute('type'); + $size ??= $attribute->getAttribute('size'); + $signed ??= $attribute->getAttribute('signed'); + $required ??= $attribute->getAttribute('required'); + $default ??= $attribute->getAttribute('default'); + $array ??= $attribute->getAttribute('array'); + $format ??= $attribute->getAttribute('format'); + $formatOptions ??= $attribute->getAttribute('formatOptions'); + $filters ??= $attribute->getAttribute('filters'); - case self::VAR_POINT: - case self::VAR_LINESTRING: - case self::VAR_POLYGON: - if (!$this->adapter->getSupportForSpatialAttributes()) { - throw new DatabaseException('Spatial attributes are not supported'); - } - if (!empty($size)) { - throw new DatabaseException('Size must be empty for spatial attributes'); - } - if (!empty($array)) { - throw new DatabaseException('Spatial attributes cannot be arrays'); - } - break; - case self::VAR_VECTOR: - if (!$this->adapter->getSupportForVectors()) { - throw new DatabaseException('Vector types are not supported by the current database'); - } - if ($array) { - throw new DatabaseException('Vector type cannot be an array'); - } - if ($size <= 0) { - throw new DatabaseException('Vector dimensions must be a positive integer'); + if ($required === true && !\is_null($default)) { + $default = null; + } + + // we need to alter table attribute type to NOT NULL/NULL for change in required + if (!$this->adapter->getSupportForSpatialIndexNull() && in_array($type, Database::SPATIAL_TYPES)) { + $altering = true; + } + + switch ($type) { + case self::VAR_STRING: + if (empty($size)) { + throw new DatabaseException('Size length is required'); + } + + if ($size > $this->adapter->getLimitForString()) { + throw new DatabaseException('Max size allowed for string is: ' . number_format($this->adapter->getLimitForString())); + } + break; + + case self::VAR_INTEGER: + $limit = ($signed) ? $this->adapter->getLimitForInt() / 2 : $this->adapter->getLimitForInt(); + if ($size > $limit) { + throw new DatabaseException('Max size allowed for int is: ' . number_format($limit)); + } + break; + case self::VAR_FLOAT: + case self::VAR_BOOLEAN: + case self::VAR_DATETIME: + if (!empty($size)) { + throw new DatabaseException('Size must be empty'); + } + break; + + case self::VAR_POINT: + case self::VAR_LINESTRING: + case self::VAR_POLYGON: + if (!$this->adapter->getSupportForSpatialAttributes()) { + throw new DatabaseException('Spatial attributes are not supported'); + } + if (!empty($size)) { + throw new DatabaseException('Size must be empty for spatial attributes'); + } + if (!empty($array)) { + throw new DatabaseException('Spatial attributes cannot be arrays'); + } + break; + case self::VAR_VECTOR: + if (!$this->adapter->getSupportForVectors()) { + throw new DatabaseException('Vector types are not supported by the current database'); + } + if ($array) { + throw new DatabaseException('Vector type cannot be an array'); + } + if ($size <= 0) { + throw new DatabaseException('Vector dimensions must be a positive integer'); + } + if ($size > self::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed ' . self::MAX_VECTOR_DIMENSIONS); + } + if ($default !== null) { + if (!\is_array($default)) { + throw new DatabaseException('Vector default value must be an array'); } - if ($size > self::MAX_VECTOR_DIMENSIONS) { - throw new DatabaseException('Vector dimensions cannot exceed ' . self::MAX_VECTOR_DIMENSIONS); + if (\count($default) !== $size) { + throw new DatabaseException('Vector default value must have exactly ' . $size . ' elements'); } - if ($default !== null) { - if (!\is_array($default)) { - throw new DatabaseException('Vector default value must be an array'); - } - if (\count($default) !== $size) { - throw new DatabaseException('Vector default value must have exactly ' . $size . ' elements'); - } - foreach ($default as $component) { - if (!\is_int($component) && !\is_float($component)) { - throw new DatabaseException('Vector default value must contain only numeric elements'); - } + foreach ($default as $component) { + if (!\is_int($component) && !\is_float($component)) { + throw new DatabaseException('Vector default value must contain only numeric elements'); } } - break; - default: - $supportedTypes = [ - self::VAR_STRING, - self::VAR_INTEGER, - self::VAR_FLOAT, - self::VAR_BOOLEAN, - self::VAR_DATETIME, - self::VAR_RELATIONSHIP - ]; - if ($this->adapter->getSupportForVectors()) { - $supportedTypes[] = self::VAR_VECTOR; - } - if ($this->adapter->getSupportForSpatialAttributes()) { - \array_push($supportedTypes, ...self::SPATIAL_TYPES); - } - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); - } + } + break; + default: + $supportedTypes = [ + self::VAR_STRING, + self::VAR_INTEGER, + self::VAR_FLOAT, + self::VAR_BOOLEAN, + self::VAR_DATETIME, + self::VAR_RELATIONSHIP + ]; + if ($this->adapter->getSupportForVectors()) { + $supportedTypes[] = self::VAR_VECTOR; + } + if ($this->adapter->getSupportForSpatialAttributes()) { + \array_push($supportedTypes, ...self::SPATIAL_TYPES); + } + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); + } + + /** Ensure required filters for the attribute are passed */ + $requiredFilters = $this->getRequiredFilters($type); + if (!empty(array_diff($requiredFilters, $filters))) { + throw new DatabaseException("Attribute of type: $type requires the following filters: " . implode(",", $requiredFilters)); + } - /** Ensure required filters for the attribute are passed */ - $requiredFilters = $this->getRequiredFilters($type); - if (!empty(array_diff($requiredFilters, $filters))) { - throw new DatabaseException("Attribute of type: $type requires the following filters: " . implode(",", $requiredFilters)); + if ($format) { + if (!Structure::hasFormat($format, $type)) { + throw new DatabaseException('Format ("' . $format . '") not available for this attribute type ("' . $type . '")'); } + } - if ($format) { - if (!Structure::hasFormat($format, $type)) { - throw new DatabaseException('Format ("' . $format . '") not available for this attribute type ("' . $type . '")'); - } + if (!\is_null($default)) { + if ($required) { + throw new DatabaseException('Cannot set a default value on a required attribute'); } - if (!\is_null($default)) { - if ($required) { - throw new DatabaseException('Cannot set a default value on a required attribute'); - } + $this->validateDefaultTypes($type, $default); + } - $this->validateDefaultTypes($type, $default); - } + $attribute + ->setAttribute('$id', $newKey ?? $id) + ->setattribute('key', $newKey ?? $id) + ->setAttribute('type', $type) + ->setAttribute('size', $size) + ->setAttribute('signed', $signed) + ->setAttribute('array', $array) + ->setAttribute('format', $format) + ->setAttribute('formatOptions', $formatOptions) + ->setAttribute('filters', $filters) + ->setAttribute('required', $required) + ->setAttribute('default', $default); - $attribute - ->setAttribute('$id', $newKey ?? $id) - ->setattribute('key', $newKey ?? $id) - ->setAttribute('type', $type) - ->setAttribute('size', $size) - ->setAttribute('signed', $signed) - ->setAttribute('array', $array) - ->setAttribute('format', $format) - ->setAttribute('formatOptions', $formatOptions) - ->setAttribute('filters', $filters) - ->setAttribute('required', $required) - ->setAttribute('default', $default); + $attributes = $collectionDoc->getAttribute('attributes'); + $attributes[$attributeIndex] = $attribute; + $collectionDoc->setAttribute('attributes', $attributes, Document::SET_TYPE_ASSIGN); - $attributes = $collectionDoc->getAttribute('attributes'); - $attributes[$attributeIndex] = $attribute; - $collectionDoc->setAttribute('attributes', $attributes, Document::SET_TYPE_ASSIGN); + if ( + $this->adapter->getDocumentSizeLimit() > 0 && + $this->adapter->getAttributeWidth($collectionDoc) >= $this->adapter->getDocumentSizeLimit() + ) { + throw new LimitException('Row width limit reached. Cannot update attribute.'); + } - if ( - $this->adapter->getDocumentSizeLimit() > 0 && - $this->adapter->getAttributeWidth($collectionDoc) >= $this->adapter->getDocumentSizeLimit() - ) { - throw new LimitException('Row width limit reached. Cannot update attribute.'); + if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) { + $attributeMap = []; + foreach ($attributes as $attrDoc) { + $key = \strtolower($attrDoc->getAttribute('key', $attrDoc->getAttribute('$id'))); + $attributeMap[$key] = $attrDoc; } - if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) { - $attributeMap = []; - foreach ($attributes as $attrDoc) { - $key = \strtolower($attrDoc->getAttribute('key', $attrDoc->getAttribute('$id'))); - $attributeMap[$key] = $attrDoc; + $indexes = $collectionDoc->getAttribute('indexes', []); + foreach ($indexes as $index) { + if ($index->getAttribute('type') !== self::INDEX_SPATIAL) { + continue; } - - $indexes = $collectionDoc->getAttribute('indexes', []); - foreach ($indexes as $index) { - if ($index->getAttribute('type') !== self::INDEX_SPATIAL) { + $indexAttributes = $index->getAttribute('attributes', []); + foreach ($indexAttributes as $attributeName) { + $lookup = \strtolower($attributeName); + if (!isset($attributeMap[$lookup])) { continue; } - $indexAttributes = $index->getAttribute('attributes', []); - foreach ($indexAttributes as $attributeName) { - $lookup = \strtolower($attributeName); - if (!isset($attributeMap[$lookup])) { - continue; - } - $attrDoc = $attributeMap[$lookup]; - $attrType = $attrDoc->getAttribute('type'); - $attrRequired = (bool)$attrDoc->getAttribute('required', false); + $attrDoc = $attributeMap[$lookup]; + $attrType = $attrDoc->getAttribute('type'); + $attrRequired = (bool)$attrDoc->getAttribute('required', false); - if (in_array($attrType, self::SPATIAL_TYPES, true) && !$attrRequired) { - throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'); - } + if (in_array($attrType, self::SPATIAL_TYPES, true) && !$attrRequired) { + throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'); } } } + } - if ($altering) { - $indexes = $collectionDoc->getAttribute('indexes'); + $updated = false; - if (!\is_null($newKey) && $id !== $newKey) { - foreach ($indexes as $index) { - if (in_array($id, $index['attributes'])) { - $index['attributes'] = array_map(function ($attribute) use ($id, $newKey) { - return $attribute === $id ? $newKey : $attribute; - }, $index['attributes']); - } - } + if ($altering) { + $indexes = $collectionDoc->getAttribute('indexes'); - /** - * Check index dependency if we are changing the key - */ - $validator = new IndexDependencyValidator( - $collectionDoc->getAttribute('indexes', []), - $this->adapter->getSupportForCastIndexArray(), - ); - - if (!$validator->isValid($attribute)) { - throw new DependencyException($validator->getDescription()); + if (!\is_null($newKey) && $id !== $newKey) { + foreach ($indexes as $index) { + if (in_array($id, $index['attributes'])) { + $index['attributes'] = array_map(function ($attribute) use ($id, $newKey) { + return $attribute === $id ? $newKey : $attribute; + }, $index['attributes']); } } /** - * Since we allow changing type & size we need to validate index length + * Check index dependency if we are changing the key */ - if ($this->validate) { - $validator = new IndexValidator( - $attributes, - $originalIndexes, - $this->adapter->getMaxIndexLength(), - $this->adapter->getInternalIndexesKeys(), - $this->adapter->getSupportForIndexArray(), - $this->adapter->getSupportForSpatialIndexNull(), - $this->adapter->getSupportForSpatialIndexOrder(), - $this->adapter->getSupportForVectors(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForMultipleFulltextIndexes(), - $this->adapter->getSupportForIdenticalIndexes(), - ); + $validator = new IndexDependencyValidator( + $collectionDoc->getAttribute('indexes', []), + $this->adapter->getSupportForCastIndexArray(), + ); - foreach ($indexes as $index) { - if (!$validator->isValid($index)) { - throw new IndexException($validator->getDescription()); - } - } + if (!$validator->isValid($attribute)) { + throw new DependencyException($validator->getDescription()); } + } - $updated = $this->adapter->updateAttribute($collection, $id, $type, $size, $signed, $array, $newKey, $required); + /** + * Since we allow changing type & size we need to validate index length + */ + if ($this->validate) { + $validator = new IndexValidator( + $attributes, + $originalIndexes, + $this->adapter->getMaxIndexLength(), + $this->adapter->getInternalIndexesKeys(), + $this->adapter->getSupportForIndexArray(), + $this->adapter->getSupportForSpatialIndexNull(), + $this->adapter->getSupportForSpatialIndexOrder(), + $this->adapter->getSupportForVectors(), + $this->adapter->getSupportForAttributes(), + $this->adapter->getSupportForMultipleFulltextIndexes(), + $this->adapter->getSupportForIdenticalIndexes(), + ); - if (!$updated) { - throw new DatabaseException('Failed to update attribute'); + foreach ($indexes as $index) { + if (!$validator->isValid($index)) { + throw new IndexException($validator->getDescription()); + } } + } + + $updated = $this->adapter->updateAttribute($collection, $id, $type, $size, $signed, $array, $newKey, $required); - $this->purgeCachedCollection($collection); + if (!$updated) { + throw new DatabaseException('Failed to update attribute'); } + } - $this->purgeCachedDocument(self::METADATA, $collection); - }); + $collectionDoc->setAttribute('attributes', $attributes); + + $this->updateMetadata( + collection: $collectionDoc, + rollbackOperation: fn () => $this->adapter->updateAttribute( + $collection, + $newKey ?? $id, + $originalType, + $originalSize, + $originalSigned, + $originalArray, + $originalKey, + $originalRequired + ), + shouldRollback: $updated, + operationDescription: "attribute update '{$id}'", + silentRollback: true + ); + + if ($altering) { + $this->withRetries(fn () => $this->purgeCachedCollection($collection)); + } + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection)); + + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection, + '$collection' => self::METADATA + ])); + } catch (\Throwable $e) { + // Ignore + } + + try { + $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); + } catch (\Throwable $e) { + // Ignore + } + + return $attribute; } /** @@ -2768,25 +2913,43 @@ public function deleteAttribute(string $collection, string $id): bool } } + $collection->setAttribute('attributes', \array_values($attributes)); + $collection->setAttribute('indexes', \array_values($indexes)); + + $shouldRollback = false; try { if (!$this->adapter->deleteAttribute($collection->getId(), $id)) { throw new DatabaseException('Failed to delete attribute'); } + $shouldRollback = true; } catch (NotFoundException) { // Ignore } - $collection->setAttribute('attributes', \array_values($attributes)); - $collection->setAttribute('indexes', \array_values($indexes)); + $this->updateMetadata( + collection: $collection, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "attribute deletion '{$id}'" + ); - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedDocument(self::METADATA, $collection->getId()); + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection->getId(), + '$collection' => self::METADATA + ])); + } catch (\Throwable $e) { + // Ignore + } - $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $attribute); + try { + $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $attribute); + } catch (\Throwable $e) { + // Ignore + } return true; } @@ -2856,20 +3019,144 @@ public function renameAttribute(string $collection, string $old, string $new): b $index->setAttribute('attributes', $indexAttributes); } - $renamed = $this->adapter->renameAttribute($collection->getId(), $old, $new); + $renamed = false; + try { + $renamed = $this->adapter->renameAttribute($collection->getId(), $old, $new); + if (!$renamed) { + throw new DatabaseException('Failed to rename attribute'); + } + } catch (\Throwable $e) { + throw new DatabaseException("Failed to rename attribute '{$old}' to '{$new}': " . $e->getMessage(), previous: $e); + } $collection->setAttribute('attributes', $attributes); $collection->setAttribute('indexes', $indexes); - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->renameAttribute($collection->getId(), $new, $old), + shouldRollback: $renamed, + operationDescription: "attribute rename '{$old}' to '{$new}'" + ); - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); + try { + $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); + } catch (\Throwable $e) { + // Ignore + } return $renamed; } + /** + * Cleanup (delete) a single attribute with retry logic + * + * @param string $collectionId The collection ID + * @param string $attributeId The attribute ID + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupAttribute( + string $collectionId, + string $attributeId, + int $maxAttempts = 3 + ): void { + $this->cleanup( + fn () => $this->adapter->deleteAttribute($collectionId, $attributeId), + 'attribute', + $attributeId, + $maxAttempts + ); + } + + /** + * Cleanup (delete) multiple attributes with retry logic + * + * @param string $collectionId The collection ID + * @param array $attributeDocuments The attribute documents to cleanup + * @param int $maxAttempts Maximum retry attempts per attribute + * @return array Array of error messages for failed cleanups (empty if all succeeded) + */ + private function cleanupAttributes( + string $collectionId, + array $attributeDocuments, + int $maxAttempts = 3 + ): array { + $errors = []; + + foreach ($attributeDocuments as $attributeDocument) { + try { + $this->cleanupAttribute($collectionId, $attributeDocument->getId(), $maxAttempts); + } catch (DatabaseException $e) { + // Continue cleaning up other attributes even if one fails + $errors[] = $e->getMessage(); + } + } + + return $errors; + } + + /** + * Cleanup (delete) a collection with retry logic + * + * @param string $collectionId The collection ID + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupCollection( + string $collectionId, + int $maxAttempts = 3 + ): void { + $this->cleanup( + fn () => $this->adapter->deleteCollection($collectionId), + 'collection', + $collectionId, + $maxAttempts + ); + } + + /** + * Cleanup (delete) a relationship with retry logic + * + * @param string $collectionId The collection ID + * @param string $relatedCollectionId The related collection ID + * @param string $type The relationship type + * @param bool $twoWay Whether the relationship is two-way + * @param string $key The relationship key + * @param string $twoWayKey The two-way relationship key + * @param string $side The relationship side + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupRelationship( + string $collectionId, + string $relatedCollectionId, + string $type, + bool $twoWay, + string $key, + string $twoWayKey, + string $side = Database::RELATION_SIDE_PARENT, + int $maxAttempts = 3 + ): void { + $this->cleanup( + fn () => $this->adapter->deleteRelationship( + $collectionId, + $relatedCollectionId, + $type, + $twoWay, + $key, + $twoWayKey, + $side + ), + 'relationship', + $key, + $maxAttempts + ); + } + /** * Create a relationship attribute * @@ -2964,11 +3251,10 @@ public function createRelationship( $this->checkAttribute($collection, $relationship); $this->checkAttribute($relatedCollection, $twoWayRelationship); - $collection->setAttribute('attributes', $relationship, Document::SET_TYPE_APPEND); - $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND); - + $junctionCollection = null; if ($type === self::RELATION_MANY_TO_MANY) { - $this->silent(fn () => $this->createCollection('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence(), [ + $junctionCollection = '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence(); + $this->silent(fn () => $this->createCollection($junctionCollection, [ new Document([ '$id' => $id, 'key' => $id, @@ -3015,54 +3301,138 @@ public function createRelationship( ); if (!$created) { + if ($junctionCollection !== null) { + try { + $this->silent(fn () => $this->cleanupCollection($junctionCollection)); + } catch (\Throwable $e) { + Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $e->getMessage()); + } + } throw new DatabaseException('Failed to create relationship'); } - $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey) { + $collection->setAttribute('attributes', $relationship, Document::SET_TYPE_APPEND); + $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND); + + $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $junctionCollection) { + $indexesCreated = []; try { $this->withTransaction(function () use ($collection, $relatedCollection) { $this->updateDocument(self::METADATA, $collection->getId(), $collection); $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); }); } catch (\Throwable $e) { - $this->adapter->deleteRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - Database::RELATION_SIDE_PARENT - ); + $this->rollbackAttributeMetadata($collection, [$id]); + $this->rollbackAttributeMetadata($relatedCollection, [$twoWayKey]); + + try { + $this->cleanupRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey, + Database::RELATION_SIDE_PARENT + ); + } catch (\Throwable $e) { + Console::error("Failed to cleanup relationship '{$id}': " . $e->getMessage()); + } + + if ($junctionCollection !== null) { + try { + $this->cleanupCollection($junctionCollection); + } catch (\Throwable $e) { + Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $e->getMessage()); + } + } throw new DatabaseException('Failed to create relationship: ' . $e->getMessage()); } $indexKey = '_index_' . $id; $twoWayIndexKey = '_index_' . $twoWayKey; + $indexesCreated = []; - switch ($type) { - case self::RELATION_ONE_TO_ONE: - $this->createIndex($collection->getId(), $indexKey, self::INDEX_UNIQUE, [$id]); - if ($twoWay) { - $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_UNIQUE, [$twoWayKey]); + try { + switch ($type) { + case self::RELATION_ONE_TO_ONE: + $this->createIndex($collection->getId(), $indexKey, self::INDEX_UNIQUE, [$id]); + $indexesCreated[] = ['collection' => $collection->getId(), 'index' => $indexKey]; + if ($twoWay) { + $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_UNIQUE, [$twoWayKey]); + $indexesCreated[] = ['collection' => $relatedCollection->getId(), 'index' => $twoWayIndexKey]; + } + break; + case self::RELATION_ONE_TO_MANY: + $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_KEY, [$twoWayKey]); + $indexesCreated[] = ['collection' => $relatedCollection->getId(), 'index' => $twoWayIndexKey]; + break; + case self::RELATION_MANY_TO_ONE: + $this->createIndex($collection->getId(), $indexKey, self::INDEX_KEY, [$id]); + $indexesCreated[] = ['collection' => $collection->getId(), 'index' => $indexKey]; + break; + case self::RELATION_MANY_TO_MANY: + // Indexes created on junction collection creation + break; + default: + throw new RelationshipException('Invalid relationship type.'); + } + } catch (\Throwable $e) { + foreach ($indexesCreated as $indexInfo) { + try { + $this->deleteIndex($indexInfo['collection'], $indexInfo['index']); + } catch (\Throwable $cleanupError) { + Console::error("Failed to cleanup index '{$indexInfo['index']}': " . $cleanupError->getMessage()); } - break; - case self::RELATION_ONE_TO_MANY: - $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_KEY, [$twoWayKey]); - break; - case self::RELATION_MANY_TO_ONE: - $this->createIndex($collection->getId(), $indexKey, self::INDEX_KEY, [$id]); - break; - case self::RELATION_MANY_TO_MANY: - // Indexes created on junction collection creation - break; - default: - throw new RelationshipException('Invalid relationship type.'); + } + + try { + $this->withTransaction(function () use ($collection, $relatedCollection, $id, $twoWayKey) { + $attributes = $collection->getAttribute('attributes', []); + $collection->setAttribute('attributes', array_filter($attributes, fn ($attr) => $attr->getId() !== $id)); + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + + $relatedAttributes = $relatedCollection->getAttribute('attributes', []); + $relatedCollection->setAttribute('attributes', array_filter($relatedAttributes, fn ($attr) => $attr->getId() !== $twoWayKey)); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); + } catch (\Throwable $cleanupError) { + Console::error("Failed to cleanup metadata for relationship '{$id}': " . $cleanupError->getMessage()); + } + + // Cleanup relationship + try { + $this->cleanupRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey, + Database::RELATION_SIDE_PARENT + ); + } catch (\Throwable $cleanupError) { + Console::error("Failed to cleanup relationship '{$id}': " . $cleanupError->getMessage()); + } + + if ($junctionCollection !== null) { + try { + $this->cleanupCollection($junctionCollection); + } catch (\Throwable $cleanupError) { + Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $cleanupError->getMessage()); + } + } + + throw new DatabaseException('Failed to create relationship indexes: ' . $e->getMessage()); } }); - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $relationship); + try { + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $relationship); + } catch (\Throwable $e) { + // Ignore + } return true; } @@ -3120,83 +3490,107 @@ public function updateRelationship( $relatedCollectionId = $attribute['options']['relatedCollection']; $relatedCollection = $this->getCollection($relatedCollectionId); - $this->updateAttributeMeta($collection->getId(), $id, function ($attribute) use ($collection, $id, $newKey, $newTwoWayKey, $twoWay, $onDelete, $type, $side) { - $altering = (!\is_null($newKey) && $newKey !== $id) - || (!\is_null($newTwoWayKey) && $newTwoWayKey !== $attribute['options']['twoWayKey']); + // Determine if we need to alter the database (rename columns/indexes) + $oldAttribute = $attributes[$attributeIndex]; + $oldTwoWayKey = $oldAttribute['options']['twoWayKey']; + $altering = (!\is_null($newKey) && $newKey !== $id) + || (!\is_null($newTwoWayKey) && $newTwoWayKey !== $oldTwoWayKey); - $relatedCollectionId = $attribute['options']['relatedCollection']; - $relatedCollection = $this->getCollection($relatedCollectionId); - $relatedAttributes = $relatedCollection->getAttribute('attributes', []); + // Validate new keys don't already exist + if ( + !\is_null($newTwoWayKey) + && \in_array($newTwoWayKey, \array_map(fn ($attribute) => $attribute['key'], $relatedCollection->getAttribute('attributes', []))) + ) { + throw new DuplicateException('Related attribute already exists'); + } - if ( - !\is_null($newTwoWayKey) - && \in_array($newTwoWayKey, \array_map(fn ($attribute) => $attribute['key'], $relatedAttributes)) - ) { - throw new DuplicateException('Related attribute already exists'); - } + $actualNewKey = $newKey ?? $id; + $actualNewTwoWayKey = $newTwoWayKey ?? $oldTwoWayKey; + $actualTwoWay = $twoWay ?? $oldAttribute['options']['twoWay']; + $actualOnDelete = $onDelete ?? $oldAttribute['options']['onDelete']; - $newKey ??= $attribute['key']; - $twoWayKey = $attribute['options']['twoWayKey']; - $newTwoWayKey ??= $attribute['options']['twoWayKey']; - $twoWay ??= $attribute['options']['twoWay']; - $onDelete ??= $attribute['options']['onDelete']; + $adapterUpdated = false; + if ($altering) { + try { + $adapterUpdated = $this->adapter->updateRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $actualTwoWay, + $id, + $oldTwoWayKey, + $side, + $actualNewKey, + $actualNewTwoWayKey + ); - $attribute->setAttribute('$id', $newKey); - $attribute->setAttribute('key', $newKey); - $attribute->setAttribute('options', [ - 'relatedCollection' => $relatedCollection->getId(), - 'relationType' => $type, - 'twoWay' => $twoWay, - 'twoWayKey' => $newTwoWayKey, - 'onDelete' => $onDelete, - 'side' => $side, - ]); + if (!$adapterUpdated) { + throw new DatabaseException('Failed to update relationship'); + } + } catch (\Throwable $e) { + throw new DatabaseException("Failed to update relationship '{$id}': " . $e->getMessage(), previous: $e); + } + } + try { + $this->updateAttributeMeta($collection->getId(), $id, function ($attribute) use ($actualNewKey, $actualNewTwoWayKey, $actualTwoWay, $actualOnDelete, $relatedCollection, $type, $side) { + $attribute->setAttribute('$id', $actualNewKey); + $attribute->setAttribute('key', $actualNewKey); + $attribute->setAttribute('options', [ + 'relatedCollection' => $relatedCollection->getId(), + 'relationType' => $type, + 'twoWay' => $actualTwoWay, + 'twoWayKey' => $actualNewTwoWayKey, + 'onDelete' => $actualOnDelete, + 'side' => $side, + ]); + }); - $this->updateAttributeMeta($relatedCollection->getId(), $twoWayKey, function ($twoWayAttribute) use ($newKey, $newTwoWayKey, $twoWay, $onDelete) { + $this->updateAttributeMeta($relatedCollection->getId(), $oldTwoWayKey, function ($twoWayAttribute) use ($actualNewKey, $actualNewTwoWayKey, $actualTwoWay, $actualOnDelete) { $options = $twoWayAttribute->getAttribute('options', []); - $options['twoWayKey'] = $newKey; - $options['twoWay'] = $twoWay; - $options['onDelete'] = $onDelete; + $options['twoWayKey'] = $actualNewKey; + $options['twoWay'] = $actualTwoWay; + $options['onDelete'] = $actualOnDelete; - $twoWayAttribute->setAttribute('$id', $newTwoWayKey); - $twoWayAttribute->setAttribute('key', $newTwoWayKey); + $twoWayAttribute->setAttribute('$id', $actualNewTwoWayKey); + $twoWayAttribute->setAttribute('key', $actualNewTwoWayKey); $twoWayAttribute->setAttribute('options', $options); }); if ($type === self::RELATION_MANY_TO_MANY) { $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - $this->updateAttributeMeta($junction, $id, function ($junctionAttribute) use ($newKey) { - $junctionAttribute->setAttribute('$id', $newKey); - $junctionAttribute->setAttribute('key', $newKey); + $this->updateAttributeMeta($junction, $id, function ($junctionAttribute) use ($actualNewKey) { + $junctionAttribute->setAttribute('$id', $actualNewKey); + $junctionAttribute->setAttribute('key', $actualNewKey); }); - $this->updateAttributeMeta($junction, $twoWayKey, function ($junctionAttribute) use ($newTwoWayKey) { - $junctionAttribute->setAttribute('$id', $newTwoWayKey); - $junctionAttribute->setAttribute('key', $newTwoWayKey); + $this->updateAttributeMeta($junction, $oldTwoWayKey, function ($junctionAttribute) use ($actualNewTwoWayKey) { + $junctionAttribute->setAttribute('$id', $actualNewTwoWayKey); + $junctionAttribute->setAttribute('key', $actualNewTwoWayKey); }); - $this->purgeCachedCollection($junction); + $this->withRetries(fn () => $this->purgeCachedCollection($junction)); } - - if ($altering) { - $updated = $this->adapter->updateRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - $side, - $newKey, - $newTwoWayKey - ); - - if (!$updated) { - throw new DatabaseException('Failed to update relationship'); + } catch (\Throwable $e) { + if ($adapterUpdated) { + try { + $this->adapter->updateRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $actualTwoWay, + $actualNewKey, + $actualNewTwoWayKey, + $side, + $id, + $oldTwoWayKey + ); + } catch (\Throwable $e) { + // Ignore } } - }); + throw $e; + } // Update Indexes $renameIndex = function (string $collection, string $key, string $newKey) { @@ -3212,59 +3606,53 @@ function ($index) use ($newKey) { ); }; - $newKey ??= $attribute['key']; - $twoWayKey = $attribute['options']['twoWayKey']; - $newTwoWayKey ??= $attribute['options']['twoWayKey']; - $twoWay ??= $attribute['options']['twoWay']; - $onDelete ??= $attribute['options']['onDelete']; - switch ($type) { case self::RELATION_ONE_TO_ONE: - if ($id !== $newKey) { - $renameIndex($collection->getId(), $id, $newKey); + if ($id !== $actualNewKey) { + $renameIndex($collection->getId(), $id, $actualNewKey); } - if ($twoWay && $twoWayKey !== $newTwoWayKey) { - $renameIndex($relatedCollection->getId(), $twoWayKey, $newTwoWayKey); + if ($actualTwoWay && $oldTwoWayKey !== $actualNewTwoWayKey) { + $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); } break; case self::RELATION_ONE_TO_MANY: if ($side === Database::RELATION_SIDE_PARENT) { - if ($twoWayKey !== $newTwoWayKey) { - $renameIndex($relatedCollection->getId(), $twoWayKey, $newTwoWayKey); + if ($oldTwoWayKey !== $actualNewTwoWayKey) { + $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); } } else { - if ($id !== $newKey) { - $renameIndex($collection->getId(), $id, $newKey); + if ($id !== $actualNewKey) { + $renameIndex($collection->getId(), $id, $actualNewKey); } } break; case self::RELATION_MANY_TO_ONE: if ($side === Database::RELATION_SIDE_PARENT) { - if ($id !== $newKey) { - $renameIndex($collection->getId(), $id, $newKey); + if ($id !== $actualNewKey) { + $renameIndex($collection->getId(), $id, $actualNewKey); } } else { - if ($twoWayKey !== $newTwoWayKey) { - $renameIndex($relatedCollection->getId(), $twoWayKey, $newTwoWayKey); + if ($oldTwoWayKey !== $actualNewTwoWayKey) { + $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); } } break; case self::RELATION_MANY_TO_MANY: $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - if ($id !== $newKey) { - $renameIndex($junction, $id, $newKey); + if ($id !== $actualNewKey) { + $renameIndex($junction, $id, $actualNewKey); } - if ($twoWayKey !== $newTwoWayKey) { - $renameIndex($junction, $twoWayKey, $newTwoWayKey); + if ($oldTwoWayKey !== $actualNewTwoWayKey) { + $renameIndex($junction, $oldTwoWayKey, $actualNewTwoWayKey); } break; default: throw new RelationshipException('Invalid relationship type.'); } - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedCollection($relatedCollection->getId()); + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); return true; } @@ -3319,16 +3707,11 @@ public function deleteRelationship(string $collection, string $id): bool $relatedCollection->setAttribute('attributes', \array_values($relatedAttributes)); - $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $side) { - try { - $this->withTransaction(function () use ($collection, $relatedCollection) { - $this->updateDocument(self::METADATA, $collection->getId(), $collection); - $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); - }); - } catch (\Throwable $e) { - throw new DatabaseException('Failed to delete relationship: ' . $e->getMessage()); - } + $collectionAttributes = $collection->getAttribute('attributes'); + $relatedCollectionAttributes = $relatedCollection->getAttribute('attributes'); + // Delete indexes BEFORE dropping columns to avoid referencing non-existent columns + $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $side) { $indexKey = '_index_' . $id; $twoWayIndexKey = '_index_' . $twoWayKey; @@ -3375,6 +3758,11 @@ public function deleteRelationship(string $collection, string $id): bool } }); + $collection = $this->silent(fn () => $this->getCollection($collection->getId())); + $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollection->getId())); + $collection->setAttribute('attributes', $collectionAttributes); + $relatedCollection->setAttribute('attributes', $relatedCollectionAttributes); + $deleted = $this->adapter->deleteRelationship( $collection->getId(), $relatedCollection->getId(), @@ -3389,10 +3777,27 @@ public function deleteRelationship(string $collection, string $id): bool throw new DatabaseException('Failed to delete relationship'); } - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedCollection($relatedCollection->getId()); + try { + $this->withRetries(function () use ($collection, $relatedCollection) { + $this->silent(function () use ($collection, $relatedCollection) { + $this->withTransaction(function () use ($collection, $relatedCollection) { + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); + }); + }); + } catch (\Throwable $e) { + throw new DatabaseException('Failed to persist metadata after retries: ' . $e->getMessage()); + } + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); - $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $relationship); + try { + $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $relationship); + } catch (\Throwable $e) { + // Ignore + } return true; } @@ -3440,13 +3845,28 @@ public function renameIndex(string $collection, string $old, string $new): bool $collection->setAttribute('indexes', $indexes); - $this->adapter->renameIndex($collection->getId(), $old, $new); - - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + $renamed = false; + try { + $renamed = $this->adapter->renameIndex($collection->getId(), $old, $new); + if (!$renamed) { + throw new DatabaseException('Failed to rename index'); + } + } catch (\Throwable $e) { + throw new DatabaseException("Failed to rename index '{$old}' to '{$new}': " . $e->getMessage(), previous: $e); } - $this->trigger(self::EVENT_INDEX_RENAME, $indexNew); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->renameIndex($collection->getId(), $new, $old), + shouldRollback: $renamed, + operationDescription: "index rename '{$old}' to '{$new}'" + ); + + try { + $this->trigger(self::EVENT_INDEX_RENAME, $indexNew); + } catch (\Throwable $e) { + // Ignore + } return true; } @@ -3590,7 +4010,7 @@ public function createIndex(string $collection, string $id, string $type, array } } - $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); + $created = false; try { $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes); @@ -3600,15 +4020,19 @@ public function createIndex(string $collection, string $id, string $type, array } } catch (DuplicateException $e) { // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. - if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { throw $e; } } - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); + + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->cleanupIndex($collection->getId(), $id), + shouldRollback: $created, + operationDescription: "index creation '{$id}'" + ); $this->trigger(self::EVENT_INDEX_CREATE, $index); @@ -3641,15 +4065,31 @@ public function deleteIndex(string $collection, string $id): bool } } + if (\is_null($indexDeleted)) { + throw new NotFoundException('Index not found'); + } + $deleted = $this->adapter->deleteIndex($collection->getId(), $id); + if (!$deleted) { + throw new DatabaseException('Failed to delete index'); + } + $collection->setAttribute('indexes', \array_values($indexes)); - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $this->updateMetadata( + collection: $collection, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "index deletion '{$id}'" + ); - $this->trigger(self::EVENT_INDEX_DELETE, $indexDeleted); + + try { + $this->trigger(self::EVENT_INDEX_DELETE, $indexDeleted); + } catch (\Throwable $e) { + // Ignore + } return $deleted; } @@ -5912,7 +6352,7 @@ public function upsertDocumentsWithIncrease( $document->getId(), )))); } else { - $old = $this->authorization->skip(fn () => $this->silent(fn () => $this->getDocument( + $old = $this->authorization->skip(fn () => $this->silent(fn () => $this->getDocument( $collection->getId(), $document->getId(), ))); @@ -7018,7 +7458,7 @@ public function purgeCachedCollection(string $collectionId): bool * @return bool * @throws Exception */ - public function purgeCachedDocument(string $collectionId, ?string $id): bool + protected function purgeCachedDocumentInternal(string $collectionId, ?string $id): bool { if ($id === null) { return true; @@ -7029,14 +7469,34 @@ public function purgeCachedDocument(string $collectionId, ?string $id): bool $this->cache->purge($collectionKey, $documentKey); $this->cache->purge($documentKey); - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $id, - '$collection' => $collectionId - ])); - return true; } + /** + * Cleans a specific document from cache and triggers EVENT_DOCUMENT_PURGE. + * And related document reference in the collection cache. + * + * Note: Do not retry this method as it triggers events. Use purgeCachedDocumentInternal() with retry instead. + * + * @param string $collectionId + * @param string|null $id + * @return bool + * @throws Exception + */ + public function purgeCachedDocument(string $collectionId, ?string $id): bool + { + $result = $this->purgeCachedDocumentInternal($collectionId, $id); + + if ($id !== null) { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $id, + '$collection' => $collectionId + ])); + } + + return $result; + } + /** * Find Documents * @@ -7133,7 +7593,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $cursor = $this->adapter->castingBefore($collection, $cursor); $cursor = $cursor->getArrayCopy(); } else { - $cursor = []; + $cursor = []; } /** @var array $queries */ @@ -8518,4 +8978,183 @@ protected function encodeSpatialData(mixed $value, string $type): string throw new DatabaseException('Unknown spatial type: ' . $type); } } + + /** + * Retry a callable with exponential backoff + * + * @param callable $operation The operation to retry + * @param int $maxAttempts Maximum number of retry attempts + * @param int $initialDelayMs Initial delay in milliseconds + * @param float $multiplier Backoff multiplier + * @return void The result of the operation + * @throws \Throwable The last exception if all retries fail + */ + private function withRetries( + callable $operation, + int $maxAttempts = 3, + int $initialDelayMs = 100, + float $multiplier = 2.0 + ): void { + $attempt = 0; + $delayMs = $initialDelayMs; + $lastException = null; + + while ($attempt < $maxAttempts) { + try { + $operation(); + return; + } catch (\Throwable $e) { + $lastException = $e; + $attempt++; + + if ($attempt >= $maxAttempts) { + break; + } + + if (\extension_loaded('swoole') && Coroutine::getCid() > 0) { + Coroutine::sleep($delayMs / 1000); + } else { + \usleep($delayMs * 1000); + } + + $delayMs = (int)($delayMs * $multiplier); + } + } + + throw $lastException; + } + + /** + * Generic cleanup operation with retry logic + * + * @param callable $operation The cleanup operation to execute + * @param string $resourceType Type of resource being cleaned up (e.g., 'attribute', 'index') + * @param string $resourceId ID of the resource being cleaned up + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanup( + callable $operation, + string $resourceType, + string $resourceId, + int $maxAttempts = 3 + ): void { + try { + $this->withRetries($operation, maxAttempts: $maxAttempts); + } catch (\Throwable $e) { + Console::error("Failed to cleanup {$resourceType} '{$resourceId}' after {$maxAttempts} attempts: " . $e->getMessage()); + throw $e; + } + } + + /** + * Cleanup (delete) an index with retry logic + * + * @param string $collectionId The collection ID + * @param string $indexId The index ID + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupIndex( + string $collectionId, + string $indexId, + int $maxAttempts = 3 + ): void { + $this->cleanup( + fn () => $this->adapter->deleteIndex($collectionId, $indexId), + 'index', + $indexId, + $maxAttempts + ); + } + + /** + * Persist metadata with automatic rollback on failure + * + * Centralizes the common pattern of: + * 1. Attempting to persist metadata with retry + * 2. Rolling back database operations if metadata persistence fails + * 3. Providing detailed error messages for both success and failure scenarios + * + * @param Document $collection The collection document to persist + * @param callable|null $rollbackOperation Cleanup operation to run if persistence fails (null if no cleanup needed) + * @param bool $shouldRollback Whether rollback should be attempted (e.g., false for duplicates in shared tables) + * @param string $operationDescription Description of the operation for error messages + * @param bool $rollbackReturnsErrors Whether rollback operation returns error array (true) or throws (false) + * @param bool $silentRollback Whether rollback errors should be silently caught (true) or thrown (false) + * @return void + * @throws DatabaseException If metadata persistence fails after all retries + */ + private function updateMetadata( + Document $collection, + ?callable $rollbackOperation, + bool $shouldRollback, + string $operationDescription = 'operation', + bool $rollbackReturnsErrors = false, + bool $silentRollback = false + ): void { + try { + if ($collection->getId() !== self::METADATA) { + $this->withRetries( + fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) + ); + } + } catch (\Throwable $e) { + // Attempt rollback only if conditions are met + if ($shouldRollback && $rollbackOperation !== null) { + if ($rollbackReturnsErrors) { + // Batch mode: rollback returns array of errors + $cleanupErrors = $rollbackOperation(); + if (!empty($cleanupErrors)) { + throw new DatabaseException( + "Failed to persist metadata after retries and cleanup encountered errors for {$operationDescription}: " . $e->getMessage() . ' | Cleanup errors: ' . implode(', ', $cleanupErrors), + previous: $e + ); + } + } elseif ($silentRollback) { + // Silent mode: swallow rollback errors + try { + $rollbackOperation(); + } catch (\Throwable $e) { + // Silent rollback - errors are swallowed + } + } else { + // Regular mode: rollback throws on failure + try { + $rollbackOperation(); + } catch (\Throwable $ex) { + throw new DatabaseException( + "Failed to persist metadata after retries and cleanup failed for {$operationDescription}: " . $ex->getMessage() . ' | Cleanup error: ' . $e->getMessage(), + previous: $e + ); + } + } + } + + throw new DatabaseException( + "Failed to persist metadata after retries for {$operationDescription}: " . $e->getMessage(), + previous: $e + ); + } + } + + /** + * Rollback metadata state by removing specified attributes from collection + * + * @param Document $collection The collection document + * @param array $attributeIds Attribute IDs to remove + * @return void + */ + private function rollbackAttributeMetadata(Document $collection, array $attributeIds): void + { + $attributes = $collection->getAttribute('attributes', []); + $filteredAttributes = \array_filter( + $attributes, + fn ($attr) => !\in_array($attr->getId(), $attributeIds) + ); + $collection->setAttribute('attributes', \array_values($filteredAttributes)); + } + }