feat: add elasticsearch command completion

This commit is contained in:
t8y2 2026-06-03 14:00:52 +08:00
parent d776e7bd37
commit 4690fd8b81
4 changed files with 484 additions and 12 deletions

View File

@ -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<typeof setTimeout> | null = null;
function buildCompletionResult(
items: ReturnType<typeof buildSqlCompletionItemsFromContext>,
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<typeof getSqlCompletionContext>): boolean {

View File

@ -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<string>();
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);
}

View File

@ -100,6 +100,7 @@ export const useConnectionStore = defineStore("connection", () => {
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
const completionObjectsCache = ref<Record<string, SqlCompletionObject[]>>({});
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
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<string[]> {
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,

View File

@ -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,
);
});