From 5493b62bb978a042699408c9eece1684459cea7a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 23 Jun 2026 23:42:30 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20SQL=20Server=20=E7=BB=93=E6=9E=9C=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=BC=A9=E8=BF=9B=E5=B9=B6=E6=A0=A1=E6=AD=A3=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QueryEditor.results-and-drop.test.tsx | 114 +++++++++++------- .../queryEditor/QueryEditorHelpers.ts | 52 +++++--- 2 files changed, 105 insertions(+), 61 deletions(-) diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index d5afce0..a6daf8f 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -7,6 +7,7 @@ import { readV2ThemeCss } from '../test/readV2ThemeCss'; import { setCurrentLanguage } from '../i18n'; import type { SavedQuery, TabData } from '../types'; import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; +import { formatSqlExecutionError } from '../utils/sqlErrorSemantics'; import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; import { normalizeQueryResultMessages } from './queryEditor/QueryEditorHelpers'; import QueryEditor, { @@ -357,11 +358,20 @@ vi.mock('./DataGrid', () => ({ GONAVI_ROW_KEY: '__gonavi_row_key__', })); +vi.mock('./LogPanel', () => ({ + default: ({ executionError }: any) => ( +
+ {executionError || 'log-panel'} +
+ ), +})); + vi.mock('@ant-design/icons', () => { const Icon = () => ; return { BugOutlined: Icon, ClearOutlined: Icon, + CopyOutlined: Icon, PlayCircleOutlined: Icon, SaveOutlined: Icon, FormatPainterOutlined: Icon, @@ -461,10 +471,14 @@ vi.mock('antd', () => { }; }); -const textContent = (node: any): string => - (node.children || []) +const textContent = (node: any): string => { + if (node == null) return ''; + if (typeof node === 'string') return node; + if (Array.isArray(node)) return node.map((item) => textContent(item)).join(''); + return (node.children || []) .map((item: any) => (typeof item === 'string' ? item : textContent(item))) .join(''); +}; const findButton = (renderer: ReactTestRenderer, text: string) => renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0]; @@ -475,6 +489,11 @@ const findButtons = (renderer: ReactTestRenderer, text: string) => const findExactButton = (renderer: ReactTestRenderer, text: string) => renderer.root.findAll((node) => node.type === 'button' && textContent(node) === text)[0]; +const findResultMessageTextarea = (renderer: ReactTestRenderer, mode: 'compact' | 'full' = 'full') => + renderer.root.find((node) => + node.type === 'textarea' && node.props['data-query-result-message-textarea'] === mode, + ); + const findEditorAction = (id: string) => editorState.editor.addAction.mock.calls .map((call: any[]) => call[0]) @@ -777,19 +796,21 @@ describe('QueryEditor external SQL save', () => { }); expect(textContent(renderer!.toJSON())).toContain('消息 1'); - expect(textContent(renderer!.toJSON())).toContain("Table 'users'. Scan count 1, logical reads 3."); - expect(dataGridState.latestProps?.columnNames).not.toEqual([]); + expect(findResultMessageTextarea(renderer!).props.value).toBe("Table 'users'. Scan count 1, logical reads 3."); + expect(dataGridState.latestProps).toBeNull(); }); - it('normalizes sqlserver mssql-prefixed message lines line-by-line', () => { + it('preserves sqlserver message indentation and blank lines after stripping mssql prefixes', () => { expect(normalizeQueryResultMessages([ "mssql: select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", "mssql: 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + '', " where funcno = @funcno and tabname = '$vendorclass'", ])).toEqual([ - "select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", - "'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", - "where funcno = @funcno and tabname = '$vendorclass'", + " select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", + " 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + '', + " where funcno = @funcno and tabname = '$vendorclass'", ]); }); @@ -933,12 +954,15 @@ describe('QueryEditor external SQL save', () => { }); expect(textContent(renderer!.toJSON())).toContain('消息 2'); - expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')"); + expect(findResultMessageTextarea(renderer!).props.value).toBe([ + "insert into c_dyscript(projectid,name) values (1,'demo')", + "insert into c_dyscript(projectid,name) values (2,'next')", + ].join('\n')); expect(textContent(renderer!.toJSON())).not.toContain('影响行数:0'); expect(dataGridState.latestProps).toBeNull(); }); - it('strips mssql prefixes before rendering sqlserver message-only results', async () => { + it('preserves sqlserver message indentation in the rendered result message textarea', async () => { storeState.connections[0].config.type = 'sqlserver'; storeState.connections[0].config.database = 'hydee'; backendApp.DBQueryMulti.mockResolvedValueOnce({ @@ -951,6 +975,7 @@ describe('QueryEditor external SQL save', () => { messages: [ "mssql: select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", "mssql: 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + '', " where funcno = @funcno and tabname = '$vendorclass'", ], }, @@ -971,11 +996,15 @@ describe('QueryEditor external SQL save', () => { }); const rendered = textContent(renderer!.toJSON()); + const messageTextarea = findResultMessageTextarea(renderer!); expect(rendered).toContain('消息 1'); - expect(rendered).toContain("select c.queryno,'' ,left(dbo.f_vendor_class"); - expect(rendered).toContain("'char','',''),'自动生成'"); - expect(rendered).toContain("where funcno = @funcno and tabname = '$vendorclass'"); - expect(rendered).not.toContain('mssql:'); + expect(messageTextarea.props.value).toBe([ + " select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", + " 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + '', + " where funcno = @funcno and tabname = '$vendorclass'", + ].join('\n')); + expect(messageTextarea.props.value).not.toContain('mssql:'); }); it('renders top-level sqlserver print messages when result sets contain only status rows', async () => { @@ -1005,7 +1034,7 @@ describe('QueryEditor external SQL save', () => { }); expect(textContent(renderer!.toJSON())).toContain('消息 2'); - expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')"); + expect(findResultMessageTextarea(renderer!).props.value).toBe("insert into c_dyscript(projectid,name) values (1,'demo')"); expect(textContent(renderer!.toJSON())).not.toContain('影响行数:0'); expect(dataGridState.latestProps).toBeNull(); }); @@ -1353,7 +1382,7 @@ describe('QueryEditor external SQL save', () => { let renderer: ReactTestRenderer; await act(async () => { - renderer = create(); + renderer = create(); }); const rendered = textContent(renderer!.toJSON()); @@ -1454,7 +1483,7 @@ describe('QueryEditor external SQL save', () => { editorState.position = { lineNumber: 3, column: 1 }; await act(async () => { - const runButton = findButton(renderer!, 'Run'); + const runButton = findButton(renderer!, '运行'); runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); await runButton.props.onClick(); }); @@ -1494,7 +1523,7 @@ describe('QueryEditor external SQL save', () => { }; await act(async () => { - const runButton = findButton(renderer!, 'Run'); + const runButton = findButton(renderer!, '运行'); runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); await runButton.props.onClick(); }); @@ -1536,7 +1565,7 @@ describe('QueryEditor external SQL save', () => { }; await act(async () => { - const runButton = findButton(renderer!, '运行'); + const runButton = findButton(renderer!, 'Run'); runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); await runButton.props.onClick(); }); @@ -1545,7 +1574,7 @@ describe('QueryEditor external SQL save', () => { await Promise.resolve(); }); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); + expect(textContent(renderer!.toJSON())).toContain('Result 1'); backendApp.DBQueryMulti.mockClear(); messageApi.info.mockClear(); @@ -1563,7 +1592,7 @@ describe('QueryEditor external SQL save', () => { }); await act(async () => { - const runButton = findButton(renderer!, '运行'); + const runButton = findButton(renderer!, 'Run'); runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); await runButton.props.onClick(); }); @@ -1694,13 +1723,15 @@ describe('QueryEditor external SQL save', () => { await findButton(renderer, 'Run').props.onClick(); }); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); + await Promise.resolve(); + await Promise.resolve(); + }); - const rendered = textContent(renderer.toJSON()); - expect(rendered).toContain('Statement 2 failed: driver exploded'); - expect(rendered).not.toContain('第 2 条语句执行失败:driver exploded'); + const rendered = textContent(renderer.toJSON()); + expect(rendered).toContain(formatSqlExecutionError('driver exploded', { + prefix: 'Statement 2 failed:', + })); + expect(rendered).not.toContain('第 2 条语句执行失败:driver exploded'); }); it('shows the Mongo zero-result success toast in English', async () => { @@ -1771,11 +1802,11 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.success).not.toHaveBeenCalledWith('已执行完成,生成 2 个结果集。'); }); - it('shows the non-Mongo zero-result success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'update users set active = 1 where 1 = 0;'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ success: true, data: [] }); + it('shows the non-Mongo zero-result success toast in English', async () => { + storeState.languagePreference = 'en-US'; + setCurrentLanguage('en-US'); + const query = 'update users set active = 1 where 1 = 0;'; + backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({ success: true, data: [] }); let renderer!: ReactTestRenderer; await act(async () => { @@ -1800,12 +1831,13 @@ describe('QueryEditor external SQL save', () => { await Promise.resolve(); }); - expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('update users set active = 1 where 1 = 0'); - expect(messageApi.success).toHaveBeenCalledWith('Execution succeeded.'); - expect(messageApi.success).not.toHaveBeenCalledWith('执行成功。'); - }); + expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); + expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); + expect(backendApp.DBQueryMultiTransactional).toHaveBeenCalledTimes(1); + expect(String(backendApp.DBQueryMultiTransactional.mock.calls[0][2])).toContain('update users set active = 1 where 1 = 0'); + expect(messageApi.success).toHaveBeenCalledWith('Execution succeeded.'); + expect(messageApi.success).not.toHaveBeenCalledWith('执行成功。'); + }); it('shows the wrapped execution failure toast in English while preserving raw error detail', async () => { storeState.languagePreference = 'en-US'; @@ -1839,7 +1871,7 @@ describe('QueryEditor external SQL save', () => { expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1'); - expect(messageApi.error).toHaveBeenCalledWith('Query execution failed: driver exploded'); + expect(messageApi.error).toHaveBeenCalledWith(`Query execution failed: ${formatSqlExecutionError('driver exploded')}`); expect(messageApi.error).not.toHaveBeenCalledWith('Error executing query: driver exploded'); }); }); @@ -1894,7 +1926,7 @@ describe('QueryEditor external SQL save', () => { await Promise.resolve(); }); - expect(messageApi.error).toHaveBeenCalledWith('Refresh failed: network down'); + expect(messageApi.error).toHaveBeenCalledWith(`Refresh failed: ${formatSqlExecutionError('network down')}`); expect(messageApi.error).not.toHaveBeenCalledWith('刷新失败: network down'); }); @@ -1920,7 +1952,7 @@ describe('QueryEditor external SQL save', () => { await Promise.resolve(); }); - expect(messageApi.error).toHaveBeenCalledWith('Refresh failed: socket closed'); + expect(messageApi.error).toHaveBeenCalledWith(`Refresh failed: ${formatSqlExecutionError('socket closed')}`); expect(messageApi.error).not.toHaveBeenCalledWith('刷新失败: socket closed'); }); }); diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index cc8dbf9..f4c0913 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -25,7 +25,19 @@ export type CompletionTriggerMeta = {dbName: string, triggerName: string, tableN export type CompletionRoutineMeta = {dbName: string, routineName: string, routineType: string, schemaName?: string}; export const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_'; -const SQLSERVER_MESSAGE_PREFIX_RE = /^\s*mssql:\s*/i; +const SQLSERVER_MESSAGE_PREFIX_RE = /^\s*mssql:/i; + +const trimBoundaryBlankEntries = (entries: string[]): string[] => { + let start = 0; + let end = entries.length; + while (start < end && !String(entries[start] || '').trim()) start++; + while (end > start && !String(entries[end - 1] || '').trim()) end--; + return entries.slice(start, end); +}; + +const stripSqlServerMessagePrefix = (line: string): string => ( + line.replace(SQLSERVER_MESSAGE_PREFIX_RE, '').replace(/^[ \t]/, '') +); export const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({ strategy: 'none', @@ -121,33 +133,33 @@ export const stripQueryIdentifierQuotes = (part: string): string => { return text; }; -export const normalizeQueryResultMessageText = (message: unknown): string => { +export const normalizeQueryResultMessageText = ( + message: unknown, + options?: { preserveIndentation?: boolean }, +): string => { const text = String(message ?? '').replace(/\r\n?/g, '\n'); - if (!text.trim()) return ''; + if (!text) return ''; - let prefixRemoved = false; - const normalizedLines = text - .split('\n') - .map((line) => { - if (!line.trim()) return ''; + const preserveIndentation = options?.preserveIndentation === true; + const normalizedLines = trimBoundaryBlankEntries( + text.split('\n').map((line) => { if (SQLSERVER_MESSAGE_PREFIX_RE.test(line)) { - prefixRemoved = true; - return line.replace(SQLSERVER_MESSAGE_PREFIX_RE, '').trimStart(); + return stripSqlServerMessagePrefix(line); } - return line; - }); - - const normalized = (prefixRemoved - ? normalizedLines.map((line) => line.trim() ? line.trimStart() : '').join('\n') - : normalizedLines.join('\n')) - .trim(); - - return prefixRemoved ? normalized : text.trim(); + return preserveIndentation ? line : (line.trim() ? line : ''); + }), + ); + if (normalizedLines.length === 0) return ''; + return preserveIndentation ? normalizedLines.join('\n') : normalizedLines.join('\n').trim(); }; export const normalizeQueryResultMessages = (messages: unknown): string[] => ( Array.isArray(messages) - ? messages.map((item) => normalizeQueryResultMessageText(item)).filter(Boolean) + ? (() => { + const preserveIndentation = messages.some((item) => SQLSERVER_MESSAGE_PREFIX_RE.test(String(item ?? ''))); + const normalized = messages.map((item) => normalizeQueryResultMessageText(item, { preserveIndentation })); + return preserveIndentation ? trimBoundaryBlankEntries(normalized) : normalized.filter(Boolean); + })() : [] );