From f7217583a363f5d19167c0ee037d21a2b5a5a766 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 4 Jun 2026 16:05:40 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sql-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=AF=B9=E8=B1=A1=E8=B7=B3=E8=BD=AC=E5=8D=A1=E6=AD=BB?= =?UTF-8?q?=E4=B8=8E=E8=BF=87=E7=A8=8B=E6=A8=A1=E6=9D=BF=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?CREATE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 跳过大 SQL 的全局对象装饰扫描,避免 Ctrl/Cmd 点击对象时读取整篇编辑器文本 - 存储过程和函数源码片段缺少 CREATE 时自动补 CREATE OR REPLACE - 增加过程修改模板与大 SQL 对象跳转回归测试 --- .../DefinitionViewer.object-edit.test.tsx | 39 ++++++++++++++ frontend/src/components/DefinitionViewer.tsx | 8 +++ .../QueryEditor.external-sql-save.test.tsx | 53 +++++++++++++++++++ frontend/src/components/QueryEditor.tsx | 31 ++++++++++- 4 files changed, 129 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DefinitionViewer.object-edit.test.tsx b/frontend/src/components/DefinitionViewer.object-edit.test.tsx index 7d399ec..fe99485 100644 --- a/frontend/src/components/DefinitionViewer.object-edit.test.tsx +++ b/frontend/src/components/DefinitionViewer.object-edit.test.tsx @@ -149,4 +149,43 @@ describe('DefinitionViewer object edit entry', () => { query: expect.stringContaining('CREATE OR REPLACE FUNCTION reporting.refresh_stats()'), })); }); + + it('adds CREATE OR REPLACE for routine source snippets returned without ddl prefix', async () => { + storeState.connections[0].config.type = 'oracle'; + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [ + { TEXT: 'PROCEDURE proc_tally2accept(p_id IN NUMBER) IS\n' }, + { TEXT: ' v_count PLS_INTEGER;\n' }, + { TEXT: 'BEGIN\n' }, + { TEXT: ' SELECT COUNT(*) INTO v_count FROM dual;\n' }, + { TEXT: 'END;\n' }, + ], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + const query = storeState.addTab.mock.calls[0][0].query; + expect(query).toContain('CREATE OR REPLACE PROCEDURE proc_tally2accept(p_id IN NUMBER)'); + expect(query).toContain('v_count PLS_INTEGER;'); + expect(query).toContain('SELECT COUNT(*) INTO v_count FROM dual;'); + }); }); diff --git a/frontend/src/components/DefinitionViewer.tsx b/frontend/src/components/DefinitionViewer.tsx index 14f7ec6..a83ae8a 100644 --- a/frontend/src/components/DefinitionViewer.tsx +++ b/frontend/src/components/DefinitionViewer.tsx @@ -51,6 +51,14 @@ const buildEditableDefinitionSql = (tab: TabData, definition: string, objectLabe return `${header}CREATE OR REPLACE VIEW ${objectName} AS\n${ensureSqlStatementTerminator(normalizedDefinition)}`; } + if ( + tab.type === 'routine-def' + && !/^\s*create\b/i.test(normalizedDefinition) + && /^\s*(function|procedure)\b/i.test(normalizedDefinition) + ) { + return `${header}${ensureSqlStatementTerminator(`CREATE OR REPLACE ${normalizedDefinition}`)}`; + } + return `${header}${ensureSqlStatementTerminator(normalizedDefinition)}`; }; diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 27ac840..0e7bd36 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -747,6 +747,59 @@ describe('QueryEditor external SQL save', () => { expect(stopPropagation).toHaveBeenCalled(); }); + it('does not read the full editor model when ctrl/cmd clicking objects in large SQL', async () => { + editorState.value = [ + ...Array.from({ length: 4000 }, (_, index) => `-- filler ${index + 1}`), + 'select * from analytics.events where id = 1', + ].join('\n'); + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] }); + backendApp.DBGetTables + .mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }) + .mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] }); + backendApp.DBGetAllColumns + .mockResolvedValueOnce({ success: true, data: [] }) + .mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + editorState.editor.getModel().getValue.mockClear(); + editorState.editor.getModel().getValueLength.mockClear(); + const lineNumber = editorState.value.split('\n').length; + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + + await act(async () => { + editorState.mouseDownListeners[0]?.({ + target: { position: { lineNumber, column: 27 } }, + event: { + browserEvent: { button: 0, buttons: 1 }, + ctrlKey: true, + metaKey: false, + preventDefault, + stopPropagation, + }, + }); + }); + + expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled(); + expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled(); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + type: 'table', + connectionId: 'conn-1', + dbName: 'analytics', + tableName: 'events', + })); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + it('shows link-style hover feedback when ctrl/cmd is pressed over a navigable identifier', async () => { editorState.value = 'select * from analytics.events where id = 1'; autoFetchState.visible = true; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index cc992b2..d1c663c 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1006,6 +1006,7 @@ const QUERY_EDITOR_IDENTIFIER_CHAR_REGEX = /[A-Za-z0-9_$`"\[\].]/; const QUERY_EDITOR_HOVER_DELAY_MS = 1000; const QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH = 200_000; const QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS = 800; +const QUERY_EDITOR_OBJECT_DECORATION_MAX_LINES = 1_000; const QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH = 50_000; const QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH = 50_000; @@ -1036,6 +1037,33 @@ const getQueryEditorObjectResolveText = ( maxTextLength = QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH, ): string => getQueryEditorModelTextIfWithinLimit(model, maxTextLength) ?? lineContent; +const getQueryEditorDecorationModelTextIfLightweight = ( + model: any, + maxTextLength: number, +): string | null => { + if (!model || typeof model.getLineCount !== 'function' || typeof model.getLineContent !== 'function') { + return getQueryEditorModelTextIfWithinLimit(model, maxTextLength); + } + + const lineCount = Number(model.getLineCount()); + if (!Number.isFinite(lineCount) || lineCount <= 0 || lineCount > QUERY_EDITOR_OBJECT_DECORATION_MAX_LINES) { + return null; + } + + const lines: string[] = []; + let textLength = 0; + for (let lineNumber = 1; lineNumber <= lineCount; lineNumber += 1) { + const lineContent = String(model.getLineContent(lineNumber) || ''); + textLength += lineContent.length + (lineNumber < lineCount ? 1 : 0); + if (textLength > maxTextLength) { + return null; + } + lines.push(lineContent); + } + + return lines.join('\n'); +}; + const maskQueryEditorSqlLiteralsAndComments = (source: string): string => { const text = String(source || '').replace(/\r\n/g, '\n'); if (!text) return ''; @@ -2099,7 +2127,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } - const text = getQueryEditorModelTextIfWithinLimit(model, maxTextLength); + const text = getQueryEditorDecorationModelTextIfLightweight(model, maxTextLength); if (text === null) { objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []); return; @@ -2809,7 +2837,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc editor.onMouseDown?.((event: any) => { const browserEvent = event?.event; - syncModifierState(browserEvent || null); const targetPosition = normalizeEditorPosition(event?.target?.position); if (!browserEvent || !targetPosition) { return;