mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Revert "fold explain into listRows/listDocuments via explain:true param"
This reverts commit 9c2d3597cc.
This commit is contained in:
+4
-8
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Explain;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Order as OrderException;
|
||||
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;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
abstract class Get extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'explainRows';
|
||||
}
|
||||
|
||||
protected function getResponseModel(): string
|
||||
{
|
||||
return UtopiaResponse::MODEL_QUERY_PLAN;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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<string> $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<int, array<string, mixed>> $entries
|
||||
* @return array<int, Document>
|
||||
*/
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
-87
@@ -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_<seq>_collection_<seq>` 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<int, array<string, mixed>> $entries
|
||||
* @return array<int, Document>
|
||||
*/
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Explain;
|
||||
|
||||
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Explain\Get as DocumentExplain;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Get extends DocumentExplain
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'explainDocumentsDBDocuments';
|
||||
}
|
||||
|
||||
protected function getResponseModel(): string
|
||||
{
|
||||
return UtopiaResponse::MODEL_QUERY_PLAN;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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(...));
|
||||
}
|
||||
}
|
||||
-1
@@ -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')
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows\Explain;
|
||||
|
||||
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Explain\Get as DocumentExplain;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Get extends DocumentExplain
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'explainRows';
|
||||
}
|
||||
|
||||
protected function getResponseModel(): string
|
||||
{
|
||||
return UtopiaResponse::MODEL_QUERY_PLAN;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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(...));
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Explain;
|
||||
|
||||
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Explain\Get as DocumentExplain;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Get extends DocumentExplain
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'explainVectorsDBDocuments';
|
||||
}
|
||||
|
||||
protected function getResponseModel(): string
|
||||
{
|
||||
return UtopiaResponse::MODEL_QUERY_PLAN;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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(...));
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
/**
|
||||
* Document list response — adds an optional `explain` field to the base list
|
||||
* shape so listDocuments can return the captured query plan alongside the
|
||||
* documents when the caller passes `explain: true`. Empty by default.
|
||||
*/
|
||||
class DocumentList extends BaseList
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Documents List', Response::MODEL_DOCUMENT_LIST, 'documents', Response::MODEL_DOCUMENT);
|
||||
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
/**
|
||||
* Row list response — adds an optional `explain` field to the base list shape
|
||||
* so listRows can return the captured query plan alongside the rows when the
|
||||
* caller passes `explain: true`. Empty by default.
|
||||
*/
|
||||
class RowList extends BaseList
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Rows List', Response::MODEL_ROW_LIST, 'rows', Response::MODEL_ROW);
|
||||
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\TablesDB;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Tests\E2E\Scopes\ApiTablesDB;
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideServer;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Query;
|
||||
|
||||
class ExplainAggressiveTest extends Scope
|
||||
{
|
||||
use ApiTablesDB;
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
|
||||
private static ?array $fixture = null;
|
||||
|
||||
private function fixture(): array
|
||||
{
|
||||
if (self::$fixture !== null) {
|
||||
return self::$fixture;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'content-type' => '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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user