diff --git a/app/init/models.php b/app/init/models.php index a9e322263f..72c07474e2 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -74,7 +74,6 @@ use Appwrite\Utopia\Response\Model\DetectionRuntime; use Appwrite\Utopia\Response\Model\DetectionVariable; use Appwrite\Utopia\Response\Model\DevKey; use Appwrite\Utopia\Response\Model\Document as ModelDocument; -use Appwrite\Utopia\Response\Model\DocumentList; use Appwrite\Utopia\Response\Model\Embedding; use Appwrite\Utopia\Response\Model\EphemeralKey; use Appwrite\Utopia\Response\Model\Error; @@ -190,7 +189,6 @@ use Appwrite\Utopia\Response\Model\QueryPlanEntry; use Appwrite\Utopia\Response\Model\Report; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; -use Appwrite\Utopia\Response\Model\RowList; use Appwrite\Utopia\Response\Model\Rule; use Appwrite\Utopia\Response\Model\Runtime; use Appwrite\Utopia\Response\Model\Schedule; @@ -239,15 +237,13 @@ Response::setModel(new Any()); Response::setModel(new Error()); Response::setModel(new ErrorDev()); -// Diagnostics — the entry model is reused as the nested type of the -// `explain` field on RowList / DocumentList. +// Diagnostics Response::setModel(new QueryPlanEntry()); +Response::setModel(new BaseList('Query Plan', Response::MODEL_QUERY_PLAN, 'queries', Response::MODEL_QUERY_PLAN_ENTRY, paging: false)); // Lists -// RowList / DocumentList are subclasses that add an optional `explain` field -// populated when listRows/listDocuments is called with `explain: true`. -Response::setModel(new RowList); -Response::setModel(new DocumentList); +Response::setModel(new BaseList('Rows List', Response::MODEL_ROW_LIST, 'rows', Response::MODEL_ROW)); +Response::setModel(new BaseList('Documents List', Response::MODEL_DOCUMENT_LIST, 'documents', Response::MODEL_DOCUMENT)); Response::setModel(new BaseList('Presences List', Response::MODEL_PRESENCE_LIST, 'presences', Response::MODEL_PRESENCE)); Response::setModel(new BaseList('Tables List', Response::MODEL_TABLE_LIST, 'tables', Response::MODEL_TABLE)); Response::setModel(new BaseList('Collections List', Response::MODEL_COLLECTION_LIST, 'collections', Response::MODEL_COLLECTION)); diff --git a/docs/references/documentsdb/explain-documents.md b/docs/references/documentsdb/explain-documents.md new file mode 100644 index 0000000000..9ffff3049e --- /dev/null +++ b/docs/references/documentsdb/explain-documents.md @@ -0,0 +1,3 @@ +Get a query plan for a `listDocuments` call without executing it. Useful for diagnosing slow reads and verifying that the indexes you created on a collection are actually being used. + +Takes the same parameters as `listDocuments`. Returns one plan entry per physical query Appwrite would have run — including the per-relationship fetches that `listDocuments` issues sequentially when your query selects related fields. Internal storage details (the permission companion table, the metadata system table, internal column names) are stripped from the output, so the response only references your collections and `$`-prefixed attributes. diff --git a/docs/references/tablesdb/explain-rows.md b/docs/references/tablesdb/explain-rows.md new file mode 100644 index 0000000000..d0edd575db --- /dev/null +++ b/docs/references/tablesdb/explain-rows.md @@ -0,0 +1,3 @@ +Get a query plan for a `listRows` call without executing it. Useful for diagnosing slow reads and verifying that the indexes you created on a table are actually being used. + +Takes the same parameters as `listRows`. Returns one plan entry per physical query Appwrite would have run — including the per-relationship fetches that `listRows` issues sequentially when your query selects related fields. Internal storage details (the permission companion table, the metadata system table, internal column names) are stripped from the output, so the response only references your tables and `$`-prefixed attributes. diff --git a/docs/references/vectorsdb/explain-documents.md b/docs/references/vectorsdb/explain-documents.md new file mode 100644 index 0000000000..ec2509d2b7 --- /dev/null +++ b/docs/references/vectorsdb/explain-documents.md @@ -0,0 +1,3 @@ +Get a query plan for a `listDocuments` call without executing it. Useful for diagnosing slow vector searches and verifying that the indexes (HNSW, IVFFLAT, etc.) you created on a collection are actually being used. + +Takes the same parameters as `listDocuments`. Returns one plan entry per physical query Appwrite would have run — including the per-relationship fetches that `listDocuments` issues sequentially when your query selects related fields. Internal storage details (the permission companion table, the metadata system table, internal column names) are stripped from the output, so the response only references your collections and `$`-prefixed attributes. 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 new file mode 100644 index 0000000000..789fce30af --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Explain/Get.php @@ -0,0 +1,231 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/documents/explain') + ->desc('Explain rows query plan') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: $this->getSDKNamespace(), + group: $this->getSDKGroup(), + name: self::getName(), + description: '/docs/references/databases/explain-rows.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ), + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. Same shape as listRows.', true) + ->param('total', true, new Boolean(true), 'When true, the explain captures the COUNT(*) call listRows fires for the total field as a second entry. Mirrors listRows default behavior.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('getDatabasesDB') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + string $databaseId, + string $collectionId, + array $queries, + bool $includeTotal, + UtopiaResponse $response, + Database $dbForProject, + User $user, + 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)); + + // listRows fires both find() and count() when `total: true` (the default). + // Mirror that exactly so explain reflects real listRows read volume. + $scope = function () use ($find, $includeTotal, $dbForDatabases, $collectionTableId, $queries): void { + $find(); + if ($includeTotal) { + $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); + } + }; + + try { + $plan = $dbForDatabases->withExplain($scope); + } catch (OrderException $e) { + $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; + $attribute = $this->isCollectionsAPI() ? 'attribute' : 'column'; + $message = "The order $attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all $documents order $attribute values are non-null."; + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } catch (Timeout) { + throw new Exception(Exception::DATABASE_TIMEOUT); + } + + $translated = $this->translatePlanCollections( + $plan->getAttribute('queries', []), + $database, + $collection, + $dbForProject, + $authorization, + ); + + $response->dynamic(new Document([ + 'queries' => $translated, + ]), $this->getResponseModel()); + } + + /** + * @param array> $entries + * @return array + */ + protected function translatePlanCollections( + array $entries, + Document $database, + Document $collection, + Database $dbForProject, + Authorization $authorization, + ): array { + $databaseSequence = $database->getSequence(); + $databaseCollectionsTable = 'database_'.$databaseSequence; + $collectionResolver = $this->buildCollectionResolver($database, $collection, $dbForProject, $authorization); + + $output = []; + foreach ($entries as $entry) { + $context = $entry['context'] ?? []; + $physicalCollection = $context['collection'] ?? null; + + if (\is_string($physicalCollection) && \str_starts_with($physicalCollection, $databaseCollectionsTable.'_collection_')) { + $relatedSequence = \substr($physicalCollection, \strlen($databaseCollectionsTable.'_collection_')); + $context['collection'] = $collectionResolver($relatedSequence) ?? $physicalCollection; + } + + $output[] = new Document([ + 'purpose' => $entry['purpose'] ?? 'find', + 'context' => $context, + 'plan' => $entry['plan'] ?? [], + ]); + } + + return $output; + } + + protected function buildCollectionResolver( + Document $database, + Document $primary, + Database $dbForProject, + Authorization $authorization, + ): callable { + $cache = [ + (string) $primary->getSequence() => $primary->getId(), + ]; + $databaseCollectionsTable = 'database_'.$database->getSequence(); + + return function (string $sequence) use (&$cache, $databaseCollectionsTable, $dbForProject, $authorization): ?string { + if (\array_key_exists($sequence, $cache)) { + return $cache[$sequence]; + } + $related = $authorization->skip(fn () => $dbForProject->findOne($databaseCollectionsTable, [ + Query::equal('$sequence', [$sequence]), + ])); + $resolved = $related->isEmpty() ? null : $related->getId(); + $cache[$sequence] = $resolved; + + return $resolved; + }; + } +} 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 83941132f5..fdcbced6f3 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 @@ -75,7 +75,6 @@ class XList extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) - ->param('explain', false, new Boolean(true), 'When true, returns the captured vendor-native query plan for each physical read this call would issue under the `explain` field on the response. Bypasses the cache so the plan always reflects a fresh execution; internal storage details are stripped.', true) ->inject('response') ->inject('dbForProject') ->inject('user') @@ -87,7 +86,7 @@ class XList extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, bool $explain, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void + 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()); @@ -148,18 +147,8 @@ class XList extends Action ? fn () => $dbForDatabases->find($collectionTableId, $queries) : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); - $explainEntries = []; - - // Explain mode bypasses cache + transaction routing on purpose: - // debug calls should always reflect a fresh planner run, not a - // cached document set or an in-transaction snapshot. - if ($explain) { - $plan = $dbForDatabases->withExplain(function () use ($find, $includeTotal, $dbForDatabases, $collectionTableId, $queries, &$documents, &$total): void { - $documents = $find(); - $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; - }); - $explainEntries = $plan->getAttribute('queries', []); - } elseif ($transactionId !== null) { + // Use transaction-aware document retrieval if transactionId is provided + if ($transactionId !== null) { $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; } elseif ((int)$ttl > 0) { @@ -258,7 +247,6 @@ class XList extends Action 'total' => $total, // rows or documents $this->getSDKGroup() => $documents, - 'explain' => $this->translatePlanCollections($explainEntries, $database, $collection, $dbForProject, $authorization), ]), $this->getResponseModel()); try { @@ -276,76 +264,4 @@ class XList extends Action protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void { } - - /** - * Rewrite each captured plan entry's context.collection from the physical - * `database__collection_` table id back to the user-facing - * collection / table ID. Relationship fanout produces entries for related - * collections, which we look up by sequence on demand. - * - * @param array> $entries - * @return array - */ - protected function translatePlanCollections( - array $entries, - Document $database, - Document $collection, - Database $dbForProject, - Authorization $authorization, - ): array { - if (empty($entries)) { - return []; - } - - $databaseCollectionsTable = 'database_' . $database->getSequence(); - $collectionResolver = $this->buildCollectionResolver($database, $collection, $dbForProject, $authorization); - - $output = []; - foreach ($entries as $entry) { - $context = $entry['context'] ?? []; - $physicalCollection = $context['collection'] ?? null; - - if (\is_string($physicalCollection) && \str_starts_with($physicalCollection, $databaseCollectionsTable . '_collection_')) { - $relatedSequence = \substr($physicalCollection, \strlen($databaseCollectionsTable . '_collection_')); - $context['collection'] = $collectionResolver($relatedSequence) ?? $physicalCollection; - } - - $output[] = new Document([ - 'purpose' => $entry['purpose'] ?? 'find', - 'context' => $context, - 'plan' => $entry['plan'] ?? [], - ]); - } - - return $output; - } - - /** - * Closure that maps a collection sequence to its user-facing ID, - * memoizing lookups so a 10-fanout relationship doesn't issue 10 reads. - */ - private function buildCollectionResolver( - Document $database, - Document $primary, - Database $dbForProject, - Authorization $authorization, - ): callable { - $cache = [ - (string) $primary->getSequence() => $primary->getId(), - ]; - $databaseCollectionsTable = 'database_' . $database->getSequence(); - - return function (string $sequence) use (&$cache, $databaseCollectionsTable, $dbForProject, $authorization): ?string { - if (\array_key_exists($sequence, $cache)) { - return $cache[$sequence]; - } - $related = $authorization->skip(fn () => $dbForProject->findOne($databaseCollectionsTable, [ - Query::equal('$sequence', [$sequence]), - ])); - $resolved = $related->isEmpty() ? null : $related->getId(); - $cache[$sequence] = $resolved; - - return $resolved; - }; - } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Explain/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Explain/Get.php new file mode 100644 index 0000000000..4ca4bacd65 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Explain/Get.php @@ -0,0 +1,63 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/explain') + ->desc('Explain documents query plan') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSDKGroup(), + name: 'explainDocuments', + description: '/docs/references/documentsdb/explain-documents.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ), + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID, 'Database ID.') + ->param('collectionId', '', new UID, 'Collection ID.') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. Same shape as listDocuments.', true) + ->param('total', true, new Boolean(true), 'When true, the explain captures the COUNT(*) call listDocuments fires for the total field as a second entry. Mirrors listDocuments default behavior.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('getDatabasesDB') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php index f59ad43354..51c0d67e8a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php @@ -56,7 +56,6 @@ class XList extends DocumentXList ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) - ->param('explain', false, new Boolean(true), 'When true, returns the captured vendor-native query plan for each physical read this listDocuments call would issue under the `explain` field on the response. Bypasses the cache so the plan always reflects a fresh execution; internal storage details are stripped.', true) ->inject('response') ->inject('dbForProject') ->inject('user') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Explain/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Explain/Get.php new file mode 100644 index 0000000000..f0d308b3ca --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Explain/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/rows/explain') + ->desc('Explain rows query plan') + ->groups(['api', 'database']) + ->label('scope', 'rows.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: $this->getSDKNamespace(), + group: $this->getSDKGroup(), + name: self::getName(), + description: '/docs/references/tablesdb/explain-rows.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ), + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject']) + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. Same shape as listRows.', true) + ->param('total', true, new Boolean(true), 'When true, the explain captures the COUNT(*) call listRows fires for the total field as a second entry. Mirrors listRows default behavior.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('getDatabasesDB') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 32c9d0ef9a..87e276719e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -58,7 +58,6 @@ class XList extends DocumentXList ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, table, schema version (columns and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; row writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true) - ->param('explain', false, new Boolean(true), 'When true, returns the captured vendor-native query plan for each physical read this listRows call would issue under the `explain` field on the response. Bypasses the cache so the plan always reflects a fresh execution; internal storage details are stripped.', true) ->inject('response') ->inject('dbForProject') ->inject('user') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Explain/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Explain/Get.php new file mode 100644 index 0000000000..2e583117ac --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Explain/Get.php @@ -0,0 +1,63 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/explain') + ->desc('Explain documents query plan') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSDKGroup(), + name: 'explainDocuments', + description: '/docs/references/vectorsdb/explain-documents.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ), + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID, 'Database ID.') + ->param('collectionId', '', new UID, 'Collection ID.') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. Same shape as listDocuments.', true) + ->param('total', true, new Boolean(true), 'When true, the explain captures the COUNT(*) call listDocuments fires for the total field as a second entry. Mirrors listDocuments default behavior.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('getDatabasesDB') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php index 349833cd27..c9ed05ac02 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php @@ -56,7 +56,6 @@ class XList extends DocumentXList ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) - ->param('explain', false, new Boolean(true), 'When true, returns the captured vendor-native query plan for each physical read this listDocuments call would issue under the `explain` field on the response. Bypasses the cache so the plan always reflects a fresh execution; internal storage details are stripped.', true) ->inject('response') ->inject('dbForProject') ->inject('user') diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php index cfdacc51d2..77c9530e08 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php @@ -11,6 +11,7 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\B use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Bulk\Upsert as UpsertRows; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Create as CreateRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Delete as DeleteRow; +use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Explain\Get as ExplainDocuments; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Get as GetRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Update as UpdateRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Upsert as UpsertRow; @@ -89,6 +90,7 @@ class DocumentsDB extends Base $service->addAction(DeleteRow::getName(), new DeleteRow); $service->addAction(DeleteRows::getName(), new DeleteRows); $service->addAction(ListRows::getName(), new ListRows); + $service->addAction(ExplainDocuments::getName(), new ExplainDocuments); $service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn); $service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn); } diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php index 765fbd4421..d32d0313b8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php @@ -59,6 +59,7 @@ use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Column\Decreme use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Column\Increment as IncrementRowColumn; use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Create as CreateRow; use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Delete as DeleteRow; +use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Explain\Get as ExplainRows; use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Get as GetRow; use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Logs\XList as ListRowLogs; use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Update as UpdateRow; @@ -221,6 +222,7 @@ class TablesDB extends Base $service->addAction(DeleteRow::getName(), new DeleteRow()); $service->addAction(DeleteRows::getName(), new DeleteRows()); $service->addAction(ListRows::getName(), new ListRows()); + $service->addAction(ExplainRows::getName(), new ExplainRows()); $service->addAction(ListRowLogs::getName(), new ListRowLogs()); $service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn()); $service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn()); diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php index 528ce3a763..c52b375c89 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Bul use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Bulk\Upsert as UpsertDocuments; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Create as CreateDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Delete as DeleteDocument; +use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Explain\Get as ExplainDocuments; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Get as GetDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Update as UpdateDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Upsert as UpsertDocument; @@ -85,6 +86,7 @@ class VectorsDB extends Base $service->addAction(UpsertDocument::getName(), new UpsertDocument); $service->addAction(GetDocument::getName(), new GetDocument); $service->addAction(ListDocuments::getName(), new ListDocuments); + $service->addAction(ExplainDocuments::getName(), new ExplainDocuments); $service->addAction(DeleteDocument::getName(), new DeleteDocument); $service->addAction(UpdateDocuments::getName(), new UpdateDocuments); $service->addAction(UpsertDocuments::getName(), new UpsertDocuments); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index db2522e41e..54a70950a5 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -115,7 +115,8 @@ class Response extends SwooleResponse public const MODEL_ROW_LIST = 'rowList'; - // Query plan entries live under list responses (rowList.explain, documentList.explain) — no standalone list model needed. + public const MODEL_QUERY_PLAN = 'queryPlan'; + public const MODEL_QUERY_PLAN_ENTRY = 'queryPlanEntry'; // Database Attributes diff --git a/src/Appwrite/Utopia/Response/Model/DocumentList.php b/src/Appwrite/Utopia/Response/Model/DocumentList.php deleted file mode 100644 index 899a25337a..0000000000 --- a/src/Appwrite/Utopia/Response/Model/DocumentList.php +++ /dev/null @@ -1,26 +0,0 @@ -addRule('explain', [ - 'type' => Response::MODEL_QUERY_PLAN_ENTRY, - 'description' => 'Captured query plans for each physical read this listDocuments call issued. Empty unless the request set `explain: true`. Internal storage details are stripped.', - 'default' => [], - 'array' => true, - 'required' => false, - ]); - } -} diff --git a/src/Appwrite/Utopia/Response/Model/RowList.php b/src/Appwrite/Utopia/Response/Model/RowList.php deleted file mode 100644 index 2e9120670c..0000000000 --- a/src/Appwrite/Utopia/Response/Model/RowList.php +++ /dev/null @@ -1,26 +0,0 @@ -addRule('explain', [ - 'type' => Response::MODEL_QUERY_PLAN_ENTRY, - 'description' => 'Captured query plans for each physical read this listRows call issued. Empty unless the request set `explain: true`. Internal storage details are stripped.', - 'default' => [], - 'array' => true, - 'required' => false, - ]); - } -} diff --git a/tests/e2e/Services/TablesDB/ExplainAggressiveTest.php b/tests/e2e/Services/TablesDB/ExplainAggressiveTest.php new file mode 100644 index 0000000000..c4cd838eb0 --- /dev/null +++ b/tests/e2e/Services/TablesDB/ExplainAggressiveTest.php @@ -0,0 +1,329 @@ + 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + + $database = $this->client->call(Client::METHOD_POST, '/tablesdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'Explain Aggressive', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $table = $this->client->call(Client::METHOD_POST, '/tablesdb/'.$databaseId.'/tables', $headers, [ + 'tableId' => ID::unique(), + 'name' => 'movies', + 'rowSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/columns/string', $headers, [ + 'key' => 'title', + 'size' => 128, + 'required' => true, + ]); + $this->client->call(Client::METHOD_POST, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/columns/string', $headers, [ + 'key' => 'status', + 'size' => 32, + 'required' => true, + ]); + $this->client->call(Client::METHOD_POST, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/columns/integer', $headers, [ + 'key' => 'releaseYear', + 'required' => true, + ]); + $this->client->call(Client::METHOD_POST, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/columns/integer', $headers, [ + 'key' => 'rating', + 'required' => true, + ]); + + $this->waitForReady($databaseId, $tableId, $headers); + + $this->client->call(Client::METHOD_POST, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/indexes', $headers, [ + 'key' => 'idx_status', + 'type' => 'key', + 'columns' => ['status'], + ]); + $this->waitForReady($databaseId, $tableId, $headers); + + for ($i = 0; $i < 20; $i++) { + $this->client->call(Client::METHOD_POST, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/rows', $headers, [ + 'rowId' => ID::unique(), + 'data' => [ + 'title' => 'movie '.$i, + 'status' => $i % 2 === 0 ? 'published' : 'draft', + 'releaseYear' => 2000 + $i, + 'rating' => ($i % 5) + 1, + ], + ]); + } + + return self::$fixture = [ + 'databaseId' => $databaseId, + 'tableId' => $tableId, + 'headers' => $headers, + ]; + } + + private function waitForReady(string $databaseId, string $tableId, array $headers): void + { + $deadline = \microtime(true) + 30; + while (\microtime(true) < $deadline) { + $cols = $this->client->call( + Client::METHOD_GET, + '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/columns', + $headers, + ); + $rows = $cols['body']['columns'] ?? []; + if (! empty($rows) && \array_reduce($rows, fn ($ok, $c) => $ok && ($c['status'] ?? '') === 'available', true)) { + return; + } + \usleep(250000); + } + $this->fail("Columns/indexes for {$databaseId}/{$tableId} never reached 'available'"); + } + + private function explain(array $queries): array + { + $f = $this->fixture(); + + return $this->client->call( + Client::METHOD_GET, + '/tablesdb/'.$f['databaseId'].'/tables/'.$f['tableId'].'/rows/explain', + $f['headers'], + ['queries' => $queries], + ); + } + + public function test_plain_find(): void + { + $r = $this->explain([Query::limit(10)->toString()]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertNotEmpty($r['body']['queries']); + $this->assertEquals('find', $r['body']['queries'][0]['purpose']); + $this->assertEquals($this->fixture()['tableId'], $r['body']['queries'][0]['context']['collection']); + } + + public function test_equal_filter_on_indexed_column(): void + { + $r = $this->explain([ + Query::equal('status', ['published'])->toString(), + Query::limit(10)->toString(), + ]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertNotEmpty($r['body']['queries']); + $this->assertArrayHasKey('plan', $r['body']['queries'][0]); + } + + public function test_multiple_filters_and(): void + { + $r = $this->explain([ + Query::equal('status', ['published'])->toString(), + Query::greaterThan('rating', 2)->toString(), + Query::limit(5)->toString(), + ]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertCount(1, $r['body']['queries']); + } + + public function test_ordering_descending(): void + { + $r = $this->explain([ + Query::orderDesc('releaseYear')->toString(), + Query::limit(5)->toString(), + ]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertNotEmpty($r['body']['queries']); + } + + public function test_ordering_ascending(): void + { + $r = $this->explain([ + Query::orderAsc('releaseYear')->toString(), + Query::limit(5)->toString(), + ]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertNotEmpty($r['body']['queries']); + } + + public function test_offset_pagination(): void + { + $r = $this->explain([ + Query::orderAsc('releaseYear')->toString(), + Query::offset(5)->toString(), + Query::limit(5)->toString(), + ]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertNotEmpty($r['body']['queries']); + } + + public function test_empty_result_set_still_captures(): void + { + $r = $this->explain([ + Query::equal('status', ['nonexistent-status'])->toString(), + ]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertNotEmpty($r['body']['queries']); + $this->assertEquals('find', $r['body']['queries'][0]['purpose']); + } + + public function test_greater_and_less_operators(): void + { + $r = $this->explain([ + Query::greaterThan('releaseYear', 2005)->toString(), + Query::lessThan('releaseYear', 2015)->toString(), + ]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertNotEmpty($r['body']['queries']); + } + + public function test_select_projection(): void + { + $r = $this->explain([ + Query::select(['title', 'releaseYear'])->toString(), + Query::limit(5)->toString(), + ]); + + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertNotEmpty($r['body']['queries']); + } + + public function test_headline_fields_present(): void + { + $r = $this->explain([Query::limit(1)->toString()]); + + $this->assertEquals(200, $r['headers']['status-code']); + $plan = $r['body']['queries'][0]['plan']; + + $this->assertArrayHasKey('engine', $plan); + $this->assertArrayHasKey('rowsScanned', $plan); + $this->assertArrayHasKey('indexUsed', $plan); + $this->assertArrayHasKey('estimatedCost', $plan); + $this->assertArrayHasKey('tree', $plan); + } + + public function test_sanitizer_hides_perms_table(): void + { + $r = $this->explain([ + Query::equal('status', ['published'])->toString(), + Query::limit(5)->toString(), + ]); + + $raw = \json_encode($r['body']['queries']); + $this->assertStringNotContainsString('_perms', $raw); + } + + public function test_sanitizer_hides_metadata_table(): void + { + $r = $this->explain([Query::limit(1)->toString()]); + + $raw = \json_encode($r['body']['queries']); + $this->assertStringNotContainsString('__metadata', $raw); + } + + public function test_sanitizer_hides_internal_columns(): void + { + $r = $this->explain([Query::limit(1)->toString()]); + + $raw = \json_encode($r['body']['queries']); + foreach (['_uid', '_createdAt', '_updatedAt', '_tenant'] as $leak) { + $this->assertStringNotContainsString($leak, $raw, "internal column '{$leak}' leaked into the plan"); + } + } + + public function test_context_collection_is_user_facing_table_id(): void + { + $r = $this->explain([Query::limit(1)->toString()]); + + foreach ($r['body']['queries'] as $entry) { + $this->assertSame( + $this->fixture()['tableId'], + $entry['context']['collection'], + 'context.collection must use the user-facing tableId', + ); + } + } + + public function test_engine_is_sql_on_maria_backed(): void + { + $r = $this->explain([Query::limit(1)->toString()]); + + // The shipped appwrite stack runs Mongo by default; engine reflects the + // actual backend. Only assert it's a known string. + $engine = $r['body']['queries'][0]['plan']['engine'] ?? null; + $this->assertContains($engine, ['sql', 'mongo']); + } + + public function test_back_to_back_calls_are_independent(): void + { + $a = $this->explain([Query::limit(1)->toString()]); + $b = $this->explain([Query::limit(2)->toString()]); + + $this->assertEquals(200, $a['headers']['status-code']); + $this->assertEquals(200, $b['headers']['status-code']); + $this->assertNotEmpty($a['body']['queries']); + $this->assertNotEmpty($b['body']['queries']); + } + + public function test_invalid_query_returns400(): void + { + $r = $this->explain(['{"method":"notARealMethod"}']); + + $this->assertEquals(400, $r['headers']['status-code']); + } + + public function test_missing_table_returns404(): void + { + $f = $this->fixture(); + + $r = $this->client->call( + Client::METHOD_GET, + '/tablesdb/'.$f['databaseId'].'/tables/nonexistent_table/rows/explain', + $f['headers'], + ['queries' => [Query::limit(1)->toString()]], + ); + + $this->assertEquals(404, $r['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php b/tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php index abf15e40db..851431354f 100644 --- a/tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php +++ b/tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php @@ -17,7 +17,7 @@ class TablesDBCustomServerTest extends Scope use ProjectCustom; use SideServer; - public function test_list_rows_returns_explain_when_requested(): void + public function test_explain_rows(): void { $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -25,7 +25,7 @@ class TablesDBCustomServerTest extends Scope $response = $this->client->call( Client::METHOD_GET, - $this->getRecordUrl($databaseId, $tableId), + $this->getRecordUrl($databaseId, $tableId).'/explain', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -35,40 +35,34 @@ class TablesDBCustomServerTest extends Scope Query::orderAsc('releaseYear')->toString(), Query::limit(10)->toString(), ], - 'explain' => true, ] ); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('queries', $response['body']); + $this->assertIsArray($response['body']['queries']); + $this->assertNotEmpty($response['body']['queries']); - // Rows still come back as normal. - $this->assertArrayHasKey('rows', $response['body']); - $this->assertIsArray($response['body']['rows']); - $this->assertArrayHasKey('total', $response['body']); - - // Explain field is populated with one entry per physical read. - $this->assertArrayHasKey('explain', $response['body']); - $this->assertIsArray($response['body']['explain']); - $this->assertNotEmpty($response['body']['explain']); - - $first = $response['body']['explain'][0]; + $first = $response['body']['queries'][0]; $this->assertEquals('find', $first['purpose']); + $this->assertArrayHasKey('context', $first); + $this->assertArrayHasKey('collection', $first['context']); $this->assertEquals($tableId, $first['context']['collection']); + $this->assertArrayHasKey('plan', $first); $this->assertArrayHasKey('engine', $first['plan']); // listRows fires find() + count() (for total) by default — explain mirrors it. - $purposes = array_column($response['body']['explain'], 'purpose'); + $purposes = array_column($response['body']['queries'], 'purpose'); $this->assertContains('find', $purposes); $this->assertContains('count', $purposes); - // Internal storage references must be stripped from the plan tree. - $rawPlan = json_encode($response['body']['explain']); + $rawPlan = json_encode($response['body']['queries']); $this->assertStringNotContainsString('_perms', $rawPlan); $this->assertStringNotContainsString('__metadata', $rawPlan); } - public function test_list_rows_explain_is_empty_by_default(): void + public function test_explain_rows_skips_count_when_total_is_false(): void { $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -76,44 +70,19 @@ class TablesDBCustomServerTest extends Scope $response = $this->client->call( Client::METHOD_GET, - $this->getRecordUrl($databaseId, $tableId), + $this->getRecordUrl($databaseId, $tableId).'/explain', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [Query::limit(10)->toString()], - // explain not passed — default false - ] - ); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertArrayHasKey('explain', $response['body']); - $this->assertEmpty($response['body']['explain']); - } - - public function test_list_rows_explain_skips_count_when_total_is_false(): void - { - $data = $this->setupDocuments(); - $databaseId = $data['databaseId']; - $tableId = $data['moviesId']; - - $response = $this->client->call( - Client::METHOD_GET, - $this->getRecordUrl($databaseId, $tableId), - array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), - [ - 'queries' => [Query::limit(10)->toString()], - 'explain' => true, 'total' => false, ] ); $this->assertEquals(200, $response['headers']['status-code']); - $purposes = array_column($response['body']['explain'], 'purpose'); + $purposes = array_column($response['body']['queries'], 'purpose'); $this->assertContains('find', $purposes); $this->assertNotContains('count', $purposes); }