diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 7ab3c27b60..4bcde99513 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -27,6 +27,7 @@ import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; import { convertSqlSelectionCase, type SqlSelectionCaseMode } from "@/lib/sql/sqlSelectionCase"; import { formatMongoShellText } from "@/lib/mongo/mongoFormatter"; +import { detectAndFormatElasticsearchRequests } from "@/lib/elasticsearch/elasticsearchFormatter"; import { useConnectionStore, COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useTheme } from "@/composables/useTheme"; @@ -2932,19 +2933,27 @@ async function formatCurrentSql() { if (props.databaseType === "mongodb") { formatted = formatMongoShellText(source, settingsStore.editorSettings.sqlFormatter); } else { - const structured = detectAndFormatStructured(source, { - indentSize: settingsStore.editorSettings.sqlFormatter.tabWidth, - useTabs: settingsStore.editorSettings.sqlFormatter.useTabs, - }); - if (structured.kind === "json" || structured.kind === "xml") { - formatted = structured.formatted; - } else if (structured.kind === "unsupported") { - // Keep invalid structured text untouched — the SQL formatter would - // silently corrupt XML-looking content. + const esRequest = detectAndFormatElasticsearchRequests(source, props.databaseType, settingsStore.editorSettings.sqlFormatter.tabWidth); + if (esRequest.kind === "elasticsearch") { + formatted = esRequest.formatted; + } else if (esRequest.kind === "unsupported") { toast(t("toolbar.formatAutoDetectFailed"), 3000); return; } else { - formatted = await formatSqlForEditing(source, props.formatDialect ?? props.dialect ?? "generic", settingsStore.editorSettings.sqlFormatter); + const structured = detectAndFormatStructured(source, { + indentSize: settingsStore.editorSettings.sqlFormatter.tabWidth, + useTabs: settingsStore.editorSettings.sqlFormatter.useTabs, + }); + if (structured.kind === "json" || structured.kind === "xml") { + formatted = structured.formatted; + } else if (structured.kind === "unsupported") { + // Keep invalid structured text untouched — the SQL formatter would + // silently corrupt XML-looking content. + toast(t("toolbar.formatAutoDetectFailed"), 3000); + return; + } else { + formatted = await formatSqlForEditing(source, props.formatDialect ?? props.dialect ?? "generic", settingsStore.editorSettings.sqlFormatter); + } } } if (view.value !== currentView || currentView.state !== originalState || currentView.state.sliceDoc(from, to) !== source) { diff --git a/apps/desktop/src/lib/__tests__/elasticsearch/elasticsearchFormatter.spec.ts b/apps/desktop/src/lib/__tests__/elasticsearch/elasticsearchFormatter.spec.ts new file mode 100644 index 0000000000..9869e6c83a --- /dev/null +++ b/apps/desktop/src/lib/__tests__/elasticsearch/elasticsearchFormatter.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { detectAndFormatElasticsearchRequests } from "@/lib/elasticsearch/elasticsearchFormatter"; + +describe("elasticsearchFormatter", () => { + describe("detectAndFormatElasticsearchRequests", () => { + it("pretty-prints a single-line GET request with a JSON body", () => { + const source = `GET /master_base/_search {"query":{"bool":{"must":[{"term":{"masterId":"47526596395"}}]}}}`; + const result = detectAndFormatElasticsearchRequests(source, "elasticsearch", 2); + expect(result).toEqual({ + kind: "elasticsearch", + formatted: `GET /master_base/_search +{ + "query": { + "bool": { + "must": [ + { + "term": { + "masterId": "47526596395" + } + } + ] + } + } +}`, + }); + }); + + it("leaves a request with no body as just the method/path line", () => { + const result = detectAndFormatElasticsearchRequests("GET /_cat/indices", "elasticsearch", 2); + expect(result).toEqual({ kind: "elasticsearch", formatted: "GET /_cat/indices" }); + }); + + it("formats multiple requests separated by a blank line, keeping the separator", () => { + const source = `GET /a/_search {"query":{"match_all":{}}}\n\nPOST /b/_refresh`; + const result = detectAndFormatElasticsearchRequests(source, "easysearch", 2); + expect(result).toEqual({ + kind: "elasticsearch", + formatted: `GET /a/_search\n{\n "query": {\n "match_all": {}\n }\n}\n\nPOST /b/_refresh`, + }); + }); + + it("reports unsupported (and keeps caller from touching the text) for an invalid JSON body", () => { + const result = detectAndFormatElasticsearchRequests(`GET /a/_search {"query":`, "elasticsearch", 2); + expect(result).toEqual({ kind: "unsupported" }); + }); + + it("reports not-elasticsearch for bare JSON with no method/path line", () => { + const result = detectAndFormatElasticsearchRequests(`{"query":{"match_all":{}}}`, "elasticsearch", 2); + expect(result).toEqual({ kind: "not-elasticsearch" }); + }); + + it("reports not-elasticsearch for non-elasticsearch database types", () => { + const source = `GET /master_base/_search {"query":{"match_all":{}}}`; + expect(detectAndFormatElasticsearchRequests(source, "mysql", 2)).toEqual({ kind: "not-elasticsearch" }); + expect(detectAndFormatElasticsearchRequests(source, undefined, 2)).toEqual({ kind: "not-elasticsearch" }); + }); + + it("formats meilisearch requests too", () => { + const result = detectAndFormatElasticsearchRequests(`POST /indexes/movies/search {"q":"batman"}`, "meilisearch", 2); + expect(result).toEqual({ + kind: "elasticsearch", + formatted: `POST /indexes/movies/search\n{\n "q": "batman"\n}`, + }); + }); + }); +}); diff --git a/apps/desktop/src/lib/elasticsearch/elasticsearchFormatter.ts b/apps/desktop/src/lib/elasticsearch/elasticsearchFormatter.ts new file mode 100644 index 0000000000..5d37da7839 --- /dev/null +++ b/apps/desktop/src/lib/elasticsearch/elasticsearchFormatter.ts @@ -0,0 +1,45 @@ +import { formatJsonSource } from "@/lib/common/safeJsonFormat"; +import { elasticsearchRestRequestRanges } from "@/lib/sql/sqlStatementRanges"; +import type { DatabaseType } from "@/types/database"; + +const ELASTICSEARCH_REQUEST_LINE = /^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(\S+)/i; + +export type ElasticsearchFormatResult = { kind: "elasticsearch"; formatted: string } | { kind: "unsupported" } | { kind: "not-elasticsearch" }; + +function formatSingleElasticsearchRequest(requestText: string, indentSize: number): string | null { + const match = requestText.match(ELASTICSEARCH_REQUEST_LINE); + if (!match) return null; + const method = match[1].toUpperCase(); + const rawPath = match[2]; + const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; + const body = requestText.slice(match[0].length).trim(); + if (!body) return `${method} ${path}`; + try { + return `${method} ${path}\n${formatJsonSource(body, indentSize)}`; + } catch { + return null; + } +} + +/** + * Formats Kibana-console-style Elasticsearch/Meilisearch REST requests + * (`METHOD /path {json body}`, possibly several in one document) by + * pretty-printing each request's JSON body and normalizing its method/path + * onto its own line. Text between requests (blank lines, comments) is kept + * as-is via {@link elasticsearchRestRequestRanges}. + */ +export function detectAndFormatElasticsearchRequests(text: string, databaseType: DatabaseType | undefined, indentSize: number): ElasticsearchFormatResult { + const ranges = elasticsearchRestRequestRanges(text, databaseType); + if (ranges.length === 0) return { kind: "not-elasticsearch" }; + + let result = ""; + let cursor = 0; + for (const range of ranges) { + const formattedOne = formatSingleElasticsearchRequest(range.sql, indentSize); + if (formattedOne === null) return { kind: "unsupported" }; + result += text.slice(cursor, range.from) + formattedOne; + cursor = range.to; + } + result += text.slice(cursor); + return { kind: "elasticsearch", formatted: result }; +}