mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
feat(elasticsearch): improve REST commands and visual filters
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user