import React, { useState, useEffect, useRef, useMemo } from 'react'; import Editor, { OnMount } from '@monaco-editor/react'; import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd'; import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined } from '@ant-design/icons'; import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { convertMongoShellToJsonCommand } from '../utils/mongodb'; import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts'; const SQL_KEYWORDS = [ 'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'ADD', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN', ]; // SQL 常用内置函数(通用,适用于 MySQL/PostgreSQL/Oracle/SQL Server 等主流数据源) const SQL_FUNCTIONS: { name: string; detail: string }[] = [ // 聚合函数 { name: 'COUNT', detail: '聚合 - 计数' }, { name: 'SUM', detail: '聚合 - 求和' }, { name: 'AVG', detail: '聚合 - 平均值' }, { name: 'MAX', detail: '聚合 - 最大值' }, { name: 'MIN', detail: '聚合 - 最小值' }, { name: 'GROUP_CONCAT', detail: '聚合 - 拼接分组值' }, // 字符串函数 { name: 'CONCAT', detail: '字符串 - 拼接' }, { name: 'CONCAT_WS', detail: '字符串 - 带分隔符拼接' }, { name: 'SUBSTRING', detail: '字符串 - 截取子串' }, { name: 'SUBSTR', detail: '字符串 - 截取子串' }, { name: 'LEFT', detail: '字符串 - 从左截取' }, { name: 'RIGHT', detail: '字符串 - 从右截取' }, { name: 'LENGTH', detail: '字符串 - 字节长度' }, { name: 'CHAR_LENGTH', detail: '字符串 - 字符长度' }, { name: 'UPPER', detail: '字符串 - 转大写' }, { name: 'LOWER', detail: '字符串 - 转小写' }, { name: 'TRIM', detail: '字符串 - 去空格' }, { name: 'LTRIM', detail: '字符串 - 去左空格' }, { name: 'RTRIM', detail: '字符串 - 去右空格' }, { name: 'REPLACE', detail: '字符串 - 替换' }, { name: 'REVERSE', detail: '字符串 - 反转' }, { name: 'REPEAT', detail: '字符串 - 重复' }, { name: 'LPAD', detail: '字符串 - 左填充' }, { name: 'RPAD', detail: '字符串 - 右填充' }, { name: 'INSTR', detail: '字符串 - 查找位置' }, { name: 'LOCATE', detail: '字符串 - 查找位置' }, { name: 'FIND_IN_SET', detail: '字符串 - 在集合中查找' }, { name: 'FORMAT', detail: '字符串 - 数字格式化' }, { name: 'SPACE', detail: '字符串 - 生成空格' }, { name: 'INSERT', detail: '字符串 - 插入替换' }, { name: 'FIELD', detail: '字符串 - 返回位置索引' }, { name: 'ELT', detail: '字符串 - 按索引返回' }, { name: 'HEX', detail: '字符串 - 十六进制编码' }, { name: 'UNHEX', detail: '字符串 - 十六进制解码' }, // 数学函数 { name: 'ABS', detail: '数学 - 绝对值' }, { name: 'CEIL', detail: '数学 - 向上取整' }, { name: 'CEILING', detail: '数学 - 向上取整' }, { name: 'FLOOR', detail: '数学 - 向下取整' }, { name: 'ROUND', detail: '数学 - 四舍五入' }, { name: 'TRUNCATE', detail: '数学 - 截断小数' }, { name: 'MOD', detail: '数学 - 取模' }, { name: 'RAND', detail: '数学 - 随机数' }, { name: 'SIGN', detail: '数学 - 符号' }, { name: 'POWER', detail: '数学 - 幂运算' }, { name: 'POW', detail: '数学 - 幂运算' }, { name: 'SQRT', detail: '数学 - 平方根' }, { name: 'LOG', detail: '数学 - 对数' }, { name: 'LOG2', detail: '数学 - 以2为底对数' }, { name: 'LOG10', detail: '数学 - 以10为底对数' }, { name: 'LN', detail: '数学 - 自然对数' }, { name: 'EXP', detail: '数学 - e的次方' }, { name: 'PI', detail: '数学 - 圆周率' }, { name: 'GREATEST', detail: '数学 - 返回最大值' }, { name: 'LEAST', detail: '数学 - 返回最小值' }, // 日期时间函数 { name: 'NOW', detail: '日期 - 当前日期时间' }, { name: 'CURDATE', detail: '日期 - 当前日期' }, { name: 'CURRENT_DATE', detail: '日期 - 当前日期' }, { name: 'CURTIME', detail: '日期 - 当前时间' }, { name: 'CURRENT_TIME', detail: '日期 - 当前时间' }, { name: 'CURRENT_TIMESTAMP', detail: '日期 - 当前时间戳' }, { name: 'SYSDATE', detail: '日期 - 系统当前时间' }, { name: 'DATE', detail: '日期 - 提取日期部分' }, { name: 'TIME', detail: '日期 - 提取时间部分' }, { name: 'YEAR', detail: '日期 - 提取年份' }, { name: 'MONTH', detail: '日期 - 提取月份' }, { name: 'DAY', detail: '日期 - 提取天' }, { name: 'DAYOFWEEK', detail: '日期 - 星期几(1=周日)' }, { name: 'DAYOFYEAR', detail: '日期 - 年中第几天' }, { name: 'HOUR', detail: '日期 - 提取小时' }, { name: 'MINUTE', detail: '日期 - 提取分钟' }, { name: 'SECOND', detail: '日期 - 提取秒' }, { name: 'DATE_FORMAT', detail: '日期 - 格式化' }, { name: 'DATE_ADD', detail: '日期 - 加日期' }, { name: 'DATE_SUB', detail: '日期 - 减日期' }, { name: 'DATEDIFF', detail: '日期 - 日期差(天)' }, { name: 'TIMEDIFF', detail: '日期 - 时间差' }, { name: 'TIMESTAMPDIFF', detail: '日期 - 时间戳差' }, { name: 'TIMESTAMPADD', detail: '日期 - 时间戳加' }, { name: 'STR_TO_DATE', detail: '日期 - 字符串转日期' }, { name: 'UNIX_TIMESTAMP', detail: '日期 - Unix时间戳' }, { name: 'FROM_UNIXTIME', detail: '日期 - 从Unix时间戳转换' }, { name: 'LAST_DAY', detail: '日期 - 月末日期' }, { name: 'WEEK', detail: '日期 - 第几周' }, { name: 'QUARTER', detail: '日期 - 第几季度' }, { name: 'ADDDATE', detail: '日期 - 加日期' }, { name: 'SUBDATE', detail: '日期 - 减日期' }, // 条件/流程控制函数 { name: 'IF', detail: '条件 - 如果' }, { name: 'IFNULL', detail: '条件 - NULL替换' }, { name: 'NULLIF', detail: '条件 - 相等返回NULL' }, { name: 'COALESCE', detail: '条件 - 返回第一个非NULL' }, { name: 'CASE', detail: '条件 - 分支表达式' }, // 类型转换 { name: 'CAST', detail: '转换 - 类型转换' }, { name: 'CONVERT', detail: '转换 - 类型/字符集转换' }, // JSON 函数 { name: 'JSON_EXTRACT', detail: 'JSON - 提取值' }, { name: 'JSON_UNQUOTE', detail: 'JSON - 去引号' }, { name: 'JSON_SET', detail: 'JSON - 设置值' }, { name: 'JSON_INSERT', detail: 'JSON - 插入值' }, { name: 'JSON_REPLACE', detail: 'JSON - 替换值' }, { name: 'JSON_REMOVE', detail: 'JSON - 删除值' }, { name: 'JSON_CONTAINS', detail: 'JSON - 包含判断' }, { name: 'JSON_OBJECT', detail: 'JSON - 构建对象' }, { name: 'JSON_ARRAY', detail: 'JSON - 构建数组' }, { name: 'JSON_LENGTH', detail: 'JSON - 元素个数' }, { name: 'JSON_TYPE', detail: 'JSON - 值类型' }, { name: 'JSON_VALID', detail: 'JSON - 验证' }, { name: 'JSON_KEYS', detail: 'JSON - 获取键列表' }, // 加密/哈希函数 { name: 'MD5', detail: '加密 - MD5哈希' }, { name: 'SHA1', detail: '加密 - SHA1哈希' }, { name: 'SHA2', detail: '加密 - SHA2哈希' }, { name: 'UUID', detail: '工具 - 生成UUID' }, // 信息函数 { name: 'DATABASE', detail: '信息 - 当前数据库' }, { name: 'USER', detail: '信息 - 当前用户' }, { name: 'VERSION', detail: '信息 - MySQL版本' }, { name: 'CONNECTION_ID', detail: '信息 - 连接ID' }, { name: 'LAST_INSERT_ID', detail: '信息 - 最后插入ID' }, { name: 'ROW_COUNT', detail: '信息 - 影响行数' }, { name: 'FOUND_ROWS', detail: '信息 - 匹配总行数' }, { name: 'CHARSET', detail: '信息 - 字符集' }, { name: 'COLLATION', detail: '信息 - 排序规则' }, // 窗口函数 { name: 'ROW_NUMBER', detail: '窗口 - 行号' }, { name: 'RANK', detail: '窗口 - 排名(有间隔)' }, { name: 'DENSE_RANK', detail: '窗口 - 排名(无间隔)' }, { name: 'NTILE', detail: '窗口 - 分桶' }, { name: 'LAG', detail: '窗口 - 前一行' }, { name: 'LEAD', detail: '窗口 - 后一行' }, { name: 'FIRST_VALUE', detail: '窗口 - 第一个值' }, { name: 'LAST_VALUE', detail: '窗口 - 最后一个值' }, { name: 'NTH_VALUE', detail: '窗口 - 第N个值' }, // 其他 { name: 'DISTINCT', detail: '修饰 - 去重' }, { name: 'EXISTS', detail: '修饰 - 存在判断' }, { name: 'BETWEEN', detail: '修饰 - 范围判断' }, { name: 'LIKE', detail: '修饰 - 模式匹配' }, { name: 'REGEXP', detail: '修饰 - 正则匹配' }, { name: 'BENCHMARK', detail: '工具 - 性能测试' }, { name: 'SLEEP', detail: '工具 - 延时' }, ]; const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); type ResultSet = { key: string; sql: string; exportSql?: string; rows: any[]; columns: string[]; tableName?: string; pkColumns: string[]; readOnly: boolean; truncated?: boolean; pkLoading?: boolean; }; // Result Sets const [resultSets, setResultSets] = useState([]); const [activeResultKey, setActiveResultKey] = useState(''); const [loading, setLoading] = useState(false); const [, setCurrentQueryId] = useState(''); const runSeqRef = useRef(0); const currentQueryIdRef = useRef(''); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); const [saveForm] = Form.useForm(); // Database Selection const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId); const [currentDb, setCurrentDb] = useState(tab.dbName || ''); const [dbList, setDbList] = useState([]); // Resizing state const [editorHeight, setEditorHeight] = useState(300); const editorRef = useRef(null); const monacoRef = useRef(null); const lastExternalQueryRef = useRef(tab.query || ''); const dragRef = useRef<{ startY: number, startHeight: number } | null>(null); const queryEditorRootRef = useRef(null); const editorPaneRef = useRef(null); const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db) const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db) const visibleDbsRef = useRef([]); // Store visible databases for cross-db intellisense const connections = useStore(state => state.connections); const queryCapableConnections = useMemo( () => connections.filter(c => getDataSourceCapabilities(c.config).supportsQueryEditor), [connections] ); const addSqlLog = useStore(state => state.addSqlLog); const addTab = useStore(state => state.addTab); const savedQueries = useStore(state => state.savedQueries); const currentConnectionIdRef = useRef(currentConnectionId); const currentDbRef = useRef(currentDb); const connectionsRef = useRef(connections); const columnsCacheRef = useRef>({}); const saveQuery = useStore(state => state.saveQuery); const theme = useStore(state => state.theme); const darkMode = theme === 'dark'; const sqlFormatOptions = useStore(state => state.sqlFormatOptions); const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); const shortcutOptions = useStore(state => state.shortcutOptions); const activeTabId = useStore(state => state.activeTabId); const currentSavedQuery = useMemo(() => { const savedId = String(tab.savedQueryId || '').trim(); if (savedId) { return savedQueries.find((item) => item.id === savedId) || null; } const tabId = String(tab.id || '').trim(); if (!tabId) { return null; } return savedQueries.find((item) => item.id === tabId) || null; }, [savedQueries, tab.id, tab.savedQueryId]); useEffect(() => { currentConnectionIdRef.current = currentConnectionId; }, [currentConnectionId]); useEffect(() => { if (!queryCapableConnections.some(c => c.id === currentConnectionId)) { const fallback = queryCapableConnections[0]?.id || ''; if (fallback && fallback !== currentConnectionId) { setCurrentConnectionId(fallback); setCurrentDb(''); } } }, [queryCapableConnections, currentConnectionId]); useEffect(() => { currentDbRef.current = currentDb; }, [currentDb]); useEffect(() => { connectionsRef.current = connections; }, [connections]); const getCurrentQuery = () => { const val = editorRef.current?.getValue?.(); if (typeof val === 'string') return val; return query || ''; }; const syncQueryToEditor = (sql: string) => { const next = sql || ''; setQuery(next); const editor = editorRef.current; if (editor && editor.getValue?.() !== next) { editor.setValue(next); } }; // If opening a saved query, load its SQL useEffect(() => { const incoming = tab.query || ''; if (incoming === lastExternalQueryRef.current) { return; } lastExternalQueryRef.current = incoming; syncQueryToEditor(incoming || 'SELECT * FROM '); }, [tab.id, tab.query]); // Fetch Database List useEffect(() => { const fetchDbs = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; const config = { ...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 res = await DBGetDatabases(config as any); if (res.success && Array.isArray(res.data)) { let dbs = res.data.map((row: any) => row.Database || row.database); // 过滤只显示 includeDatabases 中配置的数据库 const includeDbs = conn.includeDatabases; if (includeDbs && includeDbs.length > 0) { dbs = dbs.filter((db: string) => includeDbs.includes(db)); } // 存储可见数据库列表用于跨库智能提示 visibleDbsRef.current = dbs; setDbList(dbs); if (!currentDbRef.current) { if (conn.config.database && dbs.includes(conn.config.database)) setCurrentDb(conn.config.database); else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]); } } else { visibleDbsRef.current = []; setDbList([]); } }; void fetchDbs(); }, [currentConnectionId, connections]); // Fetch Metadata for Autocomplete (Cross-database) useEffect(() => { const fetchMetadata = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; const visibleDbs = visibleDbsRef.current; if (!visibleDbs || visibleDbs.length === 0) return; const config = { ...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 allTables: {dbName: string, tableName: string}[] = []; const allColumns: {dbName: string, tableName: string, name: string, type: string}[] = []; for (const dbName of visibleDbs) { // 获取表 const resTables = await DBGetTables(config as any, dbName); if (resTables.success && Array.isArray(resTables.data)) { const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string); tableNames.forEach((tableName: string) => { allTables.push({ dbName, tableName }); }); } // 获取列 (所有数据库类型都支持 DBGetAllColumns) const resCols = await DBGetAllColumns(config as any, dbName); if (resCols.success && Array.isArray(resCols.data)) { resCols.data.forEach((col: any) => { allColumns.push({ dbName, tableName: col.tableName, name: col.name, type: col.type }); }); } } tablesRef.current = allTables; allColumnsRef.current = allColumns; }; void fetchMetadata(); }, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载 // Query ID management helpers const setQueryId = (id: string) => { currentQueryIdRef.current = id; setCurrentQueryId(id); }; const clearQueryId = () => { currentQueryIdRef.current = ''; setCurrentQueryId(''); }; // Handle Resizing const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); dragRef.current = { startY: e.clientY, startHeight: editorHeight }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; const handleMouseMove = (e: MouseEvent) => { if (!dragRef.current) return; const delta = e.clientY - dragRef.current.startY; const newHeight = Math.max(100, Math.min(window.innerHeight - 200, dragRef.current.startHeight + delta)); setEditorHeight(newHeight); }; const handleMouseUp = () => { dragRef.current = null; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; // Setup Autocomplete and Editor const handleEditorDidMount: OnMount = (editor, monaco) => { editorRef.current = editor; monacoRef.current = monaco; // 应用透明主题(主题已在 main.tsx 全局注册) monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light'); monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['.'], provideCompletionItems: async (model: any, position: any) => { const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; 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 splitSchemaAndTable = (qualified: string): { schema: string; table: string } => { const raw = normalizeQualifiedName(qualified); if (!raw) return { schema: '', table: '' }; const parts = raw.split('.').filter(Boolean); if (parts.length >= 2) { return { schema: parts[parts.length - 2] || '', table: parts[parts.length - 1] || '', }; } return { schema: '', table: parts[0] || '' }; }; 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(); // 获取当前行光标前的内容 const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1); // 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列 const threePartMatch = linePrefix.match(/([`"]?\w+[`"]?)\.([`"]?\w+[`"]?)\.(\w*)$/); if (threePartMatch) { const dbPart = stripQuotes(threePartMatch[1]); const tablePart = stripQuotes(threePartMatch[2]); const colPrefix = (threePartMatch[3] || '').toLowerCase(); // 在 allColumnsRef 中查找匹配的列 const cols = allColumnsRef.current.filter(c => (c.dbName || '').toLowerCase() === dbPart.toLowerCase() && (c.tableName || '').toLowerCase() === tablePart.toLowerCase() ); 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.dbName}.${c.tableName})`, range, sortText: '0' + c.name })); return { suggestions }; } // 1) 两段式 qualifier.xxx 格式 const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_]\w*[`"]?)\.(\w*)$/); if (qualifierMatch) { const qualifier = stripQuotes(qualifierMatch[1]); const prefix = (qualifierMatch[2] || '').toLowerCase(); const qualifierLower = qualifier.toLowerCase(); // 首先检查 qualifier 是否是数据库名(跨库表提示) const visibleDbs = visibleDbsRef.current; if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) { // qualifier 是数据库名,提示该库的表 const tables = tablesRef.current.filter(t => (t.dbName || '').toLowerCase() === qualifierLower ); const filtered = prefix ? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix)) : tables; const suggestions = filtered.map(t => ({ label: t.tableName, kind: monaco.languages.CompletionItemKind.Class, insertText: t.tableName, detail: `Table (${t.dbName})`, range, sortText: '0' + t.tableName })); return { suggestions }; } // qualifier 是 schema(如 dbo/public)时,仅补全表名,避免输入 dbo. 后再补成 dbo.dbo.table const schemaTables = tablesRef.current .map(t => { const parsed = splitSchemaAndTable(t.tableName || ''); return { dbName: t.dbName || '', schema: parsed.schema, table: parsed.table, }; }) .filter(t => t.schema.toLowerCase() === qualifierLower && !!t.table); if (schemaTables.length > 0) { const filtered = prefix ? schemaTables.filter(t => t.table.toLowerCase().startsWith(prefix)) : schemaTables; const suggestions = filtered.map(t => ({ label: t.table, kind: monaco.languages.CompletionItemKind.Class, insertText: t.table, detail: `Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`, range, sortText: '0' + t.table })); return { suggestions }; } // 否则检查是否是表别名或表名,提示列 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 db.table format 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; // 解析 db.table 或 table 格式 const parts = tableIdent.split('.'); let dbName = currentDbRef.current || ''; let tableName = tableIdent; if (parts.length === 2) { dbName = parts[0]; tableName = parts[1]; } const shortTable = getLastPart(tableIdent); // 用表名作为 qualifier if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName }; const a = stripQuotes(m[2] || '').trim(); if (!a) continue; const al = a.toLowerCase(); if (reserved.has(al)) continue; aliasMap[al] = { dbName, tableName }; } const tableInfo = aliasMap[qualifier.toLowerCase()]; if (tableInfo) { // Prefer preloaded MySQL all-columns cache let cols: { name: string, type?: string, tableName?: string, dbName?: string }[]; if (allColumnsRef.current.length > 0) { cols = allColumnsRef.current .filter(c => (c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() && (c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase() ) .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName })); } else { const dbCols = await getColumnsByDB(tableInfo.tableName); cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName })); } const filtered = prefix ? cols.filter(c => (c.name || '').toLowerCase().startsWith(prefix)) : cols; const suggestions = filtered.map(c => ({ label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: c.name, detail: c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${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; while ((match = tableRegex.exec(fullText)) !== null) { const t = normalizeQualifiedName(match[1] || ''); if (!t) continue; // 存储完整标识 db.table 或 table foundTables.add(t.toLowerCase()); } const currentDatabase = currentDbRef.current || ''; const wordPrefix = (word.word || '').toLowerCase(); const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix); const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim()); const shouldBoostKeywords = !expectsTableName && wordPrefix.length > 0 && SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix)); const sortGroups = shouldBoostKeywords ? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' } : expectsTableName ? { keyword: '20', func: '25', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' } : { keyword: '30', func: '25', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' }; // 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等) // 权重最高,输入 WHERE 条件时优先显示 const relevantColumns = allColumnsRef.current .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || ''); }) .map(c => { // 当前库的表字段优先级更高 const isCurrentDb = (c.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); return { label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: c.name, detail: `${c.type} (${c.dbName}.${c.tableName})`, range, sortText: isCurrentDb ? sortGroups.columnCurrent + c.name : sortGroups.columnOther + c.name, }; }); // 表提示:当前库显示表名,其他库显示 db.table 格式 const tableSuggestions = tablesRef.current .filter(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; return startsWithPrefix(label || ''); }) .map(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText, detail: `Table (${t.dbName})`, range, sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName, }; }); // 数据库提示 const dbSuggestions = visibleDbsRef.current .filter((db) => startsWithPrefix(db)) .map(db => ({ label: db, kind: monaco.languages.CompletionItemKind.Module, insertText: db, detail: 'Database', range, sortText: sortGroups.db + db, })); // 关键字提示 const keywordSuggestions = SQL_KEYWORDS .filter((k) => startsWithPrefix(k)) .map(k => ({ label: k, kind: monaco.languages.CompletionItemKind.Keyword, insertText: k, range, sortText: sortGroups.keyword + k, })); // 内置函数提示 const funcSuggestions = SQL_FUNCTIONS .filter((f) => startsWithPrefix(f.name)) .map(f => ({ label: f.name, kind: monaco.languages.CompletionItemKind.Function, insertText: f.name + '($0)', insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, detail: f.detail, range, sortText: sortGroups.func + f.name, })); const suggestions = [ ...relevantColumns, // FROM 表的列最优先 ...tableSuggestions, // 表次之 ...dbSuggestions, // 数据库 ...funcSuggestions, // 内置函数 ...keywordSuggestions // 关键字最后 ]; return { suggestions }; } }); }; const handleFormat = () => { try { const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase }); syncQueryToEditor(formatted); } catch (e) { void message.error("格式化失败: SQL 语法可能有误"); } }; const formatSettingsMenu: MenuProps['items'] = [ { key: 'upper', label: '关键字大写', icon: sqlFormatOptions.keywordCase === 'upper' ? '✓' : undefined, onClick: () => setSqlFormatOptions({ keywordCase: 'upper' }) }, { key: 'lower', label: '关键字小写', icon: sqlFormatOptions.keywordCase === 'lower' ? '✓' : undefined, onClick: () => setSqlFormatOptions({ keywordCase: 'lower' }) }, { type: 'divider' }, { key: 'shortcut-settings', label: '快捷键管理...', onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-shortcut-settings')), }, ]; const splitSQLStatements = (sql: string): string[] => { const text = (sql || '').replace(/\r\n/g, '\n'); const statements: string[] = []; let cur = ''; let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; // postgres/kingbase: $$...$$ or $tag$...$tag$ const push = () => { const s = cur.trim(); if (s) statements.push(s); cur = ''; }; const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = i + 1 < text.length ? text[i + 1] : ''; const prev = i > 0 ? text[i - 1] : ''; const next2 = i + 2 < text.length ? text[i + 2] : ''; if (!inSingle && !inDouble && !inBacktick) { if (inLineComment) { cur += ch; if (ch === '\n') inLineComment = false; continue; } if (inBlockComment) { cur += ch; if (ch === '*' && next === '/') { cur += next; i++; inBlockComment = false; } continue; } // Start comments if (ch === '/' && next === '*') { cur += ch + next; i++; inBlockComment = true; continue; } if (ch === '#') { cur += ch; inLineComment = true; continue; } if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { cur += ch + next; i++; inLineComment = true; continue; } // Dollar-quoted strings (PG/Kingbase) if (dollarTag) { if (text.startsWith(dollarTag, i)) { cur += dollarTag; i += dollarTag.length - 1; dollarTag = null; } else { cur += ch; } continue; } if (ch === '$') { const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); if (m && m[0]) { dollarTag = m[0]; cur += dollarTag; i += dollarTag.length - 1; continue; } } } if (escaped) { cur += ch; escaped = false; continue; } if ((inSingle || inDouble) && ch === '\\') { cur += ch; escaped = true; continue; } if (!inDouble && !inBacktick && ch === '\'') { inSingle = !inSingle; cur += ch; continue; } if (!inSingle && !inBacktick && ch === '"') { inDouble = !inDouble; cur += ch; continue; } if (!inSingle && !inDouble && ch === '`') { inBacktick = !inBacktick; cur += ch; continue; } if (!inSingle && !inDouble && !inBacktick && !dollarTag && (ch === ';' || ch === ';')) { push(); continue; } cur += ch; } push(); return statements; }; // DEBT: 改用 DBQueryMulti 后前端不再逐条处理语句,此函数暂时未使用。 // 当恢复前端自动行数限制功能时需要启用。 // eslint-disable-next-line @typescript-eslint/no-unused-vars const getLeadingKeyword = (sql: string): string => { const text = (sql || '').replace(/\r\n/g, '\n'); const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch); let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = i + 1 < text.length ? text[i + 1] : ''; const prev = i > 0 ? text[i - 1] : ''; const next2 = i + 2 < text.length ? text[i + 2] : ''; if (!inSingle && !inDouble && !inBacktick) { if (inLineComment) { if (ch === '\n') inLineComment = false; continue; } if (inBlockComment) { if (ch === '*' && next === '/') { i++; inBlockComment = false; } continue; } if (ch === '/' && next === '*') { i++; inBlockComment = true; continue; } if (ch === '#') { inLineComment = true; continue; } if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { i++; inLineComment = true; continue; } if (dollarTag) { if (text.startsWith(dollarTag, i)) { i += dollarTag.length - 1; dollarTag = null; } continue; } if (ch === '$') { const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); if (m && m[0]) { dollarTag = m[0]; i += dollarTag.length - 1; continue; } } } if (escaped) { escaped = false; continue; } if ((inSingle || inDouble) && ch === '\\') { escaped = true; continue; } if (!inDouble && !inBacktick && ch === '\'') { inSingle = !inSingle; continue; } if (!inSingle && !inBacktick && ch === '"') { inDouble = !inDouble; continue; } if (!inSingle && !inDouble && ch === '`') { inBacktick = !inBacktick; continue; } if (inSingle || inDouble || inBacktick || dollarTag) continue; if (isWS(ch)) continue; if (isWord(ch)) { let j = i; while (j < text.length && isWord(text[j])) j++; return text.slice(i, j).toLowerCase(); } return ''; } return ''; }; const splitSqlTail = (sql: string): { main: string; tail: string } => { const text = (sql || '').replace(/\r\n/g, '\n'); const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; let lastMeaningful = -1; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = i + 1 < text.length ? text[i + 1] : ''; const prev = i > 0 ? text[i - 1] : ''; const next2 = i + 2 < text.length ? text[i + 2] : ''; if (!inSingle && !inDouble && !inBacktick) { if (dollarTag) { if (text.startsWith(dollarTag, i)) { lastMeaningful = i + dollarTag.length - 1; i += dollarTag.length - 1; dollarTag = null; } else if (!isWS(ch)) { lastMeaningful = i; } continue; } if (inLineComment) { if (ch === '\n') inLineComment = false; continue; } if (inBlockComment) { if (ch === '*' && next === '/') { i++; inBlockComment = false; } continue; } // Start comments if (ch === '/' && next === '*') { i++; inBlockComment = true; continue; } if (ch === '#') { inLineComment = true; continue; } if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { i++; inLineComment = true; continue; } if (ch === '$') { const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); if (m && m[0]) { dollarTag = m[0]; lastMeaningful = i + dollarTag.length - 1; i += dollarTag.length - 1; continue; } } } if (escaped) { escaped = false; } else if ((inSingle || inDouble) && ch === '\\') { escaped = true; } else { if (!inDouble && !inBacktick && ch === '\'') inSingle = !inSingle; else if (!inSingle && !inBacktick && ch === '"') inDouble = !inDouble; else if (!inSingle && !inDouble && ch === '`') inBacktick = !inBacktick; } if (!inLineComment && !inBlockComment && !isWS(ch)) { lastMeaningful = i; } } if (lastMeaningful < 0) return { main: '', tail: text }; return { main: text.slice(0, lastMeaningful + 1), tail: text.slice(lastMeaningful + 1) }; }; const findTopLevelKeyword = (sql: string, keyword: string): number => { const text = sql; const kw = keyword.toLowerCase(); const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch); let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; let parenDepth = 0; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = i + 1 < text.length ? text[i + 1] : ''; const prev = i > 0 ? text[i - 1] : ''; const next2 = i + 2 < text.length ? text[i + 2] : ''; if (!inSingle && !inDouble && !inBacktick) { if (inLineComment) { if (ch === '\n') inLineComment = false; continue; } if (inBlockComment) { if (ch === '*' && next === '/') { i++; inBlockComment = false; } continue; } if (ch === '/' && next === '*') { i++; inBlockComment = true; continue; } if (ch === '#') { inLineComment = true; continue; } if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { i++; inLineComment = true; continue; } if (dollarTag) { if (text.startsWith(dollarTag, i)) { i += dollarTag.length - 1; dollarTag = null; } continue; } if (ch === '$') { const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); if (m && m[0]) { dollarTag = m[0]; i += dollarTag.length - 1; continue; } } } if (escaped) { escaped = false; continue; } if ((inSingle || inDouble) && ch === '\\') { escaped = true; continue; } if (!inDouble && !inBacktick && ch === '\'') { inSingle = !inSingle; continue; } if (!inSingle && !inBacktick && ch === '"') { inDouble = !inDouble; continue; } if (!inSingle && !inDouble && ch === '`') { inBacktick = !inBacktick; continue; } if (inSingle || inDouble || inBacktick || dollarTag) continue; if (ch === '(') { parenDepth++; continue; } if (ch === ')') { if (parenDepth > 0) parenDepth--; continue; } if (parenDepth !== 0) continue; if (!isWord(ch)) continue; if (text.slice(i, i + kw.length).toLowerCase() !== kw) continue; const before = i - 1 >= 0 ? text[i - 1] : ''; const after = i + kw.length < text.length ? text[i + kw.length] : ''; if ((before && isWord(before)) || (after && isWord(after))) continue; return i; } return -1; }; // DEBT: 改用 DBQueryMulti 后前端不再逐条处理语句,此函数暂时未使用。 // 当恢复前端自动行数限制功能时需要启用。 // eslint-disable-next-line @typescript-eslint/no-unused-vars const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => { const normalizedType = (dbType || 'mysql').toLowerCase(); const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === 'clickhouse' || normalizedType === ''; if (!supportsLimit) return { sql, applied: false, maxRows }; if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows }; const { main, tail } = splitSqlTail(sql); if (!main.trim()) return { sql, applied: false, maxRows }; const fromPos = findTopLevelKeyword(main, 'from'); const limitPos = findTopLevelKeyword(main, 'limit'); if (limitPos >= 0 && (fromPos < 0 || limitPos > fromPos)) return { sql, applied: false, maxRows }; const fetchPos = findTopLevelKeyword(main, 'fetch'); if (fetchPos >= 0 && (fromPos < 0 || fetchPos > fromPos)) return { sql, applied: false, maxRows }; const offsetPos = findTopLevelKeyword(main, 'offset'); const forPos = findTopLevelKeyword(main, 'for'); const lockPos = findTopLevelKeyword(main, 'lock'); const candidates = [offsetPos, forPos, lockPos] .filter(pos => pos >= 0 && (fromPos < 0 || pos > fromPos)); const insertAt = candidates.length > 0 ? Math.min(...candidates) : main.length; const before = main.slice(0, insertAt).trimEnd(); const after = main.slice(insertAt).trimStart(); const nextMain = [before, `LIMIT ${maxRows}`, after].filter(Boolean).join(' ').trim(); return { sql: nextMain + tail, applied: true, maxRows }; }; const getSelectedSQL = (): string => { const editor = editorRef.current; if (!editor) return ''; const model = editor.getModel?.(); const selection = editor.getSelection?.(); if (!model || !selection) return ''; const selected = model.getValueInRange?.(selection) || ''; if (typeof selected !== 'string') return ''; if (!selected.trim()) return ''; return selected; }; const handleRun = async () => { const currentQuery = getCurrentQuery(); if (!currentQuery.trim()) return; if (!currentDb) { message.error("请先选择数据库"); return; } // 如果已有查询在运行,先取消它 if (currentQueryIdRef.current) { try { await CancelQuery(currentQueryIdRef.current); } catch (error) { // 忽略取消错误,可能查询已完成 } // 清除旧查询ID clearQueryId(); } const runSeq = ++runSeqRef.current; setLoading(true); const runStartTime = Date.now(); const conn = connections.find(c => c.id === currentConnectionId); if (!conn) { message.error("Connection not found"); if (runSeqRef.current === runSeq) setLoading(false); return; } const connCaps = getDataSourceCapabilities(conn.config); if (!connCaps.supportsQueryEditor) { message.error("当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。"); if (runSeqRef.current === runSeq) setLoading(false); return; } const config = { ...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: "" } }; try { const rawSQL = getSelectedSQL() || currentQuery; const dbType = String((config as any).type || 'mysql'); const normalizedDbType = dbType.trim().toLowerCase(); const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';'); // MongoDB 仍走逐条执行的旧路径 const isMongoDB = normalizedDbType === 'mongodb'; if (isMongoDB) { // MongoDB: 保持逐条执行 const splitInput = normalizedRawSQL .replace(/^\s*\/\/.*$/gm, '') .replace(/^\s*#.*$/gm, ''); const statements = splitSQLStatements(splitInput); if (statements.length === 0) { message.info('没有可执行的 SQL。'); setResultSets([]); setActiveResultKey(''); return; } const nextResultSets: ResultSet[] = []; const maxRows = Number(queryOptions?.maxRows) || 0; const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0; let anyTruncated = false; for (let idx = 0; idx < statements.length; idx++) { const rawStatement = statements[idx]; let executedSql = rawStatement; const shellConvert = convertMongoShellToJsonCommand(executedSql); if (shellConvert.recognized) { if (shellConvert.error) { const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; message.error(prefix + shellConvert.error); setResultSets([]); setActiveResultKey(''); return; } if (shellConvert.command) { executedSql = shellConvert.command; } } const startTime = Date.now(); let queryId: string; try { queryId = await GenerateQueryID(); } catch (error) { console.warn('GenerateQueryID failed, using local UUID fallback:', error); queryId = 'query-' + uuidv4(); } setQueryId(queryId); const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId); const duration = Date.now() - startTime; addSqlLog({ id: `log-${Date.now()}-query-${idx + 1}`, timestamp: Date.now(), sql: executedSql, status: res.success ? 'success' : 'error', duration, message: res.success ? '' : res.message, affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined), dbName: currentDb }); if (!res.success) { const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; message.error(prefix + res.message); setResultSets([]); setActiveResultKey(''); return; } if (Array.isArray(res.data)) { let rows = (res.data as any[]) || []; let truncated = false; if (wantsLimitProbe && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { truncated = true; anyTruncated = true; rows = rows.slice(0, maxRows); } const cols = (res.fields && res.fields.length > 0) ? (res.fields as string[]) : (rows.length > 0 ? Object.keys(rows[0]) : []); rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: rawStatement, rows, columns: cols, pkColumns: [], readOnly: true, truncated }); } else { const affected = Number((res.data as any)?.affectedRows); if (Number.isFinite(affected)) { const row = { affectedRows: affected }; (row as any)[GONAVI_ROW_KEY] = 0; nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: rawStatement, rows: [row], columns: ['affectedRows'], pkColumns: [], readOnly: true }); } } } setResultSets(nextResultSets); setActiveResultKey(nextResultSets[0]?.key || ''); if (statements.length > 1) { message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`); } else if (nextResultSets.length === 0) { message.success('执行成功。'); } if (anyTruncated && maxRows > 0) { message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`); } } else { // 非 MongoDB:使用 DBQueryMulti 一次性执行多条 SQL,后端返回多结果集 const fullSQL = normalizedRawSQL; if (!fullSQL.trim()) { message.info('没有可执行的 SQL。'); setResultSets([]); setActiveResultKey(''); return; } const startTime = Date.now(); let queryId: string; try { queryId = await GenerateQueryID(); } catch (error) { console.warn('GenerateQueryID failed, using local UUID fallback:', error); queryId = 'query-' + uuidv4(); } setQueryId(queryId); const res = await DBQueryMulti(config as any, currentDb, fullSQL, queryId); const duration = Date.now() - startTime; addSqlLog({ id: `log-${Date.now()}-query-multi`, timestamp: Date.now(), sql: fullSQL, status: res.success ? 'success' : 'error', duration, message: res.success ? '' : res.message, dbName: currentDb }); if (!res.success) { const errorMsg = res.message.toLowerCase(); const isCancelledError = errorMsg.includes('context canceled') || errorMsg.includes('查询已取消') || errorMsg.includes('canceled') || errorMsg.includes('cancelled') || errorMsg.includes('statement canceled') || errorMsg.includes('sql: statement canceled'); const isTimeoutError = errorMsg.includes('context deadline exceeded') || errorMsg.includes('timeout') || errorMsg.includes('超时') || errorMsg.includes('deadline exceeded'); if (isCancelledError && !isTimeoutError) { setResultSets([]); setActiveResultKey(''); if (currentQueryIdRef.current) { clearQueryId(); } return; } message.error(res.message); setResultSets([]); setActiveResultKey(''); return; } // res.data 是 ResultSetData[] 数组 const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; const nextResultSets: ResultSet[] = []; const maxRows = Number(queryOptions?.maxRows) || 0; const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; let anyTruncated = false; const pendingPk: Array<{ resultKey: string; tableName: string }> = []; // 前端也拆分语句用于匹配原始 SQL(展示和表名检测) const statements = splitSQLStatements(fullSQL); for (let idx = 0; idx < resultSetDataArray.length; idx++) { const rsData = resultSetDataArray[idx]; const rawStatement = (idx < statements.length) ? statements[idx] : ''; // 检查是否为 affectedRows 类结果集 const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1 && rsData.columns && rsData.columns.length === 1 && rsData.columns[0] === 'affectedRows'; if (isAffectedResult) { const affected = Number(rsData.rows[0]?.affectedRows); const row = { affectedRows: Number.isFinite(affected) ? affected : 0 }; (row as any)[GONAVI_ROW_KEY] = 0; nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: rawStatement, rows: [row], columns: ['affectedRows'], pkColumns: [], readOnly: true }); } else { let rows = Array.isArray(rsData.rows) ? rsData.rows : []; let truncated = false; if (Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { truncated = true; anyTruncated = true; rows = rows.slice(0, maxRows); } const cols = (rsData.columns && rsData.columns.length > 0) ? rsData.columns : (rows.length > 0 ? Object.keys(rows[0]) : []); rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); let simpleTableName: string | undefined = undefined; if (rawStatement) { const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); if (tableMatch) { simpleTableName = tableMatch[1]; if (!forceReadOnlyResult) { pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName }); } } } nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: rawStatement, rows, columns: cols, tableName: simpleTableName, pkColumns: [], readOnly: true, pkLoading: !!simpleTableName, truncated }); } } setResultSets(nextResultSets); setActiveResultKey(nextResultSets[0]?.key || ''); pendingPk.forEach(({ resultKey, tableName }) => { DBGetColumns(config as any, currentDb, tableName) .then((resCols: any) => { if (runSeqRef.current !== runSeq) return; if (!resCols?.success) { setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs)); return; } const primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name); setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkColumns: primaryKeys, pkLoading: false, readOnly: false } : rs)); }) .catch(() => { if (runSeqRef.current !== runSeq) return; setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs)); }); }); // 后端附带的提示信息(如数据源不支持原生多语句执行的回退提示) if (res.message) { message.info(res.message); } if (resultSetDataArray.length > 1) { message.success(`已执行完成,生成 ${nextResultSets.length} 个结果集。`); } else if (nextResultSets.length === 0) { message.success('执行成功。'); } if (anyTruncated && maxRows > 0) { message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`); } } } catch (e: any) { message.error("Error executing query: " + e.message); addSqlLog({ id: `log-${Date.now()}-error`, timestamp: Date.now(), sql: getSelectedSQL() || query, status: 'error', duration: Date.now() - runStartTime, message: e.message, dbName: currentDb }); setResultSets([]); setActiveResultKey(''); } finally { if (runSeqRef.current === runSeq) setLoading(false); // Clear query ID after execution completes clearQueryId(); } }; const handleCancel = async () => { if (!currentQueryIdRef.current) { message.warning('没有正在运行的查询可取消'); return; } const queryIdToCancel = currentQueryIdRef.current; try { const res = await CancelQuery(queryIdToCancel); if (res.success) { message.success('查询已取消'); // Clear query ID after successful cancellation if (currentQueryIdRef.current === queryIdToCancel) { clearQueryId() } } else { message.warning(res.message); } } catch (error: any) { message.error('取消查询失败: ' + error.message); } }; useEffect(() => { const handleSelectAllInEditor = (event: KeyboardEvent) => { if (activeTabId !== tab.id) { return; } if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') { return; } const editor = editorRef.current; if (!editor) { return; } const targetNode = event.target instanceof Node ? event.target : null; const editorHasFocus = !!editor.hasTextFocus?.(); const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode)); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); if (!editorHasFocus && !inEditorPane) { return; } if (!editorHasFocus && isEditableElement(event.target) && !inEditorPane) { return; } if (!editorHasFocus && !inQueryEditor) { return; } event.preventDefault(); event.stopPropagation(); editor.focus?.(); editor.trigger('keyboard', 'editor.action.selectAll', null); }; window.addEventListener('keydown', handleSelectAllInEditor, true); return () => { window.removeEventListener('keydown', handleSelectAllInEditor, true); }; }, [activeTabId, tab.id]); useEffect(() => { const binding = shortcutOptions.runQuery; if (!binding?.enabled || !binding.combo) { return; } const handleRunShortcut = (event: KeyboardEvent) => { if (activeTabId !== tab.id) { return; } if (!isShortcutMatch(event, binding.combo)) { return; } const editorHasFocus = !!editorRef.current?.hasTextFocus?.(); if (!editorHasFocus && !isEditableElement(event.target)) { return; } event.preventDefault(); event.stopPropagation(); void handleRun(); }; window.addEventListener('keydown', handleRunShortcut); return () => { window.removeEventListener('keydown', handleRunShortcut); }; }, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]); useEffect(() => { const handleRunActiveQuery = () => { if (activeTabId !== tab.id) { return; } void handleRun(); }; window.addEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener); return () => { window.removeEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener); }; }, [activeTabId, tab.id, handleRun]); const resolveDefaultQueryName = () => { const rawTitle = String(tab.title || '').trim(); if (!rawTitle || rawTitle.startsWith('新建查询')) { return '未命名查询'; } return rawTitle; }; const persistQuery = (payload: { id: string; name: string; createdAt?: number }) => { const sql = getCurrentQuery(); const saved = { id: payload.id, name: payload.name, sql, connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', createdAt: payload.createdAt ?? Date.now(), }; saveQuery(saved); addTab({ ...tab, title: payload.name, query: sql, connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', savedQueryId: payload.id, }); return saved; }; const handleQuickSave = () => { const existed = currentSavedQuery || null; const fallbackSavedId = String(tab.savedQueryId || '').trim(); const saveId = existed?.id || fallbackSavedId || ''; if (!saveId) { saveForm.setFieldsValue({ name: resolveDefaultQueryName() }); setIsSaveModalOpen(true); return; } const saveName = existed?.name || resolveDefaultQueryName(); persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt }); message.success('查询已保存!'); }; const handleSave = async () => { try { const values = await saveForm.validateFields(); const existed = currentSavedQuery || null; const fallbackSavedId = String(tab.savedQueryId || '').trim(); const nextSavedId = existed?.id || fallbackSavedId || `saved-${Date.now()}`; persistQuery({ id: nextSavedId, name: String(values.name || '').trim() || '未命名查询', createdAt: existed?.createdAt, }); message.success('查询已保存!'); setIsSaveModalOpen(false); } catch (e) { } }; const handleCloseResult = (key: string) => { setResultSets(prev => { const idx = prev.findIndex(r => r.key === key); if (idx < 0) return prev; const next = prev.filter(r => r.key !== key); setActiveResultKey(prevActive => { if (prevActive && prevActive !== key) return prevActive; return next[idx]?.key || next[idx - 1]?.key || next[0]?.key || ''; }); return next; }); }; return (
({ label: db, value: db }))} showSearch />
); }; export default QueryEditor;