extract prepareListContext helper so explain reuses listRows setup

This commit is contained in:
Prem Palanisamy
2026-05-23 14:10:33 +01:00
parent d9c478c5cb
commit 4d4fcd1ddd
3 changed files with 122 additions and 104 deletions
@@ -8,10 +8,15 @@ use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\CustomId;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\NotFound as NotFoundException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
abstract class Action extends DatabasesAction
{
@@ -504,4 +509,104 @@ abstract class Action extends DatabasesAction
$queueForRealtime->reset();
$queueForWebhooks->reset();
}
/**
* Shared setup for any list-style read on the documents/rows API surface.
*
* Both listRows/listDocuments and explainRows/explainDocuments need the
* same auth checks, database+collection lookup, query parse, cursor
* resolution, and find-closure construction. Centralising it here is the
* single source of truth so the explain endpoint stays byte-identical to
* the real read it's explaining.
*
* Returned bundle:
* 'database' Document the user-facing database doc
* 'collection' Document the user-facing collection doc
* 'dbForDatabases' Database the per-database adapter the read runs against
* 'queries' Query[] parsed Query objects (cursor value resolved)
* 'collectionTableId' string physical table id (`database_X_collection_Y`)
* 'hasSelects' bool whether the queries include any select
* 'find' callable closure that runs the find — wraps in skipRelationships when there are no related selects
*
* @param array<string> $queries raw stringified queries from the HTTP request
* @return array<string, mixed>
*/
protected function prepareListContext(
string $databaseId,
string $collectionId,
array $queries,
Database $dbForProject,
User $user,
callable $getDatabasesDB,
Authorization $authorization,
): array {
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (! $database->getAttribute('enabled', false) && ! $isAPIKey && ! $isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
}
$collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
if ($collection->isEmpty() || (! $collection->getAttribute('enabled', false) && ! $isAPIKey && ! $isPrivilegedUser)) {
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$dbForDatabases = $getDatabasesDB($database);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor;
if (! $validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$documentId = $cursor->getValue();
try {
$cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId));
} catch (NotFoundException) {
// The collection metadata document exists but the backing
// store has no table for it. Treat as collection not-found
// so the caller sees a 404 instead of a 500.
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
}
if ($cursorDocument->isEmpty()) {
$type = ucfirst($this->getContext());
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "$type '{$documentId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
$hasSelects = ! empty(Query::groupByType($queries)['selections']);
// When there are no select queries, relationship loading is skipped on
// the underlying find() to avoid pulling related documents the caller
// did not ask for.
$find = $hasSelects
? fn () => $dbForDatabases->find($collectionTableId, $queries)
: fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries));
return [
'database' => $database,
'collection' => $collection,
'dbForDatabases' => $dbForDatabases,
'queries' => $queries,
'collectionTableId' => $collectionTableId,
'hasSelects' => $hasSelects,
'find' => $find,
];
}
}
@@ -17,7 +17,6 @@ use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Exception\Timeout;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\ArrayList;
@@ -85,54 +84,16 @@ abstract class Get extends Action
callable $getDatabasesDB,
Authorization $authorization,
): void {
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (! $database->getAttribute('enabled', false) && ! $isAPIKey && ! $isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
}
$collection = $authorization->skip(fn () => $dbForProject->getDocument('database_'.$database->getSequence(), $collectionId));
if ($collection->isEmpty() || (! $collection->getAttribute('enabled', false) && ! $isAPIKey && ! $isPrivilegedUser)) {
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$dbForDatabases = $getDatabasesDB($database);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor;
if (! $validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$documentId = $cursor->getValue();
$cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_'.$database->getSequence().'_collection_'.$collection->getSequence(), $documentId));
if ($cursorDocument->isEmpty()) {
$type = ucfirst($this->getContext());
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "$type '{$documentId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$collectionTableId = 'database_'.$database->getSequence().'_collection_'.$collection->getSequence();
$hasSelects = ! empty(Query::groupByType($queries)['selections']);
$find = $hasSelects
? fn () => $dbForDatabases->find($collectionTableId, $queries)
: fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries));
// Reuses the same prep listRows runs (auth, lookups, query parse,
// cursor, find closure) so the explain endpoint stays byte-identical
// to the read it's explaining.
$context = $this->prepareListContext($databaseId, $collectionId, $queries, $dbForProject, $user, $getDatabasesDB, $authorization);
$database = $context['database'];
$collection = $context['collection'];
$dbForDatabases = $context['dbForDatabases'];
$queries = $context['queries'];
$collectionTableId = $context['collectionTableId'];
$find = $context['find'];
// listRows fires both find() and count() when `total: true` (the default).
// Mirror that exactly so explain reflects real listRows read volume.
@@ -88,65 +88,17 @@ class XList extends Action
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void
{
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
}
$collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$dbForDatabases = $getDatabasesDB($database);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$documentId = $cursor->getValue();
try {
$cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId));
} catch (NotFoundException) {
// The collection metadata document exists but the backing store (e.g. a
// dedicated DocumentsDB shard) has no table for it. Treat this as a
// not-found on the collection so the caller sees a 404 instead of a 500.
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
}
if ($cursorDocument->isEmpty()) {
$type = ucfirst($this->getContext());
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "$type '{$documentId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$context = $this->prepareListContext($databaseId, $collectionId, $queries, $dbForProject, $user, $getDatabasesDB, $authorization);
$database = $context['database'];
$collection = $context['collection'];
$dbForDatabases = $context['dbForDatabases'];
$queries = $context['queries'];
$collectionTableId = $context['collectionTableId'];
$find = $context['find'];
$dbStart = \microtime(true);
try {
$hasSelects = ! empty(Query::groupByType($queries)['selections']);
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
// When there are no select queries, relationship loading is skipped on the
// underlying find() to avoid pulling related documents the caller did not ask for.
$find = $hasSelects
? fn () => $dbForDatabases->find($collectionTableId, $queries)
: fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries));
// Use transaction-aware document retrieval if transactionId is provided
if ($transactionId !== null) {
$documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries);