feat(elasticsearch): improve REST commands and visual filters

This commit is contained in:
zipg authored and GitHub committed 2026-07-16 16:31:15 +08:00
1 parent bfcd7593ee
commit da7eedb49c
15 files changed
+1151 -115

No files matched your search

@@ -18,6 +18,7 @@ import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
import {
buildDocumentFilterCondition,
buildElasticsearchQueryFromRules,
combineDocumentFilterConditions,
currentDocumentFilterJson,
currentDocumentSortJson,
@@ -25,8 +26,14 @@ import {
documentFilterModeNeedsValue,
documentFilterModeOptions,
documentStoreProviderFor,
elasticsearchBoolClauseOptions,
elasticsearchQueryTypeNeedsValue,
elasticsearchQueryTypeOptions,
elasticsearchStructuredFilter,
type DocumentFilterMode,
type DocumentFilterRule,
type ElasticsearchBoolClause,
type ElasticsearchQueryType,
} from "@/lib/app/documentStoreProvider";
import { documentStoreValueForGrid, parseDocumentStoreInputValue, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues";
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
@@ -35,7 +42,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { useSettingsStore } from "@/stores/settingsStore";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -114,6 +121,7 @@ type DocumentGridChanges = {
const documentFilterBuilderOpen = ref(false);
const documentFilterRules = ref<DocumentFilterRule[]>([]);
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
const pendingDelete = ref<PendingDelete | null>(null);
@@ -175,13 +183,34 @@ const gridResult = computed<QueryResult>(() => {
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
});
const documentFilterFieldOptions = computed(() => gridResult.value.columns);
const documentStructuredFilterCount = computed(() => (appliedDocumentFilter.value ? 1 : 0));
const documentFilterFieldOptions = computed(() => {
if (documentStoreProvider.value.kind !== "elasticsearch") return gridResult.value.columns;
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
return [...new Set(names.filter(Boolean))];
});
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
const documentStructuredFilterCount = computed(() => {
if (!appliedDocumentFilter.value) return 0;
if (documentStoreProvider.value.kind !== "elasticsearch") return 1;
const query = appliedDocumentFilter.value.$esQuery;
if (!query || typeof query !== "object" || Array.isArray(query)) return 0;
const bool = (query as Record<string, unknown>).bool;
if (!bool || typeof bool !== "object" || Array.isArray(bool)) return 0;
return elasticsearchBoolClauseOptions.reduce((count, clause) => {
const rules = (bool as Record<string, unknown>)[clause];
return count + (Array.isArray(rules) ? rules.length : 0);
}, 0);
});
const documentLoadingLabelKey = computed(() => (documentLoadCancelling.value ? "common.stopping" : "common.loading"));
let documentLoadingTimer: ReturnType<typeof setInterval> | undefined;
function createDocumentFilterRule(): DocumentFilterRule {
return defaultDocumentFilterRule(uuid(), documentFilterFieldOptions.value[0] ?? "");
const fieldName = documentFilterFieldOptions.value[0] ?? "";
const rule = defaultDocumentFilterRule(uuid(), fieldName);
if (documentStoreProvider.value.kind === "elasticsearch") {
rule.elasticsearchQueryType = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(fieldName))[0];
}
return rule;
}
function ensureDocumentFilterRule() {
@@ -204,11 +233,33 @@ function updateDocumentFilterRule(ruleId: string, patch: Partial<DocumentFilterR
documentFilterRules.value = documentFilterRules.value.map((rule) => {
if (rule.id !== ruleId) return rule;
const next = { ...rule, ...patch };
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
if (documentStoreProvider.value.kind === "elasticsearch") {
const queryTypes = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(next.fieldName));
if (!next.elasticsearchQueryType || !queryTypes.includes(next.elasticsearchQueryType)) {
next.elasticsearchQueryType = queryTypes[0];
}
if (!elasticsearchQueryTypeNeedsValue(next.elasticsearchQueryType)) next.rawValue = "";
} else if (!documentFilterModeNeedsValue(next.mode)) {
next.rawValue = "";
}
return next;
});
}
function elasticsearchRuleQueryTypes(rule: DocumentFilterRule): ElasticsearchQueryType[] {
return elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(rule.fieldName));
}
function elasticsearchQueryTypeLabel(queryType: ElasticsearchQueryType): string {
const rangeOperator: Partial<Record<ElasticsearchQueryType, string>> = {
range_gt: "range >",
range_gte: "range >=",
range_lt: "range <",
range_lte: "range <=",
};
return rangeOperator[queryType] ?? queryType;
}
function resetDocumentFilterBuilder() {
appliedDocumentFilter.value = null;
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
@@ -235,6 +286,12 @@ const documentQueryPreview = computed(() => {
});
async function applyDocumentStructuredFilters() {
if (documentStoreProvider.value.kind === "elasticsearch") {
appliedDocumentFilter.value = elasticsearchStructuredFilter(buildElasticsearchQueryFromRules(documentFilterRules.value));
documentFilterBuilderOpen.value = false;
applyFilter();
return;
}
const items = documentFilterRules.value
.map((rule) => ({
rule,
@@ -250,6 +307,15 @@ async function applyDocumentStructuredFilters() {
applyFilter();
}
async function loadElasticsearchMappingFields() {
if (documentStoreProvider.value.kind !== "elasticsearch") return;
try {
elasticsearchMappingFields.value = await api.getColumns(props.connectionId, props.database, "", props.collection);
} catch {
elasticsearchMappingFields.value = [];
}
}
function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) {
appliedDocumentFilter.value = null;
resetDocumentFilterBuilder();
@@ -819,7 +885,10 @@ onMounted(async () => {
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
load();
// Mapping metadata enriches the filter builder, but it must not delay the
// first page of documents when the mapping endpoint is slow.
void loadElasticsearchMappingFields();
void load();
});
onBeforeUnmount(() => {
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
@@ -1014,7 +1083,7 @@ function resetTableSearchSplitWidth() {
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3">
<PopoverContent align="start" class="max-w-[calc(100vw-32px)] gap-3 p-3" :class="documentStoreProvider.kind === 'elasticsearch' ? 'w-[680px]' : 'w-[360px]'">
<div class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addDocumentFilterRule">
@@ -1056,7 +1125,7 @@ function resetTableSearchSplitWidth() {
<div v-if="documentFilterRules.length" class="space-y-2">
<template v-for="(rule, index) in documentFilterRules" :key="rule.id">
<div v-if="index > 0" class="flex justify-center">
<div v-if="index > 0 && documentStoreProvider.kind !== 'elasticsearch'" class="flex justify-center">
<Button
variant="ghost"
size="sm"
@@ -1070,19 +1139,44 @@ function resetTableSearchSplitWidth() {
{{ rule.conjunction }}
</Button>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto] items-center gap-1.5">
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
<div class="grid items-center gap-1.5" :class="documentStoreProvider.kind === 'elasticsearch' ? 'grid-cols-[minmax(0,0.75fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto]'">
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchClause || 'filter'" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchClause: value as ElasticsearchBoolClause })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue :placeholder="t('grid.filterBuilderColumn')" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
{{ fieldName }}
<SelectItem v-for="clause in elasticsearchBoolClauseOptions" :key="clause" :value="clause">
{{ clause }}
</SelectItem>
</SelectContent>
</Select>
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue :placeholder="t('grid.filterBuilderColumn')"> {{ rule.fieldName }}{{ elasticsearchFieldTypes.get(rule.fieldName) ? ` (${elasticsearchFieldTypes.get(rule.fieldName)})` : "" }} </SelectValue>
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
<span class="flex min-w-0 items-center gap-2">
<span class="truncate">{{ fieldName }}</span>
<span v-if="elasticsearchFieldTypes.get(fieldName)" class="shrink-0 text-[10px] text-muted-foreground">({{ elasticsearchFieldTypes.get(fieldName) }})</span>
</span>
</SelectItem>
</SelectContent>
</Select>
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchQueryType || elasticsearchRuleQueryTypes(rule)[0]" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchQueryType: value as ElasticsearchQueryType })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="queryType in elasticsearchRuleQueryTypes(rule)" :key="queryType" :value="queryType">
{{ elasticsearchQueryTypeLabel(queryType) }}
</SelectItem>
</SelectContent>
</Select>
<Select v-else :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
@@ -1094,7 +1188,7 @@ function resetTableSearchSplitWidth() {
</Select>
<Input
v-if="documentFilterModeNeedsValue(rule.mode)"
v-if="documentStoreProvider.kind === 'elasticsearch' ? elasticsearchQueryTypeNeedsValue(rule.elasticsearchQueryType) : documentFilterModeNeedsValue(rule.mode)"
:model-value="rule.rawValue"
class="h-8 min-w-0 text-xs"
:placeholder="t('grid.filterBuilderValue')"
@@ -12,6 +12,7 @@ import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
import { isMysqlExecutionErrorResult, usesMysqlProtocolDatabaseType } from "@/lib/query/queryResultError";
import { classifyRedisCommandSafety, firstRedisCommandToken } from "@/lib/redis/redisCommandSafety";
import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { isElasticsearchRestRequestText, parseElasticsearchRestRequestTarget, splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlParameterSyntax } from "@/lib/sql/sqlParameters";
import { expandSqlVariables } from "@/lib/sql/sqlVariables";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
@@ -28,7 +29,23 @@ export function stripSqlComments(sql: string): string {
.replace(/#.*$/gm, " ");
}
export function isDangerousSql(sql: string): boolean {
const ELASTICSEARCH_TRANSIENT_DELETE_PATHS = [/^\/_search\/scroll\/?$/i, /^\/_pit\/?$/i, /^\/_async_search\/[^/?]+\/?$/i];
const ELASTICSEARCH_DESTRUCTIVE_POST_PATHS = [/(?:^|\/)_(?:delete_by_query|update_by_query|bulk)(?:\/|$)/i, /^\/_reindex(?:\/|$)/i, /^\/_aliases(?:\/|$)/i, /\/_restore(?:\/|$)/i];
function isDangerousElasticsearchRequest(method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD", path: string): boolean {
const pathname = path.split("?", 1)[0].replace(/\/+$/, "") || "/";
if (method === "DELETE") return !ELASTICSEARCH_TRANSIENT_DELETE_PATHS.some((pattern) => pattern.test(pathname));
if (method === "PUT") return true;
return method === "POST" && ELASTICSEARCH_DESTRUCTIVE_POST_PATHS.some((pattern) => pattern.test(pathname));
}
export function isDangerousSql(sql: string, databaseType?: DatabaseType): boolean {
if (databaseType === "elasticsearch") {
const requests = splitSqlStatementRanges(sql, databaseType)
.map((statement) => parseElasticsearchRestRequestTarget(statement.sql))
.filter((request): request is NonNullable<typeof request> => request !== null);
if (requests.length > 0) return requests.some((request) => isDangerousElasticsearchRequest(request.method, request.path));
}
const cleaned = stripSqlComments(sql);
return cleaned.split(";").some((stmt) => DANGER_RE.test(stmt));
}
@@ -102,7 +119,7 @@ export function useSqlExecution(deps: {
deps.onMissingDatabase?.();
return;
}
if (supportsSqlTemplateParameters(deps.activeConnection.value) && prepareSqlParameterDialog(sql, sourceOffset)) return;
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset)) return;
await continueExecute(sql, sourceOffset);
}
@@ -134,7 +151,7 @@ export function useSqlExecution(deps: {
if (confirmed) await doExecute(sql, sourceOffset);
return;
}
if (isDangerousSql(sql) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
if (isDangerousSql(sql, deps.activeConnection.value?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
dangerSql.value = sql;
pendingDangerSql.value = sql;
pendingSourceOffset.value = sourceOffset;
@@ -289,8 +306,9 @@ export function useSqlExecution(deps: {
};
}
function supportsSqlTemplateParameters(connection: ConnectionConfig | undefined): boolean {
export function supportsSqlTemplateParameters(connection: Pick<ConnectionConfig, "db_type"> | undefined, sql = ""): boolean {
if (!connection) return false;
if (connection.db_type === "elasticsearch") return !isElasticsearchRestRequestText(sql);
return connection.db_type !== "redis" && connection.db_type !== "mongodb";
}
@@ -220,6 +220,54 @@ describe("splitSqlStatementRanges", () => {
});
describe("statementRangeAtCursor", () => {
it("splits Elasticsearch REST requests without semicolons", () => {
const sql = `# node information
GET /_nodes/stats/jvm?pretty
// search orders
POST /orders/_search
{
"query": { "match_all": {} }
}
HEAD /orders`;
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_nodes/stats/jvm?pretty", 'POST /orders/_search\n{\n "query": { "match_all": {} }\n}', "HEAD /orders"]);
expect(hasMultipleExecutionTargets(sql, "elasticsearch")).toBe(true);
});
it("targets the Elasticsearch request following a comment", () => {
const sql = "# JVM statistics\nGET /_nodes/stats/jvm?pretty\n\nGET /_cluster/health";
expect(statementRangeAtCursor(sql, indexOf(sql, "JVM"), "elasticsearch")?.sql).toBe("GET /_nodes/stats/jvm?pretty");
expect(statementRangeAtCursor(sql, indexOf(sql, "cluster"), "elasticsearch")?.sql).toBe("GET /_cluster/health");
});
it("splits and targets Elasticsearch requests after block comments", () => {
const sql = `/* node information
including JVM details */
GET /_nodes/stats/jvm?pretty
/* cluster information */
GET /_cluster/health`;
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_nodes/stats/jvm?pretty", "GET /_cluster/health"]);
expect(statementRangeAtCursor(sql, indexOf(sql, "JVM details"), "elasticsearch")?.sql).toBe("GET /_nodes/stats/jvm?pretty");
expect(statementRangeAtCursor(sql, indexOf(sql, "cluster information"), "elasticsearch")?.sql).toBe("GET /_cluster/health");
});
it("ignores request-looking lines inside Elasticsearch block comments", () => {
const sql = `GET /_cluster/health
/* disabled cleanup
DELETE /important-index
*/
GET /_cat/indices`;
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_cluster/health", "GET /_cat/indices"]);
expect(statementRangeAtCursor(sql, indexOf(sql, "important-index"), "elasticsearch")?.sql).toBe("GET /_cat/indices");
});
it("returns the first statement when the cursor is inside it", () => {
const sql = "SELECT 1;\nSELECT 2;";
const pos = indexOf(sql, "1");
@@ -840,7 +888,7 @@ describe("hasMultipleExecutionTargets", () => {
});
describe("supportsExecutionTargetPicker", () => {
it("enables the picker for SQL database connections and Redis", () => {
it("enables the picker for SQL database connections, Redis, and Elasticsearch", () => {
expect(supportsExecutionTargetPicker("mysql")).toBe(true);
expect(supportsExecutionTargetPicker("postgres")).toBe(true);
expect(supportsExecutionTargetPicker("sqlserver")).toBe(true);
@@ -848,7 +896,7 @@ describe("supportsExecutionTargetPicker", () => {
expect(supportsExecutionTargetPicker("jdbc")).toBe(true);
expect(supportsExecutionTargetPicker("redis")).toBe(true);
expect(supportsExecutionTargetPicker("mongodb")).toBe(false);
expect(supportsExecutionTargetPicker("elasticsearch")).toBe(false);
expect(supportsExecutionTargetPicker("elasticsearch")).toBe(true);
expect(supportsExecutionTargetPicker("qdrant")).toBe(false);
expect(supportsExecutionTargetPicker("milvus")).toBe(false);
expect(supportsExecutionTargetPicker("weaviate")).toBe(false);
@@ -5,6 +5,8 @@ import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
export type DocumentStoreKind = "mongodb" | "elasticsearch";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "less-than" | "is-null" | "is-not-null";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
export type ElasticsearchQueryType = "term" | "terms" | "match" | "match_phrase" | "wildcard" | "range_gt" | "range_gte" | "range_lt" | "range_lte" | "exists";
export type DocumentFilterRule = {
id: string;
@@ -12,6 +14,8 @@ export type DocumentFilterRule = {
mode: DocumentFilterMode;
rawValue: string;
conjunction: "AND" | "OR";
elasticsearchClause?: ElasticsearchBoolClause;
elasticsearchQueryType?: ElasticsearchQueryType;
};
export type DocumentStoreQueryPreviewOptions = {
@@ -42,6 +46,22 @@ export const documentFilterModeOptions: Array<{ value: DocumentFilterMode; label
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
];
export const elasticsearchBoolClauseOptions: ElasticsearchBoolClause[] = ["filter", "must", "should", "must_not"];
const ELASTICSEARCH_TEXT_QUERY_TYPES: ElasticsearchQueryType[] = ["match", "match_phrase", "term", "wildcard", "exists"];
const ELASTICSEARCH_KEYWORD_QUERY_TYPES: ElasticsearchQueryType[] = ["term", "terms", "wildcard", "exists"];
const ELASTICSEARCH_RANGE_QUERY_TYPES: ElasticsearchQueryType[] = ["term", "terms", "range_gt", "range_gte", "range_lt", "range_lte", "exists"];
const ELASTICSEARCH_BOOLEAN_QUERY_TYPES: ElasticsearchQueryType[] = ["term", "exists"];
export function elasticsearchQueryTypeOptions(fieldType?: string): ElasticsearchQueryType[] {
const normalized = fieldType?.trim().toLowerCase() ?? "";
if (normalized === "text" || normalized === "search_as_you_type") return ELASTICSEARCH_TEXT_QUERY_TYPES;
if (normalized === "keyword" || normalized === "constant_keyword" || normalized === "wildcard") return ELASTICSEARCH_KEYWORD_QUERY_TYPES;
if (/^(?:byte|short|integer|long|unsigned_long|half_float|float|double|scaled_float|date|date_nanos|ip)$/.test(normalized)) return ELASTICSEARCH_RANGE_QUERY_TYPES;
if (normalized === "boolean") return ELASTICSEARCH_BOOLEAN_QUERY_TYPES;
return ["term", "terms", "match", "match_phrase", "wildcard", "range_gt", "range_gte", "range_lt", "range_lte", "exists"];
}
const mongoDocumentProvider: DocumentStoreProvider = {
kind: "mongodb",
filterInputLabel: "find",
@@ -90,6 +110,8 @@ export function defaultDocumentFilterRule(id: string, fieldName = ""): DocumentF
mode: "equals",
rawValue: "",
conjunction: "AND",
elasticsearchClause: "filter",
elasticsearchQueryType: "term",
};
}
@@ -97,6 +119,68 @@ export function documentFilterModeNeedsValue(mode: DocumentFilterMode): boolean
return mode !== "is-null" && mode !== "is-not-null";
}
export function elasticsearchQueryTypeNeedsValue(queryType: ElasticsearchQueryType | undefined): boolean {
return queryType !== "exists";
}
export function buildElasticsearchQueryFromRules(rules: readonly DocumentFilterRule[]): Record<string, unknown> | null {
const boolQuery: Partial<Record<ElasticsearchBoolClause, Record<string, unknown>[]>> & { minimum_should_match?: number } = {};
for (const rule of rules) {
const query = buildElasticsearchRuleQuery(rule);
if (!query) continue;
const clause = rule.elasticsearchClause ?? "filter";
(boolQuery[clause] ??= []).push(query);
}
if (Object.keys(boolQuery).length === 0) return null;
if (boolQuery.should?.length) boolQuery.minimum_should_match = 1;
return { bool: boolQuery };
}
function buildElasticsearchRuleQuery(rule: DocumentFilterRule): Record<string, unknown> | null {
const field = rule.fieldName.trim();
const queryType = rule.elasticsearchQueryType ?? "term";
if (!field || (elasticsearchQueryTypeNeedsValue(queryType) && !rule.rawValue.trim())) return null;
if (queryType === "exists") return { exists: { field } };
const value = parseDocumentFilterValue(rule.rawValue, { kind: "elasticsearch" });
switch (queryType) {
case "term":
return { term: { [field]: value } };
case "terms": {
const values = Array.isArray(value)
? value
: rule.rawValue
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => parseDocumentFilterValue(item, { kind: "elasticsearch" }));
return values.length ? { terms: { [field]: values } } : null;
}
case "match":
return { match: { [field]: value } };
case "match_phrase":
return { match_phrase: { [field]: value } };
case "wildcard":
// Keep the compact form compatible with every supported Elasticsearch 7.x release.
// `case_insensitive` was only added in Elasticsearch 7.10.
return { wildcard: { [field]: String(value) } };
case "range_gt":
return { range: { [field]: { gt: value } } };
case "range_gte":
return { range: { [field]: { gte: value } } };
case "range_lt":
return { range: { [field]: { lt: value } } };
case "range_lte":
return { range: { [field]: { lte: value } } };
}
}
export function elasticsearchStructuredFilter(query: Record<string, unknown> | null): Record<string, unknown> | null {
return query ? { $esQuery: query } : null;
}
type DocumentFilterParseOptions = {
kind?: DocumentStoreKind;
};
@@ -298,6 +382,9 @@ function translateDocumentFilterToElasticsearchQuery(filter: Record<string, unkn
} else if (key === "$or") {
const should = translateLogicalArrayToElasticsearch("$or", value);
if (should.length > 0) filters.push({ bool: { should, minimum_should_match: 1 } });
} else if (key === "$esQuery") {
if (!isPlainRecord(value)) throw new Error("$esQuery must be an object");
filters.push(value);
} else {
filters.push(translateFieldFilterToElasticsearchQuery(key, value));
}
@@ -22,14 +22,21 @@ export interface ElasticsearchCompletionInput {
indices?: string[];
}
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD"] 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: "/_cluster/stats", apply: "_cluster/stats?pretty", method: "GET", detail: "Cluster statistics" },
{ label: "/_nodes/stats", apply: "_nodes/stats?pretty", method: "GET", detail: "Node statistics" },
{ label: "/_cat/nodes", apply: "_cat/nodes?v", method: "GET", detail: "List nodes" },
{ label: "/_cat/shards", apply: "_cat/shards?v", method: "GET", detail: "List shards" },
{ label: "/_cat/thread_pool", apply: "_cat/thread_pool?v", method: "GET", detail: "Thread pools" },
{ label: "/_aliases", apply: "_aliases", method: "GET", detail: "List aliases" },
{ label: "/_bulk", apply: "_bulk\n", method: "POST", detail: "Bulk operations" },
{ label: "/_msearch", apply: "_msearch\n", method: "POST", detail: "Multi search" },
{ label: "/_tasks", apply: "_tasks?detailed=true", method: "GET", detail: "List tasks" },
{ label: "/_count", apply: "_count", method: "GET", detail: "Count documents" },
];
@@ -86,44 +93,47 @@ export function getElasticsearchCompletionContext(text: string, cursor: number):
const firstLineEnd = text.indexOf("\n");
const firstLineLimit = firstLineEnd >= 0 ? firstLineEnd : text.length;
// Check the current line before falling back to JSON mode so completion also
// works after leading comments and on subsequent REST requests.
const methodMatch = /^\s*([A-Za-z]*)$/.exec(beforeCursorOnLine);
if (methodMatch) {
const leadingWhitespace = beforeCursorOnLine.length - beforeCursorOnLine.trimStart().length;
return {
mode: "method",
prefix: methodMatch[1] ?? "",
from: lineStart + leadingWhitespace,
};
}
const commandMatch = /^\s*([A-Za-z]+)\s+(\S*)/.exec(text.slice(lineStart, currentLineEnd));
if (commandMatch) {
const method = commandMatch[1]?.toUpperCase();
const path = commandMatch[2] ?? "";
const pathStart = lineStart + commandMatch[0].length - path.length;
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,
};
}
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,
};
const prefix = readWordPrefix(text, safeCursor);
return { mode: "method", prefix: prefix.prefix, from: prefix.from };
}
export function buildElasticsearchCompletionItems(text: string, cursor: number, input: ElasticsearchCompletionInput = {}): ElasticsearchCompletionItem[] {
@@ -1,11 +1,12 @@
import type { DatabaseType, QueryResult } from "@/types/database";
import { stripLeadingElasticsearchComments } from "@/lib/sql/sqlStatementRanges";
export interface ElasticsearchJsonResponse {
status: number;
body: string;
}
const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE)\s+\S+/i;
const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
/**
* Detect the result shape emitted for a JSON response to an explicit
@@ -14,7 +15,7 @@ const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE)\s+\S+/i;
*/
export function elasticsearchJsonResponseForResult(databaseType: DatabaseType | undefined, sourceStatement: string | undefined, result: QueryResult | undefined): ElasticsearchJsonResponse | undefined {
if (databaseType !== "elasticsearch" || !result || typeof sourceStatement !== "string") return undefined;
if (!ELASTICSEARCH_REST_STATEMENT.test(sourceStatement.trim())) return undefined;
if (!ELASTICSEARCH_REST_STATEMENT.test(stripLeadingElasticsearchComments(sourceStatement))) return undefined;
if (result.columns.length !== 2 || result.columns[0] !== "status" || result.columns[1] !== "response" || result.rows.length !== 1) return undefined;
const row = result.rows[0];
+151 -2
View File
@@ -15,7 +15,7 @@ export interface SqlTextRange {
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "neo4j"]);
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
return !!databaseType && (databaseType === "redis" || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
return !!databaseType && (databaseType === "redis" || databaseType === "elasticsearch" || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
}
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType): boolean {
@@ -36,6 +36,150 @@ interface RawStatement {
sql: string;
}
interface ElasticsearchRequestLine {
from: number;
to: number;
text: string;
}
interface ElasticsearchRequestLineCandidate {
lineIndex: number;
from: number;
}
export interface ElasticsearchRestRequestTarget {
method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
path: string;
}
const ELASTICSEARCH_REST_REQUEST_LINE = /^\s*(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
function leadingElasticsearchPreambleEnd(value: string): number {
let offset = 0;
while (offset < value.length) {
while (offset < value.length && /\s/.test(value[offset] ?? "")) offset += 1;
if (offset >= value.length) return offset;
if (value[offset] === "#" || value.startsWith("//", offset)) {
const newline = value.indexOf("\n", offset);
offset = newline < 0 ? value.length : newline + 1;
continue;
}
if (value.startsWith("/*", offset)) {
const close = value.indexOf("*/", offset + 2);
offset = close < 0 ? value.length : close + 2;
continue;
}
break;
}
return offset;
}
export function stripLeadingElasticsearchComments(value: string): string {
return value.slice(leadingElasticsearchPreambleEnd(value)).trimStart();
}
function isElasticsearchRequestPreamble(value: string): boolean {
return leadingElasticsearchPreambleEnd(value) === value.length;
}
function elasticsearchRequestLines(sql: string): ElasticsearchRequestLine[] {
const lines: ElasticsearchRequestLine[] = [];
let from = 0;
while (from <= sql.length) {
const newline = sql.indexOf("\n", from);
const to = newline >= 0 ? newline : sql.length;
lines.push({ from, to, text: sql.slice(from, to) });
if (newline < 0) break;
from = newline + 1;
}
return lines;
}
function elasticsearchRequestLineCandidates(lines: ElasticsearchRequestLine[]): ElasticsearchRequestLineCandidate[] {
const candidates: ElasticsearchRequestLineCandidate[] = [];
let inBlockComment = false;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
let offset = 0;
while (offset < line.text.length) {
if (inBlockComment) {
const close = line.text.indexOf("*/", offset);
if (close < 0) break;
inBlockComment = false;
offset = close + 2;
continue;
}
while (offset < line.text.length && /\s/.test(line.text[offset] ?? "")) offset += 1;
if (offset >= line.text.length) break;
if (line.text.startsWith("/*", offset)) {
inBlockComment = true;
offset += 2;
continue;
}
if (line.text[offset] === "#" || line.text.startsWith("//", offset)) break;
if (ELASTICSEARCH_REST_REQUEST_LINE.test(line.text.slice(offset))) {
candidates.push({ lineIndex, from: line.from + offset });
}
break;
}
}
return candidates;
}
export function parseElasticsearchRestRequestTarget(value: string): ElasticsearchRestRequestTarget | null {
const requestLine = stripLeadingElasticsearchComments(value).split("\n", 1)[0]?.trim() ?? "";
const match = requestLine.match(/^(GET|POST|PUT|DELETE|HEAD)\s+(\S+)/i);
if (!match) return null;
return {
method: match[1].toUpperCase() as ElasticsearchRestRequestTarget["method"],
path: match[2].startsWith("/") ? match[2] : `/${match[2]}`,
};
}
export function isElasticsearchRestRequestText(value: string): boolean {
return parseElasticsearchRestRequestTarget(value) !== null;
}
function splitElasticsearchRestRequestRanges(sql: string): RawStatement[] | undefined {
const lines = elasticsearchRequestLines(sql);
const candidates = elasticsearchRequestLineCandidates(lines);
const firstRequestIndex = candidates.findIndex((candidate) => isElasticsearchRequestPreamble(sql.slice(0, candidate.from)));
if (firstRequestIndex < 0) return undefined;
const requests = candidates.slice(firstRequestIndex);
const hitFroms = requests.map((request, requestIndex) => {
const previousRequestLine = requestIndex > 0 ? requests[requestIndex - 1].lineIndex : -1;
let hitFrom = request.from;
for (let preambleLine = previousRequestLine + 1; preambleLine < request.lineIndex; preambleLine += 1) {
const candidateFrom = lines[preambleLine].from;
if (isElasticsearchRequestPreamble(sql.slice(candidateFrom, request.from))) {
hitFrom = candidateFrom;
break;
}
}
return hitFrom;
});
return requests.map((request, requestIndex) => {
const from = request.from;
const rawTo = requestIndex + 1 < requests.length ? hitFroms[requestIndex + 1] : sql.length;
const to = trimRangeEnd(sql, from, rawTo);
return {
hitFrom: hitFroms[requestIndex],
from,
to,
sql: sql.slice(from, to),
};
});
}
type QuoteState = "none" | "single" | "double" | "backtick" | "bracket" | "dollar";
const COMMON_SOFT_STATEMENT_START_KEYWORDS = [
@@ -126,6 +270,11 @@ const SAP_HANA_SCRIPT_BLOCK_TERMINATORS = new Set(["IF", "FOR", "WHILE"]);
* whitespace are excluded so editor highlights stay tight).
*/
export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType): RawStatement[] {
if (databaseType === "elasticsearch") {
const requests = splitElasticsearchRestRequestRanges(sql);
if (requests) return requests;
}
const statements: RawStatement[] = [];
const len = sql.length;
const supportsDelimiterCommands = databaseType === "mysql";
@@ -401,7 +550,7 @@ export function statementRangeAtCursor(sql: string, cursorPos: number, databaseT
// Cursor in indentation or inter-statement whitespace immediately before
// the statement should still target that statement, while the returned
// execution range remains tight around the SQL text itself.
if (pos >= statement.hitFrom && pos < statement.from && sql.slice(pos, statement.from).trim() === "") {
if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (databaseType === "elasticsearch" && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
const previous = statements[index - 1];
if (previous && isCursorInSameLineDelimiterGap(sql, previous.to, pos)) {
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType);
+69
View File
@@ -168,6 +168,22 @@ function annotateQueryResultSource(result: QueryResult, sourceStatement: string,
return result;
}
const ELASTICSEARCH_REST_REQUEST = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
function elasticsearchRestRequestRanges(sql: string, databaseType?: DatabaseType) {
if (databaseType !== "elasticsearch") return [];
const requests = splitSqlStatementRanges(sql, databaseType);
return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
}
function elasticsearchHttpErrorStatus(result: QueryResult): number | undefined {
const statusIndex = result.columns.findIndex((column) => column.toLowerCase() === "status");
if (statusIndex < 0) return undefined;
const value = result.rows[0]?.[statusIndex];
const status = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
return Number.isInteger(status) && status >= 400 ? status : undefined;
}
function displayedQueryMetadataSql(tab: QueryTab, fallbackSql: string): string {
return tab.results?.length ? (tab.result?.sourceStatement ?? fallbackSql) : fallbackSql;
}
@@ -3116,6 +3132,59 @@ export const useQueryStore = defineStore("query", () => {
return;
}
const elasticsearchRequests = elasticsearchRestRequestRanges(sqlToExecute, effectiveDbType);
if (elasticsearchRequests.length > 0) {
console.info("[DBX][executeTabSql:elasticsearch-rest-batch:start]", {
traceId,
requestCount: elasticsearchRequests.length,
sql,
});
const allResults: QueryResult[] = [];
const continueOnError = settingsStore.editorSettings.continueOnErrorOnBatch;
for (const request of elasticsearchRequests) {
const current = tabs.value.find((item) => item.id === id);
if (current?.executionId !== executionId) break;
const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + request.from, to: options.sourceOffset + request.to };
try {
const result = await api.executeQuery(tab.connectionId, executionDatabase, request.sql, undefined, executionId, {
timeoutSecs: queryTimeoutSecs,
});
allResults.push(markQueryResultRowsRaw(annotateQueryResultSource(result, request.sql, tab.database || conn?.database, effectiveDbType, sourceRange)));
if (elasticsearchHttpErrorStatus(result) !== undefined && !continueOnError) break;
} catch (error) {
const latest = tabs.value.find((item) => item.id === id);
if (latest?.executionId !== executionId) break;
allResults.push(annotateQueryResultSource(toErrorResult(error), request.sql, tab.database || conn?.database, effectiveDbType, sourceRange));
if (!continueOnError) break;
}
}
console.info("[DBX][executeTabSql:elasticsearch-rest-batch:done]", {
traceId,
requestCount: elasticsearchRequests.length,
resultCount: allResults.length,
elapsed: elapsed(),
});
const current = tabs.value.find((item) => item.id === id);
if (current?.executionId === executionId && allResults.length > 0) {
const errorResultIndex = allResults.findIndex((result) => result.columns.includes("Error") || elasticsearchHttpErrorStatus(result) !== undefined);
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : 0;
current.results = allResults.length > 1 ? allResults : undefined;
current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined;
current.result = allResults[resultIndex];
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = undefined;
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = undefined;
syncDisplayedResultRun(current, current.resultBaseSql);
}
return;
}
if (tab.mode === "query") {
const prepared = await prepareEditableQueryExecution(tab, sqlToExecute, conn, effectiveDbType, traceId, elapsed);
sqlToExecute = prepared.sql;