fix(editor): improve SQL completion insertion
This commit is contained in:
parent
b5435e7795
commit
931cff48c0
|
|
@ -283,6 +283,7 @@ const editShowStatementRunButtons = ref(settingsStore.editorSettings.showStateme
|
|||
const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurrentStatementFrame);
|
||||
const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints);
|
||||
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
|
||||
const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion);
|
||||
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
|
||||
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
|
||||
const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets);
|
||||
|
|
@ -438,6 +439,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
|||
showCurrentStatementFrame: editShowCurrentStatementFrame.value,
|
||||
showInsertValueHints: editShowInsertValueHints.value,
|
||||
autoAliasTables: editAutoAliasTables.value,
|
||||
insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value,
|
||||
wordWrap: editWordWrap.value,
|
||||
vimModeEnabled: editVimModeEnabled.value,
|
||||
autoCloseBrackets: editAutoCloseBrackets.value,
|
||||
|
|
@ -689,6 +691,7 @@ function syncEditorSettingsDraftFromStore() {
|
|||
editShowCurrentStatementFrame.value = settingsStore.editorSettings.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints;
|
||||
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
|
||||
editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion;
|
||||
editWordWrap.value = settingsStore.editorSettings.wordWrap;
|
||||
editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled;
|
||||
editAutoCloseBrackets.value = settingsStore.editorSettings.autoCloseBrackets;
|
||||
|
|
@ -884,6 +887,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
|||
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
|
||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
|
||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
|
||||
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
|
||||
|
|
@ -969,6 +973,7 @@ function resetAllDefaults() {
|
|||
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
|
||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
|
||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
|
||||
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
|
||||
|
|
@ -3304,6 +3309,14 @@ onUnmounted(cleanupPreviewEditor);
|
|||
<Switch id="editor-auto-close-brackets" v-model="editAutoCloseBrackets" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-insert-space-after-completion">{{ t("settings.insertSpaceAfterCompletion") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.insertSpaceAfterCompletionDescription") }}</p>
|
||||
</div>
|
||||
<Switch id="editor-insert-space-after-completion" v-model="editInsertSpaceAfterCompletion" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-auto-alias-tables">{{ t("settings.autoAliasTables") }}</Label>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import {
|
|||
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
|
||||
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
|
||||
import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
|
||||
import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
|
||||
import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
|
||||
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
|
||||
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
|
||||
|
|
@ -2284,7 +2285,7 @@ function buildCompletionResult(items: QueryCompletionItem[], from: number, valid
|
|||
if (items.length === 0) return null;
|
||||
return {
|
||||
from,
|
||||
filter: false,
|
||||
// Keep CodeMirror's live filtering enabled so an already-open menu follows the typed prefix.
|
||||
options: items.map((item) => completionOptionForItem(item)),
|
||||
validFor,
|
||||
};
|
||||
|
|
@ -2307,6 +2308,10 @@ function localCompletionDatabaseNames(completionContext: ReturnType<typeof getSq
|
|||
return connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES);
|
||||
}
|
||||
|
||||
function shouldInsertSqlCompletionSpace(): boolean {
|
||||
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch";
|
||||
}
|
||||
|
||||
function completionOptionForItem(item: QueryCompletionItem) {
|
||||
const record = () => {
|
||||
recordCompletionSelection(item.label, item.type);
|
||||
|
|
@ -2346,7 +2351,11 @@ function completionOptionForItem(item: QueryCompletionItem) {
|
|||
apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) {
|
||||
record();
|
||||
markCompletionAccepted(item);
|
||||
const insert = item.apply ?? item.label;
|
||||
const insert = appendSqlCompletionSpace(item.apply ?? item.label, {
|
||||
enabled: shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion,
|
||||
itemType: item.type,
|
||||
nextCharacter: view.state.sliceDoc(to, to + 1),
|
||||
});
|
||||
if (codeMirrorInsertCompletionText) {
|
||||
view.dispatch(codeMirrorInsertCompletionText(view.state, insert, from, to));
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -4009,6 +4009,8 @@ export default {
|
|||
vimModeDescription: "Use Vim-style modal editing in the SQL editor",
|
||||
autoCloseBrackets: "Auto-close brackets",
|
||||
autoCloseBracketsDescription: "Automatically insert closing brackets and quotes when typing an opening one",
|
||||
insertSpaceAfterCompletion: "Insert a space after completion",
|
||||
insertSpaceAfterCompletionDescription: "Append a space after accepting a keyword, table, or column completion when the next character allows it",
|
||||
sqlSemanticDiagnosticsEnabled: "SQL semantic diagnostics",
|
||||
sqlSemanticDiagnosticsEnabledDescription: "When enabled, the editor reports semantic issues such as unknown tables and columns. Disable it to reduce parsing and metadata checks for large SQL.",
|
||||
confirmDangerousSqlExecution: "Confirm before dangerous SQL",
|
||||
|
|
|
|||
|
|
@ -4008,6 +4008,8 @@ export default withEnglishFallback({
|
|||
vimModeDescription: "在 SQL 编辑器中使用 Vim 风格的模态编辑",
|
||||
autoCloseBrackets: "自动成对补全",
|
||||
autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号",
|
||||
insertSpaceAfterCompletion: "补全后自动添加空格",
|
||||
insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格",
|
||||
sqlSemanticDiagnosticsEnabled: "SQL 语义诊断",
|
||||
sqlSemanticDiagnosticsEnabledDescription: "开启后,编辑器会提示未知表、字段等语义问题;关闭可减少 SQL 解析和元数据检查的性能开销。",
|
||||
confirmDangerousSqlExecution: "执行危险 SQL 前弹出确认",
|
||||
|
|
|
|||
|
|
@ -89,6 +89,18 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("QueryEditor completion Tab keymap", () => {
|
||||
it("keeps CodeMirror prefix filtering enabled for SQL completion results", () => {
|
||||
const javascript = ts.transpileModule(extractFunction("buildCompletionResult"), {
|
||||
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const buildCompletionResult = new Function("completionOptionForItem", `${javascript}\nreturn buildCompletionResult;`)(() => ({ label: "created_at" })) as (items: Array<{ label: string }>, from: number) => { filter?: boolean; options: Array<{ label: string }>; from: number } | null;
|
||||
|
||||
const result = buildCompletionResult([{ label: "created_at" }], 0);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).not.toHaveProperty("filter");
|
||||
});
|
||||
|
||||
it("keeps normal Tab indentation when completion is inactive", () => {
|
||||
const harness = createHarness({ completionStatus: () => null });
|
||||
const view = createView();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
|
||||
|
||||
describe("SQL completion insertion", () => {
|
||||
it("appends a space to ordinary completions by default", () => {
|
||||
expect(appendSqlCompletionSpace("SELECT", { enabled: true, itemType: "keyword" })).toBe("SELECT ");
|
||||
expect(appendSqlCompletionSpace("orders AS o", { enabled: true, itemType: "table" })).toBe("orders AS o ");
|
||||
});
|
||||
|
||||
it("does not add duplicate or invalid spaces", () => {
|
||||
expect(appendSqlCompletionSpace("SELECT ", { enabled: true, itemType: "keyword" })).toBe("SELECT ");
|
||||
expect(appendSqlCompletionSpace("public.", { enabled: true, itemType: "schema" })).toBe("public.");
|
||||
expect(appendSqlCompletionSpace("name", { enabled: true, itemType: "property" })).toBe("name");
|
||||
expect(appendSqlCompletionSpace("key", { enabled: true, itemType: "text" })).toBe("key");
|
||||
expect(appendSqlCompletionSpace("orders", { enabled: true, itemType: "table", nextCharacter: ")" })).toBe("orders");
|
||||
expect(appendSqlCompletionSpace("orders", { enabled: true, itemType: "table", nextCharacter: "," })).toBe("orders");
|
||||
});
|
||||
|
||||
it("honors the setting and leaves snippet-like completions unchanged", () => {
|
||||
expect(appendSqlCompletionSpace("SELECT", { enabled: false, itemType: "keyword" })).toBe("SELECT");
|
||||
expect(appendSqlCompletionSpace("CASE ${value}", { enabled: true, itemType: "snippet" })).toBe("CASE ${value}");
|
||||
expect(appendSqlCompletionSpace("count()", { enabled: true, itemType: "function" })).toBe("count()");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export type SqlCompletionItemType = "keyword" | "table" | "column" | "snippet" | "function" | "schema" | "property" | "text";
|
||||
|
||||
const COMPLETION_SPACE_BLOCKING_CHARACTERS = new Set([",", ";", ":", ")", "]", "}", "'", '"']);
|
||||
|
||||
export function appendSqlCompletionSpace(insertText: string, options: { enabled: boolean; itemType: SqlCompletionItemType; nextCharacter?: string }): string {
|
||||
if (!options.enabled || options.itemType === "property" || options.itemType === "text" || options.itemType === "schema" || options.itemType === "snippet" || options.itemType === "function") return insertText;
|
||||
if (!insertText || /\s$/.test(insertText) || insertText.endsWith(".")) return insertText;
|
||||
|
||||
const nextCharacter = options.nextCharacter ?? "";
|
||||
if (/\s/.test(nextCharacter) || COMPLETION_SPACE_BLOCKING_CHARACTERS.has(nextCharacter)) return insertText;
|
||||
|
||||
// Avoid producing `table AS t,` or `column)` when the cursor is before SQL punctuation.
|
||||
return `${insertText} `;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
|||
"showCurrentStatementFrame",
|
||||
"showInsertValueHints",
|
||||
"autoAliasTables",
|
||||
"insertSpaceAfterCompletion",
|
||||
"wordWrap",
|
||||
"vimModeEnabled",
|
||||
"autoCloseBrackets",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ describe("normalizeEditorSettings", () => {
|
|||
expect(normalizeEditorSettings({ autoAliasTables: false }).autoAliasTables).toBe(false);
|
||||
});
|
||||
|
||||
it("enables a trailing space after completion by default and preserves the opt-out", () => {
|
||||
expect(normalizeEditorSettings({}).insertSpaceAfterCompletion).toBe(true);
|
||||
expect(normalizeEditorSettings({ insertSpaceAfterCompletion: false }).insertSpaceAfterCompletion).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults sidebar connection sorting to manual order and preserves valid alphabetical modes", () => {
|
||||
expect(normalizeEditorSettings({}).sidebarConnectionSortMode).toBe("manual");
|
||||
expect(normalizeEditorSettings({ sidebarConnectionSortMode: "asc" }).sidebarConnectionSortMode).toBe("asc");
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@ export interface EditorSettings {
|
|||
showCurrentStatementFrame: boolean;
|
||||
showInsertValueHints: boolean;
|
||||
autoAliasTables: boolean;
|
||||
insertSpaceAfterCompletion: boolean;
|
||||
wordWrap: boolean;
|
||||
vimModeEnabled: boolean;
|
||||
autoCloseBrackets: boolean;
|
||||
|
|
@ -557,6 +558,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
|||
showCurrentStatementFrame: true,
|
||||
showInsertValueHints: true,
|
||||
autoAliasTables: true,
|
||||
insertSpaceAfterCompletion: true,
|
||||
wordWrap: false,
|
||||
vimModeEnabled: false,
|
||||
autoCloseBrackets: true,
|
||||
|
|
@ -835,6 +837,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
|||
showCurrentStatementFrame: typeof settings.showCurrentStatementFrame === "boolean" ? settings.showCurrentStatementFrame : DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame,
|
||||
showInsertValueHints: typeof settings.showInsertValueHints === "boolean" ? settings.showInsertValueHints : DEFAULT_EDITOR_SETTINGS.showInsertValueHints,
|
||||
autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables,
|
||||
insertSpaceAfterCompletion: typeof settings.insertSpaceAfterCompletion === "boolean" ? settings.insertSpaceAfterCompletion : DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion,
|
||||
wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap,
|
||||
vimModeEnabled: typeof settings.vimModeEnabled === "boolean" ? settings.vimModeEnabled : DEFAULT_EDITOR_SETTINGS.vimModeEnabled,
|
||||
autoCloseBrackets: typeof settings.autoCloseBrackets === "boolean" ? settings.autoCloseBrackets : DEFAULT_EDITOR_SETTINGS.autoCloseBrackets,
|
||||
|
|
@ -1202,6 +1205,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
if (partial.showCurrentStatementFrame !== undefined) editorSettings.value.showCurrentStatementFrame = partial.showCurrentStatementFrame === true;
|
||||
if (partial.showInsertValueHints !== undefined) editorSettings.value.showInsertValueHints = partial.showInsertValueHints === true;
|
||||
if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables;
|
||||
if (partial.insertSpaceAfterCompletion !== undefined) editorSettings.value.insertSpaceAfterCompletion = partial.insertSpaceAfterCompletion === true;
|
||||
if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap;
|
||||
if (partial.vimModeEnabled !== undefined) editorSettings.value.vimModeEnabled = partial.vimModeEnabled === true;
|
||||
if (partial.autoCloseBrackets !== undefined) editorSettings.value.autoCloseBrackets = partial.autoCloseBrackets === true;
|
||||
|
|
|
|||
|
|
@ -2596,6 +2596,19 @@ test("automatic table aliases avoid SQL keywords", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("does not force automatic table aliases when disabled", () => {
|
||||
const sql = "select * from item";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables: [{ name: "item_file", schema: "public", type: "table" }],
|
||||
columnsByTable,
|
||||
autoAliasTables: false,
|
||||
});
|
||||
|
||||
const tableItem = items.find((item) => item.type === "table" && item.label === "item_file");
|
||||
assert.ok(tableItem);
|
||||
assert.equal(tableItem!.apply, "item_file");
|
||||
});
|
||||
|
||||
test("table alias suggestions avoid existing aliases", () => {
|
||||
const items = buildSqlCompletionItems("select * from customer_orders co join customer_orders ", "select * from customer_orders co join customer_orders ".length, {
|
||||
tables: [...tables, { name: "customer_orders", schema: "public", type: "table" }],
|
||||
|
|
|
|||
Loading…
Reference in New Issue