From da7eedb49c7003e527b365eb96cd0c6e3644dc73 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 16 Jul 2026 16:31:15 +0800 Subject: [PATCH] feat(elasticsearch): improve REST commands and visual filters --- .../components/document/DocumentBrowser.vue | 124 +++++- .../src/composables/useSqlExecution.ts | 26 +- .../__tests__/sql/sqlStatementRanges.spec.ts | 52 ++- .../src/lib/app/documentStoreProvider.ts | 87 ++++ .../elasticsearch/elasticsearchCompletion.ts | 78 ++-- .../elasticsearchJsonResponse.ts | 5 +- .../desktop/src/lib/sql/sqlStatementRanges.ts | 153 ++++++- apps/desktop/src/stores/queryStore.ts | 69 +++ .../dbx-core/src/db/elasticsearch_driver.rs | 403 +++++++++++++++--- crates/dbx-core/src/schema.rs | 3 + .../app-tests/documentStoreProvider.test.ts | 51 ++- .../app-tests/elasticsearchCompletion.test.ts | 12 +- .../elasticsearchJsonResponse.test.ts | 18 + packages/app-tests/queryStore.test.ts | 167 ++++++++ packages/app-tests/useSqlExecution.test.ts | 18 +- 15 files changed, 1151 insertions(+), 115 deletions(-) diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 858641440f..2e6674a290 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -18,6 +18,7 @@ import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit"; import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle"; import { buildDocumentFilterCondition, + buildElasticsearchQueryFromRules, combineDocumentFilterConditions, currentDocumentFilterJson, currentDocumentSortJson, @@ -25,8 +26,14 @@ import { documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, + elasticsearchBoolClauseOptions, + elasticsearchQueryTypeNeedsValue, + elasticsearchQueryTypeOptions, + elasticsearchStructuredFilter, type DocumentFilterMode, type DocumentFilterRule, + type ElasticsearchBoolClause, + type ElasticsearchQueryType, } from "@/lib/app/documentStoreProvider"; import { documentStoreValueForGrid, parseDocumentStoreInputValue, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues"; import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; @@ -35,7 +42,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { useSettingsStore } from "@/stores/settingsStore"; import JsonEditNode from "./JsonEditNode.vue"; import type { EditNode } from "@/types/editor"; -import type { DatabaseType, QueryResult } from "@/types/database"; +import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database"; import type { CustomSaveHandler } from "@/composables/useDataGridEditor"; import { Splitpanes, Pane } from "splitpanes"; import "splitpanes/dist/splitpanes.css"; @@ -114,6 +121,7 @@ type DocumentGridChanges = { const documentFilterBuilderOpen = ref(false); const documentFilterRules = ref([]); const appliedDocumentFilter = ref | null>(null); +const elasticsearchMappingFields = ref([]); const pendingDelete = ref(null); @@ -175,13 +183,34 @@ const gridResult = computed(() => { return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false }; }); -const documentFilterFieldOptions = computed(() => gridResult.value.columns); -const documentStructuredFilterCount = computed(() => (appliedDocumentFilter.value ? 1 : 0)); +const documentFilterFieldOptions = computed(() => { + if (documentStoreProvider.value.kind !== "elasticsearch") return gridResult.value.columns; + const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"]; + return [...new Set(names.filter(Boolean))]; +}); +const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type]))); +const documentStructuredFilterCount = computed(() => { + if (!appliedDocumentFilter.value) return 0; + if (documentStoreProvider.value.kind !== "elasticsearch") return 1; + const query = appliedDocumentFilter.value.$esQuery; + if (!query || typeof query !== "object" || Array.isArray(query)) return 0; + const bool = (query as Record).bool; + if (!bool || typeof bool !== "object" || Array.isArray(bool)) return 0; + return elasticsearchBoolClauseOptions.reduce((count, clause) => { + const rules = (bool as Record)[clause]; + return count + (Array.isArray(rules) ? rules.length : 0); + }, 0); +}); const documentLoadingLabelKey = computed(() => (documentLoadCancelling.value ? "common.stopping" : "common.loading")); let documentLoadingTimer: ReturnType | undefined; function createDocumentFilterRule(): DocumentFilterRule { - return defaultDocumentFilterRule(uuid(), documentFilterFieldOptions.value[0] ?? ""); + const fieldName = documentFilterFieldOptions.value[0] ?? ""; + const rule = defaultDocumentFilterRule(uuid(), fieldName); + if (documentStoreProvider.value.kind === "elasticsearch") { + rule.elasticsearchQueryType = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(fieldName))[0]; + } + return rule; } function ensureDocumentFilterRule() { @@ -204,11 +233,33 @@ function updateDocumentFilterRule(ruleId: string, patch: Partial { if (rule.id !== ruleId) return rule; const next = { ...rule, ...patch }; - if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = ""; + if (documentStoreProvider.value.kind === "elasticsearch") { + const queryTypes = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(next.fieldName)); + if (!next.elasticsearchQueryType || !queryTypes.includes(next.elasticsearchQueryType)) { + next.elasticsearchQueryType = queryTypes[0]; + } + if (!elasticsearchQueryTypeNeedsValue(next.elasticsearchQueryType)) next.rawValue = ""; + } else if (!documentFilterModeNeedsValue(next.mode)) { + next.rawValue = ""; + } return next; }); } +function elasticsearchRuleQueryTypes(rule: DocumentFilterRule): ElasticsearchQueryType[] { + return elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(rule.fieldName)); +} + +function elasticsearchQueryTypeLabel(queryType: ElasticsearchQueryType): string { + const rangeOperator: Partial> = { + range_gt: "range >", + range_gte: "range >=", + range_lt: "range <", + range_lte: "range <=", + }; + return rangeOperator[queryType] ?? queryType; +} + function resetDocumentFilterBuilder() { appliedDocumentFilter.value = null; documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : []; @@ -235,6 +286,12 @@ const documentQueryPreview = computed(() => { }); async function applyDocumentStructuredFilters() { + if (documentStoreProvider.value.kind === "elasticsearch") { + appliedDocumentFilter.value = elasticsearchStructuredFilter(buildElasticsearchQueryFromRules(documentFilterRules.value)); + documentFilterBuilderOpen.value = false; + applyFilter(); + return; + } const items = documentFilterRules.value .map((rule) => ({ rule, @@ -250,6 +307,15 @@ async function applyDocumentStructuredFilters() { applyFilter(); } +async function loadElasticsearchMappingFields() { + if (documentStoreProvider.value.kind !== "elasticsearch") return; + try { + elasticsearchMappingFields.value = await api.getColumns(props.connectionId, props.database, "", props.collection); + } catch { + elasticsearchMappingFields.value = []; + } +} + function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) { appliedDocumentFilter.value = null; resetDocumentFilterBuilder(); @@ -819,7 +885,10 @@ onMounted(async () => { } catch (e) { console.warn("[DBX] ensureConnected failed for", props.connectionId, e); } - load(); + // Mapping metadata enriches the filter builder, but it must not delay the + // first page of documents when the mapping endpoint is slow. + void loadElasticsearchMappingFields(); + void load(); }); onBeforeUnmount(() => { if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value); @@ -1014,7 +1083,7 @@ function resetTableSearchSplitWidth() { - +
{{ t("grid.filter") }}