feat(editor): add SQL execution target picker with preview decoration

When the user triggers SQL execution via shortcut/toolbar/context-menu
without a manual selection, a floating picker now surfaces the available
execution targets:

- "Current SQL" — the statement at the cursor
- "All SQL"   — the entire editor content

Each candidate is highlighted as a preview decoration in the editor as
the user navigates the picker with arrow keys. Only a single entry is
shown when the cursor statement equals the full document.

前端 SQL 范围解析器 (sqlStatementRanges.ts) 按分号拆分语句,同时跳过
字符串、引号标识符、注释和美元引用体中的分隔符,与后端 dbx-core splitter
逻辑一致。选取器支持键盘导航、鼠标悬停高亮、Enter 确认、Esc/失焦取消。

Changes:
- sqlExecutionTarget.ts: add SqlExecutionCandidate, SqlExecutionOverride types
- sqlStatementRanges.ts: frontend SQL tokeniser (new, 848 lines)
- SqlExecutionTargetPicker.vue: accessible floating listbox (new, 109 lines)
- QueryEditor.vue: preview StateEffect/StateField; requestExecute() replaces
  executeCurrentSql() to route through picker
- ContentArea.vue: expose requestQueryEditorExecute() to parent
- App.vue: toolbar + global shortcut → requestActiveEditorExecute()
- i18n: editor.executionPicker keys for all locales
- tests: 26 new unit tests for sqlStatementRanges (all 1121 tests pass)
This commit is contained in:
uicky 2026-06-20 22:28:02 +08:00 committed by GitHub
parent 4590057456
commit 4d2afcad90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1468 additions and 10 deletions

View File

@ -205,6 +205,11 @@ const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tr
blockDangerousRedisCommands,
});
function requestActiveEditorExecute() {
if (contentAreaRef.value?.requestQueryEditorExecute?.()) return;
void tryExecute();
}
const dialogs = useDialogSources();
const { getDatabaseOptions } = useDatabaseOptions();
const { openLineageTarget, openDatabaseSearchTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs);
@ -1117,7 +1122,7 @@ function handleKeydown(e: KeyboardEvent) {
if (activeTab.value?.mode === "query" && isExecuteSqlShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]")) {
e.preventDefault();
e.stopPropagation();
tryExecute();
requestActiveEditorExecute();
return;
}
if (isModRShortcut(e) && e.target instanceof Element && contentAreaRef.value?.handleModRTarget(e.target)) {
@ -1378,7 +1383,7 @@ onUnmounted(() => {
:sql-keyword-case="settingsStore.editorSettings.sqlFormatter.keywordCase"
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
@update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)"
@execute="tryExecute($event)"
@execute="requestActiveEditorExecute()"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@format-sql="formatActiveSql"

View File

@ -6,9 +6,11 @@ import type { CompletionContext } from "@codemirror/autocomplete";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { search as cmSearch } from "@codemirror/search";
import EditorSearchPanel from "./EditorSearchPanel.vue";
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { copyToClipboard } from "@/lib/clipboard";
import { resolveExecutableSql, type SqlExecutionSnapshot } from "@/lib/sqlExecutionTarget";
import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sqlExecutionTarget";
import { buildExecutionCandidates, supportsExecutionTargetPicker } from "@/lib/sqlStatementRanges";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
import { formatMongoShellText } from "@/lib/mongoFormatter";
import { useConnectionStore } from "@/stores/connectionStore";
@ -66,7 +68,7 @@ const emit = defineEmits<{
selectionChange: [value: string];
cursorChange: [pos: number];
formatError: [message: string];
execute: [snapshot: SqlExecutionSnapshot];
execute: [source: SqlExecutionOverride];
save: [];
clickTable: [tableName: string];
viewTableData: [tableName: string];
@ -150,6 +152,13 @@ const contextTableName = ref<string | null>(null);
const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0);
const canCopySelectedSql = computed(() => selectedSql.value.length > 0);
const canExecuteContextSql = computed(() => executableSql.value.trim().length > 0);
// Execution target picker state
const pickerVisible = ref(false);
const pickerCandidates = ref<SqlExecutionCandidate[]>([]);
const pickerActiveIndex = ref(0);
const pickerAnchor = ref<{ left: number; top: number }>();
const executeContextMenuLabel = computed(() => t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"));
interface EditorGestureEvent extends Event {
@ -184,6 +193,9 @@ let codeMirrorRedo: typeof import("@codemirror/commands").redo | null = null;
let codeMirrorSelectAll: typeof import("@codemirror/commands").selectAll | null = null;
let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null;
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
let setPreviewRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
let semanticDiagnostics: SqlSemanticDiagnostic[] = [];
let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | null = null;
let semanticDiagnosticRunId = 0;
@ -289,11 +301,77 @@ function handleTab(view: EditorViewType): boolean {
return true;
}
function executeCurrentSql() {
if (view.value) emit("execute", sqlExecutionSnapshotFromView(view.value));
function requestExecute() {
const currentView = view.value;
if (!currentView) return false;
const selection = currentView.state.selection.main;
if (!selection.empty) {
// Has manual selection execute directly, skip picker.
emit("execute", sqlExecutionSnapshotFromView(currentView));
return true;
}
if (!supportsExecutionTargetPicker(props.databaseType)) {
emit("execute", sqlExecutionSnapshotFromView(currentView));
return true;
}
// No selection show the execution target picker.
const doc = currentView.state.doc.toString();
const cursorPos = selection.head;
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType);
if (candidates.length === 0) return false;
closePicker();
pickerCandidates.value = candidates;
pickerActiveIndex.value = 0;
pickerAnchor.value = executionPickerAnchor(currentView, cursorPos, candidates.length);
pickerVisible.value = true;
setPreviewRange({ from: candidates[0].from, to: candidates[0].to });
return true;
}
function executionPickerAnchor(currentView: EditorViewType, cursorPos: number, candidateCount: number): { left: number; top: number } | undefined {
const cursorRect = currentView.coordsAtPos(cursorPos);
const rootRect = editorRef.value?.getBoundingClientRect();
if (!cursorRect || !rootRect) return undefined;
const verticalGap = 8;
const pickerHeight = 40 + Math.max(1, candidateCount) * 36;
const verticalMargin = 12;
const left = rootRect.width / 2;
const cursorBottom = cursorRect.bottom - rootRect.top;
const maxTop = Math.max(verticalMargin, rootRect.height - pickerHeight - verticalMargin);
const top = Math.min(cursorBottom + verticalGap, maxTop);
return { left, top };
}
function setPreviewRange(range: { from: number; to: number } | null) {
if (!view.value || !setPreviewRangeEffect) return;
view.value.dispatch({
effects: setPreviewRangeEffect.of(range),
});
}
function onPickerActiveIndexChange(index: number) {
pickerActiveIndex.value = index;
const candidate = pickerCandidates.value[index];
if (candidate) {
setPreviewRange({ from: candidate.from, to: candidate.to });
}
}
function onPickerConfirm(candidate: SqlExecutionCandidate) {
closePicker();
emit("execute", candidate.sql);
}
function closePicker() {
pickerVisible.value = false;
pickerAnchor.value = undefined;
setPreviewRange(null);
// Restore focus to the CodeMirror editor.
view.value?.focus();
}
function syncContextMenuState(currentView: EditorViewType) {
selectedSql.value = selectedSqlFromView(currentView);
executableSql.value = executableSqlFromView(currentView);
@ -338,7 +416,7 @@ function clearTableNavigationHoverOnModifierRelease(event: KeyboardEvent) {
function executeFromContextMenu() {
if (!canExecuteContextSql.value) return;
executeCurrentSql();
requestExecute();
focusEditor();
}
@ -418,7 +496,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
},
...binding(shortcuts.find, openSearch),
...binding(shortcuts.replace, openReplace),
...binding(shortcuts.executeSql, executeCurrentSql),
...binding(shortcuts.executeSql, requestExecute),
...binding(shortcuts.saveSql, () => {
emit("save");
return true;
@ -1607,6 +1685,7 @@ onMounted(async () => {
runKeymapComp = new Compartment();
completionComp = new Compartment();
diagnosticComp = new Compartment();
previewRangeComp = new Compartment();
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
codeMirrorCompletionStatus = completionStatus;
codeMirrorAcceptCompletion = acceptCompletion;
@ -1666,6 +1745,28 @@ onMounted(async () => {
return [field, diagnosticTheme];
};
setPreviewRangeEffect = StateEffect.define<{ from: number; to: number } | null>();
buildPreviewRangeExtension = () => {
const effectType = setPreviewRangeEffect!;
const field = StateField.define({
create() {
return Decoration.none;
},
update(decorations, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) {
const range = effect.value;
if (!range) return Decoration.none;
return Decoration.set([Decoration.mark({ class: "cm-db-execution-preview" }).range(range.from, range.to)]);
}
}
return decorations;
},
provide: (f) => EditorView.decorations.from(f),
});
return field;
};
buildSqlSignatureExtension = () =>
showTooltip.compute(["doc", "selection"], (currentState) => {
const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head);
@ -1781,6 +1882,7 @@ onMounted(async () => {
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
buildSqlSignatureExtension(),
diagnosticComp.of(buildSqlDiagnosticExtension()),
previewRangeComp.of(buildPreviewRangeExtension()),
Prec.highest(
keymap.of([
...closeBracketsKeymap,
@ -2277,7 +2379,7 @@ function scrollCursorIntoView() {
});
}
defineExpose({ openSearch, openReplace, scrollCursorIntoView });
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute });
</script>
<template>
@ -2296,6 +2398,7 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView });
/>
</CustomContextMenu>
<EditorSearchPanel ref="searchPanelRef" :view="view" />
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
</div>
</template>
@ -2305,6 +2408,10 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView });
cursor: pointer;
}
:deep(.cm-db-execution-preview) {
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35));
}
:deep(.cm-foldMarker-svg) {
display: inline-flex;
align-items: center;

View File

@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { SqlExecutionCandidate } from "@/lib/sqlExecutionTarget";
import { useI18n } from "vue-i18n";
const props = defineProps<{
candidates: SqlExecutionCandidate[];
activeIndex: number;
anchor?: { left: number; top: number };
}>();
const emit = defineEmits<{
"update:activeIndex": [index: number];
confirm: [candidate: SqlExecutionCandidate];
cancel: [];
}>();
const { t } = useI18n();
const listboxRef = ref<HTMLDivElement>();
const optionRefs = ref<HTMLElement[]>([]);
const title = computed(() => t("editor.executionPicker.title"));
const pickerStyle = computed(() => {
if (!props.anchor) return undefined;
return {
left: `${props.anchor.left}px`,
top: `${props.anchor.top}px`,
transform: "translate(-50%, 0)",
};
});
function displayLabel(candidate: SqlExecutionCandidate) {
return t(`editor.executionPicker.${candidate.label}`);
}
function optionId(index: number) {
return `dbx-exec-target-${index}`;
}
watch(
() => props.activeIndex,
(index) => {
void nextTick(() => {
optionRefs.value[index]?.scrollIntoView({ block: "nearest" });
});
},
);
function setActiveIndex(index: number) {
emit("update:activeIndex", Math.max(0, Math.min(index, props.candidates.length - 1)));
}
function confirmCurrent() {
const candidate = props.candidates[props.activeIndex];
if (candidate) emit("confirm", candidate);
}
function onKeydown(event: KeyboardEvent) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setActiveIndex(props.activeIndex + 1);
break;
case "ArrowUp":
event.preventDefault();
setActiveIndex(props.activeIndex - 1);
break;
case "Enter":
event.preventDefault();
confirmCurrent();
break;
case "Escape":
event.preventDefault();
emit("cancel");
break;
}
}
function setOptionRef(index: number, el: Element | null) {
if (el instanceof HTMLElement) optionRefs.value[index] = el;
}
onMounted(() => {
listboxRef.value?.focus();
});
onBeforeUnmount(() => {
emit("cancel");
});
</script>
<template>
<div ref="listboxRef" role="listbox" tabindex="-1" class="sql-execution-picker absolute z-[9998] w-[520px] max-w-[calc(100vw-32px)] flex flex-col rounded-md border bg-popover shadow-lg outline-none" :style="pickerStyle" @keydown="onKeydown" @blur="emit('cancel')">
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ title }}</div>
</div>
<div class="flex flex-col py-1">
<div
v-for="(candidate, index) in candidates"
:id="optionId(index)"
:key="candidate.kind"
role="option"
:ref="(el: any) => setOptionRef(index, el as Element | null)"
:aria-selected="index === activeIndex"
class="flex items-center gap-2 mx-1 px-2 py-1.5 rounded text-sm cursor-pointer select-none"
:class="index === activeIndex ? 'bg-primary text-primary-foreground' : 'hover:bg-accent hover:text-accent-foreground'"
@mousemove="setActiveIndex(index)"
@click="emit('confirm', candidate)"
>
<span class="shrink-0 text-xs font-medium px-1.5 py-0.5 rounded border" :class="index === activeIndex ? 'border-primary-foreground/40 bg-primary-foreground/10' : 'border-border bg-muted/50'">
{{ displayLabel(candidate) }}
</span>
<span class="truncate min-w-0 font-mono text-xs opacity-80">{{ candidate.sql }}</span>
</div>
</div>
</div>
</template>

View File

@ -517,7 +517,11 @@ function handleModRTarget(target: Element): boolean {
return false;
}
defineExpose({ focusSearch, refreshData, handleModRTarget });
function requestQueryEditorExecute() {
return queryEditorRef.value?.requestExecute();
}
defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExecute });
</script>
<template>

View File

@ -434,6 +434,13 @@ export default {
close: "Close (Esc)",
noResults: "No results",
},
executionPicker: {
title: "Execution Target",
currentStatement: "Current SQL",
allStatements: "All SQL",
currentCommand: "Current command",
allCommands: "All commands",
},
},
tabs: {
sql: "SQL",

View File

@ -408,6 +408,13 @@ export default {
close: "Cerrar (Esc)",
noResults: "Sin resultados",
},
executionPicker: {
title: "Execution Target",
currentStatement: "Current SQL",
allStatements: "All SQL",
currentCommand: "Current command",
allCommands: "All commands",
},
},
tabs: {
sql: "SQL",

View File

@ -413,6 +413,13 @@ export default {
close: "Chiudi (Esc)",
noResults: "Nessun risultato",
},
executionPicker: {
title: "Execution Target",
currentStatement: "Current SQL",
allStatements: "All SQL",
currentCommand: "Current command",
allCommands: "All commands",
},
},
tabs: {
sql: "SQL",

View File

@ -433,6 +433,13 @@ export default {
close: "閉じる (Esc)",
noResults: "結果なし",
},
executionPicker: {
title: "Execution Target",
currentStatement: "Current SQL",
allStatements: "All SQL",
currentCommand: "Current command",
allCommands: "All commands",
},
},
tabs: {
sql: "SQL",

View File

@ -413,6 +413,13 @@ export default {
close: "Fechar (Esc)",
noResults: "Nenhum resultado",
},
executionPicker: {
title: "Execution Target",
currentStatement: "Current SQL",
allStatements: "All SQL",
currentCommand: "Current command",
allCommands: "All commands",
},
},
tabs: {
sql: "SQL",

View File

@ -435,6 +435,13 @@ export default {
close: "关闭 (Esc)",
noResults: "无结果",
},
executionPicker: {
title: "执行目标",
currentStatement: "当前 SQL",
allStatements: "全部 SQL",
currentCommand: "当前命令",
allCommands: "全部命令",
},
},
tabs: {
sql: "SQL",

View File

@ -413,6 +413,13 @@ export default {
close: "關閉 (Esc)",
noResults: "無結果",
},
executionPicker: {
title: "執行目標",
currentStatement: "當前 SQL",
allStatements: "全部 SQL",
currentCommand: "目前命令",
allCommands: "全部命令",
},
},
tabs: {
sql: "SQL",

View File

@ -0,0 +1,305 @@
import { describe, expect, it } from "vitest";
import { buildExecutionCandidates, fullSqlRange, splitSqlStatementRanges, statementRangeAtCursor, supportsExecutionTargetPicker } from "../sqlStatementRanges";
function indexOf(sql: string, needle: string, occurrence = 1): number {
let from = 0;
let idx = -1;
for (let i = 0; i < occurrence; i += 1) {
idx = sql.indexOf(needle, from);
if (idx === -1) return -1;
from = idx + needle.length;
}
return idx;
}
function rangeSqlTexts(ranges: Array<{ sql: string }>): string[] {
return ranges.map((range) => range.sql.trim());
}
function candidateKinds(candidates: Array<{ kind: string }>): string[] {
return candidates.map((candidate) => candidate.kind);
}
function candidateLabels(candidates: Array<{ label: string }>): string[] {
return candidates.map((candidate) => candidate.label);
}
function candidateSummaries(candidates: Array<{ kind: string; sql: string }>): string[] {
return candidates.map((candidate) => `${candidate.kind}:${candidate.sql.trim()}`);
}
describe("splitSqlStatementRanges", () => {
it("splits multiple top-level statements", () => {
const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 1", "SELECT 2", "SELECT 3"]);
});
it("keeps a trailing statement without a semicolon", () => {
const sql = "SELECT 1;\nSELECT 2";
const ranges = splitSqlStatementRanges(sql);
expect(rangeSqlTexts(ranges)).toEqual(["SELECT 1", "SELECT 2"]);
});
it("ignores semicolons inside single-quoted strings", () => {
const sql = "INSERT INTO t VALUES ('a;b;c');\nSELECT 1";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["INSERT INTO t VALUES ('a;b;c')", "SELECT 1"]);
});
it("handles doubled single quotes as escaped quotes", () => {
const sql = "SELECT 'it''s; ok';\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 'it''s; ok'", "SELECT 2"]);
});
it("ignores semicolons inside double-quoted identifiers", () => {
const sql = 'SELECT "a;b";\nSELECT 2';
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(['SELECT "a;b"', "SELECT 2"]);
});
it("ignores semicolons inside backtick identifiers (MySQL)", () => {
const sql = "SELECT `a;b`;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT `a;b`", "SELECT 2"]);
});
it("ignores semicolons inside bracket identifiers (SQL Server)", () => {
const sql = "SELECT [a;b];\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT [a;b]", "SELECT 2"]);
});
it("ignores semicolons in line comments", () => {
const sql = "SELECT 1 -- a; b\n;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 1", "SELECT 2"]);
});
it("ignores semicolons in hash line comments", () => {
const sql = "SELECT 1 # a; b\n;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 1", "SELECT 2"]);
});
it("ignores semicolons in block comments", () => {
const sql = "SELECT /* a; b */ 1;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT /* a; b */ 1", "SELECT 2"]);
});
it("handles Postgres dollar quoting", () => {
const sql = "SELECT $$ a; b $$;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT $$ a; b $$", "SELECT 2"]);
});
});
describe("statementRangeAtCursor", () => {
it("returns the first statement when the cursor is inside it", () => {
const sql = "SELECT 1;\nSELECT 2;";
const pos = indexOf(sql, "1");
const range = statementRangeAtCursor(sql, pos);
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("returns the second statement when the cursor is inside it", () => {
const sql = "SELECT 1;\nSELECT 2;";
const pos = indexOf(sql, "2");
const range = statementRangeAtCursor(sql, pos);
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("returns the statement when the cursor is in indentation before it", () => {
const sql = "SELECT 1;\n SELECT 2;";
const indentationPos = sql.indexOf(" SELECT 2") + 2;
const range = statementRangeAtCursor(sql, indentationPos);
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("returns the next same-line statement when the cursor is in whitespace before it", () => {
const sql = "SELECT 1; SELECT 2;";
const gapPos = sql.indexOf(";") + 2;
const range = statementRangeAtCursor(sql, gapPos);
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("returns a statement even without a trailing semicolon", () => {
const sql = "SELECT 1";
const pos = indexOf(sql, "1");
const range = statementRangeAtCursor(sql, pos);
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("stops at the next top-level statement start when the cursor statement has no semicolon", () => {
const sql = "SELECT 1\nSELECT 2;\nSELECT 3;";
const range = statementRangeAtCursor(sql, indexOf(sql, "1"));
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("returns the later top-level statement when earlier statements are missing semicolons", () => {
const sql = "SELECT 1\nSELECT 2;\nSELECT 3;";
const range = statementRangeAtCursor(sql, indexOf(sql, "2"));
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("keeps a multi-line select together when continuation lines do not start statements", () => {
const sql = "SELECT id,\n name\nFROM users\nWHERE active = 1\nSELECT * FROM logs;";
const range = statementRangeAtCursor(sql, indexOf(sql, "name"));
expect(range?.sql.trim()).toBe("SELECT id,\n name\nFROM users\nWHERE active = 1");
});
it("keeps a CTE main query with its WITH statement", () => {
const sql = "WITH active_users AS (\n SELECT * FROM users\n)\nSELECT * FROM active_users\nSELECT * FROM logs;";
const range = statementRangeAtCursor(sql, indexOf(sql, "active_users", 2));
expect(range?.sql.trim()).toBe("WITH active_users AS (\n SELECT * FROM users\n)\nSELECT * FROM active_users");
});
it("keeps update assignments with the UPDATE statement", () => {
const sql = "UPDATE users\nSET name = 'a'\nWHERE id = 1\nSELECT * FROM users;";
const range = statementRangeAtCursor(sql, indexOf(sql, "name"));
expect(range?.sql.trim()).toBe("UPDATE users\nSET name = 'a'\nWHERE id = 1");
});
it("keeps insert-select with the INSERT statement", () => {
const sql = "INSERT INTO archived_users (id, name)\nSELECT id, name FROM users\nUPDATE users SET archived = 1;";
const range = statementRangeAtCursor(sql, indexOf(sql, "archived_users"));
expect(range?.sql.trim()).toBe("INSERT INTO archived_users (id, name)\nSELECT id, name FROM users");
});
it("keeps explain target SQL with the EXPLAIN statement", () => {
const sql = "EXPLAIN\nSELECT * FROM users\nSELECT * FROM logs;";
const range = statementRangeAtCursor(sql, indexOf(sql, "EXPLAIN"));
expect(range?.sql.trim()).toBe("EXPLAIN\nSELECT * FROM users");
});
it("does not include comments between soft statement blocks", () => {
const sql = "SELECT 1\n-- explain the next query\n/* still next query notes */\nSELECT 2;";
const range = statementRangeAtCursor(sql, indexOf(sql, "1"));
expect(range?.sql.trim()).toBe("SELECT 1");
});
it("detects a soft statement start after a leading block comment on the same line", () => {
const sql = "SELECT 1\n/* next */ SELECT 2;";
const range = statementRangeAtCursor(sql, indexOf(sql, "2"));
expect(range?.sql.trim()).toBe("SELECT 2");
});
it("uses database-specific soft statement keywords", () => {
const sql = "SELECT 1\nDO $$ BEGIN RAISE NOTICE 'x'; END $$;";
expect(statementRangeAtCursor(sql, indexOf(sql, "1"))?.sql.trim()).toBe("SELECT 1\nDO $$ BEGIN RAISE NOTICE 'x'; END $$");
expect(statementRangeAtCursor(sql, indexOf(sql, "1"), "postgres")?.sql.trim()).toBe("SELECT 1");
expect(statementRangeAtCursor(sql, indexOf(sql, "DO"), "postgres")?.sql.trim()).toBe("DO $$ BEGIN RAISE NOTICE 'x'; END $$");
});
it("returns null when the cursor is on a blank line", () => {
const sql = "SELECT 1;\n\nSELECT 2;";
const blankLinePos = sql.indexOf("\n") + 1;
expect(statementRangeAtCursor(sql, blankLinePos)).toBeNull();
});
it("returns null for an empty document", () => {
expect(statementRangeAtCursor("", 0)).toBeNull();
});
it("does not treat comment semicolons as delimiters", () => {
const sql = "SELECT 1; -- drop; this\nSELECT 2;";
const pos = indexOf(sql, "2");
expect(statementRangeAtCursor(sql, pos)?.sql.trim()).toBe("SELECT 2");
});
it("exposes offsets aligned to the statement body", () => {
const sql = " SELECT 1;\nSELECT 2;";
const range = statementRangeAtCursor(sql, indexOf(sql, "1"));
expect(range?.from).toBe(2);
expect(range?.sql).toBe("SELECT 1");
});
});
describe("fullSqlRange", () => {
it("returns the trimmed full document", () => {
const sql = " SELECT 1; \n";
const range = fullSqlRange(sql);
expect(range?.sql).toBe("SELECT 1;");
});
it("returns null for an empty/whitespace document", () => {
expect(fullSqlRange(" \n ")).toBeNull();
});
});
describe("buildExecutionCandidates", () => {
it("returns a single candidate when only the cursor statement exists", () => {
const sql = "SELECT 1";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "1"));
expect(candidates).toHaveLength(1);
expect(candidates[0].kind).toBe("all");
});
it("returns current + all in order for multiple statements", () => {
const sql = "SELECT 1;\nSELECT 2;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "2"));
expect(candidateKinds(candidates)).toEqual(["cursor", "all"]);
});
it("uses only the cursor SQL for the first candidate when it is missing a semicolon", () => {
const sql = "SELECT 1\nSELECT 2;\nSELECT 3;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "1"));
expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 1", "all:SELECT 1\nSELECT 2;\nSELECT 3;"]);
});
it("uses the current command line for Redis cursor candidates", () => {
const sql = "GET user:1\nDEL user:2\nHGETALL user:3";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "user:2"), "redis");
expect(candidateSummaries(candidates)).toEqual(["cursor:DEL user:2", "all:GET user:1\nDEL user:2\nHGETALL user:3"]);
expect(candidateLabels(candidates)).toEqual(["currentCommand", "allCommands"]);
});
it("returns only all for Redis when the cursor is on a comment line", () => {
const sql = "GET user:1\n# comment\nDEL user:2";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "comment"), "redis");
expect(candidateSummaries(candidates)).toEqual(["all:GET user:1\n# comment\nDEL user:2"]);
});
it("returns current + all when the cursor is in indentation before a statement", () => {
const sql = "SELECT 1;\n SELECT 2;";
const indentationPos = sql.indexOf(" SELECT 2") + 2;
const candidates = buildExecutionCandidates(sql, indentationPos);
expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 2", "all:SELECT 1;\n SELECT 2;"]);
expect(candidateLabels(candidates)).toEqual(["currentStatement", "allStatements"]);
});
it("dedupes when the cursor statement equals the full document", () => {
const sql = "SELECT 1;";
const candidates = buildExecutionCandidates(sql, indexOf(sql, "1"));
expect(candidates).toHaveLength(1);
expect(candidates[0].kind).toBe("all");
});
it("returns only 'all' when the cursor is on a blank line", () => {
const sql = "SELECT 1;\n\nSELECT 2;";
const candidates = buildExecutionCandidates(sql, sql.indexOf("\n") + 1);
expect(candidateKinds(candidates)).toEqual(["all"]);
});
it("returns no candidates for an empty document", () => {
expect(buildExecutionCandidates("", 0)).toEqual([]);
});
it("returns only 'all' when the cursor has no statement but the document has SQL", () => {
// Cursor past the end on a trailing blank line.
const sql = "SELECT 1;\nSELECT 2;\n";
const candidates = buildExecutionCandidates(sql, sql.length);
expect(candidateKinds(candidates)).toEqual(["all"]);
});
});
describe("supportsExecutionTargetPicker", () => {
it("enables the picker for SQL database connections and Redis", () => {
expect(supportsExecutionTargetPicker("mysql")).toBe(true);
expect(supportsExecutionTargetPicker("postgres")).toBe(true);
expect(supportsExecutionTargetPicker("sqlserver")).toBe(true);
expect(supportsExecutionTargetPicker("sqlite")).toBe(true);
expect(supportsExecutionTargetPicker("jdbc")).toBe(true);
expect(supportsExecutionTargetPicker("redis")).toBe(true);
expect(supportsExecutionTargetPicker("mongodb")).toBe(false);
expect(supportsExecutionTargetPicker("elasticsearch")).toBe(false);
expect(supportsExecutionTargetPicker("qdrant")).toBe(false);
expect(supportsExecutionTargetPicker("milvus")).toBe(false);
expect(supportsExecutionTargetPicker("etcd")).toBe(false);
expect(supportsExecutionTargetPicker("mq")).toBe(false);
expect(supportsExecutionTargetPicker("neo4j")).toBe(false);
expect(supportsExecutionTargetPicker(undefined)).toBe(false);
});
});

View File

@ -11,6 +11,28 @@ export interface SqlExecutionSnapshot {
export type SqlExecutionOverride = string | SqlExecutionSnapshot;
export type SqlExecutionTargetKind = "cursor" | "all";
/**
* A candidate execution target surfaced by the execution target picker.
* `from`/`to` are offsets into the full document so the editor can highlight
* the corresponding range as a preview while the picker is open.
*/
export interface SqlExecutionCandidate {
kind: SqlExecutionTargetKind;
label: string;
sql: string;
from: number;
to: number;
}
export interface SqlExecutionChoiceRequest {
fullSql: string;
selectedSql: string;
cursorPos: number;
candidates: SqlExecutionCandidate[];
}
export function isSqlExecutionSnapshot(value: SqlExecutionOverride | undefined): value is SqlExecutionSnapshot {
return typeof value === "object" && value !== null && typeof value.fullSql === "string" && typeof value.selectedSql === "string" && typeof value.cursorPos === "number";
}

View File

@ -0,0 +1,848 @@
import type { SqlExecutionCandidate } from "./sqlExecutionTarget";
import type { DatabaseType } from "@/types/database";
/**
* A contiguous range of SQL text expressed as document offsets plus the
* extracted (original) substring.
*/
export interface SqlTextRange {
from: number;
to: number;
sql: string;
}
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "qdrant", "milvus", "etcd", "mq", "neo4j"]);
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
return !!databaseType && (databaseType === "redis" || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
}
interface RawStatement {
/** Start offset (inclusive) of whitespace that can still target this statement. */
hitFrom: number;
/** Start offset (inclusive) of the statement's first non-whitespace char. */
from: number;
/** End offset (exclusive) — up to and excluding the terminating semicolon. */
to: number;
/** The statement text, sliced from the source document. */
sql: string;
}
type QuoteState = "none" | "single" | "double" | "backtick" | "bracket" | "dollar";
const COMMON_SOFT_STATEMENT_START_KEYWORDS = [
"SELECT",
"WITH",
"CREATE",
"ALTER",
"DROP",
"INSERT",
"UPDATE",
"DELETE",
"MERGE",
"REPLACE",
"TRUNCATE",
"GRANT",
"REVOKE",
"COMMENT",
"EXPLAIN",
"SHOW",
"DESCRIBE",
"DESC",
"USE",
"SET",
"CALL",
"EXEC",
"EXECUTE",
"BEGIN",
"COMMIT",
"ROLLBACK",
"DECLARE",
"ANALYZE",
"VACUUM",
"PRAGMA",
"REFRESH",
"COPY",
] as const;
const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial<Record<DatabaseType, readonly string[]>> = {
mysql: ["HANDLER", "LOAD", "OPTIMIZE", "REPAIR"],
postgres: ["DO", "LISTEN", "NOTIFY", "UNLISTEN"],
sqlite: ["ATTACH", "DETACH", "REINDEX"],
duckdb: ["ATTACH", "DETACH", "EXPORT", "IMPORT", "INSTALL", "LOAD"],
clickhouse: ["ATTACH", "CHECK", "DETACH", "EXCHANGE", "KILL", "OPTIMIZE", "SYSTEM"],
sqlserver: ["BACKUP", "DBCC", "DENY", "RESTORE"],
oracle: ["FLASHBACK", "LOCK", "PURGE"],
dameng: ["FLASHBACK", "LOCK", "PURGE"],
gaussdb: ["DO", "LOCK"],
"oceanbase-oracle": ["FLASHBACK", "LOCK", "PURGE"],
redis: [],
mongodb: [],
elasticsearch: [],
qdrant: [],
milvus: [],
mq: [],
etcd: [],
};
const WITH_MAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE"]);
const EXPLAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "MERGE", "CREATE", "ALTER", "DROP"]);
const CREATE_BODY_KEYWORDS = new Set(["SELECT", "WITH", "BEGIN", "DECLARE"]);
const INSERT_BODY_KEYWORDS = new Set(["SELECT", "WITH"]);
/**
* Parse the SQL document into top-level statement ranges delimited by `;`.
*
* Delimiters inside string literals, double/backtick/bracket quoted
* identifiers, dollar-quoted bodies (Postgres), line comments (`--`, `#`) and
* block comments (`/* */`) are ignored, mirroring the backend splitter in
* `dbx-core/src/sql.rs`. Ranges are returned as `[from, to)` offsets covering
* only the statement text (the trailing semicolon and inter-statement
* whitespace are excluded so editor highlights stay tight).
*/
export function splitSqlStatementRanges(sql: string): RawStatement[] {
const statements: RawStatement[] = [];
const len = sql.length;
let statementStart = -1;
let statementEnd = -1;
let statementHitStart = 0;
let state: QuoteState = "none";
let dollarTag = "";
let i = 0;
const isWhitespace = (ch: string) => ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
const markContent = (pos: number) => {
if (statementStart === -1) statementStart = pos;
statementEnd = pos + 1;
};
const flush = () => {
if (statementStart !== -1 && statementEnd !== -1 && statementEnd > statementStart) {
statements.push({ hitFrom: statementHitStart, from: statementStart, to: statementEnd, sql: sql.slice(statementStart, statementEnd) });
}
statementStart = -1;
statementEnd = -1;
};
while (i < len) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "dollar") {
// Inside a Postgres dollar-quoted body; look for the closing $tag$.
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
markContent(i);
for (let k = 0; k < closingTag.length; k += 1) {
markContent(i + k);
}
i += closingTag.length;
state = "none";
dollarTag = "";
continue;
}
}
markContent(i);
i += 1;
continue;
}
if (state === "single") {
markContent(i);
// Backslash escapes the next char (e.g. PostgreSQL standard_conforming_strings=off style).
if (ch === "\\" && next) {
i += 2;
continue;
}
if (ch === "'") {
// Doubled single quote '' is an escaped quote, not a terminator.
if (next === "'") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
markContent(i);
if (ch === '"') {
if (next === '"') {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
markContent(i);
if (ch === "`") {
if (next === "`") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
markContent(i);
if (ch === "]") {
state = "none";
}
i += 1;
continue;
}
// state === "none"
// Line comments consume up to (and including) the newline.
if (ch === "-" && next === "-") {
const newline = sql.indexOf("\n", i);
i = newline === -1 ? len : newline + 1;
continue;
}
if (ch === "#") {
const newline = sql.indexOf("\n", i);
i = newline === -1 ? len : newline + 1;
continue;
}
// Block comments consume until the closing */.
if (ch === "/" && next === "*") {
const close = sql.indexOf("*/", i + 2);
i = close === -1 ? len : close + 2;
continue;
}
if (ch === "'") {
markContent(i);
state = "single";
i += 1;
continue;
}
if (ch === '"') {
markContent(i);
state = "double";
i += 1;
continue;
}
if (ch === "`") {
markContent(i);
state = "backtick";
i += 1;
continue;
}
if (ch === "[") {
markContent(i);
state = "bracket";
i += 1;
continue;
}
// Postgres dollar quoting: $tag$ ... $tag$ (tag may be empty, i.e. $$)
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
markContent(i);
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
state = "dollar";
continue;
}
}
if (ch === ";") {
flush();
statementHitStart = i + 1;
i += 1;
continue;
}
if (!isWhitespace(ch)) {
markContent(i);
}
i += 1;
}
// Flush any trailing statement that lacks a terminating semicolon.
flush();
return statements;
}
/**
* Returns the statement that contains `cursorPos`, or `null` when the cursor
* sits on a blank line or no statement can be resolved.
*
* The returned range covers only the statement's own text (no trailing `;`),
* which lets the editor highlight a tight preview range.
*/
export function statementRangeAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const statements = splitSqlStatementRanges(sql);
for (let index = 0; index < statements.length; index += 1) {
const statement = statements[index];
const softRanges = splitStatementRangeAtSoftStarts(sql, statement, databaseType);
// Cursor inside the statement body, including the exact start/end.
if (pos >= statement.from && pos <= statement.to) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
// 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() === "") {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
const next = statements[index + 1];
if (pos > statement.to && (!next || pos < next.hitFrom) && isCursorOnStatementLine(sql, pos, statement)) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
}
return null;
}
function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: number): SqlTextRange | null {
for (let index = 0; index < ranges.length; index += 1) {
const range = ranges[index];
if (pos >= range.from && pos <= range.to) {
return rangeFor(range, sql);
}
if (pos >= range.hitFrom && pos < range.from && sql.slice(pos, range.from).trim() === "") {
return rangeFor(range, sql);
}
const next = ranges[index + 1];
if (pos > range.to && (!next || pos < next.hitFrom) && isCursorOnStatementLine(sql, pos, range)) {
return rangeFor(range, sql);
}
}
return null;
}
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): RawStatement[] {
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType);
if (lineStarts.length <= 1) return [statement];
const boundaries: Array<{ hitFrom: number; from: number; keyword: string }> = [];
let currentKeyword = softStatementKeywordAt(sql, statement.from, databaseType);
let consumedWithMainStatement = false;
let consumedExplainStatement = false;
boundaries.push({ hitFrom: statement.hitFrom, from: statement.from, keyword: currentKeyword ?? "" });
for (const lineStart of lineStarts) {
if (lineStart.from <= statement.from) continue;
if (currentKeyword === "WITH" && !consumedWithMainStatement && WITH_MAIN_STATEMENT_KEYWORDS.has(lineStart.keyword)) {
consumedWithMainStatement = true;
continue;
}
if (currentKeyword === "EXPLAIN" && !consumedExplainStatement && EXPLAIN_STATEMENT_KEYWORDS.has(lineStart.keyword)) {
consumedExplainStatement = true;
continue;
}
if (currentKeyword === "CREATE" && CREATE_BODY_KEYWORDS.has(lineStart.keyword)) {
continue;
}
if (currentKeyword === "INSERT" && INSERT_BODY_KEYWORDS.has(lineStart.keyword)) {
continue;
}
if (currentKeyword === "UPDATE" && lineStart.keyword === "SET") {
continue;
}
boundaries.push(lineStart);
currentKeyword = lineStart.keyword;
consumedWithMainStatement = false;
consumedExplainStatement = false;
}
if (boundaries.length <= 1) return [statement];
const ranges: RawStatement[] = [];
for (let index = 0; index < boundaries.length; index += 1) {
const boundary = boundaries[index];
const next = boundaries[index + 1];
const to = next ? trimRangeEndBeforeNextBoundary(sql, boundary.from, next.from) : trimRangeEnd(sql, boundary.from, statement.to);
if (to > boundary.from) {
ranges.push({
hitFrom: boundary.hitFrom,
from: boundary.from,
to,
sql: sql.slice(boundary.from, to),
});
}
}
return ranges.length > 0 ? ranges : [statement];
}
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): Array<{ hitFrom: number; from: number; keyword: string }> {
const starts: Array<{ hitFrom: number; from: number; keyword: string }> = [];
const len = statement.to;
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let parenDepth = 0;
let lineStart = statement.from;
let firstNonWhitespaceOnLine = -1;
let i = statement.from;
while (i < len) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "none" && firstNonWhitespaceOnLine === -1 && ch !== "\n" && ch !== "\r" && !isSqlWhitespace(ch) && !startsLineComment(sql, i) && !startsBlockComment(sql, i)) {
firstNonWhitespaceOnLine = i;
if (parenDepth === 0) {
const keyword = softStatementKeywordAt(sql, i, databaseType);
if (keyword) {
starts.push({ hitFrom: lineStart, from: i, keyword });
}
}
}
if (ch === "\n") {
if (state === "lineComment") state = "none";
lineStart = i + 1;
firstNonWhitespaceOnLine = -1;
i += 1;
continue;
}
if (state === "lineComment") {
i += 1;
continue;
}
if (state === "blockComment") {
if (ch === "*" && next === "/") {
state = "none";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "dollar") {
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
i += closingTag.length;
state = "none";
dollarTag = "";
continue;
}
}
i += 1;
continue;
}
if (state === "single") {
if (ch === "\\" && next) {
i += 2;
continue;
}
if (ch === "'") {
if (next === "'") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
if (ch === '"') {
if (next === '"') {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
if (ch === "`") {
if (next === "`") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
if (ch === "]") state = "none";
i += 1;
continue;
}
// state === "none"
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "#") {
state = "lineComment";
i += 1;
continue;
}
if (ch === "/" && next === "*") {
state = "blockComment";
i += 2;
continue;
}
if (ch === "'") {
state = "single";
i += 1;
continue;
}
if (ch === '"') {
state = "double";
i += 1;
continue;
}
if (ch === "`") {
state = "backtick";
i += 1;
continue;
}
if (ch === "[") {
state = "bracket";
i += 1;
continue;
}
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
state = "dollar";
continue;
}
}
if (ch === "(") {
parenDepth += 1;
} else if (ch === ")" && parenDepth > 0) {
parenDepth -= 1;
}
i += 1;
}
return starts;
}
function softStatementKeywordAt(sql: string, pos: number, databaseType?: DatabaseType): string | null {
const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos));
if (!match) return null;
const keyword = match[0].toUpperCase();
return softStatementStartKeywords(databaseType).has(keyword) ? keyword : null;
}
function softStatementStartKeywords(databaseType?: DatabaseType): Set<string> {
return new Set([...COMMON_SOFT_STATEMENT_START_KEYWORDS, ...(databaseType ? (DATABASE_SOFT_STATEMENT_KEYWORDS[databaseType] ?? []) : [])]);
}
function startsLineComment(sql: string, pos: number): boolean {
return (sql[pos] === "-" && sql[pos + 1] === "-") || sql[pos] === "#";
}
function startsBlockComment(sql: string, pos: number): boolean {
return sql[pos] === "/" && sql[pos + 1] === "*";
}
function trimRangeEnd(sql: string, from: number, to: number): number {
let end = to;
while (end > from && isSqlWhitespace(sql[end - 1])) {
end -= 1;
}
return end;
}
function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number): number {
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let lastContentEnd = from;
let i = from;
while (i < nextBoundaryFrom) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "lineComment") {
if (ch === "\n") state = "none";
i += 1;
continue;
}
if (state === "blockComment") {
if (ch === "*" && next === "/") {
state = "none";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "dollar") {
lastContentEnd = i + 1;
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
i += closingTag.length;
lastContentEnd = i;
state = "none";
dollarTag = "";
continue;
}
}
i += 1;
continue;
}
if (state === "single") {
lastContentEnd = i + 1;
if (ch === "\\" && next) {
i += 2;
lastContentEnd = i;
continue;
}
if (ch === "'") {
if (next === "'") {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
lastContentEnd = i + 1;
if (ch === '"') {
if (next === '"') {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
lastContentEnd = i + 1;
if (ch === "`") {
if (next === "`") {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
lastContentEnd = i + 1;
if (ch === "]") state = "none";
i += 1;
continue;
}
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "#") {
state = "lineComment";
i += 1;
continue;
}
if (ch === "/" && next === "*") {
state = "blockComment";
i += 2;
continue;
}
if (ch === "'") {
state = "single";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === '"') {
state = "double";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "`") {
state = "backtick";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "[") {
state = "bracket";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
state = "dollar";
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
lastContentEnd = i;
continue;
}
}
if (!isSqlWhitespace(ch)) {
lastContentEnd = i + 1;
}
i += 1;
}
return trimRangeEnd(sql, from, lastContentEnd);
}
function isSqlWhitespace(ch: string): boolean {
return ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
}
function rangeFor(statement: RawStatement, sql: string): SqlTextRange {
return {
from: statement.from,
to: statement.to,
sql: sql.slice(statement.from, statement.to),
};
}
function clampCursor(sql: string, cursorPos: number): number {
if (!Number.isFinite(cursorPos)) return 0;
if (cursorPos < 0) return 0;
if (cursorPos > sql.length) return sql.length;
return cursorPos;
}
function isCursorOnBlankLine(sql: string, pos: number): boolean {
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
return sql.slice(lineStart, lineEnd).trim() === "";
}
function isCursorOnStatementLine(sql: string, pos: number, statement: RawStatement): boolean {
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
return statement.from >= lineStart && statement.from <= lineEnd;
}
/**
* Returns the full document as a range, or `null` when it is empty/whitespace.
*/
export function fullSqlRange(sql: string): SqlTextRange | null {
const trimmed = sql.trim();
if (!trimmed) return null;
const from = sql.length - sql.trimStart().length;
const to = from + trimmed.length;
return { from, to, sql: sql.slice(from, to) };
}
function normalizeSql(sql: string): string {
return sql.replace(/\s+/g, " ").replace(/;\s*$/, "").trim();
}
/**
* Build the ordered list of execution candidates to show in the picker.
*
* Order is always `[cursor, all]` when both are available, except when the
* cursor statement and the full document are effectively the same SQL in
* that case only a single candidate is returned to avoid duplicates.
*/
export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlExecutionCandidate[] {
const full = fullSqlRange(sql);
const cursorStatement = databaseType === "redis" ? redisCommandRangeAtCursor(sql, cursorPos) : statementRangeAtCursor(sql, cursorPos, databaseType);
if (!full && !cursorStatement) return [];
if (!full) {
return cursorStatement ? [candidateFromRange(cursorStatement, "cursor", databaseType)] : [];
}
if (!cursorStatement) {
return [candidateFromRange(full, "all", databaseType)];
}
const sameContent = normalizeSql(cursorStatement.sql) === normalizeSql(full.sql);
if (sameContent) {
return [candidateFromRange(full, "all", databaseType)];
}
return [candidateFromRange(cursorStatement, "cursor", databaseType), candidateFromRange(full, "all", databaseType)];
}
function candidateFromRange(range: SqlTextRange, kind: SqlExecutionCandidate["kind"], databaseType?: DatabaseType): SqlExecutionCandidate {
const isRedis = databaseType === "redis";
return {
kind,
label: kind === "cursor" ? (isRedis ? "currentCommand" : "currentStatement") : isRedis ? "allCommands" : "allStatements",
sql: range.sql,
from: range.from,
to: range.to,
};
}
function redisCommandRangeAtCursor(sql: string, cursorPos: number): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
const rawLine = sql.slice(lineStart, lineEnd);
const leadingWhitespace = rawLine.length - rawLine.trimStart().length;
const trimmedLine = rawLine.trim();
if (!trimmedLine || trimmedLine.startsWith("#")) return null;
const from = lineStart + leadingWhitespace;
const to = lineStart + rawLine.length - (rawLine.length - rawLine.trimEnd().length);
return {
from,
to,
sql: sql.slice(from, to),
};
}