feat(elasticsearch): improve REST commands and visual filters

This commit is contained in:
zipg
2026-07-16 16:31:15 +08:00
committed by GitHub
parent bfcd7593ee
commit da7eedb49c
15 changed files with 1151 additions and 115 deletions
@@ -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;
+350 -53
View File
@@ -1,5 +1,5 @@
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::Client as HttpClient;
use reqwest::{Client as HttpClient, Method};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashSet;
@@ -87,6 +87,11 @@ impl EsClient {
self.with_auth(req)
}
fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
let req = self.http.request(method, format!("{}{}", self.base_url, path));
self.with_auth(req)
}
fn with_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some((ref user, ref pass)) = self.auth {
req.basic_auth(user, Some(pass))
@@ -491,6 +496,12 @@ fn translate_document_filter_value(value: &serde_json::Value) -> Result<Option<s
must.push(serde_json::json!({ "bool": { "should": should, "minimum_should_match": 1 } }));
}
}
"$esQuery" => {
if !value.is_object() {
return Err("$esQuery must be an object".to_string());
}
must.push(value.clone());
}
key if key.starts_with('$') => {
return Err(format!("Unsupported Elasticsearch filter operator: {key}"));
}
@@ -737,9 +748,152 @@ pub async fn delete_document(client: &EsClient, index: &str, id: &str, routing:
Ok(1)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ElasticsearchRestBodyKind {
Json,
Ndjson,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ElasticsearchRestRequest {
method: Method,
path: String,
body: Option<String>,
body_kind: ElasticsearchRestBodyKind,
}
fn strip_leading_elasticsearch_comments(input: &str) -> &str {
let mut rest = input;
loop {
rest = rest.trim_start();
if let Some(comment) = rest.strip_prefix('#').or_else(|| rest.strip_prefix("//")) {
rest = comment.split_once('\n').map_or("", |(_, remaining)| remaining);
continue;
}
if let Some(comment) = rest.strip_prefix("/*") {
rest = comment.split_once("*/").map_or("", |(_, remaining)| remaining);
continue;
}
return rest.trim();
}
}
fn is_elasticsearch_ndjson_path(path: &str) -> bool {
let path = path.split('?').next().unwrap_or(path).trim_end_matches('/');
path == "/_bulk"
|| path.ends_with("/_bulk")
|| path == "/_msearch"
|| path.ends_with("/_msearch")
|| path == "/_msearch/template"
|| path.ends_with("/_msearch/template")
}
fn normalize_elasticsearch_rest_path(path: &str) -> String {
let (path_part, query) = path.split_once('?').map_or((path, None), |(path, query)| (path, Some(query)));
let mut normalized = String::with_capacity(path.len());
let mut chars = path_part.chars().peekable();
let mut in_date_math = false;
while let Some(ch) = chars.next() {
if ch == '%' {
let mut lookahead = chars.clone();
if let (Some(first), Some(second)) = (lookahead.next(), lookahead.next()) {
if first.is_ascii_hexdigit() && second.is_ascii_hexdigit() {
normalized.push(ch);
normalized.push(chars.next().unwrap());
normalized.push(chars.next().unwrap());
continue;
}
}
}
if ch == '<' {
in_date_math = true;
}
let encoded = if in_date_math {
match ch {
'<' => Some("%3C"),
'>' => Some("%3E"),
'/' => Some("%2F"),
'{' => Some("%7B"),
'}' => Some("%7D"),
'|' => Some("%7C"),
'+' => Some("%2B"),
':' => Some("%3A"),
',' => Some("%2C"),
_ => None,
}
} else {
None
};
if let Some(encoded) = encoded {
normalized.push_str(encoded);
} else {
normalized.push(ch);
}
if ch == '>' {
in_date_math = false;
}
}
if let Some(query) = query {
normalized.push('?');
normalized.push_str(query);
}
normalized
}
fn parse_elasticsearch_rest_request(input: &str) -> Result<ElasticsearchRestRequest, String> {
let input = strip_leading_elasticsearch_comments(input);
if input.is_empty() {
return Err("Invalid query: expected METHOD /path".to_string());
}
let (request_line, body) = input.split_once('\n').map_or((input, None), |(line, body)| {
let body = body.trim();
(line, (!body.is_empty()).then(|| body.to_string()))
});
let (method, path) =
request_line.trim().split_once(char::is_whitespace).ok_or("Invalid query: expected METHOD /path")?;
let method = method.to_ascii_uppercase();
let method = Method::from_bytes(method.as_bytes())
.map_err(|_| format!("Unsupported HTTP method: {method}. Use GET, POST, PUT, DELETE, or HEAD."))?;
if !matches!(method, Method::GET | Method::POST | Method::PUT | Method::DELETE | Method::HEAD) {
return Err(format!("Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, or HEAD.", method.as_str()));
}
let path = path.trim();
if path.is_empty() {
return Err("Invalid query: expected METHOD /path".to_string());
}
let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
let path = normalize_elasticsearch_rest_path(&path);
let body_kind = if is_elasticsearch_ndjson_path(&path) {
ElasticsearchRestBodyKind::Ndjson
} else {
ElasticsearchRestBodyKind::Json
};
Ok(ElasticsearchRestRequest { method, path, body, body_kind })
}
fn validate_elasticsearch_ndjson(body: &str) -> Result<String, String> {
for (index, line) in body.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
serde_json::from_str::<serde_json::Value>(line)
.map_err(|error| format!("Invalid NDJSON body at line {}: {error}", index + 1))?;
}
let mut normalized = body.trim_end().to_string();
normalized.push('\n');
Ok(normalized)
}
pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate::types::QueryResult, String> {
let start = std::time::Instant::now();
let input = input.trim();
let input = strip_leading_elasticsearch_comments(input);
if let Some(search_query) = parse_select_star_search_query(input) {
return execute_search_query(client, search_query, start).await;
@@ -767,59 +921,21 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate:
return execute_sql_query(client, input, start).await;
}
let (method, rest) = input.split_once(char::is_whitespace).ok_or("Invalid query: expected METHOD /path")?;
let method = method.to_uppercase();
let (path, body) = if let Some(pos) = rest.find('\n') {
let p = rest[..pos].trim();
let b = rest[pos + 1..].trim();
(p, if b.is_empty() { None } else { Some(b) })
} else {
(rest.trim(), None)
};
let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
let resp = match method.as_str() {
"GET" => {
let req = client.get(&path);
if let Some(b) = body {
let json: serde_json::Value = serde_json::from_str(b).map_err(|e| format!("Invalid JSON body: {e}"))?;
req.json(&json).send().await
} else {
req.send().await
let request = parse_elasticsearch_rest_request(input)?;
let mut builder = client.request(request.method, &request.path);
if let Some(body) = request.body {
builder = match request.body_kind {
ElasticsearchRestBodyKind::Json => {
let json: serde_json::Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid JSON body: {e}"))?;
builder.json(&json)
}
}
"POST" => {
let req = client.post(&path);
if let Some(b) = body {
let json: serde_json::Value = serde_json::from_str(b).map_err(|e| format!("Invalid JSON body: {e}"))?;
req.json(&json).send().await
} else {
req.send().await
}
}
"PUT" => {
let req = client.put(&path);
if let Some(b) = body {
let json: serde_json::Value = serde_json::from_str(b).map_err(|e| format!("Invalid JSON body: {e}"))?;
req.json(&json).send().await
} else {
req.send().await
}
}
"DELETE" => {
let req = client.delete(&path);
if let Some(b) = body {
let json: serde_json::Value = serde_json::from_str(b).map_err(|e| format!("Invalid JSON body: {e}"))?;
req.json(&json).send().await
} else {
req.send().await
}
}
_ => return Err(format!("Unsupported HTTP method: {method}. Use GET, POST, PUT, or DELETE.")),
ElasticsearchRestBodyKind::Ndjson => builder
.header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
.body(validate_elasticsearch_ndjson(&body)?),
};
}
.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
let resp = builder.send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
let status = resp.status().as_u16();
let body = resp.text().await.map_err(|e| format!("Elasticsearch response read failed: {e}"))?;
@@ -992,6 +1108,10 @@ fn parse_elasticsearch_rest_response(
return Ok(json_response_result(status, &serde_json::Value::Null, start));
}
if status >= 400 {
return Ok(raw_json_response_result(status, body_text, start));
}
if serde_json::from_str::<serde_json::Value>(body_text).is_ok() {
// Validate the payload as JSON, but retain the HTTP body verbatim so
// numeric literals are not changed by a parse/serialize round trip.
@@ -1559,6 +1679,66 @@ mod tests {
}
}
#[test]
fn parses_rest_request_after_comments_and_preserves_query_parameters() {
let request = super::parse_elasticsearch_rest_request(
"# JVM statistics\n// available on Elasticsearch 7+\nGET /_nodes/stats/jvm?pretty",
)
.unwrap();
assert_eq!(request.method, reqwest::Method::GET);
assert_eq!(request.path, "/_nodes/stats/jvm?pretty");
assert_eq!(request.body, None);
assert_eq!(request.body_kind, super::ElasticsearchRestBodyKind::Json);
}
#[test]
fn parses_rest_request_after_multiline_block_comment() {
let request = super::parse_elasticsearch_rest_request(
"/* node statistics\n safe on supported clusters */\nGET /_nodes/stats/jvm?pretty",
)
.unwrap();
assert_eq!(request.method, reqwest::Method::GET);
assert_eq!(request.path, "/_nodes/stats/jvm?pretty");
}
#[test]
fn parses_lowercase_rest_method() {
let request = super::parse_elasticsearch_rest_request("get /_cluster/health").unwrap();
assert_eq!(request.method, reqwest::Method::GET);
assert_eq!(request.path, "/_cluster/health");
}
#[test]
fn encodes_raw_date_math_paths_without_double_encoding() {
let raw = super::parse_elasticsearch_rest_request("GET /<logs-{now/d}>/_search?pretty").unwrap();
assert_eq!(raw.path, "/%3Clogs-%7Bnow%2Fd%7D%3E/_search?pretty");
let encoded = super::parse_elasticsearch_rest_request("GET /%3Clogs-%7Bnow%2Fd%7D%3E/_search?pretty").unwrap();
assert_eq!(encoded.path, "/%3Clogs-%7Bnow%2Fd%7D%3E/_search?pretty");
}
#[test]
fn detects_ndjson_endpoints_with_index_and_query_parameters() {
let request = super::parse_elasticsearch_rest_request(
"POST /orders/_bulk?refresh=true\n{\"index\":{\"_id\":\"1\"}}\n{\"name\":\"Notebook\"}",
)
.unwrap();
assert_eq!(request.method, reqwest::Method::POST);
assert_eq!(request.body_kind, super::ElasticsearchRestBodyKind::Ndjson);
assert!(request.body.is_some());
}
#[test]
fn normalizes_ndjson_with_a_required_trailing_newline() {
let body = "{\"index\":{}}\n{\"name\":\"Notebook\"}";
assert_eq!(super::validate_elasticsearch_ndjson(body).unwrap(), format!("{body}\n"));
assert!(super::validate_elasticsearch_ndjson("{\"index\":{}}\nnot-json").is_err());
}
#[test]
fn url_params_can_disable_elasticsearch_tls_verification() {
assert!(elasticsearch_accept_invalid_certs(false, Some("sslmode=disable")));
@@ -1651,6 +1831,38 @@ mod tests {
);
}
#[test]
fn builds_elasticsearch_find_body_with_native_query_builder_filter() {
let body = build_find_documents_body(
0,
25,
Some(
r#"{
"$esQuery": {
"bool": {
"must": [{"match": {"customer_name": "Customer"}}],
"filter": [{"range": {"amount": {"gte": 500}}}],
"must_not": [{"term": {"status": "cancelled"}}]
}
}
}"#,
),
None,
)
.unwrap();
assert_eq!(
body["query"],
json!({
"bool": {
"must": [{"match": {"customer_name": "Customer"}}],
"filter": [{"range": {"amount": {"gte": 500}}}],
"must_not": [{"term": {"status": "cancelled"}}]
}
})
);
}
#[test]
fn builds_elasticsearch_find_body_with_structured_filter_operators() {
let body = build_find_documents_body(
@@ -1861,6 +2073,16 @@ mod tests {
assert_eq!(result.affected_rows, 2);
}
#[test]
fn preserves_http_status_for_plain_text_rest_errors() {
let result =
super::parse_elasticsearch_rest_response(503, "service temporarily unavailable", std::time::Instant::now())
.unwrap();
assert_eq!(result.columns, vec!["status", "response"]);
assert_eq!(result.rows, vec![vec![json!(503), json!("service temporarily unavailable")]]);
}
#[test]
fn keeps_mapping_rest_response_numeric_literals_lossless() {
let body = r#"{
@@ -1941,6 +2163,81 @@ mod tests {
assert_eq!(result.rows[0][1].as_str(), Some(response_body));
}
#[tokio::test]
async fn execute_rest_head_request_returns_http_status() {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.starts_with("HEAD /orders "));
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap();
});
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
let result = super::execute_rest_query(&client, "HEAD /orders").await.unwrap();
server.await.unwrap();
assert_eq!(result.columns, vec!["status", "response"]);
assert_eq!(result.rows[0][0], json!(200));
assert_eq!(result.rows[0][1], json!("null"));
}
#[tokio::test]
async fn execute_rest_query_sends_encoded_date_math_path() {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.starts_with("GET /%3Clogs-%7Bnow%2Fd%7D%3E/_search?pretty "), "{request}");
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").await.unwrap();
});
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
super::execute_rest_query(&client, "GET /<logs-{now/d}>/_search?pretty").await.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn execute_rest_msearch_sends_ndjson_content_type_and_trailing_newline() {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
let (headers, body) = request.split_once("\r\n\r\n").unwrap();
assert!(headers.starts_with("POST /_msearch "));
assert!(headers.lines().any(|line| line.eq_ignore_ascii_case("content-type: application/x-ndjson")));
assert_eq!(body, "{\"index\":\"orders\"}\n{\"size\":0}\n");
let response_body = r#"{"responses":[]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
let result = super::execute_rest_query(
&client,
"// run two searches\nPOST /_msearch\n{\"index\":\"orders\"}\n{\"size\":0}",
)
.await
.unwrap();
server.await.unwrap();
assert_eq!(result.rows[0][0], json!(200));
}
#[tokio::test]
async fn execute_rest_delete_sends_json_body() {
use tokio::io::AsyncWriteExt;
+3
View File
@@ -4348,6 +4348,9 @@ pub async fn get_columns_core(
PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::get_columns(client, schema, table)
.await
.map(deduplicate_column_infos),
PoolKind::Elasticsearch(client) => {
db::elasticsearch_driver::get_columns(client, table).await.map(deduplicate_column_infos)
}
_ => Ok(vec![]),
}
})
+50 -1
View File
@@ -1,6 +1,16 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildDocumentFilterCondition, combineDocumentFilterConditions, currentDocumentFilterJson, documentStoreProviderFor, elasticsearchSearchBodyFromDocumentQuery, type DocumentFilterRule } from "../../apps/desktop/src/lib/app/documentStoreProvider.ts";
import {
buildDocumentFilterCondition,
buildElasticsearchQueryFromRules,
combineDocumentFilterConditions,
currentDocumentFilterJson,
documentStoreProviderFor,
elasticsearchQueryTypeOptions,
elasticsearchSearchBodyFromDocumentQuery,
elasticsearchStructuredFilter,
type DocumentFilterRule,
} from "../../apps/desktop/src/lib/app/documentStoreProvider.ts";
function rule(patch: Partial<DocumentFilterRule>): DocumentFilterRule {
return {
@@ -98,3 +108,42 @@ test("translates document filters to Elasticsearch search body previews", () =>
},
);
});
test("builds native Elasticsearch bool queries from visual rules", () => {
const query = buildElasticsearchQueryFromRules([
rule({ fieldName: "customer_name", rawValue: "Customer", elasticsearchClause: "must", elasticsearchQueryType: "match" }),
rule({ fieldName: "amount", rawValue: "500", elasticsearchClause: "filter", elasticsearchQueryType: "range_gte" }),
rule({ fieldName: "status", rawValue: "cancelled", elasticsearchClause: "must_not", elasticsearchQueryType: "term" }),
rule({ fieldName: "note", rawValue: "", elasticsearchClause: "should", elasticsearchQueryType: "exists" }),
]);
assert.deepEqual(query, {
bool: {
must: [{ match: { customer_name: "Customer" } }],
filter: [{ range: { amount: { gte: 500 } } }],
must_not: [{ term: { status: "cancelled" } }],
should: [{ exists: { field: "note" } }],
minimum_should_match: 1,
},
});
const body = elasticsearchSearchBodyFromDocumentQuery({
filterJson: currentDocumentFilterJson("", elasticsearchStructuredFilter(query), "elasticsearch"),
skip: 0,
limit: 25,
});
assert.deepEqual(body.query, query);
});
test("offers Elasticsearch query types based on mapping field type", () => {
assert.deepEqual(elasticsearchQueryTypeOptions("text"), ["match", "match_phrase", "term", "wildcard", "exists"]);
assert.deepEqual(elasticsearchQueryTypeOptions("keyword"), ["term", "terms", "wildcard", "exists"]);
assert.ok(elasticsearchQueryTypeOptions("double").includes("range_gte"));
assert.deepEqual(elasticsearchQueryTypeOptions("boolean"), ["term", "exists"]);
});
test("builds wildcard queries compatible with Elasticsearch 7.x", () => {
assert.deepEqual(buildElasticsearchQueryFromRules([rule({ fieldName: "sku", rawValue: "DBX-*", elasticsearchQueryType: "wildcard" })]), {
bool: { filter: [{ wildcard: { sku: "DBX-*" } }] },
});
});
+11 -1
View File
@@ -15,7 +15,7 @@ function applyCompletion(text: string, cursor: number, label: string): string {
test("suggests Elasticsearch HTTP methods for empty and prefix input", () => {
assert.deepEqual(
buildElasticsearchCompletionItems("", 0).map((item) => item.label),
["GET", "POST", "PUT", "DELETE"],
["GET", "POST", "PUT", "DELETE", "HEAD"],
);
const items = buildElasticsearchCompletionItems("po", 2);
@@ -27,6 +27,16 @@ test("suggests Elasticsearch root endpoints", () => {
assert.ok(items.find((item) => item.label === "/_search"));
assert.ok(items.find((item) => item.label === "/_cat/indices"));
assert.ok(items.find((item) => item.label === "/_nodes/stats"));
assert.ok(items.find((item) => item.label === "/_msearch"));
});
test("suggests methods and endpoints after comments and in later REST requests", () => {
const commentedMethod = "# inspect node\nHE";
assert.ok(buildElasticsearchCompletionItems(commentedMethod, commentedMethod.length).some((item) => item.label === "HEAD"));
const secondRequest = "GET /_cluster/health\n\nGET /_no";
assert.ok(buildElasticsearchCompletionItems(secondRequest, secondRequest.length).some((item) => item.label === "/_nodes/stats"));
});
test("suggests Elasticsearch index endpoints", () => {
+18
View File
@@ -27,6 +27,24 @@ test("classifies Elasticsearch GET mapping and POST JSON responses", () => {
});
});
test("classifies Elasticsearch HEAD responses", () => {
const response = jsonResponse({ rows: [[200, "null"]] });
assert.deepEqual(elasticsearchJsonResponseForResult("elasticsearch", "HEAD /products", response), {
status: 200,
body: "null",
});
});
test("classifies Elasticsearch responses after leading comments", () => {
const response = jsonResponse();
assert.deepEqual(elasticsearchJsonResponseForResult("elasticsearch", "/* mapping */\nGET /products/_mapping", response), {
status: 200,
body: '{\n "acknowledged": true\n}',
});
});
test("rejects SQL and non-JSON Elasticsearch result shapes", () => {
const response = jsonResponse();
+167
View File
@@ -65,6 +65,14 @@ function sqlServerConn(id: string): ConnectionConfig {
};
}
function elasticsearchConn(id: string): ConnectionConfig {
return {
...conn(id),
db_type: "elasticsearch",
port: 9200,
};
}
function sparkConn(id: string): ConnectionConfig {
return {
...conn(id),
@@ -4898,6 +4906,165 @@ test("query results keep readable table source labels with active database conte
}
});
test("Elasticsearch execute all runs each REST request separately", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
const executedRequests: string[] = [];
connectionStore.addEphemeralConnection(elasticsearchConn("es-1"));
const tabId = store.createTab("es-1", "", "Elasticsearch query");
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/execute") {
const body = JSON.parse(String(init?.body ?? "{}"));
executedRequests.push(body.sql);
return new Response(
JSON.stringify({
columns: ["status", "response"],
rows: [[200, body.sql.startsWith("HEAD") ? "null" : '{"ok":true}']],
affected_rows: 0,
execution_time_ms: 1,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("unexpected request", { status: 500 });
});
const sql = `/* 查看节点 JVM 信息 */
GET /_nodes/stats/jvm?pretty
# 判断索引是否存在
HEAD /dbx-orders
// 查询文档
POST /dbx-orders/_search
{"size":1}`;
try {
await store.executeTabSql(tabId, sql);
assert.deepEqual(executedRequests, ["GET /_nodes/stats/jvm?pretty", "HEAD /dbx-orders", 'POST /dbx-orders/_search\n{"size":1}']);
const tab = store.tabs.find((item) => item.id === tabId);
assert.deepEqual(
tab?.results?.map((result) => result.sourceStatement),
executedRequests,
);
assert.equal(tab?.activeResultIndex, 0);
assert.equal(tab?.result?.rows[0]?.[0], 200);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("Elasticsearch execute all stops after an HTTP error by default", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const settingsStore = useSettingsStore();
const originalFetch = globalThis.fetch;
const executedRequests: string[] = [];
settingsStore.updateEditorSettings({ continueOnErrorOnBatch: false });
connectionStore.addEphemeralConnection(elasticsearchConn("es-stop-on-error"));
const tabId = store.createTab("es-stop-on-error", "", "Elasticsearch query");
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
if (String(input) === "/api/query/execute") {
const body = JSON.parse(String(init?.body ?? "{}"));
executedRequests.push(body.sql);
const status = body.sql.includes("missing") ? 404 : 200;
return new Response(JSON.stringify({ columns: ["status", "response"], rows: [[status, status === 404 ? '{"error":"missing"}' : '{"ok":true}']], affected_rows: 0, execution_time_ms: 1 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("unexpected request", { status: 500 });
});
try {
await store.executeTabSql(tabId, "GET /missing/_mapping\n\nGET /_cluster/health");
assert.deepEqual(executedRequests, ["GET /missing/_mapping"]);
assert.equal(store.tabs.find((item) => item.id === tabId)?.result?.rows[0]?.[0], 404);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("Elasticsearch execute all continues after an HTTP error when enabled", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const settingsStore = useSettingsStore();
const originalFetch = globalThis.fetch;
const executedRequests: string[] = [];
settingsStore.updateEditorSettings({ continueOnErrorOnBatch: true });
connectionStore.addEphemeralConnection(elasticsearchConn("es-continue-on-error"));
const tabId = store.createTab("es-continue-on-error", "", "Elasticsearch query");
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
if (String(input) === "/api/query/execute") {
const body = JSON.parse(String(init?.body ?? "{}"));
executedRequests.push(body.sql);
const status = body.sql.includes("missing") ? 404 : 200;
return new Response(JSON.stringify({ columns: ["status", "response"], rows: [[status, status === 404 ? '{"error":"missing"}' : '{"ok":true}']], affected_rows: 0, execution_time_ms: 1 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("unexpected request", { status: 500 });
});
try {
await store.executeTabSql(tabId, "GET /missing/_mapping\n\nGET /_cluster/health");
assert.deepEqual(executedRequests, ["GET /missing/_mapping", "GET /_cluster/health"]);
const tab = store.tabs.find((item) => item.id === tabId);
assert.equal(tab?.results?.length, 2);
assert.equal(tab?.activeResultIndex, 0);
assert.equal(tab?.result?.rows[0]?.[0], 404);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("Elasticsearch executes a single REST request after a block comment", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
const executedRequests: string[] = [];
connectionStore.addEphemeralConnection(elasticsearchConn("es-single"));
const tabId = store.createTab("es-single", "", "Elasticsearch query");
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
if (String(input) === "/api/query/execute") {
const body = JSON.parse(String(init?.body ?? "{}"));
executedRequests.push(body.sql);
return new Response(
JSON.stringify({
columns: ["status", "response"],
rows: [[200, '{"nodes":{}}']],
affected_rows: 0,
execution_time_ms: 1,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("unexpected request", { status: 500 });
});
try {
await store.executeTabSql(tabId, "/* 查看节点 JVM 信息 */\nGET /_nodes/stats/jvm?pretty");
assert.deepEqual(executedRequests, ["GET /_nodes/stats/jvm?pretty"]);
assert.equal(store.tabs.find((item) => item.id === tabId)?.result?.sourceStatement, "GET /_nodes/stats/jvm?pretty");
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("tab reuse is scoped by mode and schema instead of title alone", () => {
const restoreStorage = installMemoryStorage();
try {
+17 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { isDangerousSql, stripSqlComments } from "../../apps/desktop/src/composables/useSqlExecution.ts";
import { isDangerousSql, stripSqlComments, supportsSqlTemplateParameters } from "../../apps/desktop/src/composables/useSqlExecution.ts";
test("stripSqlComments removes block comments", () => {
assert.equal(stripSqlComments("SELECT /* drop */ 1").includes("drop"), false);
@@ -66,3 +66,19 @@ test("complex SELECT with joins and functions is not dangerous", () => {
) as xxxx on m_alarm.mid = m_alarm_tmp.mid`;
assert.equal(isDangerousSql(sql), false);
});
test("distinguishes Elasticsearch REST paths from SQL template parameters", () => {
assert.equal(supportsSqlTemplateParameters({ db_type: "elasticsearch" }, "GET /_search?pretty"), false);
assert.equal(supportsSqlTemplateParameters({ db_type: "elasticsearch" }, '/* request */\nPOST /orders/_search\n{"query":{"term":{"id":":id"}}}'), false);
assert.equal(supportsSqlTemplateParameters({ db_type: "elasticsearch" }, "SELECT * FROM orders WHERE customer_id = :customer_id"), true);
assert.equal(supportsSqlTemplateParameters({ db_type: "mysql" }), true);
});
test("detects destructive Elasticsearch requests across a parsed request list", () => {
assert.equal(isDangerousSql("GET /_cluster/health\n\nDELETE /production-index", "elasticsearch"), true);
assert.equal(isDangerousSql("GET /_cluster/health\n\nPOST /orders/_delete_by_query\n{}", "elasticsearch"), true);
assert.equal(isDangerousSql('POST /_bulk\n{"delete":{"_index":"orders","_id":"1"}}', "elasticsearch"), true);
assert.equal(isDangerousSql('PUT /orders/_mapping\n{"properties":{}}', "elasticsearch"), true);
assert.equal(isDangerousSql("GET /_cluster/health\n\n/* DELETE /production-index */\nGET /_cat/indices", "elasticsearch"), false);
assert.equal(isDangerousSql('DELETE /_search/scroll\n{"scroll_id":"abc"}', "elasticsearch"), false);
});