From 0bdf23248753bafa2c14e35b8e228665ba7fb0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Fri, 4 Sep 2026 01:01:32 +0800 Subject: [PATCH] feat(elasticsearch): add hit document view and field type detection --- .../common/ElasticsearchJsonResponsePanel.vue | 67 +++++++++++++++++-- .../ElasticsearchJsonResponsePanel.spec.ts | 38 +++++++++++ .../components/document/DocumentBrowser.vue | 19 ++++-- .../DocumentBrowserFieldSearch.spec.ts | 27 ++++++++ .../dataGrid/dataGridCellTypeVisual.spec.ts | 13 ++++ .../src/lib/dataGrid/dataGridColumnType.ts | 27 +++++++- .../dbx-core/src/db/elasticsearch_driver.rs | 27 +++++++- 7 files changed, 205 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/common/ElasticsearchJsonResponsePanel.vue b/apps/desktop/src/components/common/ElasticsearchJsonResponsePanel.vue index 1a2901eab1..977368b6ef 100644 --- a/apps/desktop/src/components/common/ElasticsearchJsonResponsePanel.vue +++ b/apps/desktop/src/components/common/ElasticsearchJsonResponsePanel.vue @@ -7,7 +7,7 @@ import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue"; import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue"; import { useToast } from "@/composables/useToast"; import { copyToClipboard } from "@/lib/common/clipboard"; -import { formatJsonSource } from "@/lib/common/safeJsonFormat"; +import { formatJsonSource, parseJsonPreservingLargeNumbers, stringifyJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; import { TEXT_CONTENT_SEARCH_MATCH_LIMIT, canFullHighlightTextContent, findTextContentMatches, nextTextContentSearchMatchIndex, renderTextContentMatchesHtml, textContentSearchStatus, type TextContentMatch } from "@/lib/common/textContentSearch"; const props = defineProps<{ @@ -23,7 +23,10 @@ const emit = defineEmits<{ const { t } = useI18n(); const { toast } = useToast(); -const responseView = ref<"raw" | "json">("json"); +type ResponseView = "raw" | "json" | "document"; +type ElasticsearchHit = { label: string; record: Record }; + +const responseView = ref("json"); const responsePanelRef = ref(); const rawPreRef = ref(); const jsonEditorRef = ref<{ openSearch: () => boolean }>(); @@ -41,6 +44,29 @@ const formattedBody = computed(() => { return { valid: false, text: "" }; } }); +const documentHits = computed(() => { + try { + const response = parseJsonPreservingLargeNumbers(props.body); + const hits = response && typeof response === "object" && !Array.isArray(response) ? (response as Record).hits : undefined; + const hitDocuments = hits && typeof hits === "object" && !Array.isArray(hits) ? (hits as Record).hits : undefined; + if (!Array.isArray(hitDocuments)) return []; + return hitDocuments.flatMap((hit, index) => { + if (!hit || typeof hit !== "object" || Array.isArray(hit)) return []; + const record = hit as Record; + const id = typeof record._id === "string" && record._id ? record._id : String(index + 1); + // Keep the complete search hit, including optional fields such as + // highlights and inner hits, without coercing Elasticsearch long values. + return [{ label: id, record }]; + }); + } catch { + return []; + } +}); +const selectedDocumentIndex = ref(0); +const selectedDocument = computed(() => documentHits.value[selectedDocumentIndex.value]); +// Keep raw records and stringify only the selected hit, so a large hit list +// does not hold a formatted copy of every document in memory at once. +const selectedDocumentText = computed(() => (selectedDocument.value ? stringifyJsonPreservingLargeNumbers(selectedDocument.value.record, 2) : "")); const statusClass = computed(() => { if (props.status >= 500) return "border-destructive/40 bg-destructive/10 text-destructive"; @@ -65,6 +91,7 @@ watch( () => props.body, () => { resetResponseSearch(); + selectedDocumentIndex.value = 0; responseView.value = formattedBody.value.valid ? "json" : "raw"; }, { immediate: true }, @@ -78,7 +105,7 @@ watch(responseSearchQuery, () => { async function copyResponse() { try { - await copyToClipboard(props.body); + await copyToClipboard(responseView.value === "document" ? selectedDocumentText.value || props.body : props.body); toast(t("grid.copied"), 2000); } catch (error: any) { toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000); @@ -94,7 +121,7 @@ function handleResponsePanelPointerDown() { function focusSearch(): boolean { if (!responsePanelRef.value?.contains(document.activeElement)) return false; - if (responseView.value === "json") { + if (responseView.value !== "raw") { void nextTick(() => jsonEditorRef.value?.openSearch()); return true; } @@ -119,12 +146,17 @@ function closeResponseSearch() { resetResponseSearch(true); } -function switchResponseView(view: "raw" | "json") { +function switchResponseView(view: ResponseView) { + if (view === "document" && !documentHits.value.length) return; if (responseView.value === view) return; responseView.value = view; if (view !== "raw") resetResponseSearch(); } +function selectDocument(index: number) { + selectedDocumentIndex.value = index; +} + function moveResponseSearchMatch(delta: -1 | 1) { const count = rawSearchMatches.value.length; if (count === 0) return; @@ -211,6 +243,16 @@ defineExpose({ focusSearch }); > {{ t("redis.jsonView") }} + @@ -226,6 +268,21 @@ defineExpose({ focusSearch });
       
{{ body }}
+
+ + +
diff --git a/apps/desktop/src/components/common/__tests__/ElasticsearchJsonResponsePanel.spec.ts b/apps/desktop/src/components/common/__tests__/ElasticsearchJsonResponsePanel.spec.ts index 8e96afd236..8922ddf4f5 100644 --- a/apps/desktop/src/components/common/__tests__/ElasticsearchJsonResponsePanel.spec.ts +++ b/apps/desktop/src/components/common/__tests__/ElasticsearchJsonResponsePanel.spec.ts @@ -125,6 +125,44 @@ afterEach(() => { }); describe("ElasticsearchJsonResponsePanel search", () => { + it("shows individual Elasticsearch search hits as complete JSON documents", async () => { + const body = `{ + "hits": { + "hits": [ + { + "_id": "document-1", + "_index": "products", + "_score": 1, + "_version": 4, + "_source": { "name": "Ada", "accountId": 9007199254740993, "tags": ["admin"] }, + "highlight": { "name": ["Ada"] } + }, + { "_id": "document-2", "_source": { "name": "Grace" } } + ] + } + }`; + const { panel, panelElement } = await mountResponsePanel(body, false); + const documentButton = [...root!.querySelectorAll("button")].find((button) => button.textContent === "mongo.documentView"); + + expect(documentButton).toBeDefined(); + documentButton!.click(); + await flushUi(); + expect(root!.querySelector("[data-redis-json-editor-stub]")?.textContent).toContain('"_id": "document-1"'); + expect(root!.querySelector("[data-redis-json-editor-stub]")?.textContent).toContain('"_version": 4'); + expect(root!.querySelector("[data-redis-json-editor-stub]")?.textContent).toContain('"highlight": {'); + expect(root!.querySelector("[data-redis-json-editor-stub]")?.textContent).toContain('"accountId": 9007199254740993'); + expect(root!.querySelector("[data-redis-json-editor-stub]")?.textContent).toContain('"tags": ['); + + [...root!.querySelectorAll("aside button")].find((button) => button.textContent === "document-2")!.click(); + await flushUi(); + expect(root!.querySelector("[data-redis-json-editor-stub]")?.textContent).toContain('"_id": "document-2"'); + + panelElement.focus(); + expect(panel.focusSearch()).toBe(true); + await flushUi(); + expect(mocks.openJsonSearch).toHaveBeenCalledOnce(); + }); + it("uses the read-only Redis JSON editor for formatted JSON responses", async () => { const { panel, panelElement } = await mountResponsePanel('{"name":"Ada"}', false); const editor = root!.querySelector("[data-redis-json-editor-stub]"); diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index b935b65728..422aafb3ea 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -287,6 +287,16 @@ const documentFilterFieldSearch = ref>({}); const documentFilterRules = ref([]); const appliedDocumentFilter = ref | null>(null); const elasticsearchMappingFields = ref([]); +function elasticsearchGridColumnTypesFor(columns: readonly string[]): string[] { + const mappingTypes = elasticsearchFieldTypes.value; + return columns.map((column) => { + // Elasticsearch metadata fields are not part of an index mapping, but they + // are textual identifiers in the document grid just like mapped keywords. + if (column === "_id" || column === "_routing" || column === "_type") return "keyword"; + return mappingTypes.get(column) ?? ""; + }); +} +const elasticsearchGridColumnTypes = computed(() => elasticsearchGridColumnTypesFor(lastGridColumns.value)); const dynamodbTableDescription = ref(null); const dynamodbIndexName = ref("__table__"); const dynamodbPageCursors = ref>([undefined]); @@ -419,10 +429,11 @@ function commitLoadedDocuments(nextDocuments: JsonRecord[], nextCopyDocuments: J const gridResult = computed(() => { const docs = documents.value; + const columnTypes = documentStoreProvider.value.kind === "elasticsearch" ? elasticsearchGridColumnTypes.value : lastGridColumnTypes.value; if (!docs.length) { return { columns: lastGridColumns.value, - column_types: lastGridColumnTypes.value, + column_types: columnTypes, rows: [], affected_rows: 0, execution_time_ms: 0, @@ -432,7 +443,7 @@ const gridResult = computed(() => { return { columns: lastGridColumns.value, - column_types: lastGridColumnTypes.value, + column_types: columnTypes, rows: gridRows.value, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, @@ -513,7 +524,7 @@ async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExporte const result = mongoDocumentsToQueryResult(exportedDocuments, performance.now() - exportStartedAt, totalRows ?? exportedDocuments.length, exportedCopyDocuments, totalRows !== null); if (result.columns.length === 0) result.columns = gridResult.value.columns; - result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : undefined; + result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : kind === "elasticsearch" ? elasticsearchGridColumnTypesFor(result.columns) : undefined; result.affected_rows = exportedDocuments.length; result.truncated = (kind === "dynamodb" || kind === "elasticsearch") && !!cursor && exportedDocuments.length >= rowLimit; result.has_more = result.truncated; @@ -879,7 +890,7 @@ async function applyDocumentStructuredFilters() { async function loadElasticsearchMappingFields() { if (documentStoreProvider.value.kind !== "elasticsearch") return; try { - elasticsearchMappingFields.value = await api.getColumns(props.connectionId, props.database, "", props.collection); + elasticsearchMappingFields.value = (await api.getColumns(props.connectionId, props.database, "", props.collection)) ?? []; } catch { elasticsearchMappingFields.value = []; } diff --git a/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts b/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts index f4fff5d5ed..20fc93aa27 100644 --- a/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts +++ b/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts @@ -398,6 +398,33 @@ afterEach(() => { }); describe("DocumentBrowser Elasticsearch field search", () => { + it("passes Elasticsearch mapping types through to the table grid", async () => { + app?.unmount(); + backend.getColumns.mockResolvedValue([ + { name: "profile", data_type: "object" }, + { name: "profile.name", data_type: "keyword" }, + { name: "title", data_type: "text" }, + ]); + backend.documentFindDocuments.mockResolvedValue({ + documents: [{ _id: "document-1", title: "Example", profile: { name: "Ada" } }], + raw_documents: [], + total: 1, + total_is_exact: true, + }); + app = createApp(DocumentBrowser, { + connectionId: "connection-1", + database: "", + collection: "orders", + databaseType: "elasticsearch", + }); + app.mount(root!); + await flushUi(); + + const types = JSON.parse(root!.querySelector("[data-testid='data-grid']")!.dataset.resultColumnTypes ?? "[]"); + + expect(types).toEqual(["keyword", "text", "object"]); + }); + it("migrates hidden columns and passes a stable index layout scope without changing the query result", async () => { app?.unmount(); const legacyScopeKey = documentGridColumnVisibilityScopeKey({ diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellTypeVisual.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellTypeVisual.spec.ts index 9dc4e0d4ec..6718c1df03 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellTypeVisual.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellTypeVisual.spec.ts @@ -25,6 +25,11 @@ describe("data grid type visual kind", () => { ["UUID", "identifier"], ["BYTEA", "binary"], ["SDO_GEOMETRY", "spatial"], + ["keyword", "string"], + ["unsigned_long", "integer"], + ["scaled_float", "numeric"], + ["date_nanos", "temporal"], + ["nested", "structured"], ["inet", "unknown"], ])("maps %s to %s", (dataType, expected) => { expect(resolveDataGridTypeVisualKind(dataType)).toBe(expected); @@ -40,6 +45,10 @@ describe("data grid type visual kind", () => { ["rowversion", "sqlserver", "binary"], ["bit(8)", "postgres", "binary"], ["bit varying(8)", "postgres", "binary"], + ["long", "elasticsearch", "integer"], + ["long", "easysearch", "integer"], + ["byte", "elasticsearch", "integer"], + ["short", "elasticsearch", "integer"], ] as const)("maps %s for %s to %s", (dataType, databaseType, expected) => { expect(resolveDataGridTypeVisualKind(dataType, databaseType)).toBe(expected); }); @@ -49,6 +58,10 @@ describe("data grid type visual kind", () => { expect(resolveDataGridTypeVisualKind("timestamp", "postgres")).toBe("temporal"); expect(resolveDataGridTypeVisualKind("bit")).toBe("boolean"); expect(resolveDataGridTypeVisualKind("bit", "sqlserver")).toBe("boolean"); + expect(resolveDataGridTypeVisualKind("long")).toBe("string"); + expect(resolveDataGridTypeVisualKind("long", "oracle")).toBe("string"); + expect(resolveDataGridTypeVisualKind("byte")).toBe("unknown"); + expect(resolveDataGridTypeVisualKind("short")).toBe("unknown"); }); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts index 33892fed06..0a65bd4abb 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts @@ -1,4 +1,4 @@ -import type { DatabaseType } from "@/types/database"; +import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database"; /** * Resolve the data type to display in a data-grid column header. @@ -148,6 +148,9 @@ const NUMERIC_COLUMN_TYPE_BASES = new Set([ "smallmoneyn", "binary_float", "binary_double", + // Elasticsearch numeric mapping types. + "half_float", + "scaled_float", ]); export function isNumericColumnType(dataType: string | undefined): boolean { @@ -194,8 +197,16 @@ const INTEGER_COLUMN_TYPE_BASES = new Set([ "uint128", "uint256", "year", + // Elasticsearch integer mapping types with unambiguous names. + "unsigned_long", + "token_count", ]); +// Elasticsearch integer mapping types whose names collide with other +// databases' non-integer types (Oracle LONG is a legacy text type, Informix +// BYTE is binary), so they only apply to Elasticsearch-compatible databases. +const ELASTICSEARCH_ONLY_INTEGER_COLUMN_TYPE_BASES = new Set(["byte", "short", "long"]); + const STRING_COLUMN_TYPE_BASES = new Set([ "varchar", "varchar2", @@ -221,11 +232,20 @@ const STRING_COLUMN_TYPE_BASES = new Set([ "character varying", "national character", "national character varying", + // Elasticsearch text-like mapping types. + "keyword", + "constant_keyword", + "wildcard", + "match_only_text", + "search_as_you_type", + "completion", + "ip", + "version", ]); const BOOLEAN_COLUMN_TYPE_BASES = new Set(["bool", "boolean", "bit"]); -const TEMPORAL_COLUMN_TYPE_BASES = new Set(["date", "date32", "daten", "time", "time64", "timen", "timetz", "datetime", "datetime2", "datetime4", "datetime64", "datetimen", "datetimeoffset", "datetimeoffsetn", "smalldatetime", "timestamp", "timestampdty", "timestamptz", "interval"]); -const STRUCTURED_COLUMN_TYPE_BASES = new Set(["json", "jsonb", "jsonpath", "xml", "xmltype", "array", "map", "tuple", "struct", "row", "object", "document", "variant"]); +const TEMPORAL_COLUMN_TYPE_BASES = new Set(["date", "date_nanos", "date32", "daten", "time", "time64", "timen", "timetz", "datetime", "datetime2", "datetime4", "datetime64", "datetimen", "datetimeoffset", "datetimeoffsetn", "smalldatetime", "timestamp", "timestampdty", "timestamptz", "interval"]); +const STRUCTURED_COLUMN_TYPE_BASES = new Set(["json", "jsonb", "jsonpath", "xml", "xmltype", "array", "map", "tuple", "struct", "row", "object", "nested", "flattened", "document", "variant"]); const IDENTIFIER_COLUMN_TYPE_BASES = new Set(["uuid", "uniqueidentifier", "rowid", "urowid"]); const BINARY_COLUMN_TYPE_BASES = new Set(["bytea", "blob", "tinyblob", "mediumblob", "longblob", "binary", "varbinary", "image", "raw", "long raw", "bfile"]); const SPATIAL_COLUMN_TYPE_BASES = new Set(["geometry", "geography", "sdo_geometry", "point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection"]); @@ -280,6 +300,7 @@ export function resolveDataGridTypeVisualKind(dataType: string | undefined, data if (array) return "structured"; if (databaseType === "sqlserver" && (base === "timestamp" || base === "rowversion")) return "binary"; if (databaseType === "postgres" && (base === "bit" || base === "bit varying")) return "binary"; + if (isElasticsearchCompatibleDatabaseType(databaseType) && ELASTICSEARCH_ONLY_INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer"; if (INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer"; if (isNumericColumnType(dataType)) return "numeric"; if (BOOLEAN_COLUMN_TYPE_BASES.has(base)) return "boolean"; diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs index f25ceba787..7954973b57 100644 --- a/crates/dbx-core/src/db/elasticsearch_driver.rs +++ b/crates/dbx-core/src/db/elasticsearch_driver.rs @@ -627,16 +627,22 @@ fn collect_mapping_columns( for (name, definition) in properties { let field_name = if prefix.is_empty() { name.clone() } else { format!("{prefix}.{name}") }; let field_type = definition.get("type").and_then(Value::as_str); + let children = definition.get("properties").and_then(Value::as_object); if let Some(data_type) = field_type { push_mapping_column(&field_name, data_type, seen, columns); + } else if children.is_some() { + // Elasticsearch infers `object` for a field with `properties` when + // the mapping omits an explicit type. Return the parent too because + // the document grid renders top-level object values as one column. + push_mapping_column(&field_name, "object", seen, columns); } if let Some(fields) = definition.get("fields").and_then(Value::as_object) { collect_mapping_columns(&field_name, fields, seen, columns); } - if let Some(children) = definition.get("properties").and_then(Value::as_object) { + if let Some(children) = children { collect_mapping_columns(&field_name, children, seen, columns); } } @@ -2996,6 +3002,25 @@ mod tests { assert_eq!(names, vec!["ngx-log-1".to_string(), "ngx-log-2".to_string()]); } + #[test] + fn mapping_columns_include_implicit_object_parents() { + let mapping = json!({ + "profile": { + "properties": { + "name": { "type": "keyword" } + } + } + }); + let properties = mapping.as_object().unwrap(); + let mut seen = std::collections::HashSet::new(); + let mut columns = Vec::new(); + + super::collect_mapping_columns("", properties, &mut seen, &mut columns); + + assert!(columns.iter().any(|column| column.name == "profile" && column.data_type == "object")); + assert!(columns.iter().any(|column| column.name == "profile.name" && column.data_type == "keyword")); + } + #[test] fn connectivity_check_disabled_parses_bool_and_string() { use super::elasticsearch_connectivity_check_disabled as disabled;