fix(elasticsearch): correct totals and CAT JSON output

This commit is contained in:
onenewcode 2026-07-23 17:06:23 +08:00 committed by GitHub
parent a8e5e8d8f9
commit f07509b5ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 855 additions and 70 deletions

View File

@ -111,7 +111,7 @@ onMounted(() => {
</Button>
</header>
<div class="min-h-0 flex-1 overflow-hidden bg-background p-4">
<pre v-show="responseView === 'raw' || !parsedBody.valid" class="m-0 h-full overflow-auto bg-transparent p-0 font-mono text-sm leading-6 whitespace-pre-wrap break-words">{{ body }}</pre>
<pre v-show="responseView === 'raw' || !parsedBody.valid" class="m-0 h-full overflow-auto bg-transparent p-0 font-mono text-sm leading-6 whitespace-pre">{{ body }}</pre>
<div v-if="parsedBody.valid" v-show="responseView === 'json'" class="h-full min-h-0">
<JsonTree ref="jsonTreeRef" :value="parsedBody.value" :highlight-json="highlightJson" :virtualized="true" class="dbx-editor-font-family text-sm leading-6" />
</div>

View File

@ -16,6 +16,7 @@ import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
import {
arrayObjectAncestorPathForDocumentField,
buildDocumentFilterCondition,
@ -84,7 +85,9 @@ type ViewMode = "document" | "table";
const documents = ref<JsonRecord[]>([]);
const copyDocuments = ref<JsonRecord[]>([]);
const lastGridColumns = ref<string[]>([]);
const total = ref(0);
const total = ref<number | undefined>(undefined);
const totalIsExact = ref(true);
const paginationTotal = ref<number | undefined>(undefined);
const loading = ref(false);
const documentLoadExecutionId = ref("");
const documentLoadCancelling = ref(false);
@ -123,15 +126,26 @@ const tableFindPaneWidth = ref<number | null>(null);
const isResizingTableSearchSplit = ref(false);
let tableSearchSplitStartX = 0;
let tableSearchSplitStartWidth = 0;
let elasticsearchCountKey: string | null = null;
let elasticsearchExactTotal: number | undefined;
let elasticsearchPaginationLowerBound: number | undefined;
let elasticsearchCountExecutionId = "";
let elasticsearchCountGeneration = 0;
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
const pageTotal = computed(() => paginationTotal.value ?? total.value ?? 0);
const documentRequestLimit = computed(() => {
if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value;
return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value);
});
const tableFindPaneStyle = computed(() => {
if (tableFindPaneWidth.value == null) return {};
return { flex: `0 0 ${tableFindPaneWidth.value}px` };
});
const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings));
const documentStoreLabels = computed(() => ({
documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value, t }),
documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value ?? 0, t }),
queryPreview: documentQueryPreview.value,
}));
@ -436,7 +450,7 @@ const documentQueryPreview = computed(() => {
filterJson: filter,
sortJson: sortInput.value.trim(),
skip: page.value * pageSize.value,
limit: pageSize.value,
limit: documentRequestLimit.value,
});
});
@ -580,6 +594,7 @@ async function gridSave(changes: DocumentGridChanges) {
await api.documentInsertDocument(props.connectionId, props.database, props.collection, JSON.stringify(doc));
}
if (isEs) resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
}
@ -684,6 +699,100 @@ function startDocumentLoadingTimer() {
}, 100);
}
function elasticsearchCountFilterKey(filter: string | undefined): string {
return JSON.stringify([props.connectionId, props.database, props.collection, filter ?? ""]);
}
function cancelElasticsearchCount() {
elasticsearchCountGeneration++;
const executionId = elasticsearchCountExecutionId;
elasticsearchCountExecutionId = "";
if (executionId) void api.cancelQuery(executionId);
}
function resetElasticsearchTotals(options: { preservePaginationTotal?: boolean } = {}) {
const nextTotals = resetElasticsearchDocumentTotals(paginationTotal.value, options.preservePaginationTotal);
cancelElasticsearchCount();
elasticsearchCountKey = null;
elasticsearchExactTotal = undefined;
elasticsearchPaginationLowerBound = undefined;
paginationTotal.value = nextTotals.paginationTotal;
total.value = nextTotals.total;
totalIsExact.value = nextTotals.totalIsExact;
}
function clampPageToPaginationTotal(): boolean {
const cap = paginationTotal.value;
if (cap === undefined) return false;
const nextPage = clampDocumentPage(page.value, pageSize.value, cap);
if (page.value === nextPage) return false;
page.value = nextPage;
return true;
}
function startElasticsearchExactCount(filter: string | undefined) {
if (elasticsearchCountExecutionId || elasticsearchExactTotal !== undefined || !elasticsearchCountKey) return;
const key = elasticsearchCountKey;
const executionId = uuid();
const generation = elasticsearchCountGeneration;
elasticsearchCountExecutionId = executionId;
void api
.elasticsearchCountDocuments(props.connectionId, props.collection, filter, executionId)
.then((exactCount) => {
if (generation !== elasticsearchCountGeneration || key !== elasticsearchCountKey || executionId !== elasticsearchCountExecutionId || !Number.isFinite(exactCount) || exactCount < 0) {
return;
}
elasticsearchExactTotal = exactCount;
const totals = resolveElasticsearchDocumentTotals(elasticsearchPaginationLowerBound ?? exactCount, false, exactCount);
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
if (clampPageToPaginationTotal()) void load();
})
.catch(() => {
// The lower-bound result remains truthful when a background count fails.
})
.finally(() => {
if (generation === elasticsearchCountGeneration && executionId === elasticsearchCountExecutionId) {
elasticsearchCountExecutionId = "";
}
});
}
function applyElasticsearchSearchTotal(searchTotal: number, isExact: boolean, filter: string | undefined) {
const key = elasticsearchCountFilterKey(filter);
if (key !== elasticsearchCountKey) {
cancelElasticsearchCount();
elasticsearchCountKey = key;
elasticsearchExactTotal = undefined;
elasticsearchPaginationLowerBound = undefined;
}
elasticsearchPaginationLowerBound = searchTotal;
const totals = resolveElasticsearchDocumentTotals(searchTotal, isExact, elasticsearchExactTotal);
if (isExact) {
cancelElasticsearchCount();
elasticsearchExactTotal = searchTotal;
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
return;
}
if (elasticsearchExactTotal !== undefined) {
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
return;
}
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
startElasticsearchExactCount(filter);
}
async function load() {
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
const executionId = uuid();
@ -696,8 +805,12 @@ async function load() {
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
try {
const filter = currentDocumentFilter();
if (documentStoreProvider.value.kind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
resetElasticsearchTotals();
}
const sort = currentDocumentSortJson(sortInput.value);
const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, page.value * pageSize.value, pageSize.value, filter, undefined, sort, executionId);
const skip = page.value * pageSize.value;
const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId);
if (documentLoadExecutionId.value !== executionId) return;
const nextDocuments =
documentStoreProvider.value.kind === "elasticsearch" && result.raw_documents?.length === result.documents.length
@ -722,7 +835,14 @@ async function load() {
}
lastGridColumns.value = [...keySet];
}
total.value = result.total;
if (documentStoreProvider.value.kind === "elasticsearch") {
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
} else {
cancelElasticsearchCount();
total.value = result.total;
totalIsExact.value = true;
paginationTotal.value = result.total;
}
syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId);
} catch (e: unknown) {
if (documentLoadExecutionId.value === executionId) error.value = e instanceof Error ? e.message : String(e);
@ -736,6 +856,11 @@ async function load() {
}
}
async function refreshDocuments() {
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
}
async function cancelDocumentLoad() {
const executionId = documentLoadExecutionId.value;
if (!executionId || documentLoadCancelling.value) return;
@ -754,20 +879,22 @@ async function cancelDocumentLoad() {
function applyFilter() {
page.value = 0;
load();
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals();
void load();
}
function paginate(offset: number, limit: number) {
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
pageSize.value = normalizedLimit;
page.value = Math.floor(Math.max(0, offset) / normalizedLimit);
load();
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
page.value = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
void load();
}
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
sortInput.value = documentStoreProvider.value.sortInputForColumn(column, direction);
page.value = 0;
load();
void load();
}
function asRecord(value: unknown): JsonRecord {
@ -1129,6 +1256,7 @@ async function saveDoc() {
isNew.value = false;
documentEditMode.value = "fields";
editFields.value = [];
if (kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
if (selectedIdx.value !== null && documents.value[selectedIdx.value]) {
editJson.value = stringifyDocumentStoreValue(documents.value[selectedIdx.value], documentStoreProvider.value.kind, 2);
@ -1151,6 +1279,7 @@ async function applyDeleteDoc(idx: number) {
selectedIdx.value = null;
editJson.value = "";
}
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
@ -1176,13 +1305,13 @@ async function confirmDelete() {
function prevPage() {
if (page.value <= 0) return;
page.value--;
load();
void load();
}
function nextPage() {
if ((page.value + 1) * pageSize.value >= total.value) return;
if ((page.value + 1) * pageSize.value >= pageTotal.value) return;
page.value++;
load();
void load();
}
function docPreview(doc: JsonRecord): string {
@ -1274,6 +1403,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
cancelElasticsearchCount();
stopDocumentLoadingTimer();
endTableSearchSplitResize();
});
@ -1339,14 +1469,14 @@ defineExpose({ focusSearch });
<span class="shrink-0 ml-1">{{ documentStoreLabels.documentsLabel }}</span>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="load"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="refreshDocuments"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
<div v-if="viewMode === 'document'" class="flex items-center gap-1 ml-1">
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
<ChevronLeft class="h-3 w-3" />
</Button>
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(total / pageSize)) }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="(page + 1) * pageSize >= total" @click="nextPage">
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(pageTotal / pageSize)) }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="(page + 1) * pageSize >= pageTotal" @click="nextPage">
<ChevronRight class="h-3 w-3" />
</Button>
</div>
@ -1446,8 +1576,10 @@ defineExpose({ focusSearch });
:page-offset="page * pageSize"
:page-limit="pageSize"
:total-row-count="total"
:total-row-count-is-exact="totalIsExact"
:pagination-total-row-count="pageTotal"
@sort="onSort"
@reload="load"
@reload="refreshDocuments"
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
>
<template #search-bar="{ localFilterCount, hasLocalColumnFilters, localFilterSummaries, clearLocalFilter }: { localFilterCount: number; hasLocalColumnFilters: boolean; localFilterSummaries: LocalFilterSummary[]; clearLocalFilter: (columnIndex?: number) => void }">

View File

@ -287,6 +287,8 @@ interface DataGridProps {
pageLimit?: number;
countSql?: string;
totalRowCount?: number;
totalRowCountIsExact?: boolean;
paginationTotalRowCount?: number;
totalRowCountLoading?: boolean;
loading?: boolean;
cacheKey?: string;
@ -2363,9 +2365,13 @@ const inferredBackendTotalRowCount = computed(() => {
});
const serverKnownTotalRowCount = computed(() => props.totalRowCount ?? manualTotalRowCount.value);
const displayedTotalRowCount = computed(() => serverKnownTotalRowCount.value ?? inferredBackendTotalRowCount.value);
const totalRowCountIsExact = computed(() => props.totalRowCountIsExact !== false);
// A backend can expose an exact display total while deliberately restricting
// offset pagination to a smaller safe range.
const paginationTotalRowCount = computed(() => props.paginationTotalRowCount ?? serverKnownTotalRowCount.value);
// Only a server-confirmed total drives pagination an inferred total means
// rows exist that we never fetched, so navigation must stay inside rows.length.
const hasKnownTotalRowCount = computed(() => typeof serverKnownTotalRowCount.value === "number" && serverKnownTotalRowCount.value >= 0);
const hasKnownPaginationTotalRowCount = computed(() => typeof paginationTotalRowCount.value === "number" && paginationTotalRowCount.value >= 0);
// When context=results and the caller hasn't configured server-side
// pagination (no pageLimit), the backend handed us every row up-front and
// rowCount IS the total. Without this hint, the "page is full assume more"
@ -2391,11 +2397,11 @@ const canGoNextPage = computed(() => {
pageSize: pageSize.value,
pageOffset: props.pageOffset,
currentPage: currentPage.value,
totalRowCount: hasKnownTotalRowCount.value ? displayedTotalRowCount.value : undefined,
totalRowCount: hasKnownPaginationTotalRowCount.value ? paginationTotalRowCount.value : undefined,
allRowsLoaded: allRowsLoaded.value,
});
});
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql));
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql));
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const canCalculateTotalRowCount = computed(() => !!props.connectionId && (!!props.tableMeta || !!props.countSql));
// When a refresh/rollback completes and the current page exceeds the last
@ -2408,7 +2414,7 @@ watch(
// and the completion was triggered by a refresh/rollback.
if (!loading && prevLoading && isRefreshingData.value) {
isRefreshingData.value = false;
const total = displayedTotalRowCount.value;
const total = paginationTotalRowCount.value;
if (!total || total <= 0) return;
const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value));
if (currentPage.value <= lastPageNum) return;
@ -2571,8 +2577,8 @@ function applyCustomPageSize() {
async function lastPage() {
if (infiniteScrollEnabled.value) return;
if (hasKnownTotalRowCount.value) {
const total = displayedTotalRowCount.value ?? 0;
if (hasKnownPaginationTotalRowCount.value) {
const total = paginationTotalRowCount.value ?? 0;
if (total <= 0) return;
const lastPageNum = Math.ceil(total / pageSize.value);
if (lastPageNum <= currentPage.value) return;
@ -8916,7 +8922,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<span v-if="hasData" class="shrink-0">
{{ t(showTruncationWarning ? "grid.loadedRows" : "grid.totalRows", { count: result.rows.length }) }}
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t("grid.totalRowCount", { count: displayedTotalRowCount }) }}</span>
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountIsExact === false ? "grid.totalRowCountAtLeast" : "grid.totalRowCount", { count: displayedTotalRowCount }) }}</span>
<span v-else-if="totalRowCountBusy" class="text-muted-foreground/70">
{{ t("grid.totalRowCountLoading") }}
</span>

View File

@ -873,6 +873,7 @@ export default {
totalRows: "Total {count} rows",
loadedRows: "Loaded {count} rows",
totalRowCount: "({count} total)",
totalRowCountAtLeast: "(at least {count} total)",
totalRowCountLoading: "(counting...)",
loadingMore: "Loading more data...",
allLoaded: "all loaded",

View File

@ -822,6 +822,7 @@ export default withEnglishFallback({
totalRows: "Total {count} filas",
loadedRows: "{count} filas cargadas",
totalRowCount: "({count} en total)",
totalRowCountAtLeast: "(al menos {count} en total)",
totalRowCountLoading: "(contando...)",
loadingMore: "Cargando más datos...",
allLoaded: "todo cargado",

View File

@ -820,6 +820,7 @@ export default withEnglishFallback({
totalRows: "Totale {count} righe",
loadedRows: "{count} righe caricate",
totalRowCount: "({count} in totale)",
totalRowCountAtLeast: "(almeno {count} in totale)",
totalRowCountLoading: "(conteggio...)",
loadingMore: "Caricamento altri dati...",
allLoaded: "tutto caricato",

View File

@ -821,6 +821,7 @@ export default withEnglishFallback({
totalRows: "{count}件表示",
loadedRows: "{count}件読み込み済み",
totalRowCount: "(全{count}件)",
totalRowCountAtLeast: "(少なくとも{count}件)",
totalRowCountLoading: "(カウント中...",
calculateTotalRows: "総行数をカウント",
calculateTotalRowsInline: "(総行数をカウント)",

View File

@ -822,6 +822,7 @@ export default withEnglishFallback({
totalRows: "Total de {count} linhas",
loadedRows: "{count} linhas carregadas",
totalRowCount: "({count} no total)",
totalRowCountAtLeast: "(pelo menos {count} no total)",
totalRowCountLoading: "(contando...)",
loadingMore: "Carregando mais dados...",
allLoaded: "tudo carregado",

View File

@ -875,6 +875,7 @@ export default withEnglishFallback({
totalRows: "共 {count} 行",
loadedRows: "已加载 {count} 行",
totalRowCount: "(总计 {count} 行)",
totalRowCountAtLeast: "(至少 {count} 行)",
totalRowCountLoading: "(统计中...",
loadingMore: "加载更多数据...",
allLoaded: "已全部加载",

View File

@ -822,6 +822,7 @@ export default withEnglishFallback({
totalRows: "共 {count} 筆",
loadedRows: "已載入 {count} 筆",
totalRowCount: "(總計 {count} 筆)",
totalRowCountAtLeast: "(至少 {count} 筆)",
totalRowCountLoading: "(統計中...",
loadingMore: "載入更多資料...",
allLoaded: "已全部載入",

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
describe("Elasticsearch document totals", () => {
it("keeps a lower-bound search total separate from an exact background count", () => {
expect(resolveElasticsearchDocumentTotals(10_000, false)).toEqual({
total: 10_000,
totalIsExact: false,
paginationTotal: 10_000,
});
expect(resolveElasticsearchDocumentTotals(10_000, false, 552_033)).toEqual({
total: 552_033,
totalIsExact: true,
paginationTotal: 10_000,
});
});
it("uses exact search totals for both display and pagination", () => {
expect(resolveElasticsearchDocumentTotals(42, true, 100)).toEqual({
total: 42,
totalIsExact: true,
paginationTotal: 42,
});
});
it("clears a stale display total without losing the safe page cap during a refresh", () => {
expect(resetElasticsearchDocumentTotals(10_000, true)).toEqual({
total: undefined,
totalIsExact: true,
paginationTotal: 10_000,
});
expect(resetElasticsearchDocumentTotals(10_000)).toEqual({
total: undefined,
totalIsExact: true,
paginationTotal: undefined,
});
});
it("clamps the final request without exceeding the conservative page cap", () => {
expect(clampDocumentPage(30, 333, 10_000)).toBe(30);
expect(clampDocumentPage(31, 333, 10_000)).toBe(30);
expect(documentPageRequestLimit(30, 333, 10_000)).toBe(10);
expect(documentPageRequestLimit(0, 100, undefined)).toBe(100);
});
});

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
import type { QueryResult } from "@/types/database";
const catJsonResult: QueryResult = {
columns: ["status", "response"],
rows: [[200, '[{"index":"data_pack_and_box_index_v1","docs.count":"42"}]']],
affected_rows: 0,
execution_time_ms: 1,
};
describe("Elasticsearch JSON response detection", () => {
it("routes an unformatted CAT response to the JSON renderer", () => {
expect(elasticsearchJsonResponseForResult("elasticsearch", "GET /_cat/indices/data_pack_and_box_index_v1", catJsonResult)).toEqual({
status: 200,
body: '[{"index":"data_pack_and_box_index_v1","docs.count":"42"}]',
});
});
});

View File

@ -491,6 +491,7 @@ export const mongoDropDatabase = forward("mongoDropDatabase");
export const mongoDropCollection = forward("mongoDropCollection");
export const mongoRenameCollection = forward("mongoRenameCollection");
export const documentFindDocuments = forward("documentFindDocuments");
export const elasticsearchCountDocuments = forward("elasticsearchCountDocuments");
export const mongoFindDocuments = forward("mongoFindDocuments");
export const mongoParseShellCommand = forward("mongoParseShellCommand");
export const mongoFindOne = forward("mongoFindOne");
@ -611,6 +612,7 @@ export type {
KvPutOptions,
KvPutResponse,
KvDeleteResponse,
DocumentQueryResult,
MongoDocumentResult,
HistoryEntry,
HistoryConnectionFilter,

View File

@ -75,6 +75,7 @@ import type {
KvPutOptions,
KvPutResponse,
KvDeleteResponse,
DocumentQueryResult,
MongoDocumentResult,
MongoCollectionStatsResult,
MongoGridFsBucketInfo,
@ -2178,10 +2179,14 @@ export async function mongoFindOne(connectionId: string, database: string, colle
return post("/api/mongo/find-one", { connectionId, database, collection, filter, projection, options, executionId });
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<MongoDocumentResult> {
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<DocumentQueryResult> {
return post("/api/document-store/find-documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise<number> {
return post("/api/document-store/elasticsearch-count-documents", { connectionId, index, filter, executionId });
}
export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise<number> {
return post("/api/mongo/count-documents", { connectionId, database, collection, filter, mode, executionId });
}

View File

@ -1849,14 +1849,18 @@ export async function zookeeperDelete(connectionId: string, key: string): Promis
return invoke("zookeeper_delete", { connectionId, key });
}
// --- MongoDB ---
export interface MongoDocumentResult {
// --- Document stores ---
export interface DocumentQueryResult {
documents: any[];
raw_documents?: string[];
extended_documents?: any[];
total: number;
total_is_exact?: boolean;
}
// Kept for callers that are specifically using MongoDB APIs.
export type MongoDocumentResult = DocumentQueryResult;
export interface MongoCollectionStatsResult {
count: unknown;
size: unknown;
@ -1942,10 +1946,14 @@ export async function mongoParseShellCommand(source: string): Promise<MongoComma
return normalizeRustMongoCommand(raw);
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<MongoDocumentResult> {
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<DocumentQueryResult> {
return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise<number> {
return invoke("elasticsearch_count_documents", { connectionId, index, filter, executionId });
}
export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise<number> {
return invoke("mongo_count_documents", { connectionId, database, collection, filter, mode, executionId });
}

View File

@ -0,0 +1,49 @@
export interface ElasticsearchDocumentTotals {
total: number;
totalIsExact: boolean;
paginationTotal: number;
}
export interface ResetElasticsearchDocumentTotals {
total: undefined;
totalIsExact: boolean;
paginationTotal?: number;
}
export function resolveElasticsearchDocumentTotals(searchTotal: number, searchTotalIsExact: boolean, exactCount?: number): ElasticsearchDocumentTotals {
if (searchTotalIsExact || exactCount === undefined) {
return {
total: searchTotal,
totalIsExact: searchTotalIsExact,
paginationTotal: searchTotal,
};
}
return {
total: exactCount,
totalIsExact: true,
paginationTotal: Math.min(searchTotal, exactCount),
};
}
export function resetElasticsearchDocumentTotals(paginationTotal: number | undefined, preservePaginationTotal = false): ResetElasticsearchDocumentTotals {
return {
total: undefined,
totalIsExact: true,
paginationTotal: preservePaginationTotal ? paginationTotal : undefined,
};
}
export function clampDocumentPage(page: number, pageSize: number, paginationTotal?: number): number {
const normalizedPage = Math.max(0, Math.floor(page));
if (paginationTotal === undefined) return normalizedPage;
if (paginationTotal <= 0) return 0;
const lastPage = Math.max(0, Math.ceil(paginationTotal / Math.max(1, pageSize)) - 1);
return Math.min(normalizedPage, lastPage);
}
export function documentPageRequestLimit(page: number, pageSize: number, paginationTotal?: number): number {
const normalizedPageSize = Math.max(1, Math.floor(pageSize));
if (paginationTotal === undefined) return normalizedPageSize;
const remaining = paginationTotal - Math.max(0, Math.floor(page)) * normalizedPageSize;
return remaining > 0 ? Math.min(normalizedPageSize, remaining) : normalizedPageSize;
}

View File

@ -9,9 +9,8 @@ export interface ElasticsearchJsonResponse {
const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
/**
* Detect the result shape emitted for a JSON response to an explicit
* Elasticsearch REST request. SQL and text (such as CAT) results keep using
* the normal data-grid path.
* Detect the raw HTTP result emitted for an Elasticsearch REST request.
* DBX asks unformatted CAT requests for JSON so they use this response panel.
*/
export function elasticsearchJsonResponseForResult(databaseType: DatabaseType | undefined, sourceStatement: string | undefined, result: QueryResult | undefined): ElasticsearchJsonResponse | undefined {
if (databaseType !== "elasticsearch" || !result || typeof sourceStatement !== "string") return undefined;

View File

@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
/// Common result shape returned by document-store queries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentQueryResult {
pub documents: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_documents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extended_documents: Option<Vec<serde_json::Value>>,
pub total: u64,
// Older document stores always return an exact total. Keep that wire shape
// unchanged and only send the flag when Elasticsearch reports a lower bound.
#[serde(default = "default_total_is_exact", skip_serializing_if = "is_true")]
pub total_is_exact: bool,
}
fn default_total_is_exact() -> bool {
true
}
fn is_true(value: &bool) -> bool {
*value
}
#[cfg(test)]
mod tests {
use super::DocumentQueryResult;
#[test]
fn exact_results_keep_the_existing_wire_shape() {
let result = DocumentQueryResult {
documents: Vec::new(),
raw_documents: None,
extended_documents: None,
total: 1,
total_is_exact: true,
};
let serialized = serde_json::to_value(result).unwrap();
assert!(serialized.get("total_is_exact").is_none());
let deserialized: DocumentQueryResult = serde_json::from_value(serde_json::json!({
"documents": [],
"total": 1,
}))
.unwrap();
assert!(deserialized.total_is_exact);
}
}

View File

@ -1,4 +1,4 @@
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::{Client as HttpClient, Method, StatusCode};
use serde::Deserialize;
use serde_json::Value;
@ -7,7 +7,7 @@ use std::error::Error;
use std::time::Duration;
use super::{http_client_builder, with_connection_timeout};
use crate::db::mongo_driver::MongoDocumentResult;
use crate::db::document_result::DocumentQueryResult;
const ELASTICSEARCH_PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
@ -421,6 +421,12 @@ fn push_mapping_column(
#[derive(Deserialize)]
struct SearchResponse {
hits: SearchHits,
#[serde(rename = "_shards")]
shards: Option<ElasticsearchShards>,
#[serde(default)]
timed_out: bool,
#[serde(default)]
terminated_early: bool,
}
#[derive(Deserialize)]
@ -431,13 +437,20 @@ struct SearchHits {
enum HitsTotal {
Count(u64),
Value { value: u64 },
Value { value: u64, is_exact: bool },
}
impl HitsTotal {
fn value(&self) -> u64 {
match self {
Self::Count(value) | Self::Value { value } => *value,
Self::Count(value) | Self::Value { value, .. } => *value,
}
}
fn is_exact(&self) -> bool {
match self {
Self::Count(_) => true,
Self::Value { is_exact, .. } => *is_exact,
}
}
}
@ -452,7 +465,10 @@ impl<'de> Deserialize<'de> for HitsTotal {
return Ok(Self::Count(count));
}
if let Some(count) = value.get("value").and_then(serde_json::Value::as_u64) {
return Ok(Self::Value { value: count });
// Object totals are exact only when Elasticsearch explicitly says
// so. Treat an absent or unknown relation conservatively.
let is_exact = value.get("relation").and_then(serde_json::Value::as_str) == Some("eq");
return Ok(Self::Value { value: count, is_exact });
}
Err(serde::de::Error::custom("expected hits.total as a number or an object with value"))
}
@ -475,7 +491,7 @@ pub async fn find_documents(
limit: i64,
filter: Option<&str>,
sort: Option<&str>,
) -> Result<MongoDocumentResult, String> {
) -> Result<DocumentQueryResult, String> {
let body = build_find_documents_body(skip, limit, filter, sort)?;
let path = elasticsearch_index_path(index, "_search");
@ -491,7 +507,58 @@ pub async fn find_documents(
search_response_to_document_result(result)
}
fn search_response_to_document_result(result: SearchResponse) -> Result<MongoDocumentResult, String> {
#[derive(Deserialize)]
struct CountResponse {
count: u64,
#[serde(rename = "_shards")]
shards: ElasticsearchShards,
}
#[derive(Deserialize)]
struct ElasticsearchShards {
total: u64,
successful: u64,
#[serde(default)]
skipped: u64,
failed: u64,
}
impl ElasticsearchShards {
fn is_complete(&self) -> bool {
// Elasticsearch only skips shards that cannot match, so successful and
// skipped shards together must account for every requested shard.
self.failed == 0 && self.successful.checked_add(self.skipped) == Some(self.total)
}
}
pub async fn count_documents(client: &EsClient, index: &str, filter: Option<&str>) -> Result<u64, String> {
let body = build_count_documents_body(filter)?;
let path = elasticsearch_index_path(index, "_count");
let resp = client.post(&path).json(&body).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
if !client.response_status(&resp).is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Elasticsearch error: {body}"));
}
let result: CountResponse = resp.json().await.map_err(|e| format!("Elasticsearch count parse error: {e}"))?;
if !result.shards.is_complete() {
return Err(format!(
"Elasticsearch count returned an incomplete shard response: {} successful, {} skipped, {} failed of {} shards",
result.shards.successful, result.shards.skipped, result.shards.failed, result.shards.total,
));
}
Ok(result.count)
}
fn search_response_to_document_result(result: SearchResponse) -> Result<DocumentQueryResult, String> {
// A 200 search response can still omit failed shards. Only expose an
// exact total when both the total relation and shard metadata agree.
let total_is_exact = !result.timed_out
&& !result.terminated_early
&& result.hits.total.is_exact()
&& result.shards.as_ref().is_some_and(ElasticsearchShards::is_complete);
let total = result.hits.total.value();
let documents: Vec<serde_json::Value> = result
.hits
.hits
@ -518,11 +585,12 @@ fn search_response_to_document_result(result: SearchResponse) -> Result<MongoDoc
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Elasticsearch document serialization failed: {e}"))?;
Ok(MongoDocumentResult {
Ok(DocumentQueryResult {
documents,
raw_documents: Some(raw_documents),
extended_documents: None,
total: result.hits.total.value(),
total,
total_is_exact,
})
}
@ -544,6 +612,14 @@ fn build_find_documents_body(
Ok(serde_json::Value::Object(body))
}
fn build_count_documents_body(filter: Option<&str>) -> Result<serde_json::Value, String> {
let mut body = serde_json::Map::new();
if let Some(query) = elasticsearch_query_from_document_filter(filter)? {
body.insert("query".to_string(), query);
}
Ok(serde_json::Value::Object(body))
}
fn elasticsearch_query_from_document_filter(filter: Option<&str>) -> Result<Option<serde_json::Value>, String> {
let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
@ -865,6 +941,32 @@ fn is_elasticsearch_ndjson_path(path: &str) -> bool {
|| path.ends_with("/_msearch/template")
}
fn is_elasticsearch_cat_path(path: &str) -> bool {
let path = path.split('?').next().unwrap_or(path).trim_end_matches('/');
path == "/_cat" || path.starts_with("/_cat/")
}
fn elasticsearch_query_parameter(path: &str, name: &str) -> Option<String> {
let query = path.split_once('?')?.1;
query.split('&').find_map(|part| {
let (key, value) = part.split_once('=').unwrap_or((part, ""));
let key = percent_decode_str(key).decode_utf8_lossy();
if key.eq_ignore_ascii_case(name) {
Some(percent_decode_str(value).decode_utf8_lossy().into_owned())
} else {
None
}
})
}
fn add_default_cat_json_format(mut request: ElasticsearchRestRequest) -> ElasticsearchRestRequest {
if is_elasticsearch_cat_path(&request.path) && elasticsearch_query_parameter(&request.path, "format").is_none() {
request.path.push(if request.path.contains('?') { '&' } else { '?' });
request.path.push_str("format=json");
}
request
}
fn normalize_elasticsearch_rest_path(path: &str) -> String {
let (path_part, query) = path.split_once('?').map_or((path, None), |(path, query)| (path, Some(query)));
let mut normalized = String::with_capacity(path.len());
@ -998,7 +1100,9 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate:
return execute_sql_query(client, input, start).await;
}
let request = parse_elasticsearch_rest_request(input)?;
// CAT APIs default to text, so request JSON for an unformatted CAT call.
// The frontend renders the returned HTTP body in its JSON response panel.
let request = add_default_cat_json_format(parse_elasticsearch_rest_request(input)?);
let mut builder = client.request(request.method, &request.path);
if let Some(body) = request.body {
builder = match request.body_kind {
@ -1721,8 +1825,8 @@ fn parse_aggregations(aggs: &serde_json::Map<String, serde_json::Value>) -> (Vec
#[cfg(test)]
mod tests {
use super::{
build_find_documents_body, elasticsearch_accept_invalid_certs, elasticsearch_base_url_fallbacks,
redact_elasticsearch_url, EsClient, SearchResponse,
build_count_documents_body, build_find_documents_body, elasticsearch_accept_invalid_certs,
elasticsearch_base_url_fallbacks, redact_elasticsearch_url, EsClient, SearchResponse,
};
use serde_json::json;
use std::time::Duration;
@ -1947,6 +2051,7 @@ mod tests {
"sort": [{ "created_at": { "order": "desc" } }]
})
);
assert!(body.get("track_total_hits").is_none());
}
#[test]
@ -1981,6 +2086,95 @@ mod tests {
);
}
#[test]
fn builds_elasticsearch_count_body_with_the_same_native_query_filter() {
let body = build_count_documents_body(Some(r#"{"$esQuery":{"term":{"status":"active"}}}"#)).unwrap();
assert_eq!(body, json!({ "query": { "term": { "status": "active" } } }));
assert!(body.get("from").is_none());
assert!(body.get("size").is_none());
assert!(body.get("sort").is_none());
}
#[test]
fn builds_elasticsearch_count_body_with_structured_filter_operators() {
let filter = r#"{"$and":[{"city":{"$ne":"上海"}},{"age":{"$gte":18}}]}"#;
assert_eq!(
build_count_documents_body(Some(filter)).unwrap(),
json!({
"query": {
"bool": {
"filter": [
{ "bool": { "must_not": [{ "term": { "city": "上海" } }] } },
{ "range": { "age": { "gte": 18 } } }
]
}
}
})
);
}
#[test]
fn accepts_only_complete_elasticsearch_count_shards() {
assert!(super::ElasticsearchShards { total: 3, successful: 2, skipped: 1, failed: 0 }.is_complete());
assert!(!super::ElasticsearchShards { total: 3, successful: 2, skipped: 0, failed: 0 }.is_complete());
assert!(!super::ElasticsearchShards { total: 3, successful: 2, skipped: 0, failed: 1 }.is_complete());
}
#[tokio::test]
async fn counts_documents_with_the_translated_filter() {
use tokio::io::AsyncWriteExt;
let response_body = r#"{"count":552033,"_shards":{"total":3,"successful":3,"skipped":0,"failed":0}}"#;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.starts_with("POST /orders/_count "));
let body = request.split_once("\r\n\r\n").unwrap().1;
assert_eq!(
serde_json::from_str::<serde_json::Value>(body).unwrap(),
json!({ "query": { "term": { "status": "active" } } })
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body,
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
assert_eq!(super::count_documents(&client, "orders", Some(r#"{"status":"active"}"#)).await.unwrap(), 552_033);
server.await.unwrap();
}
#[tokio::test]
async fn rejects_partial_elasticsearch_document_count() {
use tokio::io::AsyncWriteExt;
let response_body = r#"{"count":4,"_shards":{"total":3,"successful":2,"skipped":0,"failed":1}}"#;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.starts_with("POST /orders/_count "));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body,
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
let error = super::count_documents(&client, "orders", None).await.unwrap_err();
assert!(error.contains("incomplete shard response: 2 successful, 0 skipped, 1 failed of 3 shards"));
server.await.unwrap();
}
#[test]
fn builds_elasticsearch_find_body_with_structured_filter_operators() {
let body = build_find_documents_body(
@ -2053,6 +2247,7 @@ mod tests {
#[test]
fn parses_search_total_from_elasticsearch_6_number_shape() {
let response: SearchResponse = serde_json::from_value(json!({
"_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 },
"hits": {
"total": 5,
"hits": []
@ -2061,11 +2256,13 @@ mod tests {
.unwrap();
assert_eq!(response.hits.total.value(), 5);
assert!(response.hits.total.is_exact());
}
#[test]
fn parses_search_total_from_elasticsearch_7_object_shape() {
let response: SearchResponse = serde_json::from_value(json!({
"_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 },
"hits": {
"total": { "value": 5, "relation": "eq" },
"hits": []
@ -2074,6 +2271,78 @@ mod tests {
.unwrap();
assert_eq!(response.hits.total.value(), 5);
assert!(response.hits.total.is_exact());
let result = super::search_response_to_document_result(response).unwrap();
assert!(result.total_is_exact);
}
#[test]
fn preserves_elasticsearch_lower_bound_total_relation() {
let response: SearchResponse = serde_json::from_value(json!({
"hits": {
"total": { "value": 10_000, "relation": "gte" },
"hits": []
}
}))
.unwrap();
assert_eq!(response.hits.total.value(), 10_000);
assert!(!response.hits.total.is_exact());
let result = super::search_response_to_document_result(response).unwrap();
assert_eq!(result.total, 10_000);
assert!(!result.total_is_exact);
}
#[test]
fn treats_search_total_as_a_lower_bound_when_a_shard_failed() {
let response: SearchResponse = serde_json::from_value(json!({
"_shards": { "total": 3, "successful": 2, "skipped": 0, "failed": 1 },
"hits": {
"total": { "value": 5, "relation": "eq" },
"hits": []
}
}))
.unwrap();
let result = super::search_response_to_document_result(response).unwrap();
assert_eq!(result.total, 5);
assert!(!result.total_is_exact);
}
#[test]
fn treats_timed_out_or_terminated_searches_as_lower_bounds() {
for response in [
json!({
"timed_out": true,
"_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 },
"hits": { "total": { "value": 5, "relation": "eq" }, "hits": [] }
}),
json!({
"terminated_early": true,
"_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 },
"hits": { "total": { "value": 5, "relation": "eq" }, "hits": [] }
}),
] {
let response: SearchResponse = serde_json::from_value(response).unwrap();
let result = super::search_response_to_document_result(response).unwrap();
assert_eq!(result.total, 5);
assert!(!result.total_is_exact);
}
}
#[test]
fn treats_search_total_without_shard_metadata_as_a_lower_bound() {
let response: SearchResponse = serde_json::from_value(json!({
"hits": {
"total": { "value": 5, "relation": "eq" },
"hits": []
}
}))
.unwrap();
let result = super::search_response_to_document_result(response).unwrap();
assert_eq!(result.total, 5);
assert!(!result.total_is_exact);
}
#[test]
@ -2191,6 +2460,22 @@ mod tests {
assert_eq!(result.affected_rows, 2);
}
#[test]
fn adds_json_format_only_when_cat_format_is_not_explicit() {
let plain =
super::add_default_cat_json_format(super::parse_elasticsearch_rest_request("GET /_cat/indices").unwrap());
assert_eq!(plain.path, "/_cat/indices?format=json");
let defaulted =
super::add_default_cat_json_format(super::parse_elasticsearch_rest_request("GET /_cat/indices?v").unwrap());
assert_eq!(defaulted.path, "/_cat/indices?v&format=json");
let explicit_text = super::add_default_cat_json_format(
super::parse_elasticsearch_rest_request("GET /_cat/indices?format=txt").unwrap(),
);
assert_eq!(explicit_text.path, "/_cat/indices?format=txt");
}
#[test]
fn preserves_http_status_for_plain_text_rest_errors() {
let result =
@ -2223,11 +2508,10 @@ mod tests {
}
#[tokio::test]
async fn execute_rest_query_keeps_plain_text_response_body() {
async fn execute_rest_query_preserves_index_specific_cat_json_response() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let body =
"health status index docs.count store.size\ngreen open app-log-2026-07 42 10mb\n";
let body = r#"[{"health":"green","index":"app-log-2026-07","docs.count":"42"}]"#;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
@ -2235,7 +2519,67 @@ mod tests {
let mut request = [0_u8; 1024];
let read = socket.read(&mut request).await.unwrap();
let request = String::from_utf8_lossy(&request[..read]);
assert!(request.starts_with("GET /_cat/indices "));
assert!(request.starts_with("GET /_cat/indices/data_pack_and_box_index_v1?format=json "));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
let result = super::execute_rest_query(&client, "GET /_cat/indices/data_pack_and_box_index_v1").await.unwrap();
server.await.unwrap();
assert_eq!(result.columns, vec!["status", "response"]);
assert_eq!(result.rows, vec![vec![json!(200), json!(body)]]);
}
#[tokio::test]
async fn execute_rest_query_preserves_explicit_cat_json_response() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let body = r#"[{"index":"data_pack_and_box_index_v1","docs.count":"42"}]"#;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0_u8; 1024];
let read = socket.read(&mut request).await.unwrap();
let request = String::from_utf8_lossy(&request[..read]);
assert!(request.starts_with("GET /_cat/indices/data_pack_and_box_index_v1?format=json "));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
let result = super::execute_rest_query(&client, "GET /_cat/indices/data_pack_and_box_index_v1?format=json")
.await
.unwrap();
server.await.unwrap();
assert_eq!(result.columns, vec!["status", "response"]);
assert_eq!(result.rows, vec![vec![json!(200), json!(body)]])
}
#[tokio::test]
async fn execute_rest_query_keeps_default_cat_text_when_server_does_not_return_json() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let body = "health status\ngreen open\n";
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0_u8; 1024];
let read = socket.read(&mut request).await.unwrap();
let request = String::from_utf8_lossy(&request[..read]);
assert!(request.starts_with("GET /_cat/indices?format=json "));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
@ -2249,8 +2593,36 @@ mod tests {
server.await.unwrap();
assert_eq!(result.columns, vec!["response"]);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[1][0], json!("green open app-log-2026-07 42 10mb"));
assert_eq!(result.rows, vec![vec![json!("health status")], vec![json!("green open")]]);
}
#[tokio::test]
async fn execute_rest_query_keeps_explicit_text_cat_response() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let body = "health status\ngreen open\n";
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0_u8; 1024];
let read = socket.read(&mut request).await.unwrap();
let request = String::from_utf8_lossy(&request[..read]);
assert!(request.starts_with("GET /_cat/indices?format=txt "));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
let result = super::execute_rest_query(&client, "GET /_cat/indices?format=txt").await.unwrap();
server.await.unwrap();
assert_eq!(result.columns, vec!["response"]);
assert_eq!(result.rows, vec![vec![json!("health status")], vec![json!("green open")]]);
}
#[tokio::test]

View File

@ -2,6 +2,7 @@ pub mod agent_driver;
pub mod clickhouse_driver;
pub mod cloudflare_d1;
pub use cloudflare_d1 as cloudflare_d1_driver;
pub mod document_result;
pub mod duckdb_driver;
pub mod duckdb_sql;
#[cfg(feature = "duckdb-bundled")]

View File

@ -12,15 +12,9 @@ use futures::{io::AsyncReadExt, io::AsyncWriteExt, TryStreamExt};
use percent_encoding::percent_decode_str;
use std::{collections::HashSet, time::Duration};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MongoDocumentResult {
pub documents: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_documents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extended_documents: Option<Vec<serde_json::Value>>,
pub total: u64,
}
pub use super::document_result::DocumentQueryResult;
/// Backward-compatible name for callers of Mongo-specific APIs.
pub type MongoDocumentResult = DocumentQueryResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MongoDropIndexesResult {
@ -676,7 +670,13 @@ pub async fn find_documents(
extended_documents.push(Bson::Document(doc).into_relaxed_extjson());
}
Ok(MongoDocumentResult { documents, raw_documents: None, extended_documents: Some(extended_documents), total })
Ok(MongoDocumentResult {
documents,
raw_documents: None,
extended_documents: Some(extended_documents),
total,
total_is_exact: true,
})
}
#[derive(Debug, Default, Deserialize)]
@ -812,7 +812,13 @@ pub async fn find_documents_extended_json(
documents.push(bson_to_browser_json(&Bson::Document(doc)));
}
Ok(MongoDocumentResult { extended_documents: Some(documents.clone()), documents, raw_documents: None, total })
Ok(MongoDocumentResult {
extended_documents: Some(documents.clone()),
documents,
raw_documents: None,
total,
total_is_exact: true,
})
}
/// Run `db.collection.aggregate(pipeline, options)`.
@ -858,6 +864,7 @@ pub async fn aggregate_documents(
raw_documents: None,
extended_documents: Some(vec![extended]),
total: 1,
total_is_exact: true,
});
}
@ -909,7 +916,13 @@ async fn drain_document_cursor(
documents.truncate(max_rows);
extended_documents.truncate(max_rows);
}
Ok(MongoDocumentResult { documents, raw_documents: None, extended_documents: Some(extended_documents), total })
Ok(MongoDocumentResult {
documents,
raw_documents: None,
extended_documents: Some(extended_documents),
total,
total_is_exact: true,
})
}
fn parse_aggregate_options_document(options_json: Option<&str>) -> Result<Document, String> {
@ -963,7 +976,13 @@ pub async fn distinct(
let extended_documents = values.into_iter().map(|value| value.into_relaxed_extjson()).collect::<Vec<_>>();
let total = documents.len() as u64;
Ok(MongoDocumentResult { documents, raw_documents: None, extended_documents: Some(extended_documents), total })
Ok(MongoDocumentResult {
documents,
raw_documents: None,
extended_documents: Some(extended_documents),
total,
total_is_exact: true,
})
}
pub async fn create_index(
@ -1329,12 +1348,14 @@ fn single_document_result(document: Option<Document>) -> MongoDocumentResult {
raw_documents: None,
extended_documents: Some(vec![Bson::Document(document).into_relaxed_extjson()]),
total: 1,
total_is_exact: true,
},
None => MongoDocumentResult {
documents: Vec::new(),
raw_documents: None,
extended_documents: Some(Vec::new()),
total: 0,
total_is_exact: true,
},
}
}

View File

@ -483,7 +483,7 @@ pub async fn find_documents(
collection: &str,
skip: u64,
limit: i64,
) -> Result<crate::db::mongo_driver::MongoDocumentResult, String> {
) -> Result<crate::db::document_result::DocumentQueryResult, String> {
if client.kind == VectorDbKind::ChromaDb {
let start = std::time::Instant::now();
let url = format!(
@ -520,11 +520,12 @@ pub async fn find_documents(
Value::Object(map)
})
.collect();
return Ok(crate::db::mongo_driver::MongoDocumentResult {
return Ok(crate::db::document_result::DocumentQueryResult {
documents,
raw_documents: None,
extended_documents: None,
total: result.affected_rows,
total_is_exact: true,
});
}
@ -567,11 +568,12 @@ pub async fn find_documents(
Value::Object(map)
})
.collect();
Ok(crate::db::mongo_driver::MongoDocumentResult {
Ok(crate::db::document_result::DocumentQueryResult {
documents,
raw_documents: None,
extended_documents: None,
total: result.affected_rows,
total_is_exact: true,
})
}

View File

@ -1,6 +1,6 @@
use crate::connection::{AppState, PoolKind};
use crate::db::agent_driver::mongo_document_id_params;
use crate::db::mongo_driver::MongoDocumentResult;
use crate::db::document_result::DocumentQueryResult;
use crate::db::{elasticsearch_driver, mongo_driver, vector_driver};
pub use crate::db::vector_driver::CollectionInfo;
@ -377,7 +377,7 @@ pub async fn find_documents_core(
filter: Option<&str>,
projection: Option<&str>,
sort: Option<&str>,
) -> Result<MongoDocumentResult, String> {
) -> Result<DocumentQueryResult, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
@ -425,6 +425,24 @@ pub async fn find_documents_core(
}
}
pub async fn count_elasticsearch_documents_core(
state: &AppState,
connection_id: &str,
index: &str,
filter: Option<&str>,
) -> Result<u64, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::Elasticsearch(client) => {
let client = client.clone();
drop(connections);
elasticsearch_driver::count_documents(&client, index, filter).await
}
_ => Err("Not an Elasticsearch connection".to_string()),
}
}
fn is_unknown_agent_method_error(error: &str, method: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains(method) && (lower.contains("unknown method") || lower.contains("method not found"))

View File

@ -476,6 +476,10 @@ async fn main() {
.route("/document-store/list-databases", post(routes::document_store::list_databases))
.route("/document-store/list-collections", post(routes::document_store::list_collections))
.route("/document-store/find-documents", post(routes::document_store::find_documents))
.route(
"/document-store/elasticsearch-count-documents",
post(routes::document_store::elasticsearch_count_documents),
)
.route("/document-store/list-gridfs-buckets", post(routes::document_store::list_gridfs_buckets))
.route("/document-store/create-gridfs-bucket", post(routes::document_store::create_gridfs_bucket))
.route("/document-store/delete-gridfs-bucket", post(routes::document_store::delete_gridfs_bucket))

View File

@ -69,6 +69,15 @@ pub struct DocumentFindRequest {
pub execution_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ElasticsearchCountDocumentsRequest {
pub connection_id: String,
pub index: String,
pub filter: Option<String>,
pub execution_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentInsertRequest {
@ -167,7 +176,7 @@ pub async fn list_collections(
pub async fn find_documents(
State(state): State<Arc<WebState>>,
Json(req): Json<DocumentFindRequest>,
) -> Result<Json<dbx_core::db::mongo_driver::MongoDocumentResult>, AppError> {
) -> Result<Json<dbx_core::db::document_result::DocumentQueryResult>, AppError> {
let result = run_cancellable(
&state,
req.execution_id,
@ -187,6 +196,24 @@ pub async fn find_documents(
Ok(Json(result))
}
pub async fn elasticsearch_count_documents(
State(state): State<Arc<WebState>>,
Json(req): Json<ElasticsearchCountDocumentsRequest>,
) -> Result<Json<u64>, AppError> {
let result = run_cancellable(
&state,
req.execution_id,
dbx_core::document_ops::count_elasticsearch_documents_core(
&state.app,
&req.connection_id,
&req.index,
req.filter.as_deref(),
),
)
.await?;
Ok(Json(result))
}
pub async fn insert_document(
State(state): State<Arc<WebState>>,
Json(req): Json<DocumentInsertRequest>,

View File

@ -3,7 +3,7 @@ use std::sync::Arc;
use tauri::State;
use crate::commands::connection::{ensure_connection_writable, AppState};
use dbx_core::db::mongo_driver::MongoDocumentResult;
use dbx_core::db::document_result::DocumentQueryResult;
use dbx_core::document_ops::CollectionInfo;
pub(crate) async fn run_cancellable<T, F>(
@ -58,7 +58,7 @@ pub async fn document_find_documents(
projection: Option<String>,
sort: Option<String>,
execution_id: Option<String>,
) -> Result<MongoDocumentResult, String> {
) -> Result<DocumentQueryResult, String> {
let app = state.inner().clone();
run_cancellable(
&app,
@ -78,6 +78,23 @@ pub async fn document_find_documents(
.await
}
#[tauri::command]
pub async fn elasticsearch_count_documents(
state: State<'_, Arc<AppState>>,
connection_id: String,
index: String,
filter: Option<String>,
execution_id: Option<String>,
) -> Result<u64, String> {
let app = state.inner().clone();
run_cancellable(
&app,
execution_id,
dbx_core::document_ops::count_elasticsearch_documents_core(&app, &connection_id, &index, filter.as_deref()),
)
.await
}
#[tauri::command]
pub async fn document_insert_document(
state: State<'_, Arc<AppState>>,

View File

@ -1388,6 +1388,7 @@ pub fn run() {
commands::document_cmd::document_list_databases,
commands::document_cmd::document_list_collections,
commands::document_cmd::document_find_documents,
commands::document_cmd::elasticsearch_count_documents,
commands::document_cmd::document_list_gridfs_buckets,
commands::document_cmd::document_create_gridfs_bucket,
commands::document_cmd::document_delete_gridfs_bucket,