From b9c743d67ed62c444fda93c92d9a16e5191df3e6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 23 May 2026 17:07:47 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=20SQL=20=E7=BC=96=E8=BE=91=E5=99=A8=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E4=B8=8E=E5=8E=86=E5=8F=B2=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持仅执行选中 SQL、光标所在语句和增量新增语句 - 持久化查询草稿,避免重启后丢失历史 SQL - 在表字段提示中展示注释信息 - 修复清空默认 SQL 后被自动回填的问题 Refs #483 --- .../QueryEditor.external-sql-save.test.tsx | 617 +++++++++++++++++- frontend/src/components/QueryEditor.tsx | 448 +++++++++++-- frontend/src/store.test.ts | 119 ++++ frontend/src/store.ts | 160 ++++- .../src/utils/sqlStatementSelection.test.ts | 44 +- frontend/src/utils/sqlStatementSelection.ts | 42 ++ internal/connection/types.go | 1 + internal/db/clickhouse_impl.go | 8 +- internal/db/dameng_impl.go | 9 +- internal/db/highgo_impl.go | 19 +- internal/db/iris_impl.go | 1 + internal/db/kingbase_impl.go | 19 +- internal/db/mariadb_impl.go | 3 +- internal/db/mysql_impl.go | 3 +- internal/db/oracle_impl.go | 9 +- internal/db/postgres_impl.go | 19 +- internal/db/sqlite_impl.go | 1 + internal/db/sqlserver_impl.go | 8 +- internal/db/tdengine_impl.go | 1 + internal/db/vastbase_impl.go | 19 +- 20 files changed, 1431 insertions(+), 119 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 1dc4ee1..60718a3 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -23,10 +23,11 @@ const storeState = vi.hoisted(() => ({ ], addSqlLog: vi.fn(), addTab: vi.fn(), + updateQueryTabDraft: vi.fn(), savedQueries: [] as SavedQuery[], saveQuery: vi.fn(), theme: 'light', - appearance: { uiVersion: 'legacy' as const }, + appearance: { uiVersion: 'legacy' as 'legacy' | 'v2' }, sqlFormatOptions: { keywordCase: 'upper' as const }, setSqlFormatOptions: vi.fn(), queryOptions: { maxRows: 5000 }, @@ -47,6 +48,7 @@ const storeState = vi.hoisted(() => ({ })); const backendApp = vi.hoisted(() => ({ + DBQuery: vi.fn(), DBQueryWithCancel: vi.fn(), DBQueryMulti: vi.fn(), DBGetTables: vi.fn(), @@ -74,25 +76,82 @@ const editorState = vi.hoisted(() => { const state = { value: '', editor: null as any, + position: { lineNumber: 1, column: 1 }, + selection: null as any, + providers: [] as any[], + cursorPositionListeners: [] as Array<(event: any) => void>, + hasTextFocus: true, + }; + const offsetAt = (position: { lineNumber: number; column: number }) => { + const text = state.value; + let offset = 0; + for (let lineNumber = 1; lineNumber < Math.max(1, position.lineNumber); lineNumber++) { + const nextLineBreak = text.indexOf('\n', offset); + if (nextLineBreak === -1) { + return text.length; + } + offset = nextLineBreak + 1; + } + return Math.min(text.length, offset + Math.max(0, position.column - 1)); + }; + const positionAt = (offset: number) => { + const text = state.value.replace(/\r\n/g, '\n'); + const safeOffset = Math.max(0, Math.min(text.length, Number(offset) || 0)); + const prefix = text.slice(0, safeOffset); + const lines = prefix.split('\n'); + return { lineNumber: lines.length, column: (lines[lines.length - 1]?.length || 0) + 1 }; + }; + const valueInRange = (range: any) => { + if (!range) return ''; + const start = offsetAt({ lineNumber: range.startLineNumber, column: range.startColumn }); + const end = offsetAt({ lineNumber: range.endLineNumber, column: range.endColumn }); + return state.value.slice(Math.min(start, end), Math.max(start, end)); + }; + const model = { + getValue: () => state.value, + setValue: (value: string) => { + state.value = value; + }, + getValueInRange: valueInRange, + getLineContent: (lineNumber: number) => state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '', + getLineCount: () => state.value.replace(/\r\n/g, '\n').split('\n').length, + getLineMaxColumn: (lineNumber: number) => (state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '').length + 1, + getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1, word: '' }), + getOffsetAt: offsetAt, + getPositionAt: positionAt, }; state.editor = { getValue: vi.fn(() => state.value), setValue: vi.fn((value: string) => { state.value = value; }), - getModel: vi.fn(() => ({ - getValue: () => state.value, - setValue: (value: string) => { - state.value = value; - }, - getValueInRange: () => '', - getLineContent: () => '', - getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }), - })), - getSelection: vi.fn(() => null), + getModel: vi.fn(() => model), + getPosition: vi.fn(() => state.position), + setPosition: vi.fn((position: any) => { + state.position = position; + }), + getSelection: vi.fn(() => state.selection), + setSelection: vi.fn((selection: any) => { + state.selection = selection; + }), + executeEdits: vi.fn((_source: string, edits: any[]) => { + edits.forEach((edit) => { + const start = offsetAt({ lineNumber: edit.range.startLineNumber, column: edit.range.startColumn }); + const end = offsetAt({ lineNumber: edit.range.endLineNumber, column: edit.range.endColumn }); + state.value = state.value.slice(0, start) + edit.text + state.value.slice(end); + }); + }), addAction: vi.fn(), onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })), - hasTextFocus: vi.fn(() => true), + onDidChangeCursorPosition: vi.fn((listener: (event: any) => void) => { + state.cursorPositionListeners.push(listener); + return { dispose: vi.fn() }; + }), + hasTextFocus: vi.fn(() => state.hasTextFocus), + revealLineInCenterIfOutsideViewport: vi.fn(), + revealRangeInCenterIfOutsideViewport: vi.fn(), + focus: vi.fn(), + trigger: vi.fn(), }; return state; }); @@ -119,7 +178,31 @@ vi.mock('@monaco-editor/react', () => ({ editor: { setTheme: vi.fn() }, languages: { CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 }, - registerCompletionItemProvider: vi.fn(), + CompletionItemInsertTextRule: { InsertAsSnippet: 1 }, + registerCompletionItemProvider: vi.fn((_language: string, provider: any) => { + editorState.providers.push(provider); + return { dispose: vi.fn() }; + }), + }, + Range: class { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + constructor(startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) { + this.startLineNumber = startLineNumber; + this.startColumn = startColumn; + this.endLineNumber = endLineNumber; + this.endColumn = endColumn; + } + }, + Position: class { + lineNumber: number; + column: number; + constructor(lineNumber: number, column: number) { + this.lineNumber = lineNumber; + this.column = column; + } }, }); }, []); @@ -145,12 +228,13 @@ vi.mock('@ant-design/icons', () => { CloseOutlined: Icon, StopOutlined: Icon, RobotOutlined: Icon, + DatabaseOutlined: Icon, }; }); vi.mock('antd', () => { - const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => ( - ); @@ -169,7 +253,15 @@ vi.mock('antd', () => { Dropdown: ({ children }: any) => <>{children}, Tooltip: ({ children }: any) => <>{children}, Select: () => null, - Tabs: ({ items }: any) =>
{items?.[0]?.children}
, + Tabs: ({ activeKey, items }: any) => { + const activeItem = items?.find((item: any) => item.key === activeKey) || items?.[0]; + return ( +
+
{items?.map((item: any) => {item.label})}
+
{activeItem?.children}
+
+ ); + }, }; }); @@ -205,6 +297,7 @@ describe('QueryEditor external SQL save', () => { messageApi.success.mockReset(); messageApi.error.mockReset(); messageApi.warning.mockReset(); + backendApp.DBQuery.mockResolvedValue({ success: true, data: [] }); backendApp.WriteSQLFile.mockResolvedValue({ success: true }); backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] }); @@ -212,10 +305,18 @@ describe('QueryEditor external SQL save', () => { backendApp.GenerateQueryID.mockResolvedValue('query-1'); storeState.connections[0].config.type = 'mysql'; storeState.connections[0].config.database = 'main'; + storeState.appearance.uiVersion = 'legacy'; dataGridState.latestProps = null; editorState.value = ''; + editorState.position = { lineNumber: 1, column: 1 }; + editorState.selection = null; + editorState.providers = []; + editorState.cursorPositionListeners = []; + editorState.hasTextFocus = true; editorState.editor.getValue.mockClear(); editorState.editor.setValue.mockClear(); + editorState.editor.executeEdits.mockClear(); + storeState.updateQueryTabDraft.mockReset(); }); afterEach(() => { @@ -223,6 +324,29 @@ describe('QueryEditor external SQL save', () => { vi.clearAllMocks(); }); + it('shows the default SQL template for a fresh blank query tab', async () => { + await act(async () => { + create(); + }); + + expect(editorState.value).toBe('SELECT * FROM '); + }); + + it('keeps the editor empty when a tab draft is externally synced to an empty query', async () => { + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create(); + }); + + await act(async () => { + renderer.update(); + }); + + expect(editorState.value).toBe(''); + expect(editorState.editor.setValue).toHaveBeenCalledWith(''); + }); + it('writes external SQL file tabs back to disk without creating saved queries', async () => { let renderer!: ReactTestRenderer; const filePath = '/Users/me/Documents/gonavi-queries/report.sql'; @@ -538,6 +662,467 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读:main.users 未检测到主键或可用唯一索引,无法安全提交修改。'); }); + it('runs the SQL statement at the cursor instead of the whole editor when nothing is selected', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['two'], rows: [{ two: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.position = { lineNumber: 2, column: 8 }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.(); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); + expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({ + sql: expect.stringContaining('select 2 as two'), + })); + }); + + it('keeps cursor statement execution available in v2 UI', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['two'], rows: [{ two: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.position = { lineNumber: 2, column: 8 }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.(); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); + }); + + it('uses the last editor cursor position when the run button takes focus', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['two'], rows: [{ two: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.cursorPositionListeners.forEach((listener) => { + listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } }); + }); + editorState.hasTextFocus = false; + editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); + }); + + it('prefers the last editor cursor event even if Monaco still reports text focus', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['two'], rows: [{ two: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.cursorPositionListeners.forEach((listener) => { + listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } }); + }); + editorState.hasTextFocus = true; + editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); + }); + + it('uses Monaco active selection position when run button focus drifts onto a blank line', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['b'], rows: [{ b: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.selection = { + startLineNumber: 2, + startColumn: 'select 2 as b;'.length + 1, + endLineNumber: 2, + endColumn: 'select 2 as b;'.length + 1, + positionLineNumber: 2, + positionColumn: 'select 2 as b;'.length + 1, + }; + editorState.position = { lineNumber: 3, column: 1 }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); + expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); + }); + + it('keeps cursor statement execution when CRLF line endings put the cursor after a semicolon', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['b'], rows: [{ b: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; + editorState.selection = { + startLineNumber: 2, + startColumn: 'select 2 as b;'.length + 1, + endLineNumber: 2, + endColumn: 'select 2 as b;'.length + 1, + positionLineNumber: 2, + positionColumn: 'select 2 as b;'.length + 1, + }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); + expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); + }); + + it('does not execute SQL when the cursor is on a blank line', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.position = { lineNumber: 3, column: 1 }; + editorState.selection = { + startLineNumber: 3, + startColumn: 1, + endLineNumber: 3, + endColumn: 1, + positionLineNumber: 3, + positionColumn: 1, + }; + editorState.cursorPositionListeners.forEach((listener) => { + listener({ position: { lineNumber: 3, column: 1 } }); + }); + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); + expect(messageApi.info).toHaveBeenCalledWith('没有可执行的 SQL。'); + }); + + it('runs only appended SQL and keeps existing results after a full editor execution', async () => { + backendApp.DBQueryMulti + .mockResolvedValueOnce({ + success: true, + data: [{ columns: ['a'], rows: [{ a: 1 }] }], + }) + .mockResolvedValueOnce({ + success: true, + data: [{ columns: ['b'], rows: [{ b: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + editorState.value = 'select 1 as a;\nselect 2 as b;'; + editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a'); + expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b'); + expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a'); + expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)'); + expect(textContent(renderer!.toJSON())).toContain('结果 2 (1)'); + }); + + it('replaces the current result when rerunning the same cursor SQL', async () => { + backendApp.DBQueryMulti + .mockResolvedValueOnce({ + success: true, + data: [{ columns: ['a'], rows: [{ a: 1 }] }], + }) + .mockResolvedValueOnce({ + success: true, + data: [{ columns: ['a'], rows: [{ a: 10 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; + editorState.selection = { + startLineNumber: 1, + startColumn: 'select 1 as a;'.length + 1, + endLineNumber: 1, + endColumn: 'select 1 as a;'.length + 1, + positionLineNumber: 1, + positionColumn: 'select 1 as a;'.length + 1, + }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const tabLabels = renderer!.root.findAll((node) => textContent(node).includes('结果 ')); + expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)'); + expect(textContent(renderer!.toJSON())).not.toContain('结果 2 (1)'); + expect(tabLabels.length).toBeGreaterThan(0); + expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 10 })])); + expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); + expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 1 as a'); + expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 2 as b'); + }); + + it('appends a result when running a different cursor SQL after an existing result', async () => { + backendApp.DBQueryMulti + .mockResolvedValueOnce({ + success: true, + data: [{ columns: ['a'], rows: [{ a: 1 }] }], + }) + .mockResolvedValueOnce({ + success: true, + data: [{ columns: ['b'], rows: [{ b: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; + editorState.selection = { + startLineNumber: 1, + startColumn: 'select 1 as a;'.length + 1, + endLineNumber: 1, + endColumn: 'select 1 as a;'.length + 1, + positionLineNumber: 1, + positionColumn: 'select 1 as a;'.length + 1, + }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; + editorState.selection = { + startLineNumber: 2, + startColumn: 'select 2 as b;'.length + 1, + endLineNumber: 2, + endColumn: 'select 2 as b;'.length + 1, + positionLineNumber: 2, + positionColumn: 'select 2 as b;'.length + 1, + }; + + await act(async () => { + const runButton = findButton(renderer!, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); + expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b'); + expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a'); + expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 3 as c'); + expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)'); + expect(textContent(renderer!.toJSON())).toContain('结果 2 (1)'); + }); + + it('runs selected SQL before cursor SQL', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['selected'], rows: [{ selected: 2 }] }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + editorState.position = { lineNumber: 1, column: 4 }; + editorState.selection = { + startLineNumber: 2, + startColumn: 1, + endLineNumber: 2, + endColumn: 'select 2 as selected'.length + 1, + }; + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as selected'), 'query-1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); + }); + it('allows editable table columns while leaving expression columns out of commits', async () => { backendApp.DBQueryMulti.mockResolvedValueOnce({ success: true, diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 8fff448..f718843 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -6,7 +6,7 @@ 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 { DBQuery, 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"; @@ -17,7 +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 { resolveCurrentSqlStatementRange } from '../utils/sqlStatementSelection'; +import { resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; import { isMacLikePlatform } from '../utils/appearance'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator'; @@ -193,8 +193,10 @@ let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables; let sharedCurrentDb = ''; let sharedCurrentConnectionId = ''; let sharedConnections: any[] = []; -let sharedTablesData: {dbName: string, tableName: string}[] = []; -let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type: string}[] = []; +type CompletionTableMeta = {dbName: string, tableName: string, comment?: string}; +type CompletionColumnMeta = {dbName: string, tableName: string, name: string, type: string, comment?: string}; +let sharedTablesData: CompletionTableMeta[] = []; +let sharedAllColumnsData: CompletionColumnMeta[] = []; let sharedVisibleDbs: string[] = []; let sharedColumnsCacheData: Record = {}; @@ -469,6 +471,176 @@ const buildQueryRowIDExpression = (dbType: string, sourceAlias?: string): string `${sourceAlias ? `${sourceAlias}.` : ''}ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}` ); +const escapeMetadataSqlLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); + +const quoteSqlServerDbIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; + +const normalizeMetadataDialect = (conn: any): string => { + const type = String(conn?.config?.type || '').trim().toLowerCase(); + const driver = String(conn?.config?.driver || '').trim(); + const dialect = resolveSqlDialect(type, driver, { + oceanBaseProtocol: conn?.config?.oceanBaseProtocol, + }); + if (dialect === 'diros' || dialect === 'sphinx' || dialect === 'mariadb' || dialect === 'oceanbase') return 'mysql'; + if (dialect === 'dameng') return 'oracle'; + return String(dialect || '').toLowerCase(); +}; + +const buildCompletionTableCommentSQL = (dialect: string, dbName: string): string => { + const db = String(dbName || '').trim(); + const escapedDb = escapeMetadataSqlLiteral(db); + switch (dialect) { + case 'mysql': + case 'starrocks': + return `SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment FROM information_schema.tables WHERE table_schema = '${escapedDb}' AND table_type = 'BASE TABLE' ORDER BY table_name`; + case 'postgres': + case 'kingbase': + case 'vastbase': + case 'highgo': + case 'opengauss': + return `SELECT n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, c.relname`; + case 'sqlserver': { + const safeDb = quoteSqlServerDbIdentifier(db); + return `SELECT s.name + '.' + t.name AS table_name, ep.value AS table_comment FROM ${safeDb}.sys.tables t JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id LEFT JOIN ${safeDb}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' WHERE t.type = 'U' ORDER BY s.name, t.name`; + } + case 'clickhouse': + return `SELECT name AS table_name, comment AS table_comment FROM system.tables WHERE database = '${escapedDb}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`; + case 'oracle': { + const owner = escapedDb.toUpperCase(); + return `SELECT table_name, comments AS table_comment FROM all_tab_comments WHERE owner = '${owner}' ORDER BY table_name`; + } + default: + return ''; + } +}; + +const getCaseInsensitiveValue = (row: Record, keys: string[]): any => { + for (const key of keys) { + for (const rowKey of Object.keys(row || {})) { + if (rowKey.toLowerCase() === key.toLowerCase()) { + return row[rowKey]; + } + } + } + return undefined; +}; + +const normalizeCommentText = (value: unknown): string => { + if (value === null || value === undefined) return ''; + const text = String(value).trim(); + if (!text || text.toLowerCase() === '') return ''; + return text; +}; + +const buildCompletionDocumentation = (comment?: string): string | undefined => { + const text = normalizeCommentText(comment); + return text ? `备注:${text}` : undefined; +}; + +const appendCommentToDetail = (detail: string, comment?: string): string => { + const text = normalizeCommentText(comment); + return text ? `${detail} - ${text}` : detail; +}; + +const stripCompletionIdentifierQuotes = (ident: string): string => { + let raw = String(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 normalizeCompletionQualifiedName = (ident: string): string => { + const raw = String(ident || '').trim(); + if (!raw) return raw; + return raw + .split('.') + .map(p => stripCompletionIdentifierQuotes(p.trim())) + .filter(Boolean) + .join('.'); +}; + +const getCompletionQualifiedNameLastPart = (qualified: string): string => { + const raw = normalizeCompletionQualifiedName(qualified); + if (!raw) return raw; + const parts = raw.split('.').filter(Boolean); + return parts[parts.length - 1] || raw; +}; + +const splitCompletionSchemaAndTable = (qualified: string): { schema: string; table: string } => { + const raw = normalizeCompletionQualifiedName(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 DEFAULT_QUERY_TEMPLATE = 'SELECT * FROM '; + +const getTabQueryValue = (tab: TabData): string => ( + typeof tab.query === 'string' ? tab.query : '' +); + +const getInitialEditorQuery = (tab: TabData): string => { + const tabQuery = getTabQueryValue(tab); + if (tabQuery || tab.filePath || tab.savedQueryId || tab.readOnly) { + return tabQuery; + } + return DEFAULT_QUERY_TEMPLATE; +}; + +const resolveNextResultSetIndex = (sets: Array<{ key?: string }>): number => { + const maxIndex = sets.reduce((max, item) => { + const match = String(item?.key || '').match(/^result-(\d+)$/); + const index = match ? Number(match[1]) : 0; + return Number.isFinite(index) ? Math.max(max, index) : max; + }, 0); + return maxIndex + 1; +}; + +const areSqlStatementListsEqual = (left: string[], right: string[]): boolean => ( + left.length === right.length && left.every((statement, index) => statement === right[index]) +); + +const normalizeExecutedSqlKey = (sql: string): string => String(sql || '') + .replace(/\r\n/g, '\n') + .replace(/;/g, ';') + .trim() + .replace(/;+\s*$/g, '') + .trim(); + +const normalizeEditorPosition = (position: any): { lineNumber: number; column: number } | null => { + if (!position) return null; + const lineNumber = Number(position.positionLineNumber ?? position.lineNumber ?? position.endLineNumber ?? position.startLineNumber ?? position.selectionStartLineNumber); + const column = Number(position.positionColumn ?? position.column ?? position.endColumn ?? position.startColumn ?? position.selectionStartColumn); + if (!Number.isFinite(lineNumber) || !Number.isFinite(column) || lineNumber < 1 || column < 1) { + return null; + } + return { lineNumber, column }; +}; + +const getNormalizedOffsetAtPosition = ( + sqlText: string, + position: { lineNumber: number; column: number }, +): number => { + const text = String(sqlText || '').replace(/\r\n/g, '\n'); + const lines = text.split('\n'); + const targetLineIndex = Math.max(0, Math.min(lines.length - 1, position.lineNumber - 1)); + let offset = 0; + for (let index = 0; index < targetLineIndex; index++) { + offset += (lines[index]?.length || 0) + 1; + } + return Math.max(0, Math.min(text.length, offset + Math.max(0, position.column - 1))); +}; + const resolveQueryLocatorPlan = async ({ statement, dbType, @@ -610,7 +782,7 @@ const resolveQueryLocatorPlan = async ({ }; const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => { - const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); + const [query, setQuery] = useState(getInitialEditorQuery(tab)); type ResultSet = { key: string; @@ -648,12 +820,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const monacoRef = useRef(null); const runQueryActionRef = useRef(null); const selectCurrentStatementActionRef = useRef(null); - const lastExternalQueryRef = useRef(tab.query || ''); + const lastExternalQueryRef = useRef(getTabQueryValue(tab)); + const lastEditorCursorPositionRef = useRef(null); + const lastExecutedEditorQueryRef = useRef(''); 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 tablesRef = useRef([]); // Store tables for autocomplete (cross-db) + const allColumnsRef = useRef([]); // Store all columns (cross-db) const visibleDbsRef = useRef([]); // Store visible databases for cross-db intellisense const connections = useStore(state => state.connections); @@ -663,6 +837,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ); const addSqlLog = useStore(state => state.addSqlLog); const addTab = useStore(state => state.addTab); + const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft); const savedQueries = useStore(state => state.savedQueries); const currentConnectionIdRef = useRef(currentConnectionId); const currentDbRef = useRef(currentDb); @@ -730,6 +905,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc currentDbRef.current = currentDb; }, [currentDb]); + useEffect(() => { + updateQueryTabDraft(tab.id, { + query, + connectionId: currentConnectionId, + dbName: currentDb, + }); + }, [currentConnectionId, currentDb, query, tab.id, updateQueryTabDraft]); + // 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量 // 确保 completion provider 始终使用当前活跃 Tab 的上下文 useEffect(() => { @@ -789,12 +972,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // If opening a saved query, load its SQL useEffect(() => { - const incoming = tab.query || ''; + const incoming = getTabQueryValue(tab); if (incoming === lastExternalQueryRef.current) { return; } lastExternalQueryRef.current = incoming; - syncQueryToEditor(incoming || 'SELECT * FROM '); + syncQueryToEditor(incoming); }, [tab.id, tab.query]); // Fetch Database List @@ -871,16 +1054,41 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; // 加载所有可见数据库的表 - const allTables: {dbName: string, tableName: string}[] = []; - const allColumns: {dbName: string, tableName: string, name: string, type: string}[] = []; + const allTables: CompletionTableMeta[] = []; + const allColumns: CompletionColumnMeta[] = []; + const metadataDialect = normalizeMetadataDialect(conn); for (const dbName of visibleDbs) { + const tableComments = new Map(); + const tableCommentSQL = buildCompletionTableCommentSQL(metadataDialect, dbName); + if (tableCommentSQL) { + try { + const resTableComments = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, tableCommentSQL); + if (resTableComments.success && Array.isArray(resTableComments.data)) { + resTableComments.data.forEach((row: any) => { + const tableName = normalizeCommentText(getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'name', 'Name'])); + if (!tableName) return; + tableComments.set(tableName.toLowerCase(), normalizeCommentText(getCaseInsensitiveValue(row, ['table_comment', 'TABLE_COMMENT', 'comment', 'comments', 'Comment', 'COMMENTS']))); + }); + } + } catch { + // 表备注只是补全增强,失败时保留原有表名补全。 + } + } + // 获取表 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 }); + const parsed = splitCompletionSchemaAndTable(String(tableName || '')); + allTables.push({ + dbName, + tableName, + comment: tableComments.get(String(tableName || '').toLowerCase()) + || (parsed.table ? tableComments.get(parsed.table.toLowerCase()) : undefined) + || undefined + }); }); } @@ -892,7 +1100,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc dbName, tableName: col.tableName, name: col.name, - type: col.type + type: col.type, + comment: normalizeCommentText(col.comment ?? col.Comment ?? col.COLUMN_COMMENT ?? col.column_comment ?? '') }); }); } @@ -945,10 +1154,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const handleEditorDidMount: OnMount = (editor, monaco) => { editorRef.current = editor; monacoRef.current = monaco; + lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.()); // 应用透明主题(主题由 MonacoEditor 包装组件按需注册) monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light'); + editor.onDidChangeCursorPosition?.((event: any) => { + const position = normalizeEditorPosition(event?.position); + if (position) { + lastEditorCursorPositionRef.current = position; + } + }); + // 注册 AI 右键菜单操作 const aiActions = [ { id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' }, @@ -1039,46 +1256,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc 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 stripQuotes = stripCompletionIdentifierQuotes; + const normalizeQualifiedName = normalizeCompletionQualifiedName; + const getLastPart = getCompletionQualifiedNameLastPart; + const splitSchemaAndTable = splitCompletionSchemaAndTable; const buildConnConfig = () => { const connId = sharedCurrentConnectionId; @@ -1140,7 +1321,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: c.name, - detail: `${c.type} (${c.dbName}.${c.tableName})`, + detail: appendCommentToDetail(`${c.type} (${c.dbName}.${c.tableName})`, c.comment), + documentation: buildCompletionDocumentation(c.comment), range, sortText: '0' + c.name })); @@ -1169,7 +1351,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc label: t.tableName, kind: monaco.languages.CompletionItemKind.Class, insertText: t.tableName, - detail: `Table (${t.dbName})`, + detail: appendCommentToDetail(`Table (${t.dbName})`, t.comment), + documentation: buildCompletionDocumentation(t.comment), range, sortText: '0' + t.tableName })); @@ -1184,6 +1367,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc dbName: t.dbName || '', schema: parsed.schema, table: parsed.table, + comment: t.comment, }; }) .filter(t => t.schema.toLowerCase() === qualifierLower && !!t.table); @@ -1197,7 +1381,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc label: t.table, kind: monaco.languages.CompletionItemKind.Class, insertText: t.table, - detail: `Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`, + detail: appendCommentToDetail(`Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`, t.comment), + documentation: buildCompletionDocumentation(t.comment), range, sortText: '0' + t.table })); @@ -1242,7 +1427,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const tableInfo = aliasMap[qualifier.toLowerCase()]; if (tableInfo) { // Prefer preloaded MySQL all-columns cache - let cols: { name: string, type?: string, tableName?: string, dbName?: string }[]; + let cols: { name: string, type?: string, tableName?: string, dbName?: string, comment?: string }[]; if (sharedAllColumnsData.length > 0) { const tiTableLower = (tableInfo.tableName || '').toLowerCase(); cols = sharedAllColumnsData @@ -1254,10 +1439,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const parsed = splitSchemaAndTable(c.tableName || ''); return (parsed.table || '').toLowerCase() === tiTableLower; }) - .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName })); + .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName, comment: c.comment })); } else { const dbCols = await getColumnsByDB(tableInfo.tableName); - cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName })); + cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName, comment: c.comment })); } const filtered = prefix @@ -1268,7 +1453,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc 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})` : ''), + detail: appendCommentToDetail( + c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''), + c.comment, + ), + documentation: buildCompletionDocumentation(c.comment), range, sortText: '0' + c.name })); @@ -1318,7 +1507,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: c.name, - detail: `${c.type} (${c.dbName}.${c.tableName})`, + detail: appendCommentToDetail(`${c.type} (${c.dbName}.${c.tableName})`, c.comment), + documentation: buildCompletionDocumentation(c.comment), range, sortText: isCurrentDb ? sortGroups.columnCurrent + c.name : sortGroups.columnOther + c.name, }; @@ -1358,7 +1548,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc label, kind: monaco.languages.CompletionItemKind.Class, insertText: label, - detail: `Table (${t.dbName})`, + detail: appendCommentToDetail(`Table (${t.dbName})`, t.comment), + documentation: buildCompletionDocumentation(t.comment), range, sortText: sortGroups.tableOther + t.tableName, }; @@ -1376,7 +1567,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc label, kind: monaco.languages.CompletionItemKind.Class, insertText, - detail: `Table${schemaInfo}`, + detail: appendCommentToDetail(`Table${schemaInfo}`, t.comment), + documentation: buildCompletionDocumentation(t.comment), range, sortText: sortGroups.tableCurrent + pureTable, }; @@ -1747,6 +1939,89 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return selected; }; + const mergeResultSets = (previous: ResultSet[], next: ResultSet[], replaceAll: boolean): ResultSet[] => { + if (replaceAll || previous.length === 0) { + return next.map((result, index) => ({ ...result, key: `result-${index + 1}` })); + } + + const merged = [...previous]; + next.forEach((result) => { + const incomingKey = normalizeExecutedSqlKey(result.exportSql || result.sql); + const existingIndex = merged.findIndex((item) => normalizeExecutedSqlKey(item.exportSql || item.sql) === incomingKey); + if (existingIndex >= 0) { + merged[existingIndex] = { ...result, key: merged[existingIndex].key }; + return; + } + merged.push({ ...result, key: `result-${resolveNextResultSetIndex(merged)}` }); + }); + return merged; + }; + + const resolveExecutableSQLAtEditorPosition = (model: any, sqlText: string, position: any): string => { + const normalizedPosition = normalizeEditorPosition(position); + if (!normalizedPosition) return ''; + const cursorOffset = getNormalizedOffsetAtPosition(sqlText, normalizedPosition); + const resolved = resolveExecutableSql(sqlText, cursorOffset, ''); + return resolved?.sql || ''; + }; + + const getExecutableSQLAtCurrentCursor = (model: any, sqlText: string): string => { + const editor = editorRef.current; + const liveSelection = normalizeEditorPosition(editor?.getSelection?.()); + if (liveSelection) { + return resolveExecutableSQLAtEditorPosition(model, sqlText, liveSelection); + } + + const livePosition = normalizeEditorPosition(editor?.getPosition?.()); + const cachedPosition = normalizeEditorPosition(lastEditorCursorPositionRef.current); + const candidates: Array<{ lineNumber: number; column: number }> = []; + if (cachedPosition) candidates.push(cachedPosition); + if (livePosition) candidates.push(livePosition); + const seen = new Set(); + + for (const position of candidates) { + const key = `${position.lineNumber}:${position.column}`; + if (seen.has(key)) continue; + seen.add(key); + const sql = resolveExecutableSQLAtEditorPosition(model, sqlText, position); + if (sql.trim()) return sql; + } + + const fallbackPosition = cachedPosition || livePosition; + return resolveExecutableSQLAtEditorPosition(model, sqlText, fallbackPosition); + }; + + const getExecutableSQL = (): string => { + const editor = editorRef.current; + const model = editor?.getModel?.(); + const currentQuery = getCurrentQuery(); + const selectedSQL = getSelectedSQL(); + const selected = selectedSQL.trim(); + if (!selected && resultSets.length > 0 && lastExecutedEditorQueryRef.current && currentQuery.startsWith(lastExecutedEditorQueryRef.current)) { + const appendedSQL = currentQuery.slice(lastExecutedEditorQueryRef.current.length); + if (appendedSQL.trim()) { + return appendedSQL; + } + } + if (!model || !editor) { + return selectedSQL || currentQuery; + } + + if (selected) { + return selectedSQL; + } + return getExecutableSQLAtCurrentCursor(model, String(model.getValue?.() ?? currentQuery)); + }; + + const captureEditorCursorPosition = (event?: React.MouseEvent) => { + event?.preventDefault(); + const editor = editorRef.current; + const position = normalizeEditorPosition(editor?.getSelection?.()) || normalizeEditorPosition(editor?.getPosition?.()); + if (position) { + lastEditorCursorPositionRef.current = position; + } + }; + // 精准重查询单个结果集(提交事务 / 刷新按钮使用),不会重跑整个编辑器 SQL const handleReloadResult = async (resultKey: string, sql: string) => { if (!sql?.trim() || !currentDb) return; @@ -1816,6 +2091,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const handleRun = async () => { const currentQuery = getCurrentQuery(); if (!currentQuery.trim()) return; + const executableSQL = getExecutableSQL(); + if (!executableSQL.trim()) { + message.info('没有可执行的 SQL。'); + setResultSets([]); + setActiveResultKey(''); + return; + } if (!currentDb) { message.error("请先选择数据库"); return; @@ -1858,7 +2140,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; try { - const rawSQL = getSelectedSQL() || currentQuery; + const rawSQL = executableSQL; const rpcConfig = buildRpcConnectionConfig(config) as any; const dbType = String(rpcConfig.type || 'mysql'); const driver = String((config as any).driver || ''); @@ -1876,6 +2158,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc .replace(/^\s*\/\/.*$/gm, '') .replace(/^\s*#.*$/gm, ''); const statements = splitSQLStatements(splitInput); + const didExecuteAppendedSql = resultSets.length > 0 + && lastExecutedEditorQueryRef.current + && currentQuery.startsWith(lastExecutedEditorQueryRef.current) + && normalizedRawSQL.trim() === currentQuery.slice(lastExecutedEditorQueryRef.current.length).replace(/;/g, ';').trim(); + const didExecuteWholeEditor = areSqlStatementListsEqual( + splitSQLStatements(currentQuery.replace(/;/g, ';')), + statements, + ); if (statements.length === 0) { message.info('没有可执行的 SQL。'); setResultSets([]); @@ -1980,8 +2270,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } } - setResultSets(nextResultSets); + const shouldReplaceAllResults = didExecuteWholeEditor; + setResultSets(prev => mergeResultSets(prev, nextResultSets, shouldReplaceAllResults)); setActiveResultKey(nextResultSets[0]?.key || ''); + if (didExecuteAppendedSql || didExecuteWholeEditor) { + lastExecutedEditorQueryRef.current = currentQuery; + } if (statements.length > 1) { message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`); } else if (nextResultSets.length === 0) { @@ -1991,6 +2285,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } else { // 非 MongoDB:使用 DBQueryMulti 一次性执行多条 SQL,后端返回多结果集 const sourceStatements = splitSQLStatements(normalizedRawSQL); + const didExecuteAppendedSql = resultSets.length > 0 + && lastExecutedEditorQueryRef.current + && currentQuery.startsWith(lastExecutedEditorQueryRef.current) + && normalizedRawSQL.trim() === currentQuery.slice(lastExecutedEditorQueryRef.current.length).replace(/;/g, ';').trim(); + const didExecuteWholeEditor = areSqlStatementListsEqual( + splitSQLStatements(currentQuery.replace(/;/g, ';')), + sourceStatements, + ); if (sourceStatements.length === 0) { message.info('没有可执行的 SQL。'); setResultSets([]); @@ -2136,8 +2438,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } - setResultSets(nextResultSets); + const shouldReplaceAllResults = didExecuteWholeEditor; + setResultSets(prev => mergeResultSets(prev, nextResultSets, shouldReplaceAllResults)); setActiveResultKey(nextResultSets[0]?.key || ''); + if (didExecuteAppendedSql || didExecuteWholeEditor) { + lastExecutedEditorQueryRef.current = currentQuery; + } executablePlans.forEach((plan) => { if (plan.warning) message.warning(plan.warning); @@ -2159,7 +2465,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc addSqlLog({ id: `log-${Date.now()}-error`, timestamp: Date.now(), - sql: getSelectedSQL() || query, + sql: executableSQL || getExecutableSQL() || query, status: 'error', duration: Date.now() - runStartTime, message: e.message, @@ -2395,6 +2701,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc text: (position.column > 1 ? '\n' : '') + mText, forceMoveMarkers: true }]); + const nextValue = editor.getValue?.(); + if (typeof nextValue === 'string') { + setQuery(nextValue); + } // 定位并滚动到可见区域 const targetLine = position.lineNumber + (position.column > 1 ? 1 : 0); @@ -2626,7 +2936,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc : '运行' } > - diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 610efb2..2c015b1 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -566,6 +566,125 @@ describe('store appearance persistence', () => { ]); }); + it('persists open query tab drafts and restores them after reload', async () => { + const { useStore } = await importStore(); + + useStore.getState().addTab({ + id: 'query-tab-1', + title: '临时 SQL', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + query: 'select * from users where id = 1;', + }); + useStore.getState().updateQueryTabDraft('query-tab-1', { + query: 'select * from orders where status = "paid";', + connectionId: 'conn-2', + dbName: 'reporting', + }); + + const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.tabs).toEqual([ + expect.objectContaining({ + id: 'query-tab-1', + title: '临时 SQL', + type: 'query', + connectionId: 'conn-2', + dbName: 'reporting', + query: 'select * from orders where status = "paid";', + }), + ]); + expect(persisted.state.activeTabId).toBe('query-tab-1'); + + vi.resetModules(); + const reloaded = await importStore(); + expect(reloaded.useStore.getState().tabs).toEqual([ + expect.objectContaining({ + id: 'query-tab-1', + type: 'query', + connectionId: 'conn-2', + dbName: 'reporting', + query: 'select * from orders where status = "paid";', + }), + ]); + expect(reloaded.useStore.getState().activeTabId).toBe('query-tab-1'); + }); + + it('only restores persisted query tabs with useful SQL state', async () => { + storage.setItem('lite-db-storage', JSON.stringify({ + state: { + tabs: [ + { + id: 'query-1', + title: '有效 SQL', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + query: 'select 1;', + }, + { + id: 'table-1', + title: 'users', + type: 'table', + connectionId: 'conn-1', + dbName: 'main', + tableName: 'users', + }, + { + id: 'empty-query', + title: '空查询', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + query: ' ', + }, + ], + activeTabId: 'table-1', + }, + version: 9, + })); + + const { useStore } = await importStore(); + + expect(useStore.getState().tabs).toEqual([ + expect.objectContaining({ + id: 'query-1', + type: 'query', + query: 'select 1;', + }), + ]); + expect(useStore.getState().activeTabId).toBe('query-1'); + }); + + it('persists recent SQL execution logs and trims oversized entries', async () => { + const { useStore } = await importStore(); + const longSql = `select '${'x'.repeat(120 * 1024)}'`; + + useStore.getState().addSqlLog({ + id: 'log-1', + timestamp: 100, + sql: longSql, + status: 'success', + duration: 12, + dbName: 'main', + }); + + const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.sqlLogs).toHaveLength(1); + expect(persisted.state.sqlLogs[0].sql.length).toBe(100 * 1024); + expect(persisted.state.sqlLogs[0].dbName).toBe('main'); + + vi.resetModules(); + const reloaded = await importStore(); + expect(reloaded.useStore.getState().sqlLogs[0]).toEqual(expect.objectContaining({ + id: 'log-1', + status: 'success', + duration: 12, + dbName: 'main', + })); + expect(reloaded.useStore.getState().sqlLogs[0]?.sql.length).toBe(100 * 1024); + }); + it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => { const { useStore } = await importStore(); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 47e4216..6564766 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -76,6 +76,12 @@ const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15; const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300; const PERSIST_VERSION = 9; const PERSIST_STORAGE_KEY = "lite-db-storage"; +const MAX_PERSISTED_QUERY_TABS = 20; +const MAX_PERSISTED_QUERY_LENGTH = 1024 * 1024; +const MAX_SQL_LOGS = 1000; +const MAX_PERSISTED_SQL_LOGS = 200; +const MAX_PERSISTED_SQL_LOG_LENGTH = 100 * 1024; +const MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH = 2 * 1024; const DEFAULT_CONNECTION_TYPE = "mysql"; const DEFAULT_JVM_PORT = 9010; const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = { @@ -852,6 +858,10 @@ interface AppState { reorderTags: (tagIds: string[]) => void; addTab: (tab: TabData) => void; + updateQueryTabDraft: ( + id: string, + draft: Partial>, + ) => void; closeTab: (id: string) => void; closeOtherTabs: (id: string) => void; closeTabsToLeft: (id: string) => void; @@ -1050,6 +1060,97 @@ const sanitizeExternalSQLDirectories = ( return result; }; +const sanitizeQueryTabs = (value: unknown): TabData[] => { + if (!Array.isArray(value)) return []; + const result: TabData[] = []; + const seenIds = new Set(); + + value.forEach((entry, index) => { + if (!entry || typeof entry !== "object") return; + const raw = entry as Record; + if (raw.type !== "query") return; + + const query = typeof raw.query === "string" ? raw.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) : ""; + const filePath = toTrimmedString(raw.filePath); + const savedQueryId = toTrimmedString(raw.savedQueryId); + if (!query.trim() && !filePath && !savedQueryId) return; + + let id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`; + if (seenIds.has(id)) { + id = `${id}-${index + 1}`; + } + seenIds.add(id); + + result.push({ + id, + title: toTrimmedString(raw.title, "新建查询") || "新建查询", + type: "query", + connectionId: toTrimmedString(raw.connectionId), + dbName: toTrimmedString(raw.dbName), + query, + filePath: filePath || undefined, + savedQueryId: savedQueryId || undefined, + readOnly: raw.readOnly === true, + }); + }); + + return result.slice(0, MAX_PERSISTED_QUERY_TABS); +}; + +const sanitizeActiveTabId = (activeTabId: unknown, tabs: TabData[]): string | null => { + const id = toTrimmedString(activeTabId); + if (id && tabs.some((tab) => tab.id === id)) { + return id; + } + return tabs[0]?.id || null; +}; + +const sanitizeSqlLogs = (value: unknown, limit = MAX_PERSISTED_SQL_LOGS): SqlLog[] => { + if (!Array.isArray(value)) return []; + const result: SqlLog[] = []; + const seenIds = new Set(); + + value.forEach((entry, index) => { + if (!entry || typeof entry !== "object") return; + const raw = entry as Record; + const sql = typeof raw.sql === "string" ? raw.sql.slice(0, MAX_PERSISTED_SQL_LOG_LENGTH) : ""; + if (!sql.trim()) return; + + let id = toTrimmedString(raw.id, `log-${index + 1}`) || `log-${index + 1}`; + if (seenIds.has(id)) { + id = `${id}-${index + 1}`; + } + seenIds.add(id); + + const status = raw.status === "error" ? "error" : "success"; + const timestamp = Number(raw.timestamp); + const duration = Number(raw.duration); + const affectedRows = Number(raw.affectedRows); + const log: SqlLog = { + id, + timestamp: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Date.now(), + sql, + status, + duration: Number.isFinite(duration) && duration >= 0 ? duration : 0, + dbName: toTrimmedString(raw.dbName) || undefined, + }; + + const message = typeof raw.message === "string" + ? raw.message.slice(0, MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH) + : ""; + if (message) { + log.message = message; + } + if (Number.isFinite(affectedRows)) { + log.affectedRows = affectedRows; + } + + result.push(log); + }); + + return result.slice(0, limit); +}; + const hasLegacyConnectionSecrets = ( connections: SavedConnection[], ): boolean => { @@ -1649,6 +1750,51 @@ export const useStore = create()( return { tabs: [...state.tabs, tab], activeTabId: tab.id }; }), + updateQueryTabDraft: (id, draft) => + set((state) => { + const tabId = toTrimmedString(id); + if (!tabId) return state; + + let changed = false; + const nextTabs = state.tabs.map((tab) => { + if (tab.id !== tabId || tab.type !== "query") return tab; + const nextTab: TabData = { ...tab }; + + if (draft.query !== undefined) { + const nextQuery = typeof draft.query === "string" ? draft.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) : ""; + if (nextTab.query !== nextQuery) { + nextTab.query = nextQuery; + changed = true; + } + } + if (draft.connectionId !== undefined) { + const nextConnectionId = toTrimmedString(draft.connectionId); + if (nextTab.connectionId !== nextConnectionId) { + nextTab.connectionId = nextConnectionId; + changed = true; + } + } + if (draft.dbName !== undefined) { + const nextDbName = toTrimmedString(draft.dbName); + if ((nextTab.dbName || "") !== nextDbName) { + nextTab.dbName = nextDbName; + changed = true; + } + } + if (draft.title !== undefined) { + const nextTitle = toTrimmedString(draft.title, nextTab.title) || nextTab.title; + if (nextTab.title !== nextTitle) { + nextTab.title = nextTitle; + changed = true; + } + } + + return nextTab; + }); + + return changed ? { tabs: nextTabs } : state; + }), + closeTab: (id) => set((state) => { const newTabs = state.tabs.filter((t) => t.id !== id); @@ -1926,7 +2072,7 @@ export const useStore = create()( }), addSqlLog: (log) => - set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs + set((state) => ({ sqlLogs: sanitizeSqlLogs([log, ...state.sqlLogs], MAX_SQL_LOGS) })), clearSqlLogs: () => set({ sqlLogs: [] }), recordTableAccess: (connectionId, dbName, tableName) => @@ -2245,6 +2391,9 @@ export const useStore = create()( ) as Partial; const nextState: Partial = { ...state }; nextState.connections = sanitizeConnections(state.connections); + const safeTabs = sanitizeQueryTabs(state.tabs); + nextState.tabs = safeTabs; + nextState.activeTabId = sanitizeActiveTabId(state.activeTabId, safeTabs); if (version < 5) { nextState.connectionTags = sanitizeConnectionTags( state.connectionTags, @@ -2273,6 +2422,7 @@ export const useStore = create()( nextState.shortcutOptions = sanitizeShortcutOptions( state.shortcutOptions, ); + nextState.sqlLogs = sanitizeSqlLogs(state.sqlLogs); const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets); const existingSnippetIds = new Set(existingSnippets.map((s) => s.id)); const missingSnippets = DEFAULT_SQL_SNIPPETS.filter( @@ -2318,11 +2468,14 @@ export const useStore = create()( const state = unwrapPersistedAppState( persistedState, ) as Partial; + const safeTabs = sanitizeQueryTabs(state.tabs); return { ...currentState, ...state, connections: sanitizeConnections(state.connections), connectionTags: sanitizeConnectionTags(state.connectionTags), + tabs: safeTabs, + activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs), savedQueries: sanitizeSavedQueries(state.savedQueries), externalSQLDirectories: sanitizeExternalSQLDirectories( state.externalSQLDirectories, @@ -2352,6 +2505,7 @@ export const useStore = create()( sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), queryOptions: sanitizeQueryOptions(state.queryOptions), shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions), + sqlLogs: sanitizeSqlLogs(state.sqlLogs), sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), @@ -2361,7 +2515,10 @@ export const useStore = create()( }; }, partialize: (state) => { + const tabs = sanitizeQueryTabs(state.tabs); const partialState: Partial = { + tabs, + activeTabId: sanitizeActiveTabId(state.activeTabId, tabs), connectionTags: state.connectionTags, savedQueries: state.savedQueries, externalSQLDirectories: state.externalSQLDirectories, @@ -2377,6 +2534,7 @@ export const useStore = create()( sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions, shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions), + sqlLogs: sanitizeSqlLogs(state.sqlLogs), sqlSnippets: state.sqlSnippets, tableAccessCount: state.tableAccessCount, tableSortPreference: state.tableSortPreference, diff --git a/frontend/src/utils/sqlStatementSelection.test.ts b/frontend/src/utils/sqlStatementSelection.test.ts index 5202323..95a940e 100644 --- a/frontend/src/utils/sqlStatementSelection.test.ts +++ b/frontend/src/utils/sqlStatementSelection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { findSqlStatementRanges, resolveCurrentSqlStatementRange } from './sqlStatementSelection'; +import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from './sqlStatementSelection'; describe('sqlStatementSelection', () => { it('resolves the statement containing the cursor', () => { @@ -38,4 +38,46 @@ describe('sqlStatementSelection', () => { it('returns null when there is no statement', () => { expect(resolveCurrentSqlStatementRange(' \n\t ', 0)).toBeNull(); }); + + it('prefers a non-empty selection for executable SQL', () => { + const sql = 'select 1;\nselect 2;'; + + expect(resolveExecutableSql(sql, sql.indexOf('1'), ' select selected ')?.sql).toBe(' select selected '); + expect(resolveExecutableSql(sql, sql.indexOf('1'), ' select selected ')?.source).toBe('selection'); + }); + + it('uses the statement containing the cursor for executable SQL', () => { + const sql = 'select 1;\n\nselect 2 from users;\nselect 3'; + + expect(resolveExecutableSql(sql, sql.indexOf('users'))).toEqual({ + sql: 'select 2 from users', + source: 'statement', + }); + }); + + it('keeps execution on the statement when the cursor lands after its semicolon', () => { + const sql = 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;'; + const afterSecondSemicolon = sql.indexOf('select 2 as b') + 'select 2 as b;'.length; + + expect(resolveExecutableSql(sql, afterSecondSemicolon)).toEqual({ + sql: 'select 2 as b', + source: 'statement', + }); + }); + + it('falls back to the current line when the cursor is not inside a statement', () => { + const sql = 'select 1;\n\n select 2'; + + expect(resolveExecutableSql(sql, sql.indexOf('\n\n') + 1)).toBeNull(); + expect(resolveExecutableSql(sql, sql.indexOf(' select 2'))).toEqual({ + sql: 'select 2', + source: 'statement', + }); + }); + + it('does not jump to the next statement when executing from blank space', () => { + const sql = 'select 1;\n\nselect 2;'; + + expect(resolveExecutableSql(sql, sql.indexOf('\n\n') + 1)).toBeNull(); + }); }); diff --git a/frontend/src/utils/sqlStatementSelection.ts b/frontend/src/utils/sqlStatementSelection.ts index decf24b..181dc51 100644 --- a/frontend/src/utils/sqlStatementSelection.ts +++ b/frontend/src/utils/sqlStatementSelection.ts @@ -4,6 +4,13 @@ export interface SqlStatementRange { text: string; } +export type SqlExecutionSelectionSource = 'selection' | 'statement' | 'line'; + +export interface SqlExecutionSelection { + sql: string; + source: SqlExecutionSelectionSource; +} + const isWhitespace = (ch: string): boolean => ( ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' ); @@ -157,3 +164,38 @@ export const resolveCurrentSqlStatementRange = (sql: string, cursorOffset: numbe return ranges[ranges.length - 1]; }; + +export const resolveExecutableSql = ( + sql: string, + cursorOffset: number, + selectedSql = '', +): SqlExecutionSelection | null => { + const selected = String(selectedSql || '').trim(); + if (selected) { + return { sql: selectedSql, source: 'selection' }; + } + + const text = String(sql || '').replace(/\r\n/g, '\n'); + const offset = Math.max(0, Math.min(text.length, Number.isFinite(cursorOffset) ? cursorOffset : 0)); + const ranges = findSqlStatementRanges(text); + const statement = ranges.find((range) => offset >= range.start && offset <= range.end); + if (statement?.text.trim()) { + return { sql: statement.text, source: 'statement' }; + } + + const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; + const nextLineBreak = text.indexOf('\n', offset); + const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak; + const line = text.slice(lineStart, lineEnd).trim(); + if (line) { + const lineStatement = [...ranges].reverse().find((range) => range.start < lineEnd && range.end >= lineStart); + if (lineStatement?.text.trim()) { + return { sql: lineStatement.text, source: 'statement' }; + } + } + if (line) { + return { sql: line, source: 'line' }; + } + + return null; +}; diff --git a/internal/connection/types.go b/internal/connection/types.go index 53a5c2f..8ecc96f 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -178,6 +178,7 @@ type ColumnDefinitionWithTable struct { TableName string `json:"tableName"` Name string `json:"name"` Type string `json:"type"` + Comment string `json:"comment,omitempty"` } // UpdateRow 表示一行更新操作,Keys 为 WHERE 条件,Values 为 SET 值。 diff --git a/internal/db/clickhouse_impl.go b/internal/db/clickhouse_impl.go index 7c3c3f3..b80d337 100644 --- a/internal/db/clickhouse_impl.go +++ b/internal/db/clickhouse_impl.go @@ -925,7 +925,8 @@ SELECT database, table, name, - type + type, + comment FROM system.columns WHERE database = '%s' ORDER BY table, position`, @@ -937,7 +938,8 @@ SELECT database, table, name, - type + type, + comment FROM system.columns WHERE database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA') ORDER BY database, table, position` @@ -954,6 +956,7 @@ ORDER BY database, table, position` tableValue, hasTable := getClickHouseValueFromRow(row, "table", "table_name") nameValue, hasName := getClickHouseValueFromRow(row, "name", "column_name") typeValue, _ := getClickHouseValueFromRow(row, "type", "data_type") + commentValue, _ := getClickHouseValueFromRow(row, "comment") if !hasTable || !hasName { continue } @@ -970,6 +973,7 @@ ORDER BY database, table, position` TableName: tableName, Name: strings.TrimSpace(fmt.Sprintf("%v", nameValue)), Type: strings.TrimSpace(fmt.Sprintf("%v", typeValue)), + Comment: strings.TrimSpace(fmt.Sprintf("%v", commentValue)), }) } return result, nil diff --git a/internal/db/dameng_impl.go b/internal/db/dameng_impl.go index 9f6d106..d9ce83b 100644 --- a/internal/db/dameng_impl.go +++ b/internal/db/dameng_impl.go @@ -480,9 +480,11 @@ func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet) } func (d *DamengDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { - query := fmt.Sprintf(`SELECT table_name, column_name, data_type - FROM all_tab_columns - WHERE owner = '%s'`, strings.ToUpper(dbName)) + query := fmt.Sprintf(`SELECT c.table_name, c.column_name, c.data_type, cc.comments AS comment + FROM all_tab_columns c + LEFT JOIN all_col_comments cc + ON cc.owner = c.owner AND cc.table_name = c.table_name AND cc.column_name = c.column_name + WHERE c.owner = '%s'`, strings.ReplaceAll(strings.ToUpper(dbName), "'", "''")) data, _, err := d.Query(query) if err != nil { @@ -495,6 +497,7 @@ func (d *DamengDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWi TableName: fmt.Sprintf("%v", row["TABLE_NAME"]), Name: fmt.Sprintf("%v", row["COLUMN_NAME"]), Type: fmt.Sprintf("%v", row["DATA_TYPE"]), + Comment: fmt.Sprintf("%v", row["COMMENT"]), } cols = append(cols, col) } diff --git a/internal/db/highgo_impl.go b/internal/db/highgo_impl.go index ba0b7da..7dd6145 100644 --- a/internal/db/highgo_impl.go +++ b/internal/db/highgo_impl.go @@ -523,11 +523,19 @@ ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema)) func (h *HighGoDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { query := ` -SELECT table_schema, table_name, column_name, data_type -FROM information_schema.columns -WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - AND table_schema NOT LIKE 'pg|_%' ESCAPE '|' -ORDER BY table_schema, table_name, ordinal_position` +SELECT + c.table_schema, + c.table_name, + c.column_name, + c.data_type, + col_description(cls.oid, a.attnum) AS comment +FROM information_schema.columns c +LEFT JOIN pg_namespace n ON n.nspname = c.table_schema +LEFT JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name +LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name +WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') + AND c.table_schema NOT LIKE 'pg|_%' ESCAPE '|' +ORDER BY c.table_schema, c.table_name, c.ordinal_position` data, _, err := h.Query(query) if err != nil { @@ -547,6 +555,7 @@ ORDER BY table_schema, table_name, ordinal_position` TableName: tableName, Name: fmt.Sprintf("%v", row["column_name"]), Type: fmt.Sprintf("%v", row["data_type"]), + Comment: fmt.Sprintf("%v", row["comment"]), } cols = append(cols, col) } diff --git a/internal/db/iris_impl.go b/internal/db/iris_impl.go index 309c931..b89ed2a 100644 --- a/internal/db/iris_impl.go +++ b/internal/db/iris_impl.go @@ -406,6 +406,7 @@ func (i *IrisDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWith TableName: tableName, Name: name, Type: buildIRISColumnType(row), + Comment: rowString(row, "DESCRIPTION", "description", "COMMENT", "comment"), }) } sort.SliceStable(cols, func(a, b int) bool { diff --git a/internal/db/kingbase_impl.go b/internal/db/kingbase_impl.go index 9fa12f1..3addd15 100644 --- a/internal/db/kingbase_impl.go +++ b/internal/db/kingbase_impl.go @@ -951,11 +951,19 @@ func splitKingbaseQualifiedTable(tableName string) (schema string, table string) func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { // dbName 在本项目语义里是“数据库”,schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。 query := ` - SELECT table_schema, table_name, column_name, data_type - FROM information_schema.columns - WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - AND table_schema NOT LIKE 'pg|_%' ESCAPE '|' - ORDER BY table_schema, table_name, ordinal_position` + SELECT + c.table_schema, + c.table_name, + c.column_name, + c.data_type, + col_description(cls.oid, a.attnum) AS comment + FROM information_schema.columns c + LEFT JOIN pg_namespace n ON n.nspname = c.table_schema + LEFT JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name + LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name + WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') + AND c.table_schema NOT LIKE 'pg|_%' ESCAPE '|' + ORDER BY c.table_schema, c.table_name, c.ordinal_position` data, _, err := k.Query(query) if err != nil { @@ -974,6 +982,7 @@ func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinition TableName: tableName, Name: fmt.Sprintf("%v", row["column_name"]), Type: fmt.Sprintf("%v", row["data_type"]), + Comment: fmt.Sprintf("%v", row["comment"]), } cols = append(cols, col) } diff --git a/internal/db/mariadb_impl.go b/internal/db/mariadb_impl.go index ac51f49..5e1d29d 100644 --- a/internal/db/mariadb_impl.go +++ b/internal/db/mariadb_impl.go @@ -445,10 +445,10 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e } func (m *MariaDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { - query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName) if dbName == "" { return nil, fmt.Errorf("获取全部列信息需要指定数据库名称") } + query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", strings.ReplaceAll(dbName, "'", "''")) data, _, err := m.Query(query) if err != nil { @@ -461,6 +461,7 @@ func (m *MariaDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWit TableName: fmt.Sprintf("%v", row["TABLE_NAME"]), Name: fmt.Sprintf("%v", row["COLUMN_NAME"]), Type: fmt.Sprintf("%v", row["COLUMN_TYPE"]), + Comment: fmt.Sprintf("%v", row["COLUMN_COMMENT"]), } cols = append(cols, col) } diff --git a/internal/db/mysql_impl.go b/internal/db/mysql_impl.go index cbae90f..aefc0e5 100644 --- a/internal/db/mysql_impl.go +++ b/internal/db/mysql_impl.go @@ -1302,10 +1302,10 @@ func formatMySQLDateTime(t time.Time) string { } func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { - query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName) if dbName == "" { return nil, fmt.Errorf("获取全部列信息需要指定数据库名称") } + query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", strings.ReplaceAll(dbName, "'", "''")) data, _, err := m.Query(query) if err != nil { @@ -1318,6 +1318,7 @@ func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWit TableName: fmt.Sprintf("%v", row["TABLE_NAME"]), Name: fmt.Sprintf("%v", row["COLUMN_NAME"]), Type: fmt.Sprintf("%v", row["COLUMN_TYPE"]), + Comment: fmt.Sprintf("%v", row["COLUMN_COMMENT"]), } cols = append(cols, col) } diff --git a/internal/db/oracle_impl.go b/internal/db/oracle_impl.go index a1b92f8..52763ef 100644 --- a/internal/db/oracle_impl.go +++ b/internal/db/oracle_impl.go @@ -747,9 +747,11 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet) } func (o *OracleDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { - query := fmt.Sprintf(`SELECT table_name, column_name, data_type - FROM all_tab_columns - WHERE owner = '%s'`, strings.ToUpper(dbName)) + query := fmt.Sprintf(`SELECT c.table_name, c.column_name, c.data_type, cc.comments AS comment + FROM all_tab_columns c + LEFT JOIN all_col_comments cc + ON cc.owner = c.owner AND cc.table_name = c.table_name AND cc.column_name = c.column_name + WHERE c.owner = '%s'`, strings.ReplaceAll(strings.ToUpper(dbName), "'", "''")) data, _, err := o.Query(query) if err != nil { @@ -762,6 +764,7 @@ func (o *OracleDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWi TableName: fmt.Sprintf("%v", row["TABLE_NAME"]), Name: fmt.Sprintf("%v", row["COLUMN_NAME"]), Type: fmt.Sprintf("%v", row["DATA_TYPE"]), + Comment: fmt.Sprintf("%v", row["COMMENT"]), } cols = append(cols, col) } diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index 327830c..989a3d1 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -603,11 +603,19 @@ ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema)) func (p *PostgresDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { query := ` -SELECT table_schema, table_name, column_name, data_type -FROM information_schema.columns -WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - AND table_schema NOT LIKE 'pg|_%' ESCAPE '|' -ORDER BY table_schema, table_name, ordinal_position` +SELECT + c.table_schema, + c.table_name, + c.column_name, + c.data_type, + col_description(cls.oid, a.attnum) AS comment +FROM information_schema.columns c +LEFT JOIN pg_namespace n ON n.nspname = c.table_schema +LEFT JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name +LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name +WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') + AND c.table_schema NOT LIKE 'pg|_%' ESCAPE '|' +ORDER BY c.table_schema, c.table_name, c.ordinal_position` data, _, err := p.Query(query) if err != nil { @@ -627,6 +635,7 @@ ORDER BY table_schema, table_name, ordinal_position` TableName: tableName, Name: fmt.Sprintf("%v", row["column_name"]), Type: fmt.Sprintf("%v", row["data_type"]), + Comment: fmt.Sprintf("%v", row["comment"]), } cols = append(cols, col) } diff --git a/internal/db/sqlite_impl.go b/internal/db/sqlite_impl.go index cfbf997..55ef3aa 100644 --- a/internal/db/sqlite_impl.go +++ b/internal/db/sqlite_impl.go @@ -736,6 +736,7 @@ func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWi TableName: table, Name: col.Name, Type: col.Type, + Comment: col.Comment, }) } } diff --git a/internal/db/sqlserver_impl.go b/internal/db/sqlserver_impl.go index d682907..6ed6389 100644 --- a/internal/db/sqlserver_impl.go +++ b/internal/db/sqlserver_impl.go @@ -355,13 +355,14 @@ ORDER BY c.column_id`, func (s *SqlServerDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { safeDB := quoteBracket(dbName) query := fmt.Sprintf(` -SELECT s.name AS schema_name, t.name AS table_name, c.name AS column_name, tp.name AS data_type +SELECT s.name AS schema_name, t.name AS table_name, c.name AS column_name, tp.name AS data_type, ep.value AS comment FROM [%s].sys.columns c JOIN [%s].sys.tables t ON c.object_id = t.object_id JOIN [%s].sys.schemas s ON t.schema_id = s.schema_id JOIN [%s].sys.types tp ON c.user_type_id = tp.user_type_id +LEFT JOIN [%s].sys.extended_properties ep ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description' WHERE t.type = 'U' -ORDER BY s.name, t.name, c.column_id`, safeDB, safeDB, safeDB, safeDB) +ORDER BY s.name, t.name, c.column_id`, safeDB, safeDB, safeDB, safeDB, safeDB) data, _, err := s.Query(query) if err != nil { @@ -379,6 +380,9 @@ ORDER BY s.name, t.name, c.column_id`, safeDB, safeDB, safeDB, safeDB) Name: fmt.Sprintf("%v", row["column_name"]), Type: fmt.Sprintf("%v", row["data_type"]), } + if v, ok := row["comment"]; ok && v != nil { + col.Comment = fmt.Sprintf("%v", v) + } cols = append(cols, col) } return cols, nil diff --git a/internal/db/tdengine_impl.go b/internal/db/tdengine_impl.go index 540ab73..059f601 100644 --- a/internal/db/tdengine_impl.go +++ b/internal/db/tdengine_impl.go @@ -374,6 +374,7 @@ func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinition TableName: table, Name: col.Name, Type: col.Type, + Comment: col.Comment, }) } } diff --git a/internal/db/vastbase_impl.go b/internal/db/vastbase_impl.go index 0041f01..5db6f2d 100644 --- a/internal/db/vastbase_impl.go +++ b/internal/db/vastbase_impl.go @@ -522,11 +522,19 @@ ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema)) func (v *VastbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { query := ` -SELECT table_schema, table_name, column_name, data_type -FROM information_schema.columns -WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - AND table_schema NOT LIKE 'pg|_%' ESCAPE '|' -ORDER BY table_schema, table_name, ordinal_position` +SELECT + c.table_schema, + c.table_name, + c.column_name, + c.data_type, + col_description(cls.oid, a.attnum) AS comment +FROM information_schema.columns c +LEFT JOIN pg_namespace n ON n.nspname = c.table_schema +LEFT JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name +LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name +WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') + AND c.table_schema NOT LIKE 'pg|_%' ESCAPE '|' +ORDER BY c.table_schema, c.table_name, c.ordinal_position` data, _, err := v.Query(query) if err != nil { @@ -546,6 +554,7 @@ ORDER BY table_schema, table_name, ordinal_position` TableName: tableName, Name: fmt.Sprintf("%v", row["column_name"]), Type: fmt.Sprintf("%v", row["data_type"]), + Comment: fmt.Sprintf("%v", row["comment"]), } cols = append(cols, col) }