feat(elasticsearch): improve REST commands and visual filters
This commit is contained in:
15 files changed
+1151
-115
No files matched your search
@@ -18,6 +18,7 @@ import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
||||
import {
|
||||
buildDocumentFilterCondition,
|
||||
buildElasticsearchQueryFromRules,
|
||||
combineDocumentFilterConditions,
|
||||
currentDocumentFilterJson,
|
||||
currentDocumentSortJson,
|
||||
@@ -25,8 +26,14 @@ import {
|
||||
documentFilterModeNeedsValue,
|
||||
documentFilterModeOptions,
|
||||
documentStoreProviderFor,
|
||||
elasticsearchBoolClauseOptions,
|
||||
elasticsearchQueryTypeNeedsValue,
|
||||
elasticsearchQueryTypeOptions,
|
||||
elasticsearchStructuredFilter,
|
||||
type DocumentFilterMode,
|
||||
type DocumentFilterRule,
|
||||
type ElasticsearchBoolClause,
|
||||
type ElasticsearchQueryType,
|
||||
} from "@/lib/app/documentStoreProvider";
|
||||
import { documentStoreValueForGrid, parseDocumentStoreInputValue, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues";
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
@@ -35,7 +42,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
import type { EditNode } from "@/types/editor";
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
@@ -114,6 +121,7 @@ type DocumentGridChanges = {
|
||||
const documentFilterBuilderOpen = ref(false);
|
||||
const documentFilterRules = ref<DocumentFilterRule[]>([]);
|
||||
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
|
||||
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
|
||||
|
||||
const pendingDelete = ref<PendingDelete | null>(null);
|
||||
|
||||
@@ -175,13 +183,34 @@ const gridResult = computed<QueryResult>(() => {
|
||||
|
||||
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
|
||||
});
|
||||
const documentFilterFieldOptions = computed(() => gridResult.value.columns);
|
||||
const documentStructuredFilterCount = computed(() => (appliedDocumentFilter.value ? 1 : 0));
|
||||
const documentFilterFieldOptions = computed(() => {
|
||||
if (documentStoreProvider.value.kind !== "elasticsearch") return gridResult.value.columns;
|
||||
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
|
||||
return [...new Set(names.filter(Boolean))];
|
||||
});
|
||||
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
|
||||
const documentStructuredFilterCount = computed(() => {
|
||||
if (!appliedDocumentFilter.value) return 0;
|
||||
if (documentStoreProvider.value.kind !== "elasticsearch") return 1;
|
||||
const query = appliedDocumentFilter.value.$esQuery;
|
||||
if (!query || typeof query !== "object" || Array.isArray(query)) return 0;
|
||||
const bool = (query as Record<string, unknown>).bool;
|
||||
if (!bool || typeof bool !== "object" || Array.isArray(bool)) return 0;
|
||||
return elasticsearchBoolClauseOptions.reduce((count, clause) => {
|
||||
const rules = (bool as Record<string, unknown>)[clause];
|
||||
return count + (Array.isArray(rules) ? rules.length : 0);
|
||||
}, 0);
|
||||
});
|
||||
const documentLoadingLabelKey = computed(() => (documentLoadCancelling.value ? "common.stopping" : "common.loading"));
|
||||
let documentLoadingTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function createDocumentFilterRule(): DocumentFilterRule {
|
||||
return defaultDocumentFilterRule(uuid(), documentFilterFieldOptions.value[0] ?? "");
|
||||
const fieldName = documentFilterFieldOptions.value[0] ?? "";
|
||||
const rule = defaultDocumentFilterRule(uuid(), fieldName);
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||
rule.elasticsearchQueryType = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(fieldName))[0];
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
function ensureDocumentFilterRule() {
|
||||
@@ -204,11 +233,33 @@ function updateDocumentFilterRule(ruleId: string, patch: Partial<DocumentFilterR
|
||||
documentFilterRules.value = documentFilterRules.value.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
const next = { ...rule, ...patch };
|
||||
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||
const queryTypes = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(next.fieldName));
|
||||
if (!next.elasticsearchQueryType || !queryTypes.includes(next.elasticsearchQueryType)) {
|
||||
next.elasticsearchQueryType = queryTypes[0];
|
||||
}
|
||||
if (!elasticsearchQueryTypeNeedsValue(next.elasticsearchQueryType)) next.rawValue = "";
|
||||
} else if (!documentFilterModeNeedsValue(next.mode)) {
|
||||
next.rawValue = "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function elasticsearchRuleQueryTypes(rule: DocumentFilterRule): ElasticsearchQueryType[] {
|
||||
return elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(rule.fieldName));
|
||||
}
|
||||
|
||||
function elasticsearchQueryTypeLabel(queryType: ElasticsearchQueryType): string {
|
||||
const rangeOperator: Partial<Record<ElasticsearchQueryType, string>> = {
|
||||
range_gt: "range >",
|
||||
range_gte: "range >=",
|
||||
range_lt: "range <",
|
||||
range_lte: "range <=",
|
||||
};
|
||||
return rangeOperator[queryType] ?? queryType;
|
||||
}
|
||||
|
||||
function resetDocumentFilterBuilder() {
|
||||
appliedDocumentFilter.value = null;
|
||||
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
|
||||
@@ -235,6 +286,12 @@ const documentQueryPreview = computed(() => {
|
||||
});
|
||||
|
||||
async function applyDocumentStructuredFilters() {
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||
appliedDocumentFilter.value = elasticsearchStructuredFilter(buildElasticsearchQueryFromRules(documentFilterRules.value));
|
||||
documentFilterBuilderOpen.value = false;
|
||||
applyFilter();
|
||||
return;
|
||||
}
|
||||
const items = documentFilterRules.value
|
||||
.map((rule) => ({
|
||||
rule,
|
||||
@@ -250,6 +307,15 @@ async function applyDocumentStructuredFilters() {
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
async function loadElasticsearchMappingFields() {
|
||||
if (documentStoreProvider.value.kind !== "elasticsearch") return;
|
||||
try {
|
||||
elasticsearchMappingFields.value = await api.getColumns(props.connectionId, props.database, "", props.collection);
|
||||
} catch {
|
||||
elasticsearchMappingFields.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) {
|
||||
appliedDocumentFilter.value = null;
|
||||
resetDocumentFilterBuilder();
|
||||
@@ -819,7 +885,10 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||||
}
|
||||
load();
|
||||
// Mapping metadata enriches the filter builder, but it must not delay the
|
||||
// first page of documents when the mapping endpoint is slow.
|
||||
void loadElasticsearchMappingFields();
|
||||
void load();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
||||
@@ -1014,7 +1083,7 @@ function resetTableSearchSplitWidth() {
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3">
|
||||
<PopoverContent align="start" class="max-w-[calc(100vw-32px)] gap-3 p-3" :class="documentStoreProvider.kind === 'elasticsearch' ? 'w-[680px]' : 'w-[360px]'">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addDocumentFilterRule">
|
||||
@@ -1056,7 +1125,7 @@ function resetTableSearchSplitWidth() {
|
||||
|
||||
<div v-if="documentFilterRules.length" class="space-y-2">
|
||||
<template v-for="(rule, index) in documentFilterRules" :key="rule.id">
|
||||
<div v-if="index > 0" class="flex justify-center">
|
||||
<div v-if="index > 0 && documentStoreProvider.kind !== 'elasticsearch'" class="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1070,19 +1139,44 @@ function resetTableSearchSplitWidth() {
|
||||
{{ rule.conjunction }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto] items-center gap-1.5">
|
||||
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
|
||||
<div class="grid items-center gap-1.5" :class="documentStoreProvider.kind === 'elasticsearch' ? 'grid-cols-[minmax(0,0.75fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto]'">
|
||||
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchClause || 'filter'" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchClause: value as ElasticsearchBoolClause })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue :placeholder="t('grid.filterBuilderColumn')" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
|
||||
{{ fieldName }}
|
||||
<SelectItem v-for="clause in elasticsearchBoolClauseOptions" :key="clause" :value="clause">
|
||||
{{ clause }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
|
||||
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue :placeholder="t('grid.filterBuilderColumn')"> {{ rule.fieldName }}{{ elasticsearchFieldTypes.get(rule.fieldName) ? ` (${elasticsearchFieldTypes.get(rule.fieldName)})` : "" }} </SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate">{{ fieldName }}</span>
|
||||
<span v-if="elasticsearchFieldTypes.get(fieldName)" class="shrink-0 text-[10px] text-muted-foreground">({{ elasticsearchFieldTypes.get(fieldName) }})</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchQueryType || elasticsearchRuleQueryTypes(rule)[0]" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchQueryType: value as ElasticsearchQueryType })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem v-for="queryType in elasticsearchRuleQueryTypes(rule)" :key="queryType" :value="queryType">
|
||||
{{ elasticsearchQueryTypeLabel(queryType) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-else :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -1094,7 +1188,7 @@ function resetTableSearchSplitWidth() {
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
v-if="documentFilterModeNeedsValue(rule.mode)"
|
||||
v-if="documentStoreProvider.kind === 'elasticsearch' ? elasticsearchQueryTypeNeedsValue(rule.elasticsearchQueryType) : documentFilterModeNeedsValue(rule.mode)"
|
||||
:model-value="rule.rawValue"
|
||||
class="h-8 min-w-0 text-xs"
|
||||
:placeholder="t('grid.filterBuilderValue')"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
|
||||
import { isMysqlExecutionErrorResult, usesMysqlProtocolDatabaseType } from "@/lib/query/queryResultError";
|
||||
import { classifyRedisCommandSafety, firstRedisCommandToken } from "@/lib/redis/redisCommandSafety";
|
||||
import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { isElasticsearchRestRequestText, parseElasticsearchRestRequestTarget, splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
|
||||
import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlParameterSyntax } from "@/lib/sql/sqlParameters";
|
||||
import { expandSqlVariables } from "@/lib/sql/sqlVariables";
|
||||
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
|
||||
@@ -28,7 +29,23 @@ export function stripSqlComments(sql: string): string {
|
||||
.replace(/#.*$/gm, " ");
|
||||
}
|
||||
|
||||
export function isDangerousSql(sql: string): boolean {
|
||||
const ELASTICSEARCH_TRANSIENT_DELETE_PATHS = [/^\/_search\/scroll\/?$/i, /^\/_pit\/?$/i, /^\/_async_search\/[^/?]+\/?$/i];
|
||||
const ELASTICSEARCH_DESTRUCTIVE_POST_PATHS = [/(?:^|\/)_(?:delete_by_query|update_by_query|bulk)(?:\/|$)/i, /^\/_reindex(?:\/|$)/i, /^\/_aliases(?:\/|$)/i, /\/_restore(?:\/|$)/i];
|
||||
|
||||
function isDangerousElasticsearchRequest(method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD", path: string): boolean {
|
||||
const pathname = path.split("?", 1)[0].replace(/\/+$/, "") || "/";
|
||||
if (method === "DELETE") return !ELASTICSEARCH_TRANSIENT_DELETE_PATHS.some((pattern) => pattern.test(pathname));
|
||||
if (method === "PUT") return true;
|
||||
return method === "POST" && ELASTICSEARCH_DESTRUCTIVE_POST_PATHS.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
|
||||
export function isDangerousSql(sql: string, databaseType?: DatabaseType): boolean {
|
||||
if (databaseType === "elasticsearch") {
|
||||
const requests = splitSqlStatementRanges(sql, databaseType)
|
||||
.map((statement) => parseElasticsearchRestRequestTarget(statement.sql))
|
||||
.filter((request): request is NonNullable<typeof request> => request !== null);
|
||||
if (requests.length > 0) return requests.some((request) => isDangerousElasticsearchRequest(request.method, request.path));
|
||||
}
|
||||
const cleaned = stripSqlComments(sql);
|
||||
return cleaned.split(";").some((stmt) => DANGER_RE.test(stmt));
|
||||
}
|
||||
@@ -102,7 +119,7 @@ export function useSqlExecution(deps: {
|
||||
deps.onMissingDatabase?.();
|
||||
return;
|
||||
}
|
||||
if (supportsSqlTemplateParameters(deps.activeConnection.value) && prepareSqlParameterDialog(sql, sourceOffset)) return;
|
||||
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset)) return;
|
||||
await continueExecute(sql, sourceOffset);
|
||||
}
|
||||
|
||||
@@ -134,7 +151,7 @@ export function useSqlExecution(deps: {
|
||||
if (confirmed) await doExecute(sql, sourceOffset);
|
||||
return;
|
||||
}
|
||||
if (isDangerousSql(sql) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
|
||||
if (isDangerousSql(sql, deps.activeConnection.value?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
|
||||
dangerSql.value = sql;
|
||||
pendingDangerSql.value = sql;
|
||||
pendingSourceOffset.value = sourceOffset;
|
||||
@@ -289,8 +306,9 @@ export function useSqlExecution(deps: {
|
||||
};
|
||||
}
|
||||
|
||||
function supportsSqlTemplateParameters(connection: ConnectionConfig | undefined): boolean {
|
||||
export function supportsSqlTemplateParameters(connection: Pick<ConnectionConfig, "db_type"> | undefined, sql = ""): boolean {
|
||||
if (!connection) return false;
|
||||
if (connection.db_type === "elasticsearch") return !isElasticsearchRestRequestText(sql);
|
||||
return connection.db_type !== "redis" && connection.db_type !== "mongodb";
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,54 @@ describe("splitSqlStatementRanges", () => {
|
||||
});
|
||||
|
||||
describe("statementRangeAtCursor", () => {
|
||||
it("splits Elasticsearch REST requests without semicolons", () => {
|
||||
const sql = `# node information
|
||||
GET /_nodes/stats/jvm?pretty
|
||||
|
||||
// search orders
|
||||
POST /orders/_search
|
||||
{
|
||||
"query": { "match_all": {} }
|
||||
}
|
||||
|
||||
HEAD /orders`;
|
||||
|
||||
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_nodes/stats/jvm?pretty", 'POST /orders/_search\n{\n "query": { "match_all": {} }\n}', "HEAD /orders"]);
|
||||
expect(hasMultipleExecutionTargets(sql, "elasticsearch")).toBe(true);
|
||||
});
|
||||
|
||||
it("targets the Elasticsearch request following a comment", () => {
|
||||
const sql = "# JVM statistics\nGET /_nodes/stats/jvm?pretty\n\nGET /_cluster/health";
|
||||
|
||||
expect(statementRangeAtCursor(sql, indexOf(sql, "JVM"), "elasticsearch")?.sql).toBe("GET /_nodes/stats/jvm?pretty");
|
||||
expect(statementRangeAtCursor(sql, indexOf(sql, "cluster"), "elasticsearch")?.sql).toBe("GET /_cluster/health");
|
||||
});
|
||||
|
||||
it("splits and targets Elasticsearch requests after block comments", () => {
|
||||
const sql = `/* node information
|
||||
including JVM details */
|
||||
GET /_nodes/stats/jvm?pretty
|
||||
|
||||
/* cluster information */
|
||||
GET /_cluster/health`;
|
||||
|
||||
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_nodes/stats/jvm?pretty", "GET /_cluster/health"]);
|
||||
expect(statementRangeAtCursor(sql, indexOf(sql, "JVM details"), "elasticsearch")?.sql).toBe("GET /_nodes/stats/jvm?pretty");
|
||||
expect(statementRangeAtCursor(sql, indexOf(sql, "cluster information"), "elasticsearch")?.sql).toBe("GET /_cluster/health");
|
||||
});
|
||||
|
||||
it("ignores request-looking lines inside Elasticsearch block comments", () => {
|
||||
const sql = `GET /_cluster/health
|
||||
|
||||
/* disabled cleanup
|
||||
DELETE /important-index
|
||||
*/
|
||||
GET /_cat/indices`;
|
||||
|
||||
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_cluster/health", "GET /_cat/indices"]);
|
||||
expect(statementRangeAtCursor(sql, indexOf(sql, "important-index"), "elasticsearch")?.sql).toBe("GET /_cat/indices");
|
||||
});
|
||||
|
||||
it("returns the first statement when the cursor is inside it", () => {
|
||||
const sql = "SELECT 1;\nSELECT 2;";
|
||||
const pos = indexOf(sql, "1");
|
||||
@@ -840,7 +888,7 @@ describe("hasMultipleExecutionTargets", () => {
|
||||
});
|
||||
|
||||
describe("supportsExecutionTargetPicker", () => {
|
||||
it("enables the picker for SQL database connections and Redis", () => {
|
||||
it("enables the picker for SQL database connections, Redis, and Elasticsearch", () => {
|
||||
expect(supportsExecutionTargetPicker("mysql")).toBe(true);
|
||||
expect(supportsExecutionTargetPicker("postgres")).toBe(true);
|
||||
expect(supportsExecutionTargetPicker("sqlserver")).toBe(true);
|
||||
@@ -848,7 +896,7 @@ describe("supportsExecutionTargetPicker", () => {
|
||||
expect(supportsExecutionTargetPicker("jdbc")).toBe(true);
|
||||
expect(supportsExecutionTargetPicker("redis")).toBe(true);
|
||||
expect(supportsExecutionTargetPicker("mongodb")).toBe(false);
|
||||
expect(supportsExecutionTargetPicker("elasticsearch")).toBe(false);
|
||||
expect(supportsExecutionTargetPicker("elasticsearch")).toBe(true);
|
||||
expect(supportsExecutionTargetPicker("qdrant")).toBe(false);
|
||||
expect(supportsExecutionTargetPicker("milvus")).toBe(false);
|
||||
expect(supportsExecutionTargetPicker("weaviate")).toBe(false);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
|
||||
|
||||
export type DocumentStoreKind = "mongodb" | "elasticsearch";
|
||||
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "less-than" | "is-null" | "is-not-null";
|
||||
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
|
||||
export type ElasticsearchQueryType = "term" | "terms" | "match" | "match_phrase" | "wildcard" | "range_gt" | "range_gte" | "range_lt" | "range_lte" | "exists";
|
||||
|
||||
export type DocumentFilterRule = {
|
||||
id: string;
|
||||
@@ -12,6 +14,8 @@ export type DocumentFilterRule = {
|
||||
mode: DocumentFilterMode;
|
||||
rawValue: string;
|
||||
conjunction: "AND" | "OR";
|
||||
elasticsearchClause?: ElasticsearchBoolClause;
|
||||
elasticsearchQueryType?: ElasticsearchQueryType;
|
||||
};
|
||||
|
||||
export type DocumentStoreQueryPreviewOptions = {
|
||||
@@ -42,6 +46,22 @@ export const documentFilterModeOptions: Array<{ value: DocumentFilterMode; label
|
||||
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
|
||||
];
|
||||
|
||||
export const elasticsearchBoolClauseOptions: ElasticsearchBoolClause[] = ["filter", "must", "should", "must_not"];
|
||||
|
||||
const ELASTICSEARCH_TEXT_QUERY_TYPES: ElasticsearchQueryType[] = ["match", "match_phrase", "term", "wildcard", "exists"];
|
||||
const ELASTICSEARCH_KEYWORD_QUERY_TYPES: ElasticsearchQueryType[] = ["term", "terms", "wildcard", "exists"];
|
||||
const ELASTICSEARCH_RANGE_QUERY_TYPES: ElasticsearchQueryType[] = ["term", "terms", "range_gt", "range_gte", "range_lt", "range_lte", "exists"];
|
||||
const ELASTICSEARCH_BOOLEAN_QUERY_TYPES: ElasticsearchQueryType[] = ["term", "exists"];
|
||||
|
||||
export function elasticsearchQueryTypeOptions(fieldType?: string): ElasticsearchQueryType[] {
|
||||
const normalized = fieldType?.trim().toLowerCase() ?? "";
|
||||
if (normalized === "text" || normalized === "search_as_you_type") return ELASTICSEARCH_TEXT_QUERY_TYPES;
|
||||
if (normalized === "keyword" || normalized === "constant_keyword" || normalized === "wildcard") return ELASTICSEARCH_KEYWORD_QUERY_TYPES;
|
||||
if (/^(?:byte|short|integer|long|unsigned_long|half_float|float|double|scaled_float|date|date_nanos|ip)$/.test(normalized)) return ELASTICSEARCH_RANGE_QUERY_TYPES;
|
||||
if (normalized === "boolean") return ELASTICSEARCH_BOOLEAN_QUERY_TYPES;
|
||||
return ["term", "terms", "match", "match_phrase", "wildcard", "range_gt", "range_gte", "range_lt", "range_lte", "exists"];
|
||||
}
|
||||
|
||||
const mongoDocumentProvider: DocumentStoreProvider = {
|
||||
kind: "mongodb",
|
||||
filterInputLabel: "find",
|
||||
@@ -90,6 +110,8 @@ export function defaultDocumentFilterRule(id: string, fieldName = ""): DocumentF
|
||||
mode: "equals",
|
||||
rawValue: "",
|
||||
conjunction: "AND",
|
||||
elasticsearchClause: "filter",
|
||||
elasticsearchQueryType: "term",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,6 +119,68 @@ export function documentFilterModeNeedsValue(mode: DocumentFilterMode): boolean
|
||||
return mode !== "is-null" && mode !== "is-not-null";
|
||||
}
|
||||
|
||||
export function elasticsearchQueryTypeNeedsValue(queryType: ElasticsearchQueryType | undefined): boolean {
|
||||
return queryType !== "exists";
|
||||
}
|
||||
|
||||
export function buildElasticsearchQueryFromRules(rules: readonly DocumentFilterRule[]): Record<string, unknown> | null {
|
||||
const boolQuery: Partial<Record<ElasticsearchBoolClause, Record<string, unknown>[]>> & { minimum_should_match?: number } = {};
|
||||
|
||||
for (const rule of rules) {
|
||||
const query = buildElasticsearchRuleQuery(rule);
|
||||
if (!query) continue;
|
||||
const clause = rule.elasticsearchClause ?? "filter";
|
||||
(boolQuery[clause] ??= []).push(query);
|
||||
}
|
||||
|
||||
if (Object.keys(boolQuery).length === 0) return null;
|
||||
if (boolQuery.should?.length) boolQuery.minimum_should_match = 1;
|
||||
return { bool: boolQuery };
|
||||
}
|
||||
|
||||
function buildElasticsearchRuleQuery(rule: DocumentFilterRule): Record<string, unknown> | null {
|
||||
const field = rule.fieldName.trim();
|
||||
const queryType = rule.elasticsearchQueryType ?? "term";
|
||||
if (!field || (elasticsearchQueryTypeNeedsValue(queryType) && !rule.rawValue.trim())) return null;
|
||||
|
||||
if (queryType === "exists") return { exists: { field } };
|
||||
const value = parseDocumentFilterValue(rule.rawValue, { kind: "elasticsearch" });
|
||||
switch (queryType) {
|
||||
case "term":
|
||||
return { term: { [field]: value } };
|
||||
case "terms": {
|
||||
const values = Array.isArray(value)
|
||||
? value
|
||||
: rule.rawValue
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => parseDocumentFilterValue(item, { kind: "elasticsearch" }));
|
||||
return values.length ? { terms: { [field]: values } } : null;
|
||||
}
|
||||
case "match":
|
||||
return { match: { [field]: value } };
|
||||
case "match_phrase":
|
||||
return { match_phrase: { [field]: value } };
|
||||
case "wildcard":
|
||||
// Keep the compact form compatible with every supported Elasticsearch 7.x release.
|
||||
// `case_insensitive` was only added in Elasticsearch 7.10.
|
||||
return { wildcard: { [field]: String(value) } };
|
||||
case "range_gt":
|
||||
return { range: { [field]: { gt: value } } };
|
||||
case "range_gte":
|
||||
return { range: { [field]: { gte: value } } };
|
||||
case "range_lt":
|
||||
return { range: { [field]: { lt: value } } };
|
||||
case "range_lte":
|
||||
return { range: { [field]: { lte: value } } };
|
||||
}
|
||||
}
|
||||
|
||||
export function elasticsearchStructuredFilter(query: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
return query ? { $esQuery: query } : null;
|
||||
}
|
||||
|
||||
type DocumentFilterParseOptions = {
|
||||
kind?: DocumentStoreKind;
|
||||
};
|
||||
@@ -298,6 +382,9 @@ function translateDocumentFilterToElasticsearchQuery(filter: Record<string, unkn
|
||||
} else if (key === "$or") {
|
||||
const should = translateLogicalArrayToElasticsearch("$or", value);
|
||||
if (should.length > 0) filters.push({ bool: { should, minimum_should_match: 1 } });
|
||||
} else if (key === "$esQuery") {
|
||||
if (!isPlainRecord(value)) throw new Error("$esQuery must be an object");
|
||||
filters.push(value);
|
||||
} else {
|
||||
filters.push(translateFieldFilterToElasticsearchQuery(key, value));
|
||||
}
|
||||
|
||||
@@ -22,14 +22,21 @@ export interface ElasticsearchCompletionInput {
|
||||
indices?: string[];
|
||||
}
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD"] as const;
|
||||
|
||||
const ROOT_ENDPOINTS = [
|
||||
{ label: "/_search", apply: "_search", method: "GET", detail: "Search all indices" },
|
||||
{ label: "/_cat/indices", apply: "_cat/indices?v", method: "GET", detail: "List indices" },
|
||||
{ label: "/_cluster/health", apply: "_cluster/health", method: "GET", detail: "Cluster health" },
|
||||
{ label: "/_cluster/stats", apply: "_cluster/stats?pretty", method: "GET", detail: "Cluster statistics" },
|
||||
{ label: "/_nodes/stats", apply: "_nodes/stats?pretty", method: "GET", detail: "Node statistics" },
|
||||
{ label: "/_cat/nodes", apply: "_cat/nodes?v", method: "GET", detail: "List nodes" },
|
||||
{ label: "/_cat/shards", apply: "_cat/shards?v", method: "GET", detail: "List shards" },
|
||||
{ label: "/_cat/thread_pool", apply: "_cat/thread_pool?v", method: "GET", detail: "Thread pools" },
|
||||
{ label: "/_aliases", apply: "_aliases", method: "GET", detail: "List aliases" },
|
||||
{ label: "/_bulk", apply: "_bulk\n", method: "POST", detail: "Bulk operations" },
|
||||
{ label: "/_msearch", apply: "_msearch\n", method: "POST", detail: "Multi search" },
|
||||
{ label: "/_tasks", apply: "_tasks?detailed=true", method: "GET", detail: "List tasks" },
|
||||
{ label: "/_count", apply: "_count", method: "GET", detail: "Count documents" },
|
||||
];
|
||||
|
||||
@@ -86,44 +93,47 @@ export function getElasticsearchCompletionContext(text: string, cursor: number):
|
||||
const firstLineEnd = text.indexOf("\n");
|
||||
const firstLineLimit = firstLineEnd >= 0 ? firstLineEnd : text.length;
|
||||
|
||||
// Check the current line before falling back to JSON mode so completion also
|
||||
// works after leading comments and on subsequent REST requests.
|
||||
const methodMatch = /^\s*([A-Za-z]*)$/.exec(beforeCursorOnLine);
|
||||
if (methodMatch) {
|
||||
const leadingWhitespace = beforeCursorOnLine.length - beforeCursorOnLine.trimStart().length;
|
||||
return {
|
||||
mode: "method",
|
||||
prefix: methodMatch[1] ?? "",
|
||||
from: lineStart + leadingWhitespace,
|
||||
};
|
||||
}
|
||||
|
||||
const commandMatch = /^\s*([A-Za-z]+)\s+(\S*)/.exec(text.slice(lineStart, currentLineEnd));
|
||||
if (commandMatch) {
|
||||
const method = commandMatch[1]?.toUpperCase();
|
||||
const path = commandMatch[2] ?? "";
|
||||
const pathStart = lineStart + commandMatch[0].length - path.length;
|
||||
const pathCursor = Math.max(0, safeCursor - pathStart);
|
||||
const boundedPathCursor = Math.min(pathCursor, path.length);
|
||||
const beforePathCursor = path.slice(0, boundedPathCursor);
|
||||
const segmentStartInPath = beforePathCursor.lastIndexOf("/") + 1;
|
||||
const prefix = beforePathCursor.slice(segmentStartInPath);
|
||||
const segmentIndex = beforePathCursor.slice(0, segmentStartInPath).split("/").filter(Boolean).length;
|
||||
|
||||
return {
|
||||
mode: "path",
|
||||
prefix,
|
||||
from: pathStart + segmentStartInPath,
|
||||
method,
|
||||
path,
|
||||
segmentIndex,
|
||||
};
|
||||
}
|
||||
|
||||
if (lineStart > 0 || safeCursor > firstLineLimit || looksLikeJsonBody(text, safeCursor)) {
|
||||
const jsonPrefix = readJsonPrefix(text, safeCursor);
|
||||
return { mode: "json", prefix: jsonPrefix.prefix, from: jsonPrefix.from };
|
||||
}
|
||||
|
||||
const methodMatch = /^([A-Za-z]*)$/.exec(beforeCursorOnLine);
|
||||
if (methodMatch) {
|
||||
return {
|
||||
mode: "method",
|
||||
prefix: methodMatch[1] ?? "",
|
||||
from: lineStart,
|
||||
};
|
||||
}
|
||||
|
||||
const commandMatch = /^([A-Za-z]+)\s+(\S*)/.exec(text.slice(lineStart, currentLineEnd));
|
||||
if (!commandMatch) {
|
||||
const prefix = readWordPrefix(text, safeCursor);
|
||||
return { mode: "method", prefix: prefix.prefix, from: prefix.from };
|
||||
}
|
||||
|
||||
const method = commandMatch[1]?.toUpperCase();
|
||||
const path = commandMatch[2] ?? "";
|
||||
const pathStart = lineStart + (commandMatch[0].indexOf(path) >= 0 ? commandMatch[0].indexOf(path) : 0);
|
||||
const pathCursor = Math.max(0, safeCursor - pathStart);
|
||||
const boundedPathCursor = Math.min(pathCursor, path.length);
|
||||
const beforePathCursor = path.slice(0, boundedPathCursor);
|
||||
const segmentStartInPath = beforePathCursor.lastIndexOf("/") + 1;
|
||||
const prefix = beforePathCursor.slice(segmentStartInPath);
|
||||
const segmentIndex = beforePathCursor.slice(0, segmentStartInPath).split("/").filter(Boolean).length;
|
||||
|
||||
return {
|
||||
mode: "path",
|
||||
prefix,
|
||||
from: pathStart + segmentStartInPath,
|
||||
method,
|
||||
path,
|
||||
segmentIndex,
|
||||
};
|
||||
const prefix = readWordPrefix(text, safeCursor);
|
||||
return { mode: "method", prefix: prefix.prefix, from: prefix.from };
|
||||
}
|
||||
|
||||
export function buildElasticsearchCompletionItems(text: string, cursor: number, input: ElasticsearchCompletionInput = {}): ElasticsearchCompletionItem[] {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import { stripLeadingElasticsearchComments } from "@/lib/sql/sqlStatementRanges";
|
||||
|
||||
export interface ElasticsearchJsonResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE)\s+\S+/i;
|
||||
const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
|
||||
|
||||
/**
|
||||
* Detect the result shape emitted for a JSON response to an explicit
|
||||
@@ -14,7 +15,7 @@ const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE)\s+\S+/i;
|
||||
*/
|
||||
export function elasticsearchJsonResponseForResult(databaseType: DatabaseType | undefined, sourceStatement: string | undefined, result: QueryResult | undefined): ElasticsearchJsonResponse | undefined {
|
||||
if (databaseType !== "elasticsearch" || !result || typeof sourceStatement !== "string") return undefined;
|
||||
if (!ELASTICSEARCH_REST_STATEMENT.test(sourceStatement.trim())) return undefined;
|
||||
if (!ELASTICSEARCH_REST_STATEMENT.test(stripLeadingElasticsearchComments(sourceStatement))) return undefined;
|
||||
if (result.columns.length !== 2 || result.columns[0] !== "status" || result.columns[1] !== "response" || result.rows.length !== 1) return undefined;
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user