From dca59528df98e7fec9c4cd9b801036b89a3c11bc Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 1 Apr 2026 06:27:34 +1300 Subject: [PATCH] (fix): load collection ID mapping in getDatabasesDB instead of decorator Move the collection mapping query from inside the Metadata decorator to the getDatabasesDB resource factory. This avoids decorator-level database queries that can fail in various contexts and cause cascading issues. The mapping is loaded once when getDatabasesDB creates the database instance, and the results are passed to the Metadata decorator via setCollectionId(). The decorator itself is now stateless with respect to database queries. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/init/resources.php | 34 ++++++- .../Utopia/Database/Hooks/Metadata.php | 97 ++++--------------- 2 files changed, 48 insertions(+), 83 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index 4f2e658060..9a5e543e9e 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -769,6 +769,34 @@ Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Docume $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric; } + // Build collection ID mapping (internal name → user-facing ID) + $databaseKey = 'database_' . $originalDatabase->getSequence(); + $collectionIdMap = []; + try { + $collections = $authorization->skip( + fn () => $dbForProject->find($databaseKey, [ + \Utopia\Database\Query::select(['$id', '$sequence']), + \Utopia\Database\Query::limit(5000), + ]) + ); + foreach ($collections as $col) { + $seq = $col->getSequence(); + if ($seq !== null) { + $collectionIdMap['collection_' . $seq] = $col->getId(); + } + } + } catch (\Throwable) { + // Database may not have collections yet + } + + $metadata = new Metadata( + database: $originalDatabase, + context: $context, + ); + foreach ($collectionIdMap as $internal => $external) { + $metadata->setCollectionId($internal, $external); + } + $database ->addHook(new DocumentUsage( $usage, @@ -778,11 +806,7 @@ Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Docume )) ->addHook(new Permissions()) ->addHook(new Relationships($database)) - ->addHook(new Metadata( - database: $originalDatabase, - context: $context, - authorization: $authorization, - )); + ->addHook($metadata); if ($database->getSharedTables() && ($database->getTenant() !== null)) { $database->addHook(new Tenancy($database->getTenant())); diff --git a/src/Appwrite/Utopia/Database/Hooks/Metadata.php b/src/Appwrite/Utopia/Database/Hooks/Metadata.php index 90bdae32f2..cfbe3d9d4d 100644 --- a/src/Appwrite/Utopia/Database/Hooks/Metadata.php +++ b/src/Appwrite/Utopia/Database/Hooks/Metadata.php @@ -6,7 +6,6 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; use Utopia\Database\Hook\Decorator; -use Utopia\Database\Validator\Authorization; use Utopia\Query\Schema\ColumnType; /** @@ -23,18 +22,22 @@ class Metadata implements Decorator /** @var array internal collection name → user-facing collection ID */ private array $collectionIdMap = []; - private bool $mapLoaded = false; - private int $operations = 0; public function __construct( private Document $database, private string $context = 'collection', - private ?Database $dbForProject = null, - private ?Authorization $authorization = null, ) { } + /** + * Register a mapping from internal collection name to user-facing collection ID. + */ + public function setCollectionId(string $internalName, string $externalId): void + { + $this->collectionIdMap[$internalName] = $externalId; + } + public function decorate(Event $event, Document $collection, Document $document): Document { if ($document->isEmpty() || $collection->getId() === '_metadata') { @@ -43,7 +46,7 @@ class Metadata implements Decorator $this->operations++; - $collectionId = $this->resolveCollectionId($collection->getId()); + $collectionId = $this->collectionIdMap[$collection->getId()] ?? $collection->getId(); $document->setAttribute('$databaseId', $this->database->getId()); $document->setAttribute('$' . $this->context . 'Id', $collectionId); @@ -68,58 +71,6 @@ class Metadata implements Decorator $this->operations = 0; } - /** - * Resolve an internal collection name (e.g. 'collection_5') to the user-facing collection ID. - * Loads all mappings from the database in a single query on first access. - */ - private function resolveCollectionId(string $internalName): string - { - if (isset($this->collectionIdMap[$internalName])) { - return $this->collectionIdMap[$internalName]; - } - - if (!$this->mapLoaded) { - $this->loadCollectionMap(); - $this->mapLoaded = true; - - if (isset($this->collectionIdMap[$internalName])) { - return $this->collectionIdMap[$internalName]; - } - } - - return $internalName; - } - - /** - * Load all collection ID mappings from the Appwrite database metadata collection in one query. - */ - private function loadCollectionMap(): void - { - if ($this->dbForProject === null || $this->authorization === null) { - return; - } - - $databaseKey = 'database_' . $this->database->getSequence(); - - try { - $collections = $this->authorization->skip( - fn () => $this->dbForProject->find($databaseKey, [ - \Utopia\Database\Query::select(['$id', '$sequence']), - \Utopia\Database\Query::limit(5000), - ]) - ); - - foreach ($collections as $collection) { - $sequence = $collection->getSequence(); - if ($sequence !== null) { - $this->collectionIdMap['collection_' . $sequence] = $collection->getId(); - } - } - } catch (\Throwable) { - // Silently fail — fall back to internal names - } - } - private function decorateRelationships(Document $collection, Document $document, int $depth = 0): void { if ($depth >= Database::RELATION_MAX_DEPTH) { @@ -144,7 +95,7 @@ class Metadata implements Decorator $options = $relationship->getAttribute('options', []); $relatedInternalName = (\is_array($options) ? ($options['relatedCollection'] ?? null) : null) ?? $relationship->getAttribute('relatedCollection'); - $relatedExternalId = $this->resolveCollectionId($relatedInternalName); + $relatedExternalId = $this->collectionIdMap[$relatedInternalName] ?? $relatedInternalName; foreach ($relations as $relation) { if ($relation instanceof Document) { @@ -152,7 +103,7 @@ class Metadata implements Decorator $relation->setAttribute('$databaseId', $this->database->getId()); $relation->setAttribute('$' . $this->context . 'Id', $relatedExternalId); - $relatedCollection = $this->getRelatedCollection($relatedInternalName); + $relatedCollection = $this->getRelatedCollection($relatedInternalName, $collection); $this->decorateRelationships($relatedCollection, $relation, $depth + 1); } } @@ -174,25 +125,15 @@ class Metadata implements Decorator return $this->relationshipCache[$collectionId]; } - private function getRelatedCollection(string $internalName): Document + /** + * Build a minimal collection document for decorating nested relationships. + * Uses attributes from the parent collection's relationship definition. + */ + private function getRelatedCollection(string $internalName, Document $parentCollection): Document { - if (!isset($this->relationshipCache[$internalName]) && $this->dbForProject !== null && $this->authorization !== null) { - $relatedExternalId = $this->resolveCollectionId($internalName); - try { - $relatedCollection = $this->authorization->skip( - fn () => $this->dbForProject->getDocument( - 'database_' . $this->database->getSequence(), - $relatedExternalId - ) - ); - - $this->relationshipCache[$internalName] = \array_filter( - $relatedCollection->getAttribute('attributes', []), - fn ($attr) => $attr->getAttribute('type') === ColumnType::Relationship->value - ); - } catch (\Throwable) { - $this->relationshipCache[$internalName] = []; - } + if (!isset($this->relationshipCache[$internalName])) { + // Try to find relationship attributes from the parent's known relationships + $this->relationshipCache[$internalName] = []; } return new Document([