From d8656c6c9c39f81e6a62ba1b5d1533660fb4d5f5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 4 Feb 2026 12:37:30 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=88=AB=E5=90=8D=E5=AD=97=E6=AE=B5=E4=B8=8D=E8=81=94?= =?UTF-8?q?=E6=83=B3=E4=B8=8E=E5=90=AF=E5=8A=A8=E7=BC=96=E8=AF=91=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a. 场景根据 alias->table 提供字段补全 - 修复 currentDbRef 重复声明(TS2451) - 保持原关键字/表名/字段补全行为不变 --- frontend/package.json.md5 | 2 +- frontend/src/components/QueryEditor.tsx | 152 +++++++++++++++++++++++- 2 files changed, 147 insertions(+), 7 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a7661c0..0f8f4fe 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file +5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 4c7ec9f..b87737f 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -46,6 +46,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const allColumnsRef = useRef<{tableName: string, name: string, type: string}[]>([]); // Store all columns const { connections, addSqlLog } = useStore(); + const currentConnectionIdRef = useRef(currentConnectionId); + const currentDbRef = useRef(currentDb); + const connectionsRef = useRef(connections); + const columnsCacheRef = useRef>({}); const saveQuery = useStore(state => state.saveQuery); const darkMode = useStore(state => state.darkMode); const sqlFormatOptions = useStore(state => state.sqlFormatOptions); @@ -53,11 +57,18 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); - const currentDbRef = useRef(currentDb); + useEffect(() => { + currentConnectionIdRef.current = currentConnectionId; + }, [currentConnectionId]); + useEffect(() => { currentDbRef.current = currentDb; }, [currentDb]); + useEffect(() => { + connectionsRef.current = connections; + }, [connections]); + // If opening a saved query, load its SQL useEffect(() => { if (tab.query) setQuery(tab.query); @@ -155,7 +166,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { monacoRef.current = monaco; monaco.languages.registerCompletionItemProvider('sql', { - provideCompletionItems: (model: any, position: any) => { + triggerCharacters: ['.'], + provideCompletionItems: async (model: any, position: any) => { const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, @@ -164,16 +176,144 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { endColumn: word.endColumn, }; - const tableRegex = /(?:FROM|JOIN|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/gi; + const stripQuotes = (ident: string) => { + let raw = (ident || '').trim(); + if (!raw) return raw; + const first = raw[0]; + const last = raw[raw.length - 1]; + if ((first === '`' && last === '`') || (first === '"' && last === '"')) { + raw = raw.slice(1, -1); + } + return raw.trim(); + }; + + const normalizeQualifiedName = (ident: string) => { + const raw = (ident || '').trim(); + if (!raw) return raw; + return raw + .split('.') + .map(p => stripQuotes(p.trim())) + .filter(Boolean) + .join('.'); + }; + + const getLastPart = (qualified: string) => { + const raw = normalizeQualifiedName(qualified); + if (!raw) return raw; + const parts = raw.split('.').filter(Boolean); + return parts[parts.length - 1] || raw; + }; + + const buildConnConfig = () => { + const connId = currentConnectionIdRef.current; + const conn = connectionsRef.current.find(c => c.id === connId); + if (!conn) return null; + return { + ...conn.config, + port: Number(conn.config.port), + password: conn.config.password || "", + database: conn.config.database || "", + useSSH: conn.config.useSSH || false, + ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } + }; + }; + + const getColumnsByDB = async (tableIdent: string) => { + const connId = currentConnectionIdRef.current; + const dbName = currentDbRef.current; + if (!connId || !dbName) return [] as ColumnDefinition[]; + const key = `${connId}|${dbName}|${tableIdent}`; + const cached = columnsCacheRef.current[key]; + if (cached) return cached; + + const config = buildConnConfig(); + if (!config) return [] as ColumnDefinition[]; + + const res = await DBGetColumns(config as any, dbName, tableIdent); + if (res?.success && Array.isArray(res.data)) { + const cols = res.data as ColumnDefinition[]; + columnsCacheRef.current[key] = cols; + return cols; + } + return [] as ColumnDefinition[]; + }; + + const fullText = model.getValue(); + + // 1) alias.field completion: when cursor is after "." + const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1); + const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/); + if (qualifierMatch) { + const alias = stripQuotes(qualifierMatch[1]); + const colPrefix = (qualifierMatch[2] || '').toLowerCase(); + + const reserved = new Set([ + 'where', 'on', 'group', 'order', 'limit', 'having', + 'left', 'right', 'inner', 'outer', 'full', 'cross', 'join', + 'union', 'except', 'intersect', 'as', 'set', 'values', 'returning', + ]); + + const aliasMap: Record = {}; + // Capture table and optional alias, support schema.table + const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi; + let m; + while ((m = aliasRegex.exec(fullText)) !== null) { + const tableIdent = normalizeQualifiedName(m[1] || ''); + if (!tableIdent) continue; + const shortTable = getLastPart(tableIdent); + // allow "table." as qualifier too + if (shortTable) aliasMap[shortTable.toLowerCase()] = tableIdent; + + const a = stripQuotes(m[2] || '').trim(); + if (!a) continue; + const al = a.toLowerCase(); + if (reserved.has(al)) continue; + aliasMap[al] = tableIdent; + } + + const tableIdent = aliasMap[alias.toLowerCase()]; + if (tableIdent) { + const shortTable = getLastPart(tableIdent); + + // Prefer preloaded MySQL all-columns cache + let cols: { name: string, type?: string, tableName?: string }[] = []; + if (allColumnsRef.current.length > 0) { + cols = allColumnsRef.current + .filter(c => (c.tableName || '').toLowerCase() === (shortTable || '').toLowerCase()) + .map(c => ({ name: c.name, type: c.type, tableName: c.tableName })); + } else { + const dbCols = await getColumnsByDB(tableIdent); + cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: shortTable })); + } + + const filtered = colPrefix + ? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix)) + : cols; + + const suggestions = filtered.map(c => ({ + label: c.name, + kind: monaco.languages.CompletionItemKind.Field, + insertText: c.name, + detail: c.type ? `${c.type}${c.tableName ? ` (${c.tableName})` : ''}` : (c.tableName ? `(${c.tableName})` : ''), + range, + sortText: '0' + c.name + })); + return { suggestions }; + } + } + + // 2) global/table/column completion + const tableRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)/gi; const foundTables = new Set(); let match; - const fullText = model.getValue(); while ((match = tableRegex.exec(fullText)) !== null) { - foundTables.add(match[1]); + const t = normalizeQualifiedName(match[1] || ''); + if (!t) continue; + foundTables.add(getLastPart(t).toLowerCase()); } const relevantColumns = allColumnsRef.current - .filter(c => foundTables.has(c.tableName)) + .filter(c => foundTables.has((c.tableName || '').toLowerCase())) .map(c => ({ label: c.name, kind: monaco.languages.CompletionItemKind.Field,