diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
index 15bb0419..80dd8027 100644
--- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx
+++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
@@ -1760,6 +1760,132 @@ describe('QueryEditor external SQL save', () => {
});
});
+ it('prioritizes SQL keywords for a new statement instead of leaking previous statement columns', async () => {
+ let renderer!: ReactTestRenderer;
+ autoFetchState.visible = true;
+ storeState.connections[0].config.type = 'mysql';
+ storeState.connections[0].config.database = 'main';
+ backendApp.DBGetDatabases.mockResolvedValueOnce({
+ success: true,
+ data: [{ Database: 'main' }, { Database: 'analytics' }],
+ });
+ backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => {
+ if (dbName === 'main') {
+ return { success: true, data: [{ Tables_in_main: 'users' }] };
+ }
+ if (dbName === 'analytics') {
+ return { success: true, data: [{ Tables_in_analytics: 'events' }] };
+ }
+ return { success: true, data: [] };
+ });
+ backendApp.DBGetAllColumns.mockImplementation(async (_config: any, dbName: string) => {
+ if (dbName === 'main') {
+ return {
+ success: true,
+ data: [{ tableName: 'users', name: 'updated_by', type: 'varchar(32)' }],
+ };
+ }
+ if (dbName === 'analytics') {
+ return {
+ success: true,
+ data: [{ tableName: 'events', name: 'update_time', type: 'timestamp' }],
+ };
+ }
+ return { success: true, data: [] };
+ });
+
+ editorState.value = 'SELECT *\nFROM analytics.events;\nupdate';
+ await act(async () => {
+ renderer = create();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const sqlProvider = findSqlCompletionProvider();
+ expect(sqlProvider).toBeTruthy();
+
+ editorState.latestOnChange?.(editorState.value);
+ const result = await sqlProvider.provideCompletionItems(
+ editorState.editor.getModel(),
+ { lineNumber: 3, column: 'update'.length + 1 },
+ );
+ const labels = result.suggestions.map((item: any) => item.label);
+
+ expect(labels[0]).toBe('UPDATE');
+ expect(labels).not.toContain('update_time');
+
+ await act(async () => {
+ renderer.unmount();
+ });
+ });
+
+ it('limits column completion to tables referenced before the cursor in the current statement', async () => {
+ let renderer!: ReactTestRenderer;
+ autoFetchState.visible = true;
+ storeState.connections[0].config.type = 'mysql';
+ storeState.connections[0].config.database = 'main';
+ backendApp.DBGetDatabases.mockResolvedValueOnce({
+ success: true,
+ data: [{ Database: 'main' }, { Database: 'analytics' }],
+ });
+ backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => {
+ if (dbName === 'main') {
+ return { success: true, data: [{ Tables_in_main: 'users' }] };
+ }
+ if (dbName === 'analytics') {
+ return { success: true, data: [{ Tables_in_analytics: 'events' }] };
+ }
+ return { success: true, data: [] };
+ });
+ backendApp.DBGetAllColumns.mockImplementation(async (_config: any, dbName: string) => {
+ if (dbName === 'main') {
+ return {
+ success: true,
+ data: [{ tableName: 'users', name: 'updated_by', type: 'varchar(32)' }],
+ };
+ }
+ if (dbName === 'analytics') {
+ return {
+ success: true,
+ data: [{ tableName: 'events', name: 'update_time', type: 'timestamp' }],
+ };
+ }
+ return { success: true, data: [] };
+ });
+
+ editorState.value = 'SELECT * FROM analytics.events;\nSELECT * FROM main.users WHERE upd';
+ await act(async () => {
+ renderer = create();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const sqlProvider = findSqlCompletionProvider();
+ expect(sqlProvider).toBeTruthy();
+
+ editorState.latestOnChange?.(editorState.value);
+ const result = await sqlProvider.provideCompletionItems(
+ editorState.editor.getModel(),
+ { lineNumber: 2, column: 'SELECT * FROM main.users WHERE upd'.length + 1 },
+ );
+ const labels = result.suggestions.map((item: any) => item.label);
+
+ expect(labels).toContain('updated_by');
+ expect(labels).not.toContain('update_time');
+
+ await act(async () => {
+ renderer.unmount();
+ });
+ });
+
it('resolves database and table targets for ctrl/cmd navigation', () => {
const tables = [
{ dbName: 'main', tableName: 'users' },
@@ -7619,6 +7745,86 @@ describe('QueryEditor external SQL save', () => {
expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。');
});
+ it('runs the statement at the cursor end from the keyboard shortcut when nothing is selected', async () => {
+ storeState.shortcutOptions.runQuery.mac = { enabled: true, combo: 'Meta+Enter' };
+ storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+Enter' };
+ backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({
+ success: true,
+ data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }],
+ });
+ const windowListeners: Record void)[]> = {};
+ vi.stubGlobal('window', {
+ addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
+ windowListeners[type] ||= [];
+ windowListeners[type].push(listener);
+ }),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ setTimeout,
+ clearTimeout,
+ requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ }),
+ cancelAnimationFrame: vi.fn(),
+ innerHeight: 900,
+ });
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+
+ editorState.selection = null;
+ editorState.position = {
+ lineNumber: 2,
+ column: "UPDATE uk_user SET email = NULL WHERE email = 'liuzhen@mail.chat5188.com'".length + 1,
+ };
+ editorState.cursorPositionListeners.forEach((listener) => {
+ listener({ position: editorState.position });
+ });
+
+ const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`);
+ const event = {
+ ctrlKey: !isMacRuntime,
+ metaKey: isMacRuntime,
+ altKey: false,
+ shiftKey: false,
+ key: 'Enter',
+ target: null,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ };
+
+ await act(async () => {
+ windowListeners.keydown?.forEach((listener) => listener(event));
+ for (let i = 0; i < 6; i += 1) {
+ await Promise.resolve();
+ }
+ });
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+ expect(backendApp.DBQueryMultiTransactional).toHaveBeenCalledWith(
+ expect.anything(),
+ 'main',
+ "UPDATE uk_user SET email = NULL WHERE email = 'liuzhen@mail.chat5188.com'",
+ 'query-1',
+ );
+ expect(String(backendApp.DBQueryMultiTransactional.mock.calls[0][2])).not.toContain('SELECT * FROM uk_back_corp');
+ expect(messageApi.info).not.toHaveBeenCalledWith('没有可选择的 SQL 语句。');
+
+ await act(async () => {
+ renderer!.unmount();
+ });
+ });
+
it('shows "No executable SQL." in English when the cursor is on a blank line', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 049c368e..222fb8bc 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -27,7 +27,7 @@ import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/q
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { formatSqlExecutionError, hasLocalizedSqlTimeoutKeyword } from '../utils/sqlErrorSemantics';
import { canReusePendingSqlEditorTransactionForType, shouldUseSqlEditorManagedTransactionForType } from '../utils/sqlEditorTransaction';
-import { findSqlStatementRanges, resolveExecutableSql } from '../utils/sqlStatementSelection';
+import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection';
import { isMacLikePlatform } from '../utils/appearance';
import { splitSidebarQualifiedName } from '../utils/sidebarLocate';
import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata';
@@ -3218,9 +3218,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
const fullText = model.getValue();
+ const cursorOffset = getNormalizedOffsetAtPosition(fullText, {
+ lineNumber: Number(position?.lineNumber || 1),
+ column: Number(position?.column || 1),
+ });
+ const currentStatementRange = resolveCurrentSqlStatementRange(fullText, cursorOffset);
// 获取当前行光标前的内容
const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
+ const currentStatementPrefix = currentStatementRange
+ ? fullText.slice(currentStatementRange.start, cursorOffset)
+ : fullText.slice(0, cursorOffset);
+ const completionScopeText = currentStatementPrefix || linePrefix;
// 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列
const threePartMatch = linePrefix.match(QUERY_EDITOR_SQL_THREE_PART_COMPLETION_REGEX);
@@ -3358,7 +3367,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
// 否则检查是否是表别名或表名,提示列
- const aliasMap = buildQueryEditorAliasMap(fullText, sharedCurrentDb || '');
+ const aliasMap = buildQueryEditorAliasMap(completionScopeText, sharedCurrentDb || '');
const tableInfo = aliasMap[qualifier.toLowerCase()];
if (tableInfo) {
@@ -3392,7 +3401,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
tableRegex.lastIndex = 0;
const foundTables = new Set();
let match;
- while ((match = tableRegex.exec(fullText)) !== null) {
+ while ((match = tableRegex.exec(completionScopeText)) !== null) {
const t = normalizeQualifiedName(match[1] || '');
if (!t) continue;
// 存储完整标识 db.table 或 table
@@ -3414,11 +3423,22 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix);
const expectsRoutineName = /\bCALL\s+[`"]?[\w.]*$/i.test(linePrefix);
+ const matchesKeywordPrefix = wordPrefix.length > 0
+ && dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
+ const statementPrefixBeforeWord = currentStatementPrefix.slice(
+ 0,
+ Math.max(0, currentStatementPrefix.length - String(word.word || '').length),
+ );
+ const isNewStatementKeywordContext = !expectsTableName
+ && !expectsRoutineName
+ && matchesKeywordPrefix
+ && !statementPrefixBeforeWord.trim();
const shouldBoostKeywords = !expectsTableName
&& !expectsRoutineName
- && wordPrefix.length > 0
- && dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
- const sortGroups = shouldBoostKeywords
+ && matchesKeywordPrefix;
+ const sortGroups = isNewStatementKeywordContext
+ ? { keyword: '00', func: '10', routineCurrent: '11', routineOther: '12', tableCurrent: '20', tableOther: '21', columnCurrent: '30', columnOther: '31', db: '40' }
+ : shouldBoostKeywords
? { keyword: '00', func: '05', routineCurrent: '06', routineOther: '07', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
: expectsRoutineName
? { keyword: '30', func: '40', routineCurrent: '00', routineOther: '01', columnCurrent: '20', columnOther: '21', tableCurrent: '10', tableOther: '11', db: '35' }
@@ -3480,7 +3500,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const referencedColumns: CompletionColumnMeta[] = [];
if (!expectsTableName) {
- const aliasMapForReferencedTables = buildQueryEditorAliasMap(fullText, currentDatabase);
+ const aliasMapForReferencedTables = buildQueryEditorAliasMap(completionScopeText, currentDatabase);
const seenReferencedTables = new Set();
for (const tableInfo of Object.values(aliasMapForReferencedTables)) {
const key = `${String(tableInfo.dbName || '').toLowerCase()}.${String(tableInfo.tableName || '').toLowerCase()}`;
@@ -3652,14 +3672,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
sortText: sortGroups.func + f.name,
}));
- const suggestions = [
- ...relevantColumns, // FROM 表的列最优先
- ...tableSuggestions, // 表次之
- ...dbSuggestions, // 数据库
- ...routineSuggestions, // 存储过程/函数
- ...funcSuggestions, // 内置函数
- ...keywordSuggestions // 关键字最后
- ];
+ const suggestions = isNewStatementKeywordContext
+ ? [
+ ...keywordSuggestions,
+ ...funcSuggestions,
+ ...tableSuggestions,
+ ...dbSuggestions,
+ ...routineSuggestions,
+ ...relevantColumns,
+ ]
+ : [
+ ...relevantColumns, // FROM 表的列最优先
+ ...tableSuggestions, // 表次之
+ ...dbSuggestions, // 数据库
+ ...routineSuggestions, // 存储过程/函数
+ ...funcSuggestions, // 内置函数
+ ...keywordSuggestions // 关键字最后
+ ];
return { suggestions };
}
}));
@@ -4956,10 +4985,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
const handleRunSelectedShortcut = async () => {
- if (!getSelectedSQL().trim()) {
- message.info(translate('query_editor.message.no_selectable_sql'));
- return;
- }
await handleRun();
};