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) {