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