mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-11 07:21:37 +08:00
🐛 fix(query-editor): 修复补全范围与光标执行
- 仅按当前语句与光标前范围构建别名和表引用,避免跨语句泄露其他库表字段补全 - 在新语句起始关键字场景优先提升 SQL 关键字候选,避免关键字被字段建议压到后面 - Ctrl/Cmd+Enter 无选中内容时回退执行光标所在语句,不再误报没有可选择的 SQL 语句 - 补充补全排序、列范围和光标末尾执行的回归测试
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user