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:
parent
4590057456
commit
4d2afcad90
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -435,6 +435,13 @@ export default {
|
|||
close: "关闭 (Esc)",
|
||||
noResults: "无结果",
|
||||
},
|
||||
executionPicker: {
|
||||
title: "执行目标",
|
||||
currentStatement: "当前 SQL",
|
||||
allStatements: "全部 SQL",
|
||||
currentCommand: "当前命令",
|
||||
allCommands: "全部命令",
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
|
|
|||
|
|
@ -413,6 +413,13 @@ export default {
|
|||
close: "關閉 (Esc)",
|
||||
noResults: "無結果",
|
||||
},
|
||||
executionPicker: {
|
||||
title: "執行目標",
|
||||
currentStatement: "當前 SQL",
|
||||
allStatements: "全部 SQL",
|
||||
currentCommand: "目前命令",
|
||||
allCommands: "全部命令",
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue