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;