diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 8ea484a..66d4412 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -1575,6 +1575,32 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已导出!'); }); + it('shows Chinese semantic meaning for SQL execution errors', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: false, + message: 'pq: syntax error at or near "from"', + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await findButton(renderer, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const pageText = textContent(renderer!.root); + expect(pageText).toContain('执行失败'); + expect(pageText).toContain('中文语义:SQL 语法错误'); + expect(pageText).toContain('处理建议:'); + expect(pageText).toContain('原始错误:pq: syntax error at or near "from"'); + }); + it('automatically appends hidden primary key locator columns for editable query results', async () => { storeState.connections[0].config.type = 'oracle'; storeState.connections[0].config.database = 'ORCLPDB1'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 58c79cc..320745d 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -17,6 +17,7 @@ import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSql import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart } from '../utils/sql'; +import { formatSqlExecutionError } from '../utils/sqlErrorSemantics'; import { resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; import { isMacLikePlatform } from '../utils/appearance'; import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; @@ -4031,7 +4032,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, sql, queryId); if (!res?.success) { - message.error('刷新失败: ' + (res?.message || '未知错误')); + message.error('刷新失败: ' + formatSqlExecutionError(res?.message || '未知错误')); return; } @@ -4073,7 +4074,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc : rs )); } catch (err: any) { - message.error('刷新失败: ' + (err?.message || '未知错误')); + message.error('刷新失败: ' + formatSqlExecutionError(err?.message || err || '未知错误')); } finally { setLoading(false); } @@ -4172,7 +4173,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (shellConvert.recognized) { if (shellConvert.error) { const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; - setExecutionError(prefix + shellConvert.error); + setExecutionError(formatSqlExecutionError(shellConvert.error, { prefix })); setResultSets([]); setActiveResultKey(''); return; @@ -4211,7 +4212,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }); if (!res.success) { const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; - setExecutionError(prefix + res.message); + setExecutionError(formatSqlExecutionError(res.message, { prefix })); setResultSets([]); setActiveResultKey(''); return; @@ -4376,7 +4377,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } - setExecutionError(res.message); + setExecutionError(formatSqlExecutionError(res.message)); setResultSets([]); setActiveResultKey(''); return; @@ -4513,7 +4514,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } catch (e: any) { - message.error("Error executing query: " + e.message); + const formattedError = formatSqlExecutionError(e?.message || e); + message.error("执行失败: " + formattedError); addSqlLog({ id: `log-${Date.now()}-error`, timestamp: Date.now(), @@ -4523,6 +4525,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc message: e.message, dbName: currentDb }); + setExecutionError(formattedError); setResultSets([]); setActiveResultKey(''); } finally { diff --git a/frontend/src/utils/sqlErrorSemantics.test.ts b/frontend/src/utils/sqlErrorSemantics.test.ts new file mode 100644 index 0000000..e5c753d --- /dev/null +++ b/frontend/src/utils/sqlErrorSemantics.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { formatSqlExecutionError } from './sqlErrorSemantics'; + +describe('formatSqlExecutionError', () => { + it('adds Chinese semantic explanation for SQL syntax errors and keeps raw text', () => { + const formatted = formatSqlExecutionError('pq: syntax error at or near "from"'); + + expect(formatted).toContain('中文语义:SQL 语法错误'); + expect(formatted).toContain('处理建议:'); + expect(formatted).toContain('原始错误:pq: syntax error at or near "from"'); + }); + + it('recognizes missing table errors', () => { + const formatted = formatSqlExecutionError('ERROR: relation "orders" does not exist'); + + expect(formatted).toContain('中文语义:表或对象不存在'); + expect(formatted).toContain('原始错误:ERROR: relation "orders" does not exist'); + }); + + it('recognizes duplicate key errors with statement prefix', () => { + const formatted = formatSqlExecutionError('Duplicate entry "1" for key "PRIMARY"', { + prefix: '第 2 条语句执行失败:', + }); + + expect(formatted.startsWith('第 2 条语句执行失败:\n中文语义:唯一约束或主键冲突')).toBe(true); + expect(formatted).toContain('原始错误:Duplicate entry "1" for key "PRIMARY"'); + }); + + it('falls back to a generic database execution error', () => { + const formatted = formatSqlExecutionError('driver returned unexpected status 123'); + + expect(formatted).toContain('中文语义:数据库执行错误'); + expect(formatted).toContain('原始错误:driver returned unexpected status 123'); + }); + + it('does not format an already formatted message again', () => { + const raw = [ + '中文语义:SQL 语法错误。通常是关键字、逗号、括号、引号、语句顺序或当前数据库方言不匹配。', + '处理建议:检查报错位置附近的 SQL 片段,并确认当前连接的数据源类型与 SQL 方言一致。', + '原始错误:pq: syntax error at or near "from"', + ].join('\n'); + + expect(formatSqlExecutionError(raw)).toBe(raw); + }); +}); diff --git a/frontend/src/utils/sqlErrorSemantics.ts b/frontend/src/utils/sqlErrorSemantics.ts new file mode 100644 index 0000000..5916108 --- /dev/null +++ b/frontend/src/utils/sqlErrorSemantics.ts @@ -0,0 +1,177 @@ +export type SqlExecutionErrorFormatOptions = { + prefix?: string; +}; + +type SqlErrorSemanticRule = { + label: string; + explanation: string; + suggestion: string; + patterns: RegExp[]; +}; + +const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [ + { + label: 'SQL 语法错误', + explanation: '通常是关键字、逗号、括号、引号、语句顺序或当前数据库方言不匹配。', + suggestion: '检查报错位置附近的 SQL 片段,并确认当前连接的数据源类型与 SQL 方言一致。', + patterns: [ + /syntax error/i, + /sql syntax/i, + /sqlstate\s*42601/i, + /near\s+["'`].+["'`]\s*:?\s*syntax error/i, + /ora-00933/i, + /ora-00936/i, + /you have an error in your sql syntax/i, + ], + }, + { + label: '表或对象不存在', + explanation: 'SQL 引用了当前库或 schema 中找不到的表、视图、序列或其他数据库对象。', + suggestion: '确认对象名称、大小写、schema/database 前缀,以及当前查询所选数据库是否正确。', + patterns: [ + /relation\s+["'`].+["'`]\s+does not exist/i, + /table\s+.+doesn'?t exist/i, + /no such table/i, + /invalid object name/i, + /ora-00942/i, + /object\s+.+does not exist/i, + ], + }, + { + label: '字段不存在', + explanation: 'SQL 引用了结果集中不存在、拼写不一致或当前表没有的字段。', + suggestion: '检查字段名、别名、大小写、引用表别名,以及字段是否属于当前 FROM/JOIN 的对象。', + patterns: [ + /column\s+["'`].+["'`]\s+does not exist/i, + /unknown column/i, + /invalid column name/i, + /ora-00904/i, + /no such column/i, + ], + }, + { + label: '唯一约束或主键冲突', + explanation: '插入或更新的数据与唯一索引、主键或唯一约束中的已有数据重复。', + suggestion: '检查重复键值,必要时改为 UPDATE、UPSERT,或调整唯一键字段值。', + patterns: [ + /duplicate key/i, + /duplicate entry/i, + /unique constraint failed/i, + /violates unique constraint/i, + /ora-00001/i, + ], + }, + { + label: '权限不足', + explanation: '当前数据库账号没有执行该 SQL 或访问相关对象的权限。', + suggestion: '确认账号权限、schema 授权、只读连接限制,以及是否需要由管理员授权。', + patterns: [ + /permission denied/i, + /access denied/i, + /not authorized/i, + /insufficient privileges/i, + /ora-01031/i, + ], + }, + { + label: '数据类型或格式不匹配', + explanation: '写入、比较或转换的数据格式不符合目标字段或表达式要求。', + suggestion: '检查日期、数字、布尔值、枚举值、隐式转换和字段类型,必要时显式 CAST。', + patterns: [ + /invalid input syntax/i, + /incorrect\s+.+\s+value/i, + /data truncated/i, + /truncated incorrect/i, + /conversion failed/i, + /invalid number/i, + /ora-01722/i, + ], + }, + { + label: '约束校验失败', + explanation: '数据不满足外键、非空、检查约束或引用完整性规则。', + suggestion: '检查关联父表记录、必填字段、CHECK 条件,以及写入顺序是否正确。', + patterns: [ + /foreign key constraint/i, + /violates foreign key constraint/i, + /cannot be null/i, + /not null constraint failed/i, + /check constraint/i, + /constraint failed/i, + ], + }, + { + label: '查询超时或被取消', + explanation: 'SQL 执行时间超过超时限制,或执行过程被手动取消。', + suggestion: '检查 SQL 执行计划、过滤条件和索引,必要时缩小查询范围或调整超时时间。', + patterns: [ + /context deadline exceeded/i, + /statement canceled/i, + /statement cancelled/i, + /context canceled/i, + /context cancelled/i, + /timeout/i, + /timed out/i, + ], + }, + { + label: '数据库连接或认证失败', + explanation: '客户端无法连接数据库,或认证信息、网络、实例状态存在问题。', + suggestion: '检查主机、端口、账号密码、网络连通性、代理/SSH 隧道和数据库服务状态。', + patterns: [ + /password authentication failed/i, + /connection refused/i, + /no route to host/i, + /server has gone away/i, + /too many connections/i, + /connection reset/i, + /connection timeout/i, + ], + }, +]; + +const normalizeErrorText = (raw: unknown): string => { + if (raw instanceof Error) { + return raw.message || String(raw); + } + if (typeof raw === 'string') { + return raw; + } + if (raw == null) { + return ''; + } + try { + return JSON.stringify(raw); + } catch { + return String(raw); + } +}; + +const findSqlErrorSemantic = (message: string): SqlErrorSemanticRule | null => { + const text = String(message || ''); + return SQL_ERROR_RULES.find((rule) => rule.patterns.some((pattern) => pattern.test(text))) || null; +}; + +export const formatSqlExecutionError = ( + raw: unknown, + options: SqlExecutionErrorFormatOptions = {}, +): string => { + const rawMessage = normalizeErrorText(raw).trim() || '未知错误'; + if (/中文语义:/.test(rawMessage) && /原始错误:/.test(rawMessage)) { + return rawMessage; + } + + const semantic = findSqlErrorSemantic(rawMessage) || { + label: '数据库执行错误', + explanation: '数据库返回了执行失败信息,当前未匹配到更具体的错误类型。', + suggestion: '结合原始错误、SQL 片段和当前数据库方言继续排查。', + }; + const prefix = String(options.prefix || '').trim(); + + return [ + prefix, + `中文语义:${semantic.label}。${semantic.explanation}`, + `处理建议:${semantic.suggestion}`, + `原始错误:${rawMessage}`, + ].filter(Boolean).join('\n'); +};