(fix): restore Metadata decorator with lazy collection mapping, remove processDocument

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) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-04-01 21:42:00 +13:00
co-authored by Claude Opus 4.6
parent 088f1c2953
commit dc77abf161
8 changed files with 56 additions and 167 deletions
+8 -1
View File
@@ -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()));
@@ -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<string, array<mixed>> $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;
}
}
@@ -389,19 +389,6 @@ class Create extends Action
->setParam('tableId', $collection->getId())
->setContext($this->getCollectionsEventsContext(), $collection);
/** @var array<string, array<mixed>> $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
@@ -122,24 +122,9 @@ class Get extends Action
throw new Exception($this->getNotFoundException(), params: [$documentId]);
}
$operations = 0;
/** @var array<string, array<mixed>> $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());
}
@@ -262,17 +262,6 @@ class Update extends Action
throw new Exception($this->getStructureException(), $e->getMessage());
}
/** @var array<string, array<mixed>> $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);
@@ -283,17 +283,6 @@ class Upsert extends Action
$document = $upserted[0];
/** @var array<string, array<mixed>> $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);
@@ -218,21 +218,7 @@ class XList extends Action
throw new Exception(Exception::DATABASE_TIMEOUT);
}
$operations = 0;
/** @var array<string, array<mixed>> $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);
@@ -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<string, array<Document>> */
private array $relationshipCache = [];
/** @var array<string, string> internal collection name user-facing collection ID */
/** @var array<string, string> 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) {