feat: add elasticsearch command completion
This commit is contained in:
parent
d776e7bd37
commit
4690fd8b81
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue