From dc77abf1618e1e825376953e2df0bf959e65dbaa Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 1 Apr 2026 21:42:00 +1300 Subject: [PATCH] (fix): restore Metadata decorator with lazy collection mapping, remove processDocument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove processDocument() and all its calls — the decorator approach is the intended design. The Metadata decorator lazily loads the collection ID mapping from dbForProject on first use, wrapped in silent() to prevent lifecycle hooks. Maps both relative (collection_N) and full (database_M_collection_N) keys to user-facing IDs. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/init/resources.php | 9 +- .../Collections/Documents/Action.php | 98 ------------------- .../Collections/Documents/Create.php | 13 --- .../Databases/Collections/Documents/Get.php | 19 +--- .../Collections/Documents/Update.php | 11 --- .../Collections/Documents/Upsert.php | 11 --- .../Databases/Collections/Documents/XList.php | 16 +-- .../Utopia/Database/Hooks/Metadata.php | 46 ++++++++- 8 files changed, 56 insertions(+), 167 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index 0c77d935f7..fd3225e9d8 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -30,6 +30,7 @@ use Appwrite\Usage\Context as UsageContext; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Hooks\DocumentUsage; use Appwrite\Utopia\Database\Hooks\FunctionCache; +use Appwrite\Utopia\Database\Hooks\Metadata; use Appwrite\Utopia\Database\Hooks\Usage; use Appwrite\Utopia\Database\Hooks\UserEvents; use Appwrite\Utopia\Request; @@ -776,7 +777,13 @@ Http::setResource('getDatabasesDB', function (Group $pools, Database $dbForProje $databaseIdCollectionIdDocumentsMetric, )) ->addHook(new Permissions()) - ->addHook(new Relationships($database)); + ->addHook(new Relationships($database)) + ->addHook(new Metadata( + database: $originalDatabase, + dbForProject: $dbForProject, + authorization: $authorization, + context: $context, + )); if ($database->getSharedTables() && ($database->getTenant() !== null)) { $database->addHook(new Tenancy($database->getTenant())); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 3389dc9c6c..b7cec8d582 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -402,102 +402,4 @@ abstract class Action extends DatabasesAction $queueForWebhooks->reset(); } - /** - * Stamp database/collection metadata onto a document and recursively - * process relationship documents. Called from endpoint actions where - * the user-facing collection ID is available. - * - * @param array> $collectionsCache - */ - protected function processDocument( - Document $database, - Document $collection, - Document $document, - Database $dbForProject, - array &$collectionsCache, - \Utopia\Database\Validator\Authorization $authorization, - ?int &$operations = null, - int $depth = 0, - ): bool { - if ($operations !== null && $document->isEmpty()) { - return false; - } - - if ($operations !== null) { - $operations++; - } - - $collectionId = $collection->getId(); - $document->removeAttribute('$collection'); - $document->setAttribute('$databaseId', $database->getId()); - $document->setAttribute('$' . $this->getCollectionsEventsContext() . 'Id', $collectionId); - - if ($depth >= Database::RELATION_MAX_DEPTH) { - return true; - } - - $relationships = $collectionsCache[$collectionId] ??= \array_filter( - $collection->getAttribute('attributes', []), - fn ($attr) => $attr->getAttribute('type') === \Utopia\Query\Schema\ColumnType::Relationship->value - ); - - foreach ($relationships as $relationship) { - $key = $relationship->getAttribute('key'); - $related = $document->getAttribute($key); - - if (empty($related)) { - if (\in_array(\gettype($related), ['array', 'object']) && $operations !== null) { - $operations++; - } - continue; - } - - $relations = \is_array($related) ? $related : [$related]; - $options = $relationship->getAttribute('options', []); - $relatedCollectionId = (\is_array($options) ? ($options['relatedCollection'] ?? null) : null) - ?? $relationship->getAttribute('relatedCollection'); - - if (!isset($collectionsCache[$relatedCollectionId])) { - $relatedCollectionDoc = $authorization->skip( - fn () => $dbForProject->getDocument( - 'database_' . $database->getSequence(), - $relatedCollectionId - ) - ); - - $collectionsCache[$relatedCollectionId] = \array_filter( - $relatedCollectionDoc->getAttribute('attributes', []), - fn ($attr) => $attr->getAttribute('type') === \Utopia\Query\Schema\ColumnType::Relationship->value - ); - } - - foreach ($relations as $relation) { - if ($relation instanceof Document) { - $relatedCollection = new Document([ - '$id' => $relatedCollectionId, - 'attributes' => $collectionsCache[$relatedCollectionId], - ]); - - $this->processDocument( - database: $database, - collection: $relatedCollection, - document: $relation, - dbForProject: $dbForProject, - collectionsCache: $collectionsCache, - authorization: $authorization, - operations: $operations, - depth: $depth + 1 - ); - } - } - - if (\is_array($related)) { - $document->setAttribute($relationship->getAttribute('key'), \array_values($relations)); - } elseif (empty($relations)) { - $document->setAttribute($relationship->getAttribute('key'), null); - } - } - - return true; - } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 76f2ecc1e6..478834f656 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -389,19 +389,6 @@ class Create extends Action ->setParam('tableId', $collection->getId()) ->setContext($this->getCollectionsEventsContext(), $collection); - /** @var array> $collectionsCache */ - $collectionsCache = []; - foreach ($created as $document) { - $this->processDocument( - database: $database, - collection: $collection, - document: $document, - dbForProject: $dbForProject, - collectionsCache: $collectionsCache, - authorization: $authorization - ); - } - $usage ->addMetric($this->getDatabasesOperationWriteMetric(), 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); // per collection diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index bd67bc77ac..1f4cca9ded 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -122,24 +122,9 @@ class Get extends Action throw new Exception($this->getNotFoundException(), params: [$documentId]); } - $operations = 0; - /** @var array> $collectionsCache */ - $collectionsCache = []; - $this->processDocument( - database: $database, - collection: $collection, - document: $document, - dbForProject: $dbForProject, - collectionsCache: $collectionsCache, - authorization: $authorization, - operations: $operations - ); - $usage - ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); - - $response->addHeader('X-Debug-Operations', $operations); + ->addMetric($this->getDatabasesOperationReadMetric(), 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), 1); $response->dynamic($document, $this->getResponseModel()); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index 87f4344f39..fa35089022 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -262,17 +262,6 @@ class Update extends Action throw new Exception($this->getStructureException(), $e->getMessage()); } - /** @var array> $collectionsCache */ - $collectionsCache = []; - $this->processDocument( - database: $database, - collection: $collection, - document: $document, - dbForProject: $dbForProject, - collectionsCache: $collectionsCache, - authorization: $authorization, - ); - $usage ->addMetric($this->getDatabasesOperationWriteMetric(), 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index 3696d15657..c95206c58b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -283,17 +283,6 @@ class Upsert extends Action $document = $upserted[0]; - /** @var array> $collectionsCache */ - $collectionsCache = []; - $this->processDocument( - database: $database, - collection: $collection, - document: $document, - dbForProject: $dbForProject, - collectionsCache: $collectionsCache, - authorization: $authorization - ); - $usage ->addMetric($this->getDatabasesOperationWriteMetric(), 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index eae691a365..c30b175e02 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -218,21 +218,7 @@ class XList extends Action throw new Exception(Exception::DATABASE_TIMEOUT); } - $operations = 0; - /** @var array> $collectionsCache */ - $collectionsCache = []; - foreach ($documents as $document) { - $this->processDocument( - database: $database, - collection: $collection, - document: $document, - dbForProject: $dbForProject, - collectionsCache: $collectionsCache, - authorization: $authorization, - operations: $operations - ); - } - + $operations = \count($documents); $usage ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); diff --git a/src/Appwrite/Utopia/Database/Hooks/Metadata.php b/src/Appwrite/Utopia/Database/Hooks/Metadata.php index b901d718f6..17656d5de6 100644 --- a/src/Appwrite/Utopia/Database/Hooks/Metadata.php +++ b/src/Appwrite/Utopia/Database/Hooks/Metadata.php @@ -6,24 +6,33 @@ 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; /** * Stamps database/collection metadata onto every document returned from the database, * and recursively decorates nested relationship documents. + * + * Lazily loads the collection mapping from dbForProject on first use to resolve + * internal collection names (database_N_collection_M) to user-facing collection IDs. */ class Metadata implements Decorator { /** @var array> */ private array $relationshipCache = []; - /** @var array internal collection name → user-facing collection ID */ + /** @var array internal collection name -> user-facing collection ID */ private array $collectionIdMap = []; + /** @var bool whether the collection map has been loaded */ + private bool $mapLoaded = false; + private int $operations = 0; public function __construct( private Document $database, + private Database $dbForProject, + private Authorization $authorization, private string $context = 'collection', ) { } @@ -44,6 +53,8 @@ class Metadata implements Decorator $this->operations++; + $this->ensureMapLoaded(); + $collectionId = $this->collectionIdMap[$collection->getId()] ?? $collection->getId(); $document->setAttribute('$databaseId', $this->database->getId()); $document->setAttribute('$' . $this->context . 'Id', $collectionId); @@ -63,6 +74,39 @@ class Metadata implements Decorator $this->operations = 0; } + /** + * Lazily load collection mapping from dbForProject. + * Queries are wrapped in authorization->skip() and dbForProject->silent() + * to prevent lifecycle hooks from firing and avoid permission checks. + */ + private function ensureMapLoaded(): void + { + if ($this->mapLoaded) { + return; + } + + $this->mapLoaded = true; + $databaseSequence = $this->database->getSequence(); + $metadataCollection = 'database_' . $databaseSequence; + + $collections = $this->authorization->skip( + fn () => $this->dbForProject->silent( + fn () => $this->dbForProject->find($metadataCollection) + ) + ); + + foreach ($collections as $collection) { + $externalId = $collection->getId(); + $sequence = $collection->getSequence(); + + $relativeKey = 'collection_' . $sequence; + $fullKey = 'database_' . $databaseSequence . '_collection_' . $sequence; + + $this->collectionIdMap[$relativeKey] = $externalId; + $this->collectionIdMap[$fullKey] = $externalId; + } + } + private function decorateRelationships(Document $collection, Document $document, int $depth = 0): void { if ($depth >= Database::RELATION_MAX_DEPTH) {