🐛 fix(query-editor): 修复补全范围与光标执行

- 仅按当前语句与光标前范围构建别名和表引用,避免跨语句泄露其他库表字段补全
- 在新语句起始关键字场景优先提升 SQL 关键字候选,避免关键字被字段建议压到后面
- Ctrl/Cmd+Enter 无选中内容时回退执行光标所在语句,不再误报没有可选择的 SQL 语句
- 补充补全排序、列范围和光标末尾执行的回归测试
This commit is contained in:
Syngnat
2026-07-01 09:17:33 +08:00
parent 923e789211
commit 66b14766a3
2 changed files with 250 additions and 19 deletions

View File

@@ -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(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
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(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
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<string, ((event?: any) => 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(<QueryEditor tab={createTab({
dbName: 'main',
query: [
"SELECT * FROM uk_back_corp WHERE mobile = '18823406451';",
"UPDATE uk_user SET email = NULL WHERE email = 'liuzhen@mail.chat5188.com'",
].join('\n'),
})} />);
});
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');

View File

@@ -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<string>();
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<string>();
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();
};