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 d62782f95e..758fa73e7a 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 @@ -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 $queries raw stringified queries from the HTTP request + * @return array + */ + 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, + ]; + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Explain/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Explain/Get.php index 789fce30af..9e6a8ad56c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Explain/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Explain/Get.php @@ -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. 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 fdcbced6f3..6fedda922e 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 @@ -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);