diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 23024463c..9f35d950f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -22,6 +22,13 @@ import { shouldAutoOpenSqlCompletion, extractCteDefinitions, } from "@/lib/sqlCompletion"; +import { + buildElasticsearchCompletionItemsFromContext, + getElasticsearchCompletionContext, + getElasticsearchCompletionResultValidFor, + shouldAutoOpenElasticsearchCompletion, + type ElasticsearchCompletionItem, +} from "@/lib/elasticsearchCompletion"; import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation"; import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sqlDiagnostics"; import { @@ -57,7 +64,12 @@ import { shouldRunSqlSemanticDiagnostics, type SqlSemanticDiagnostic, } from "@/lib/sqlSemanticDiagnostics"; -import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject } from "@/lib/sqlCompletion"; +import type { + SqlCompletionColumn, + SqlCompletionForeignKey, + SqlCompletionItem, + SqlCompletionObject, +} from "@/lib/sqlCompletion"; import type { DatabaseType, ForeignKeyInfo, @@ -844,15 +856,12 @@ function unregisterTableReferenceDropListener() { let completionEpoch = 0; let completionDebounceTimer: ReturnType | null = null; -function buildCompletionResult( - items: ReturnType, - position: number, - prefixLength: number, - fullDoc: string, -) { +type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem; + +function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp) { if (items.length === 0) return null; return { - from: position - prefixLength, + from, filter: false, options: items.map((item) => (item.type === "snippet" || item.type === "function") && item.apply @@ -870,16 +879,44 @@ function buildCompletionResult( boost: item.boost, }, ), - validFor: getSqlCompletionResultValidFor(fullDoc, position), + validFor, }; } +async function provideElasticsearchCompletions( + currentState: import("@codemirror/state").EditorState, + position: number, + explicit: boolean, +) { + if (!props.connectionId) return null; + const epoch = ++completionEpoch; + const fullDoc = currentState.doc.toString(); + if (!explicit && !shouldAutoOpenElasticsearchCompletion(fullDoc, position)) return null; + + const completionContext = getElasticsearchCompletionContext(fullDoc, position); + let indices: string[] = []; + if (props.database != null && completionContext.mode === "path") { + try { + indices = await connectionStore.listElasticsearchCompletionIndices(props.connectionId, props.database); + } catch { + indices = []; + } + } + if (epoch !== completionEpoch) return null; + + const items = buildElasticsearchCompletionItemsFromContext(completionContext, { indices }); + return buildCompletionResult(items, completionContext.from, getElasticsearchCompletionResultValidFor()); +} + async function provideSqlCompletions( currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean, ) { if (!props.connectionId) return null; + if (props.databaseType === "elasticsearch") { + return provideElasticsearchCompletions(currentState, position, explicit); + } const hasDatabase = props.database != null; const epoch = ++completionEpoch; @@ -900,7 +937,11 @@ async function provideSqlCompletions( snippets: settingsStore.editorSettings.snippets, dialect: props.dialect, }); - return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc); + return buildCompletionResult( + items, + position - completionContext.prefix.length, + getSqlCompletionResultValidFor(fullDoc, position), + ); } const needsAsyncData = @@ -922,7 +963,11 @@ async function provideSqlCompletions( snippets: settingsStore.editorSettings.snippets, dialect: props.dialect, }); - return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc); + return buildCompletionResult( + items, + position - completionContext.prefix.length, + getSqlCompletionResultValidFor(fullDoc, position), + ); } // Cancel any pending debounced completion @@ -1185,7 +1230,11 @@ async function performAsyncCompletionWithResult( dialect: props.dialect, }); - return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc); + return buildCompletionResult( + items, + position - completionContext.prefix.length, + getSqlCompletionResultValidFor(fullDoc, position), + ); } function isReferencedTableQualifier(completionContext: ReturnType): boolean { diff --git a/apps/desktop/src/lib/elasticsearchCompletion.ts b/apps/desktop/src/lib/elasticsearchCompletion.ts new file mode 100644 index 000000000..8422d78d5 --- /dev/null +++ b/apps/desktop/src/lib/elasticsearchCompletion.ts @@ -0,0 +1,305 @@ +export type ElasticsearchCompletionMode = "method" | "path" | "json"; + +export interface ElasticsearchCompletionItem { + label: string; + type: "keyword" | "property" | "text" | "snippet"; + detail?: string; + apply?: string; + boost: number; +} + +export interface ElasticsearchCompletionContext { + mode: ElasticsearchCompletionMode; + prefix: string; + from: number; + method?: string; + path?: string; + segmentIndex?: number; +} + +export interface ElasticsearchCompletionInput { + indices?: string[]; +} + +const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE"] as const; + +const ROOT_ENDPOINTS = [ + { label: "/_search", apply: "_search", method: "GET", detail: "Search all indices" }, + { label: "/_cat/indices", apply: "_cat/indices?v", method: "GET", detail: "List indices" }, + { label: "/_cluster/health", apply: "_cluster/health", method: "GET", detail: "Cluster health" }, + { label: "/_aliases", apply: "_aliases", method: "GET", detail: "List aliases" }, + { label: "/_bulk", apply: "_bulk\n", method: "POST", detail: "Bulk operations" }, + { label: "/_count", apply: "_count", method: "GET", detail: "Count documents" }, +]; + +const INDEX_ENDPOINTS = [ + { + label: "_search", + apply: '_search\n{\n "query": {\n "match_all": {}\n }\n}', + method: "GET", + detail: "Search this index", + }, + { label: "_mapping", apply: "_mapping", method: "GET", detail: "Show mapping" }, + { label: "_settings", apply: "_settings", method: "GET", detail: "Show settings" }, + { label: "_count", apply: "_count", method: "GET", detail: "Count documents" }, + { + label: "_doc", + apply: '_doc/${}\n{\n "${}": "${}"\n}', + method: "POST", + detail: "Create document", + }, + { label: "_refresh", apply: "_refresh", method: "POST", detail: "Refresh index" }, +]; + +const JSON_KEYWORDS = [ + "query", + "bool", + "must", + "should", + "must_not", + "filter", + "match", + "match_all", + "term", + "terms", + "range", + "exists", + "sort", + "aggs", + "aggregations", + "size", + "from", + "_source", + "fields", + "track_total_hits", +]; + +const JSON_SNIPPETS = [ + { + label: "match_all", + apply: '"match_all": {}', + detail: "Match all documents", + }, + { + label: "bool", + apply: '"bool": {\n "must": [\n {}\n ],\n "filter": []\n}', + detail: "Bool query", + }, + { + label: "range", + apply: '"range": {\n "${field}": {\n "gte": "${value}"\n }\n}', + detail: "Range query", + }, + { + label: "terms", + apply: '"terms": {\n "${field}": []\n}', + detail: "Terms query", + }, +]; + +export function getElasticsearchCompletionContext(text: string, cursor: number): ElasticsearchCompletionContext { + const safeCursor = Math.max(0, Math.min(cursor, text.length)); + const lineStart = text.lastIndexOf("\n", safeCursor - 1) + 1; + const lineEnd = text.indexOf("\n", lineStart); + const currentLineEnd = lineEnd >= 0 ? lineEnd : text.length; + const beforeCursorOnLine = text.slice(lineStart, safeCursor); + const firstLineEnd = text.indexOf("\n"); + const firstLineLimit = firstLineEnd >= 0 ? firstLineEnd : text.length; + + if (lineStart > 0 || safeCursor > firstLineLimit || looksLikeJsonBody(text, safeCursor)) { + const jsonPrefix = readJsonPrefix(text, safeCursor); + return { mode: "json", prefix: jsonPrefix.prefix, from: jsonPrefix.from }; + } + + const methodMatch = /^([A-Za-z]*)$/.exec(beforeCursorOnLine); + if (methodMatch) { + return { + mode: "method", + prefix: methodMatch[1] ?? "", + from: lineStart, + }; + } + + const commandMatch = /^([A-Za-z]+)\s+(\S*)/.exec(text.slice(lineStart, currentLineEnd)); + if (!commandMatch) { + const prefix = readWordPrefix(text, safeCursor); + return { mode: "method", prefix: prefix.prefix, from: prefix.from }; + } + + const method = commandMatch[1]?.toUpperCase(); + const path = commandMatch[2] ?? ""; + const pathStart = lineStart + (commandMatch[0].indexOf(path) >= 0 ? commandMatch[0].indexOf(path) : 0); + const pathCursor = Math.max(0, safeCursor - pathStart); + const boundedPathCursor = Math.min(pathCursor, path.length); + const beforePathCursor = path.slice(0, boundedPathCursor); + const segmentStartInPath = beforePathCursor.lastIndexOf("/") + 1; + const prefix = beforePathCursor.slice(segmentStartInPath); + const segmentIndex = beforePathCursor.slice(0, segmentStartInPath).split("/").filter(Boolean).length; + + return { + mode: "path", + prefix, + from: pathStart + segmentStartInPath, + method, + path, + segmentIndex, + }; +} + +export function buildElasticsearchCompletionItems( + text: string, + cursor: number, + input: ElasticsearchCompletionInput = {}, +): ElasticsearchCompletionItem[] { + const context = getElasticsearchCompletionContext(text, cursor); + return buildElasticsearchCompletionItemsFromContext(context, input); +} + +export function buildElasticsearchCompletionItemsFromContext( + context: ElasticsearchCompletionContext, + input: ElasticsearchCompletionInput = {}, +): ElasticsearchCompletionItem[] { + if (context.mode === "method") return methodItems(context.prefix); + if (context.mode === "json") return jsonItems(context.prefix); + return pathItems(context, input.indices ?? []); +} + +export function shouldAutoOpenElasticsearchCompletion(text: string, cursor: number): boolean { + const previousChar = text[cursor - 1]; + if (!previousChar) return false; + if (/[{,}\]\n\r]/.test(previousChar)) return false; + if (/[\w/_."]/.test(previousChar)) return true; + return false; +} + +export function getElasticsearchCompletionResultValidFor(): RegExp { + return /[\w/_."]*$/; +} + +function methodItems(prefix: string): ElasticsearchCompletionItem[] { + return HTTP_METHODS.filter((method) => matchesPrefix(method, prefix)).map((method) => ({ + label: method, + type: "keyword", + detail: "HTTP method", + apply: `${method} /`, + boost: 120, + })); +} + +function pathItems(context: ElasticsearchCompletionContext, indices: string[]): ElasticsearchCompletionItem[] { + const items: ElasticsearchCompletionItem[] = []; + const path = context.path ?? ""; + const segments = path.split("/").filter(Boolean); + const isFirstSegment = context.segmentIndex === 0; + const isRootApiSegment = isFirstSegment && context.prefix.startsWith("_"); + + if (isFirstSegment && !isRootApiSegment) { + items.push(...indexItems(context.prefix, indices)); + } + + if (isFirstSegment || isRootApiSegment) { + items.push(...rootEndpointItems(context.prefix)); + } + + if (segments.length >= 1 && !segments[0]?.startsWith("_")) { + items.push(...indexEndpointItems(context.prefix)); + } + + return dedupeAndSort(items); +} + +function indexItems(prefix: string, indices: string[]): ElasticsearchCompletionItem[] { + return indices + .filter((index) => matchesFuzzyPrefix(index, prefix)) + .slice(0, 100) + .map((index) => ({ + label: index, + type: "text" as const, + detail: "index", + apply: index, + boost: index.toLowerCase().startsWith(prefix.toLowerCase()) ? 110 : 80, + })); +} + +function rootEndpointItems(prefix: string): ElasticsearchCompletionItem[] { + const normalizedPrefix = prefix.startsWith("/") ? prefix.slice(1) : prefix; + return ROOT_ENDPOINTS.filter((endpoint) => matchesFuzzyPrefix(endpoint.label.slice(1), normalizedPrefix)).map( + (endpoint) => ({ + label: endpoint.label, + type: endpoint.apply.includes("\n") ? ("snippet" as const) : ("property" as const), + detail: `${endpoint.method} ${endpoint.detail}`, + apply: endpoint.apply, + boost: 95, + }), + ); +} + +function indexEndpointItems(prefix: string): ElasticsearchCompletionItem[] { + return INDEX_ENDPOINTS.filter((endpoint) => matchesFuzzyPrefix(endpoint.label, prefix)).map((endpoint) => ({ + label: endpoint.label, + type: endpoint.apply.includes("\n") ? ("snippet" as const) : ("property" as const), + detail: `${endpoint.method} ${endpoint.detail}`, + apply: endpoint.apply, + boost: 100, + })); +} + +function jsonItems(prefix: string): ElasticsearchCompletionItem[] { + const normalizedPrefix = prefix.replace(/^"/, ""); + const keyItems = JSON_KEYWORDS.filter((key) => matchesFuzzyPrefix(key, normalizedPrefix)).map((key) => ({ + label: `"${key}"`, + type: "property" as const, + detail: "Query DSL field", + apply: `"${key}"`, + boost: key.startsWith(normalizedPrefix) ? 95 : 70, + })); + const snippetItems = JSON_SNIPPETS.filter((snippet) => matchesFuzzyPrefix(snippet.label, normalizedPrefix)).map( + (snippet) => ({ + label: snippet.label, + type: "snippet" as const, + detail: snippet.detail, + apply: snippet.apply, + boost: 105, + }), + ); + return dedupeAndSort([...snippetItems, ...keyItems]); +} + +function looksLikeJsonBody(text: string, cursor: number): boolean { + const before = text.slice(0, cursor); + return before.includes("\n") || before.lastIndexOf("{") > before.lastIndexOf("\n"); +} + +function readJsonPrefix(text: string, cursor: number): { prefix: string; from: number } { + let from = cursor; + while (from > 0 && /[\w_"]/.test(text[from - 1] ?? "")) from--; + return { prefix: text.slice(from, cursor), from }; +} + +function readWordPrefix(text: string, cursor: number): { prefix: string; from: number } { + let from = cursor; + while (from > 0 && /[A-Za-z]/.test(text[from - 1] ?? "")) from--; + return { prefix: text.slice(from, cursor), from }; +} + +function matchesPrefix(value: string, prefix: string): boolean { + return value.toLowerCase().startsWith(prefix.toLowerCase()); +} + +function matchesFuzzyPrefix(value: string, prefix: string): boolean { + const normalizedValue = value.toLowerCase(); + const normalizedPrefix = prefix.toLowerCase(); + return !normalizedPrefix || normalizedValue.includes(normalizedPrefix); +} + +function dedupeAndSort(items: ElasticsearchCompletionItem[]): ElasticsearchCompletionItem[] { + const seen = new Set(); + const deduped: ElasticsearchCompletionItem[] = []; + for (const item of items) { + const key = `${item.type}:${item.label}:${item.apply ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + } + return deduped.sort((a, b) => b.boost - a.boost); +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 7d459814c..3e843e389 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -100,6 +100,7 @@ export const useConnectionStore = defineStore("connection", () => { const completionTablesCache = ref>({}); const completionObjectsCache = ref>({}); const completionColumnsCache = ref>({}); + const elasticsearchCompletionIndicesCache = ref>({}); const schemaListCache = ref>({}); const transferSource = ref<{ connectionId: string; database: string } | null>(null); const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null); @@ -562,6 +563,9 @@ export const useConnectionStore = defineStore("connection", () => { for (const key of Object.keys(schemaListCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete schemaListCache.value[key]; } + for (const key of Object.keys(elasticsearchCompletionIndicesCache.value)) { + if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key]; + } } async function removeConnection(id: string) { @@ -1453,6 +1457,18 @@ export const useConnectionStore = defineStore("connection", () => { return schemas; } + async function listElasticsearchCompletionIndices(connectionId: string, database: string): Promise { + const cacheKey = `${connectionId}:${database}`; + if (elasticsearchCompletionIndicesCache.value[cacheKey]) { + return elasticsearchCompletionIndicesCache.value[cacheKey]; + } + await ensureConnected(connectionId); + const indices = await api.mongoListCollections(connectionId, database); + elasticsearchCompletionIndicesCache.value[cacheKey] = indices; + evictOldestCacheEntries(elasticsearchCompletionIndicesCache.value, COMPLETION_CACHE_MAX); + return elasticsearchCompletionIndicesCache.value[cacheKey]; + } + async function listCompletionTables( connectionId: string, database: string, @@ -2093,6 +2109,7 @@ export const useConnectionStore = defineStore("connection", () => { listCompletionObjects, listCompletionColumns, listCompletionSchemas, + listElasticsearchCompletionIndices, exportConnectionsToFile, readImportFile, importConnectionsFromFile, diff --git a/packages/app-tests/elasticsearchCompletion.test.ts b/packages/app-tests/elasticsearchCompletion.test.ts new file mode 100644 index 000000000..de21927d0 --- /dev/null +++ b/packages/app-tests/elasticsearchCompletion.test.ts @@ -0,0 +1,101 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + buildElasticsearchCompletionItems, + getElasticsearchCompletionContext, + shouldAutoOpenElasticsearchCompletion, +} from "../../apps/desktop/src/lib/elasticsearchCompletion.ts"; +import { buildSqlCompletionItems } from "../../apps/desktop/src/lib/sqlCompletion.ts"; + +const indices = ["orders", "order_items", "users"]; + +function applyCompletion(text: string, cursor: number, label: string): string { + const context = getElasticsearchCompletionContext(text, cursor); + const item = buildElasticsearchCompletionItems(text, cursor, { indices }).find( + (candidate) => candidate.label === label, + ); + assert.ok(item, `Expected completion item ${label}`); + return `${text.slice(0, context.from)}${item.apply ?? item.label}${text.slice(cursor)}`; +} + +test("suggests Elasticsearch HTTP methods for empty and prefix input", () => { + assert.deepEqual( + buildElasticsearchCompletionItems("", 0).map((item) => item.label), + ["GET", "POST", "PUT", "DELETE"], + ); + + const items = buildElasticsearchCompletionItems("po", 2); + assert.equal(items.find((item) => item.label === "POST")?.apply, "POST /"); +}); + +test("suggests Elasticsearch root endpoints", () => { + const items = buildElasticsearchCompletionItems("GET /_", "GET /_".length); + + assert.ok(items.find((item) => item.label === "/_search")); + assert.ok(items.find((item) => item.label === "/_cat/indices")); +}); + +test("suggests Elasticsearch index endpoints", () => { + const items = buildElasticsearchCompletionItems("GET /orders/_", "GET /orders/_".length); + + assert.ok(items.find((item) => item.label === "_search")); + assert.ok(items.find((item) => item.label === "_mapping")); + assert.ok(items.find((item) => item.label === "_count")); +}); + +test("suggests Elasticsearch indices by prefix", () => { + const items = buildElasticsearchCompletionItems("GET /ord", "GET /ord".length, { indices }); + + assert.deepEqual( + items.filter((item) => item.detail === "index").map((item) => item.label), + ["orders", "order_items"], + ); +}); + +test("index completion preserves endpoint suffix after cursor", () => { + const text = "GET /ord/_search"; + const cursor = "GET /ord".length; + + assert.equal(applyCompletion(text, cursor, "orders"), "GET /orders/_search"); +}); + +test("suggests Elasticsearch JSON DSL keys and snippets", () => { + const keyItems = buildElasticsearchCompletionItems( + 'GET /orders/_search\n{\n "qu', + 'GET /orders/_search\n{\n "qu'.length, + ); + assert.ok(keyItems.find((item) => item.label === '"query"')); + + const snippetItems = buildElasticsearchCompletionItems( + 'GET /orders/_search\n{\n "match_', + 'GET /orders/_search\n{\n "match_'.length, + ); + const matchAll = snippetItems.find((item) => item.label === "match_all"); + assert.ok(matchAll); + assert.doesNotThrow(() => JSON.parse(`{${matchAll?.apply}}`)); +}); + +test("Elasticsearch completion auto trigger ignores structural JSON punctuation", () => { + assert.equal(shouldAutoOpenElasticsearchCompletion("GET /", "GET /".length), true); + assert.equal(shouldAutoOpenElasticsearchCompletion("GET /_", "GET /_".length), true); + assert.equal(shouldAutoOpenElasticsearchCompletion("GET /orders/_search\n{", "GET /orders/_search\n{".length), false); + assert.equal( + shouldAutoOpenElasticsearchCompletion( + 'GET /orders/_search\n{"query": {},', + 'GET /orders/_search\n{"query": {},'.length, + ), + false, + ); +}); + +test("SQL completion does not include Elasticsearch endpoints", () => { + const items = buildSqlCompletionItems("select", "select".length, { + tables: [], + columnsByTable: new Map(), + }); + + assert.equal( + items.some((item) => item.label === "/_search" || item.label === "_search"), + false, + ); +});