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, RobotOutlined } from '@ant-design/icons'; import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition, IndexDefinition } from '../types'; import { useStore } from '../store'; import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb"; import { getShortcutDisplay, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding } from "../utils/shortcuts"; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect'; import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart } from '../utils/sql'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator'; 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: '工具 - 延时' }, ]; // HMR 重载时释放旧注册避免补全项重复 const _g = globalThis as any; if (!_g.__gonaviSqlCompletionState) { _g.__gonaviSqlCompletionState = { registered: false, disposables: [] as any[] }; } let sqlCompletionRegistered = _g.__gonaviSqlCompletionState.registered; let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables; // 模块级共享变量:completion provider 从这些变量读取当前活跃 Tab 的状态。 // 每个 QueryEditor 实例在成为活跃 Tab 时更新这些变量,确保 provider 始终使用正确的上下文。 let sharedCurrentDb = ''; let sharedCurrentConnectionId = ''; let sharedConnections: any[] = []; let sharedTablesData: {dbName: string, tableName: string}[] = []; let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type: string}[] = []; let sharedVisibleDbs: string[] = []; let sharedColumnsCacheData: Record = {}; const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_'; const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({ strategy: 'none', columns: [], valueColumns: [], readOnly: true, reason, }); type SimpleSelectInfo = { selectsAll: boolean; selectsBareAll: boolean; writableColumns: Record; }; type QueryStatementPlan = { originalSql: string; executedSql: string; tableRef?: QueryResultTableRef; pkColumns: string[]; editLocator?: EditRowLocator; warning?: string; }; const stripQueryIdentifierQuotes = (part: string): string => { const text = String(part || '').trim(); if (!text) return ''; if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) { return text.slice(1, -1).trim(); } if (text.startsWith('[') && text.endsWith(']')) { return text.slice(1, -1).trim(); } return text; }; const splitTopLevelComma = (text: string): string[] => { const parts: string[] = []; let current = ''; let parenDepth = 0; let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; for (let index = 0; index < text.length; index++) { const ch = text[index]; if (escaped) { current += ch; escaped = false; continue; } if ((inSingle || inDouble) && ch === '\\') { current += ch; escaped = true; continue; } if (!inDouble && !inBacktick && ch === "'") { inSingle = !inSingle; current += ch; continue; } if (!inSingle && !inBacktick && ch === '"') { inDouble = !inDouble; current += ch; continue; } if (!inSingle && !inDouble && ch === '`') { inBacktick = !inBacktick; current += ch; continue; } if (!inSingle && !inDouble && !inBacktick) { if (ch === '(') parenDepth++; if (ch === ')' && parenDepth > 0) parenDepth--; if (ch === ',' && parenDepth === 0) { parts.push(current.trim()); current = ''; continue; } } current += ch; } if (current.trim()) parts.push(current.trim()); return parts; }; const SIMPLE_IDENTIFIER_PATH_RE = /^(?:[`"\[]?[A-Za-z_][\w$]*[`"\]]?\s*\.\s*){0,2}[`"\[]?[A-Za-z_][\w$]*[`"\]]?$/; const QUERY_ALIAS_RESERVED = new Set([ 'where', 'group', 'order', 'having', 'limit', 'fetch', 'offset', 'join', 'left', 'right', 'inner', 'outer', 'on', 'union', 'for', 'connect', 'start', 'window', 'sample', 'pivot', 'unpivot', 'qualify', 'model', ]); const getLastIdentifierPart = (path: string): string => { const parts = String(path || '').split('.').map((part) => stripQueryIdentifierQuotes(part.trim())).filter(Boolean); return parts[parts.length - 1] || ''; }; const resolveSimpleSelectItemColumn = (item: string): { resultName: string; sourceName: string } | 'all' | undefined => { const text = String(item || '').trim(); if (!text) return undefined; if (text === '*' || /\.\s*\*$/.test(text)) return 'all'; let expr = text; let alias = ''; const asMatch = text.match(/^(.*?)\s+AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)$/i); if (asMatch) { expr = asMatch[1].trim(); alias = stripQueryIdentifierQuotes(asMatch[2]); } else { const bareAliasMatch = text.match(/^(.*?)\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)$/); if (bareAliasMatch && SIMPLE_IDENTIFIER_PATH_RE.test(bareAliasMatch[1].trim())) { const candidateAlias = stripQueryIdentifierQuotes(bareAliasMatch[2]); if (candidateAlias && !QUERY_ALIAS_RESERVED.has(candidateAlias.toLowerCase())) { expr = bareAliasMatch[1].trim(); alias = candidateAlias; } } } if (!SIMPLE_IDENTIFIER_PATH_RE.test(expr)) return undefined; const sourceName = getLastIdentifierPart(expr); const resultName = alias || sourceName; return sourceName && resultName ? { resultName, sourceName } : undefined; }; const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => { const match = String(sql || '').match(/^\s*SELECT\s+([\s\S]+?)\s+FROM\s+/i); if (!match) return undefined; const selectList = match[1].trim(); if (!selectList || /^DISTINCT\b/i.test(selectList)) return undefined; const writableColumns: Record = {}; let selectsAll = false; let selectsBareAll = false; for (const item of splitTopLevelComma(selectList)) { const trimmedItem = String(item || '').trim(); const resolved = resolveSimpleSelectItemColumn(item); if (!resolved) continue; if (resolved === 'all') { selectsAll = true; if (trimmedItem === '*') { selectsBareAll = true; } continue; } writableColumns[resolved.resultName] = resolved.sourceName; } return { selectsAll, selectsBareAll, writableColumns }; }; const appendQuerySelectExpressions = (sql: string, expressions: string[]): string => { if (expressions.length === 0) return sql; return String(sql || '').replace( /^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+[\s\S]*)$/i, (_match, prefix, selectList, rest) => `${prefix}${String(selectList).trimEnd()}, ${expressions.join(', ')}${rest}`, ); }; const QUERY_LOCATOR_SOURCE_ALIAS = 'gonavi_query_source'; const rewriteOracleSelectAllWithExpressions = (sql: string, expressions: string[]): string | undefined => { if (expressions.length === 0) return undefined; const match = String(sql || '').match(/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+)([\s\S]*)$/i); if (!match) return undefined; const prefix = match[1]; const selectList = match[2].trim(); const fromKeyword = match[3]; const fromTail = match[4]; const selectItems = splitTopLevelComma(selectList); if (selectItems.length === 0) return undefined; let selectAllFound = false; for (const item of selectItems) { if (String(item || '').trim() === '*') { selectAllFound = true; break; } } if (!selectAllFound) return undefined; const fromTrimmed = fromTail.trimStart(); const tableMatch = fromTrimmed.match(/^((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})([\s\S]*)$/); if (!tableMatch) return undefined; const tableText = tableMatch[1]; const afterTable = tableMatch[2] || ''; const parseAlias = (tail: string): { alias: string; remainder: string } => { const trimmedTail = String(tail || '').trimStart(); if (!trimmedTail) { return { alias: '', remainder: tail }; } const asMatch = trimmedTail.match(/^AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/i); if (asMatch) { const candidate = stripQueryIdentifierQuotes(asMatch[1]); if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) { return { alias: candidate, remainder: asMatch[2] || '' }; } } const bareMatch = trimmedTail.match(/^([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/); if (bareMatch) { const candidate = stripQueryIdentifierQuotes(bareMatch[1]); if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) { return { alias: candidate, remainder: bareMatch[2] || '' }; } } return { alias: '', remainder: tail }; }; const parsedAlias = parseAlias(afterTable); const sourceAlias = parsedAlias.alias || QUERY_LOCATOR_SOURCE_ALIAS; const qualifiedExpressions = expressions .map((expression) => { const trimmed = String(expression || '').trim(); if (!trimmed) return ''; if (/^ROWID\b/i.test(trimmed)) { return trimmed.replace(/^(\s*)ROWID\b/i, `$1${sourceAlias}.ROWID`); } return trimmed; }) .filter(Boolean); if (qualifiedExpressions.length === 0) return undefined; const rewrittenSelectItems = selectItems.map((item) => { const trimmed = String(item || '').trim(); if (trimmed === '*') { return `${sourceAlias}.*`; } return item.trimEnd(); }); const aliasClause = parsedAlias.alias ? ` ${parsedAlias.alias}` : ` ${sourceAlias}`; const finalSelectItems = [...rewrittenSelectItems, ...qualifiedExpressions]; return `${prefix}${finalSelectItems.join(', ')}${fromKeyword}${tableText}${aliasClause}${parsedAlias.remainder}`; }; const findWritableResultColumnForSource = (writableColumns: Record, target: string): string | undefined => { const normalizedTarget = String(target || '').trim().toLowerCase(); return Object.entries(writableColumns || {}).find(([, sourceColumn]) => ( String(sourceColumn || '').trim().toLowerCase() === normalizedTarget ))?.[0]; }; const buildQueryLocatorAlias = (column: string, index: number): string => { const normalized = String(column || '').trim().replace(/[^A-Za-z0-9_]/g, '_').slice(0, 48) || 'column'; return `${QUERY_LOCATOR_ALIAS_PREFIX}${index}_${normalized}`; }; const buildQueryLocatorColumnExpression = (dbType: string, column: string, alias: string): string => ( `${quoteIdentPart(dbType, column)} AS ${quoteIdentPart(dbType, alias)}` ); const buildQueryRowIDExpression = (dbType: string, sourceAlias?: string): string => ( `${sourceAlias ? `${sourceAlias}.` : ''}ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}` ); const resolveQueryLocatorPlan = async ({ statement, dbType, currentDb, config, forceReadOnly, }: { statement: string; dbType: string; currentDb: string; config: any; forceReadOnly: boolean; }): Promise => { const plan: QueryStatementPlan = { originalSql: statement, executedSql: statement, pkColumns: [], }; if (forceReadOnly) return plan; const tableRef = extractQueryResultTableRef(statement, dbType, currentDb); if (!tableRef) return plan; plan.tableRef = tableRef; const selectInfo = parseSimpleSelectInfo(statement); if (!selectInfo) { // 聚合、函数和表达式结果天然无法安全回写到单行,静默保持只读即可。 return plan; } if (!selectInfo.selectsAll && Object.keys(selectInfo.writableColumns).length === 0) { return plan; } try { const [resCols, resIndexes] = await Promise.all([ DBGetColumns(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName), DBGetIndexes(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName) .catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })), ]); if (!resCols?.success || !Array.isArray(resCols.data)) { const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`; plan.editLocator = buildQueryReadOnlyLocator(reason); plan.warning = `查询结果保持只读:${reason}`; return plan; } const tableColumns = resCols.data as ColumnDefinition[]; const tableColumnNames = tableColumns.map((column) => String(column?.name || '').trim()).filter(Boolean); const primaryKeys = tableColumns .filter((column: any) => column?.key === 'PRI') .map((column: any) => String(column?.name || '').trim()) .filter(Boolean); const indexes = resIndexes?.success && Array.isArray(resIndexes.data) ? resIndexes.data as IndexDefinition[] : []; const writableColumns: Record = selectInfo.selectsAll ? Object.fromEntries(tableColumnNames.map((column) => [column, column])) : {}; Object.entries(selectInfo.writableColumns).forEach(([resultColumn, sourceColumn]) => { writableColumns[resultColumn] = sourceColumn; }); const appendExpressions: string[] = []; const hiddenColumns: string[] = []; let needsOracleRowIDExpression = false; const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => { const valueColumns = locatorColumns.map((column, index) => { const selectedColumn = findWritableResultColumnForSource(writableColumns, column); if (selectedColumn) return selectedColumn; const alias = buildQueryLocatorAlias(column, index + 1); appendExpressions.push(buildQueryLocatorColumnExpression(dbType, column, alias)); hiddenColumns.push(alias); return alias; }); return { strategy, columns: locatorColumns, valueColumns, hiddenColumns: hiddenColumns.length > 0 ? [...hiddenColumns] : undefined, writableColumns, readOnly: false, }; }; if (primaryKeys.length > 0) { plan.pkColumns = primaryKeys; plan.editLocator = buildColumnLocator('primary-key', primaryKeys); } else { const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes); const uniqueKeyGroup = uniqueKeyGroups.find((group) => group.length > 0); if (uniqueKeyGroup) { plan.editLocator = buildColumnLocator('unique-key', uniqueKeyGroup); } else if (isOracleLikeDialect(dbType)) { needsOracleRowIDExpression = true; plan.editLocator = { strategy: 'oracle-rowid', columns: ['ROWID'], valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN], hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN], writableColumns, readOnly: false, }; } else { const reason = !resIndexes?.success ? '无法加载唯一索引元数据,无法安全提交修改。' : '未检测到主键或可用唯一索引,无法安全提交修改。'; plan.editLocator = buildQueryReadOnlyLocator(reason); plan.warning = `查询结果保持只读:${tableRef.metadataDbName}.${tableRef.metadataTableName} ${reason}`; } } const executableAppendExpressions = [ ...(needsOracleRowIDExpression ? [buildQueryRowIDExpression(dbType)] : []), ...appendExpressions, ]; if (executableAppendExpressions.length > 0 && isOracleLikeDialect(dbType) && selectInfo.selectsBareAll) { const rewritten = rewriteOracleSelectAllWithExpressions(statement, executableAppendExpressions); if (rewritten) { plan.executedSql = rewritten; return plan; } const reason = 'Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。'; plan.editLocator = buildQueryReadOnlyLocator(reason); plan.warning = `查询结果保持只读:${reason}`; return plan; } plan.executedSql = appendQuerySelectExpressions(statement, executableAppendExpressions); return plan; } catch { const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`; plan.editLocator = buildQueryReadOnlyLocator(reason); plan.warning = `查询结果保持只读:${reason}`; return plan; } }; const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); type ResultSet = { key: string; sql: string; exportSql?: string; rows: any[]; columns: string[]; tableName?: string; pkColumns: string[]; editLocator?: EditRowLocator; readOnly: boolean; truncated?: boolean; pkLoading?: boolean; }; // Result Sets const [resultSets, setResultSets] = useState([]); const [activeResultKey, setActiveResultKey] = useState(''); const [loading, setLoading] = useState(false); const [executionError, setExecutionError] = useState(''); 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 runQueryActionRef = 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 autoFetchVisible = useAutoFetchVisibility(); 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]); // 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量 // 确保 completion provider 始终使用当前活跃 Tab 的上下文 useEffect(() => { if (activeTabId !== tab.id) return; sharedCurrentDb = currentDb; sharedCurrentConnectionId = currentConnectionId; sharedConnections = connections; sharedTablesData = tablesRef.current; sharedAllColumnsData = allColumnsRef.current; sharedVisibleDbs = visibleDbsRef.current; sharedColumnsCacheData = columnsCacheRef.current; }, [activeTabId, tab.id, currentDb, currentConnectionId, connections]); 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(() => { if (!autoFetchVisible) { return; } 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(buildRpcConnectionConfig(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; if (activeTabId === tab.id) { sharedVisibleDbs = 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 = []; if (activeTabId === tab.id) { sharedVisibleDbs = []; } setDbList([]); } }; void fetchDbs(); }, [autoFetchVisible, currentConnectionId, connections]); // Fetch Metadata for Autocomplete (Cross-database) useEffect(() => { if (!autoFetchVisible) { return; } 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(buildRpcConnectionConfig(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(buildRpcConnectionConfig(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; // 如果当前 Tab 是活跃 Tab,同步更新共享变量 if (activeTabId === tab.id) { sharedTablesData = allTables; sharedAllColumnsData = allColumns; } }; void fetchMetadata(); }, [autoFetchVisible, 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'); // 注册 AI 右键菜单操作 const aiActions = [ { id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' }, { id: 'ai.explainSQL', label: '🤖 AI 解释 SQL', useSelection: true, prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n{SQL}\n```' }, { id: 'ai.optimizeSQL', label: '🤖 AI 优化 SQL', useSelection: true, prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n{SQL}\n```' }, ]; aiActions.forEach(action => { editor.addAction({ id: action.id, label: action.label, contextMenuGroupId: '9_ai', contextMenuOrder: 1, run: (ed: any) => { const selection = ed.getModel()?.getValueInRange(ed.getSelection()); const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current); const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDbRef.current || '默认'}"】\n` : ''; let prompt = ctxText + action.prompt; if (action.useSelection && selection) { prompt = prompt.replace('{SQL}', selection); } // 打开 AI 面板并填入 prompt const store = useStore.getState(); if (!store.aiPanelVisible) { store.setAIPanelVisible(true); } // 通过自定义事件将 prompt 发送到 AI 面板 window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); }, }); }); // Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding const runBinding = shortcutOptions.runQuery; if (runBinding?.enabled && runBinding.combo) { const keyBinding = comboToMonacoKeyBinding( runBinding.combo, monaco.KeyMod, monaco.KeyCode ); if (keyBinding) { runQueryActionRef.current = editor.addAction({ id: 'gonavi.runQuery', label: 'GoNavi: 执行 SQL', keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:run-active-query')); }, }); } } // HMR 重载时释放旧注册避免补全项重复 if (!sqlCompletionRegistered) { sqlCompletionRegistered = true; _g.__gonaviSqlCompletionState.registered = true; sqlCompletionDisposables.forEach((d: any) => d?.dispose?.()); sqlCompletionDisposables.length = 0; sqlCompletionDisposables.push(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 activeConnection = sharedConnections.find(c => c.id === sharedCurrentConnectionId); const activeDialect = resolveSqlDialect( String(activeConnection?.config?.type || ''), String(activeConnection?.config?.driver || ''), { oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol }, ); const dialectKeywords = resolveSqlKeywords(activeDialect); const dialectFunctions = resolveSqlFunctions(activeDialect); 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 = sharedCurrentConnectionId; const conn = sharedConnections.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 = sharedCurrentConnectionId; const dbName = sharedCurrentDb; if (!connId || !dbName) return [] as ColumnDefinition[]; const key = `${connId}|${dbName}|${tableIdent}`; const cached = sharedColumnsCacheData[key]; if (cached) return cached; const config = buildConnConfig(); if (!config) return [] as ColumnDefinition[]; const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableIdent); if (res?.success && Array.isArray(res.data)) { const cols = res.data as ColumnDefinition[]; sharedColumnsCacheData[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 = sharedAllColumnsData.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 = sharedVisibleDbs; if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) { // qualifier 是数据库名,提示该库的表 const tables = sharedTablesData.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 = sharedTablesData .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 = sharedCurrentDb || ''; 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 (sharedAllColumnsData.length > 0) { const tiTableLower = (tableInfo.tableName || '').toLowerCase(); cols = sharedAllColumnsData .filter(c => { if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false; const cTableLower = (c.tableName || '').toLowerCase(); if (cTableLower === tiTableLower) return true; // schema.table 格式匹配纯表名 const parsed = splitSchemaAndTable(c.tableName || ''); return (parsed.table || '').toLowerCase() === tiTableLower; }) .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 = sharedCurrentDb || ''; 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 && dialectKeywords.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 = sharedAllColumnsData .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); // 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users) const parsed = splitSchemaAndTable(c.tableName || ''); const pureIdent = (parsed.table || '').toLowerCase(); return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && 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, }; }); // 表提示:当前库智能处理 schema.table 格式 // 1. 构建纯表名到 schema 列表的映射,检测同名表 const currentDbTables = sharedTablesData.filter(t => (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase() ); const tableNameToSchemas = new Map(); for (const t of currentDbTables) { const parsed = splitSchemaAndTable(t.tableName || ''); const pureTable = (parsed.table || t.tableName || '').toLowerCase(); const schemas = tableNameToSchemas.get(pureTable) || []; schemas.push(parsed.schema || ''); tableNameToSchemas.set(pureTable, schemas); } const tableSuggestions = sharedTablesData .filter(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); if (!isCurrentDb) { // 跨库:用 db.table 格式匹配 return startsWithPrefix(`${t.dbName}.${t.tableName}`); } // 当前库:同时用完整名和纯表名匹配 const parsed = splitSchemaAndTable(t.tableName || ''); const pureTable = parsed.table || t.tableName || ''; return startsWithPrefix(t.tableName || '') || startsWithPrefix(pureTable); }) .map(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); if (!isCurrentDb) { const label = `${t.dbName}.${t.tableName}`; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText: label, detail: `Table (${t.dbName})`, range, sortText: sortGroups.tableOther + t.tableName, }; } // 当前库:检查是否有跨 schema 同名表 const parsed = splitSchemaAndTable(t.tableName || ''); const pureTable = parsed.table || t.tableName || ''; const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || []; const hasDuplicate = schemas.length > 1; // 同名表存在于多个 schema → 显示 schema.table;否则只显示纯表名 const label = hasDuplicate ? t.tableName : pureTable; const insertText = hasDuplicate ? t.tableName : pureTable; const schemaInfo = parsed.schema ? ` (${parsed.schema})` : ''; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText, detail: `Table${schemaInfo}`, range, sortText: sortGroups.tableCurrent + pureTable, }; }); // 数据库提示 const dbSuggestions = sharedVisibleDbs .filter((db) => startsWithPrefix(db)) .map(db => ({ label: db, kind: monaco.languages.CompletionItemKind.Module, insertText: db, detail: 'Database', range, sortText: sortGroups.db + db, })); // 关键字提示 const keywordSuggestions = dialectKeywords .filter((k) => startsWithPrefix(k)) .map(k => ({ label: k, kind: monaco.languages.CompletionItemKind.Keyword, insertText: k, range, sortText: sortGroups.keyword + k, })); // 内置函数提示 const funcSuggestions = dialectFunctions .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 }; } })); // 注册 / 斜杠命令 AI 快捷补全 const slashCmdDefs = [ { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' }, { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:' }, { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n{SQL}\n```', useSelection: true }, { cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n{SQL}\n```', useSelection: true }, { cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' }, { cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' }, { cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' }, { cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' }, ]; // 全局变量存储命令定义,供 onDidChangeModelContent 使用 (window as any).__gonaviSlashCmdDefs = slashCmdDefs; sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['/'], provideCompletionItems: (model: any, position: any) => { const lineContent = model.getLineContent(position.lineNumber); const textBefore = lineContent.substring(0, position.column - 1).trimStart(); if (!textBefore.startsWith('/')) { return { suggestions: [] }; } const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: position.column - textBefore.length, endColumn: position.column, }; return { suggestions: slashCmdDefs.map((c, i) => ({ label: `${c.cmd} ${c.label}`, kind: monaco.languages.CompletionItemKind.Event, detail: c.desc, insertText: `__AI_${c.cmd.slice(1).toUpperCase()}__`, range, sortText: String(i).padStart(2, '0'), })), }; }, })); // SQL snippet completion provider monaco.languages.registerCompletionItemProvider('sql', { provideCompletionItems: (model: any, position: any) => { const word = model.getWordUntilPosition(position); const prefix = word.word.toLowerCase(); if (!prefix) return { suggestions: [] }; const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; const allSnippets = useStore.getState().sqlSnippets; const matched = allSnippets.filter(s => s.prefix.toLowerCase().startsWith(prefix) || s.name.toLowerCase().includes(prefix) ); return { suggestions: matched.map(s => ({ label: s.prefix, kind: monaco.languages.CompletionItemKind.Snippet, insertText: s.body, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, detail: s.name, documentation: s.description || s.body, range, sortText: '04' + s.prefix, })), }; }, }); } // end sqlCompletionRegistered guard // 每个编辑器实例都注册内容变化监听(检测斜杠命令标记) let _handlingSlash = false; editor.onDidChangeModelContent(() => { if (_handlingSlash) return; const model = editor.getModel(); if (!model) return; const content = model.getValue(); const markerMatch = content.match(/__AI_(\w+)__/); if (!markerMatch) return; const cmdKey = markerMatch[1].toLowerCase(); const defs = (window as any).__gonaviSlashCmdDefs || []; const cmdDef = defs.find((c: any) => c.cmd === `/${cmdKey}`); if (!cmdDef) return; // 清除标记文本(带递归保护) _handlingSlash = true; const fullText = model.getValue(); const newText = fullText.replace(markerMatch[0], '').replace(/^\s*\n/, ''); model.setValue(newText); _handlingSlash = false; // 组装 prompt const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current); const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDbRef.current || '默认'}"】\n` : ''; let finalPrompt = ctxText + cmdDef.prompt; if (cmdDef.useSelection) { const sel = editor.getSelection(); const selText = sel ? model.getValueInRange(sel) : ''; finalPrompt = finalPrompt.replace('{SQL}', selText || getCurrentQuery()); } // 打开 AI 面板并注入 prompt const store = useStore.getState(); if (!store.aiPanelVisible) { store.setAIPanelVisible(true); } setTimeout(() => { window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: finalPrompt } })); }, store.aiPanelVisible ? 0 : 350); }); }; const handleFormat = () => { try { const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase }); syncQueryToEditor(formatted); } catch (e) { void message.error("格式化失败: SQL 语法可能有误"); } }; const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => { const editor = editorRef.current; const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || ''; const fullSQL = getCurrentQuery(); const conn = connections.find(c => c.id === currentConnectionId); const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDb || '默认'}"】\n` : ''; const prompts: Record = { generate: `${ctxText}请根据当前数据库表结构生成查询语句:`, explain: `${ctxText}请解释以下 SQL 语句的执行逻辑:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``, optimize: `${ctxText}请分析以下 SQL 语句的性能并给出优化建议:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``, schema: `${ctxText}请针对当前数据库的表结构进行系统分析,并给出性能和设计上的优化建议。`, }; const store = useStore.getState(); if (!store.aiPanelVisible) { store.setAIPanelVisible(true); } window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: prompts[action] } })); }; 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: 'snippet-settings', label: '代码片段管理...', onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')), }, { 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; }; 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; }; // 精准重查询单个结果集(提交事务 / 刷新按钮使用),不会重跑整个编辑器 SQL const handleReloadResult = async (resultKey: string, sql: string) => { if (!sql?.trim() || !currentDb) return; 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: "" } }; try { setLoading(true); // 使用 DBQueryMulti 保持和首次查询一致的后端路径 let queryId: string; try { queryId = await GenerateQueryID(); } catch { queryId = 'reload-' + Date.now(); } const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, sql, queryId); if (!res?.success) { message.error('刷新失败: ' + (res?.message || '未知错误')); return; } // 取第一个结果集(单条 SQL 只有一个结果集) const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; if (resultSetDataArray.length === 0) return; const rsData = resultSetDataArray[0]; const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1 && rsData.columns && rsData.columns.length === 1 && rsData.columns[0] === 'affectedRows'; if (isAffectedResult) return; // 不应该出现,但保险起见 let rows = Array.isArray(rsData.rows) ? rsData.rows : []; const maxRows = Number(queryOptions?.maxRows) || 0; let truncated = false; if (Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { truncated = 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; }); // 只更新匹配的结果集的 rows 和 columns,保留 tableName/pkColumns/readOnly 等元数据 setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, rows, columns: cols, truncated } : rs )); } catch (err: any) { message.error('刷新失败: ' + (err?.message || '未知错误')); } finally { setLoading(false); } }; 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); setExecutionError(''); 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: "" }, timeout: Math.max(Number(conn.config.timeout) || 30, 120), }; try { const rawSQL = getSelectedSQL() || currentQuery; const rpcConfig = buildRpcConnectionConfig(config) as any; const dbType = String(rpcConfig.type || 'mysql'); const driver = String((config as any).driver || ''); const normalizedDbType = String(resolveSqlDialect(dbType, driver, { oceanBaseProtocol: (config as any).oceanBaseProtocol, })).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} 条语句执行失败:` : ''; setExecutionError(prefix + shellConvert.error); setResultSets([]); setActiveResultKey(''); return; } if (shellConvert.command) { executedSql = shellConvert.command; } } if (wantsLimitProbe) { const limitResult = applyMongoQueryAutoLimit(executedSql, maxRows); if (limitResult.applied) { executedSql = limitResult.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(buildRpcConnectionConfig(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} 条语句执行失败:` : ''; setExecutionError(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('执行成功。'); } } else { // 非 MongoDB:使用 DBQueryMulti 一次性执行多条 SQL,后端返回多结果集 const sourceStatements = splitSQLStatements(normalizedRawSQL); if (sourceStatements.length === 0) { message.info('没有可执行的 SQL。'); setResultSets([]); setActiveResultKey(''); return; } const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; const statementPlans: QueryStatementPlan[] = []; for (const statement of sourceStatements) { statementPlans.push(await resolveQueryLocatorPlan({ statement, dbType: normalizedDbType, currentDb, config, forceReadOnly: forceReadOnlyResult, })); } // 自动给 SELECT 语句注入行数限制(防止大结果集卡死) const maxRowsForLimit = Number(queryOptions?.maxRows) || 0; let anyLimitApplied = false; const executablePlans = statementPlans.map((plan) => { if (!Number.isFinite(maxRowsForLimit) || maxRowsForLimit <= 0) return plan; const result = applyQueryAutoLimit(plan.executedSql, normalizedDbType, maxRowsForLimit, driver); if (result.applied) anyLimitApplied = true; return { ...plan, executedSql: result.sql }; }); const fullSQL = executablePlans.map((plan) => plan.executedSql).join(';\n'); 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(buildRpcConnectionConfig(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; } setExecutionError(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; let anyTruncated = false; for (let idx = 0; idx < resultSetDataArray.length; idx++) { const rsData = resultSetDataArray[idx]; const plan = executablePlans[idx]; const originalSql = plan?.originalSql || ''; const executedSql = plan?.executedSql || originalSql; // 检查是否为 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: executedSql, exportSql: originalSql, rows: [row], columns: ['affectedRows'], pkColumns: [], readOnly: true }); } else { let rows = Array.isArray(rsData.rows) ? rsData.rows : []; let truncated = false; // 仅当前端自动注入了 LIMIT 时才做兜底截断;用户手写 LIMIT 时尊重原始结果 if (anyLimitApplied && 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; }); const tableRef = plan?.tableRef; const editLocator = plan?.editLocator; nextResultSets.push({ key: `result-${idx + 1}`, sql: executedSql, exportSql: originalSql, rows, columns: cols, tableName: tableRef?.tableName, pkColumns: plan?.pkColumns || [], editLocator, readOnly: forceReadOnlyResult || !editLocator || editLocator.readOnly, truncated }); } } setResultSets(nextResultSets); setActiveResultKey(nextResultSets[0]?.key || ''); executablePlans.forEach((plan) => { if (plan.warning) message.warning(plan.warning); }); // 后端附带的提示信息(如数据源不支持原生多语句执行的回退提示) if (res.message) { message.info(res.message); } if (resultSetDataArray.length > 1) { message.success(`已执行完成,生成 ${nextResultSets.length} 个结果集。`); } else if (nextResultSets.length === 0) { message.success('执行成功。'); } } } 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, true); return () => { window.removeEventListener('keydown', handleRunShortcut, true); }; }, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]); // Re-register Monaco internal keybinding when runQuery shortcut changes useEffect(() => { if (runQueryActionRef.current) { runQueryActionRef.current.dispose(); runQueryActionRef.current = null; } const editor = editorRef.current; const monaco = monacoRef.current; if (!editor || !monaco) return; const binding = shortcutOptions.runQuery; if (!binding?.enabled || !binding.combo) return; const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); if (keyBinding) { runQueryActionRef.current = editor.addAction({ id: 'gonavi.runQuery', label: 'GoNavi: 执行 SQL', keybindings: [keyBinding.keyMod | keyBinding.keyCode], run: () => { window.dispatchEvent(new CustomEvent('gonavi:run-active-query')); }, }); } return () => { if (runQueryActionRef.current) { runQueryActionRef.current.dispose(); runQueryActionRef.current = null; } }; }, [shortcutOptions.runQuery]); 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]); // 监听由 TabManager 分发的专用注入事件 useEffect(() => { const handleInsertSql = (e: any) => { if (e.detail?.tabId !== tab.id || !e.detail?.sql) return; const { sql: sqlText, connectionId, dbName } = e.detail; // 同步更新 ref,防止异步 fetchDbs 竞态覆盖正确的 dbName if (connectionId && connectionId !== currentConnectionId) { if (dbName) { currentDbRef.current = dbName; setCurrentDb(dbName); } setCurrentConnectionId(connectionId); } else if (dbName && dbName !== currentDb) { currentDbRef.current = dbName; setCurrentDb(dbName); } const editor = editorRef.current; const monaco = monacoRef.current; if (editor && monaco) { const model = editor.getModel(); const existingContent = editor.getValue?.() || ''; // runImmediately 模式下,如果编辑器内容已是待注入的 SQL(TabManager 创建时已传入), // 跳过追加,直接选中全部内容并执行 if (e.detail.runImmediately && existingContent.trim() === sqlText.trim()) { if (model) { const lineCount = model.getLineCount(); const maxCol = model.getLineMaxColumn(lineCount); editor.setSelection(new monaco.Range(1, 1, lineCount, maxCol)); editor.focus(); setTimeout(() => handleRun(), 500); } } else { let position = editor.getPosition(); if (!position && model) { const lineCount = model.getLineCount(); const maxCol = model.getLineMaxColumn(lineCount); position = new monaco.Position(lineCount, maxCol); } if (position) { const mText = (sqlText.endsWith('\n') ? sqlText : sqlText + '\n'); const startRange = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column); editor.executeEdits('ai-insert', [{ range: startRange, text: (position.column > 1 ? '\n' : '') + mText, forceMoveMarkers: true }]); // 定位并滚动到可见区域 const targetLine = position.lineNumber + (position.column > 1 ? 1 : 0); editor.revealLineInCenterIfOutsideViewport(targetLine); editor.setPosition({ lineNumber: targetLine + mText.split('\n').length - 1, column: 1 }); editor.focus(); if (!e.detail.runImmediately) { message.success('代码已在当前光标处成功插入'); } if (e.detail.runImmediately) { const endPosition = editor.getPosition(); editor.setSelection(new monaco.Range( targetLine, 1, endPosition.lineNumber, endPosition.column )); // 🔧 延迟 500ms 等待连接/数据库切换的 setState 生效后再执行 setTimeout(() => handleRun(), 500); } } } } else { setQuery((prev: string) => prev ? prev + '\n' + sqlText : sqlText); message.success('代码已追加'); } }; window.addEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener); return () => window.removeEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener); }, [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 = async () => { const filePath = String(tab.filePath || '').trim(); if (filePath) { const sql = getCurrentQuery(); try { const res = await WriteSQLFile(filePath, sql); if (!res.success) { message.error('保存 SQL 文件失败: ' + (res.message || '未知错误')); return; } addTab({ ...tab, query: sql, connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', filePath, savedQueryId: undefined, }); message.success('SQL 文件已保存!'); } catch (error) { message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error))); } return; } 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;