diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index 6da266a..3a1e9ef 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -742,6 +742,134 @@ describe('QueryEditor external SQL save', () => { renderer?.unmount(); }); + it('runs the whole Oracle procedure when the cursor is in the exception tail', async () => { + storeState.connections[0].config.type = 'oracle'; + storeState.connections[0].config.database = 'ORCLPDB1'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }], + }); + const plsql = [ + '-- 修改函数/存储过程:H2.cproc_tzhssr_order2sale_A1', + '-- 请确认语法兼容当前数据库后执行', + 'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1(', + ' p_sourceid IN VARCHAR2,', + ' p_msg_out OUT NVARCHAR2', + ') AS', + ' v_ecnt NUMBER;', + ' CURSOR cur_ware IS', + ' SELECT d.goodsid', + ' FROM t_order_d d', + ' ORDER BY CASE', + " WHEN d.goodsqty > 0 THEN '1'", + " ELSE '2'", + ' END, d.goodsid;', + 'BEGIN', + ' FOR row_ware IN cur_ware LOOP', + ' IF row_ware.goodsid IS NOT NULL THEN', + ' BEGIN', + ' SELECT COUNT(*) INTO v_ecnt FROM dual;', + ' EXCEPTION', + ' WHEN no_data_found THEN', + ' v_ecnt := 0;', + ' END;', + ' END IF;', + ' END LOOP;', + " p_msg_out := '';", + 'EXCEPTION', + ' WHEN OTHERS THEN', + " p_msg_out := substr('订单核销失败,错误信息:' || SQLERRM || ',错误位置:' ||", + ' dbms_utility.format_error_backtrace, 1, 1000);', + 'END cproc_tzhssr_order2sale_A1;', + '/;', + ].join('\n'); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + const tailLine = plsql.split('\n').findIndex((line) => line.includes('p_msg_out := substr')) + 1; + editorState.position = { lineNumber: tailLine, column: 5 }; + editorState.selection = { + startLineNumber: tailLine, + startColumn: 5, + endLineNumber: tailLine, + endColumn: 5, + positionLineNumber: tailLine, + positionColumn: 5, + }; + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]); + expect(executedSql).toContain('CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1'); + expect(executedSql).toContain('p_msg_out OUT NVARCHAR2'); + expect(executedSql).toContain('p_msg_out := substr'); + expect(executedSql).not.toBe(plsql.split('\n').slice(tailLine - 1).join('\n')); + expect(executedSql).not.toContain('/;'); + renderer?.unmount(); + }); + + it('runs the preceding Oracle procedure when the cursor is on the SQLPlus slash delimiter', async () => { + storeState.connections[0].config.type = 'oracle'; + storeState.connections[0].config.database = 'ORCLPDB1'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }], + }); + const plsql = [ + 'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1(', + ' p_sourceid IN VARCHAR2,', + ' p_msg_out OUT NVARCHAR2', + ') AS', + 'BEGIN', + " p_msg_out := '';", + 'EXCEPTION', + ' WHEN OTHERS THEN', + ' p_msg_out := SQLERRM;', + 'END cproc_tzhssr_order2sale_A1;', + '/;', + ].join('\n'); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + const slashLine = plsql.split('\n').findIndex((line) => line.startsWith('/')) + 1; + editorState.position = { lineNumber: slashLine, column: 1 }; + editorState.selection = { + startLineNumber: slashLine, + startColumn: 1, + endLineNumber: slashLine, + endColumn: 1, + positionLineNumber: slashLine, + positionColumn: 1, + }; + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]); + expect(executedSql).toContain('CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1'); + expect(executedSql).toContain('p_msg_out OUT NVARCHAR2'); + expect(executedSql).toContain('END cproc_tzhssr_order2sale_A1;'); + expect(executedSql).not.toContain('/;'); + renderer?.unmount(); + }); + it('renders result grid for sqlserver exec statements that return rows', async () => { storeState.connections[0].config.type = 'sqlserver'; storeState.connections[0].config.database = 'master'; diff --git a/frontend/src/utils/sqlStatementSelection.test.ts b/frontend/src/utils/sqlStatementSelection.test.ts index 7c3a6a9..e0e7330 100644 --- a/frontend/src/utils/sqlStatementSelection.test.ts +++ b/frontend/src/utils/sqlStatementSelection.test.ts @@ -285,6 +285,103 @@ describe('sqlStatementSelection', () => { }); }); + it('keeps large Oracle procedures intact when the cursor is in the exception tail', () => { + const sql = [ + '-- 修改函数/存储过程:H2.cproc_tzhssr_order2sale_A1', + '-- 请确认语法兼容当前数据库后执行', + 'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1(', + ' p_sourceid IN VARCHAR2,', + ' p_msg_out OUT NVARCHAR2', + ') AS', + ' v_ecnt NUMBER;', + ' CURSOR cur_ware IS', + ' SELECT d.goodsid, d.goodsqty', + ' FROM t_order_d d', + ' ORDER BY CASE', + " WHEN d.goodsqty > 0 THEN '1'", + " ELSE '2'", + ' END, d.goodsid;', + 'BEGIN', + ' FOR row_ware IN cur_ware LOOP', + ' IF row_ware.goodsqty > 0 THEN', + ' BEGIN', + ' SELECT COUNT(*) INTO v_ecnt FROM dual;', + ' EXCEPTION', + ' WHEN no_data_found THEN', + ' v_ecnt := 0;', + ' END;', + ' ELSE', + ' BEGIN', + ' v_ecnt := 0;', + ' END;', + ' END IF;', + ' END LOOP;', + " p_msg_out := '';", + 'EXCEPTION', + ' WHEN OTHERS THEN', + " p_msg_out := substr('订单核销失败,错误信息:' || SQLERRM || ',错误位置:' ||", + ' dbms_utility.format_error_backtrace, 1, 1000);', + 'END cproc_tzhssr_order2sale_A1;', + '/ -- SQLPlus delimiter from PL/SQL tools', + 'SELECT 1 FROM dual;', + ].join('\n'); + + const ranges = findSqlStatementRanges(sql).map((range) => range.text); + + expect(ranges).toHaveLength(2); + expect(ranges[0]).toContain('CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1'); + expect(ranges[0]).toContain('p_msg_out OUT NVARCHAR2'); + expect(ranges[0]).toContain('EXCEPTION'); + expect(ranges[0]).toContain('END cproc_tzhssr_order2sale_A1;'); + expect(ranges[1]).toBe('SELECT 1 FROM dual'); + expect(resolveExecutableSql(sql, sql.indexOf('p_msg_out := substr'))).toEqual({ + sql: ranges[0], + source: 'statement', + }); + expect(resolveExecutableSql(sql, sql.indexOf('/ -- SQLPlus delimiter'))).toEqual({ + sql: ranges[0], + source: 'statement', + }); + expect(resolveCurrentSqlStatementRange(sql, sql.indexOf('/ -- SQLPlus delimiter'))?.text).toBe(ranges[0]); + }); + + it('skips optional semicolons after SQL*Plus slash delimiters', () => { + const sql = [ + 'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1(', + ' p_msg_out OUT NVARCHAR2', + ') AS', + 'BEGIN', + " p_msg_out := '';", + 'EXCEPTION', + ' WHEN OTHERS THEN', + ' p_msg_out := SQLERRM;', + 'END cproc_tzhssr_order2sale_A1;', + '/;', + 'SELECT 1 FROM dual;', + ].join('\n'); + + const ranges = findSqlStatementRanges(sql).map((range) => range.text); + + expect(ranges).toEqual([ + [ + 'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1(', + ' p_msg_out OUT NVARCHAR2', + ') AS', + 'BEGIN', + " p_msg_out := '';", + 'EXCEPTION', + ' WHEN OTHERS THEN', + ' p_msg_out := SQLERRM;', + 'END cproc_tzhssr_order2sale_A1;', + ].join('\n'), + 'SELECT 1 FROM dual', + ]); + expect(resolveExecutableSql(sql, sql.indexOf('/;'))).toEqual({ + sql: ranges[0], + source: 'statement', + }); + }); + it('keeps Oracle PACKAGE specification and body definitions as complete executable statements', () => { const sql = [ 'CREATE OR REPLACE PACKAGE pkg_order AS', diff --git a/frontend/src/utils/sqlStatementSelection.ts b/frontend/src/utils/sqlStatementSelection.ts index bb353bc..bb68fe6 100644 --- a/frontend/src/utils/sqlStatementSelection.ts +++ b/frontend/src/utils/sqlStatementSelection.ts @@ -74,7 +74,13 @@ const resolveStandaloneSqlSlashLineEnd = (text: string, index: number): number | } let lineEnd = index + 1; + let seenOptionalSemicolon = false; while (lineEnd < text.length && text[lineEnd] !== '\n') { + if (text[lineEnd] === ';' && !seenOptionalSemicolon) { + seenOptionalSemicolon = true; + lineEnd += 1; + continue; + } if (text[lineEnd] === '-' && text[lineEnd + 1] === '-') { while (lineEnd < text.length && text[lineEnd] !== '\n') { lineEnd += 1; @@ -89,6 +95,37 @@ const resolveStandaloneSqlSlashLineEnd = (text: string, index: number): number | return lineEnd; }; +const resolveStandaloneSqlSlashLineAtOffset = ( + text: string, + offset: number, +): { lineStart: number; lineEnd: number; slashIndex: number } | null => { + const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; + const nextLineBreak = text.indexOf('\n', lineStart); + const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak; + + let slashIndex = lineStart; + while (slashIndex < lineEnd && isHorizontalWhitespace(text[slashIndex])) { + slashIndex += 1; + } + if (slashIndex >= lineEnd || text[slashIndex] !== '/') { + return null; + } + + const resolvedLineEnd = resolveStandaloneSqlSlashLineEnd(text, slashIndex); + if (resolvedLineEnd === null || resolvedLineEnd !== lineEnd) { + return null; + } + + return { lineStart, lineEnd, slashIndex }; +}; + +const findPreviousSqlStatementRange = ( + ranges: SqlStatementRange[], + offset: number, +): SqlStatementRange | null => ( + [...ranges].reverse().find((range) => range.end <= offset) || null +); + const shouldEnterPlsqlBeginBlock = (text: string, tokenEnd: number): boolean => { const nextChar = nextSqlSignificantChar(text, tokenEnd); if (!nextChar || nextChar === ';') return false; @@ -385,6 +422,11 @@ export const resolveCurrentSqlStatementRange = (sql: string, cursorOffset: numbe return containingRange; } + const slashLine = resolveStandaloneSqlSlashLineAtOffset(text, offset); + if (slashLine) { + return findPreviousSqlStatementRange(ranges, slashLine.lineStart); + } + const nextRange = ranges.find((range) => offset < range.start); if (nextRange) { return nextRange; @@ -411,6 +453,14 @@ export const resolveExecutableSql = ( return { sql: statement.text, source: 'statement' }; } + const slashLine = resolveStandaloneSqlSlashLineAtOffset(text, offset); + if (slashLine) { + const previousStatement = findPreviousSqlStatementRange(ranges, slashLine.lineStart); + return previousStatement?.text.trim() + ? { sql: previousStatement.text, source: 'statement' } + : null; + } + const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; const nextLineBreak = text.indexOf('\n', offset); const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak; diff --git a/internal/app/methods_db_multi_test.go b/internal/app/methods_db_multi_test.go index 218059e..8e462dd 100644 --- a/internal/app/methods_db_multi_test.go +++ b/internal/app/methods_db_multi_test.go @@ -533,6 +533,56 @@ END;` } } +func TestDBQueryMultiSkipsOracleSqlPlusSlashDelimiterWithSemicolon(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + }) + + fakeDB := &fakeBatchWriteDB{} + newDatabaseFunc = func(dbType string) (db.Database, error) { + return fakeDB, nil + } + + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + config := connection.ConnectionConfig{ + Type: "oracle", + Host: "127.0.0.1", + Port: 1521, + User: "app", + } + query := `CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1( + p_msg_out OUT NVARCHAR2 +) AS +BEGIN + p_msg_out := ''; +EXCEPTION + WHEN OTHERS THEN + p_msg_out := SQLERRM; +END cproc_tzhssr_order2sale_A1; +/;` + wantExecuted := `CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1( + p_msg_out OUT NVARCHAR2 +) AS +BEGIN + p_msg_out := ''; +EXCEPTION + WHEN OTHERS THEN + p_msg_out := SQLERRM; +END cproc_tzhssr_order2sale_A1;` + + result := app.DBQueryMulti(config, "ORCLPDB1", query, "oracle-sqlplus-slash-semicolon-test") + if !result.Success { + t.Fatalf("expected DBQueryMulti success, got failure: %s", result.Message) + } + if fakeDB.execCalls != 1 || len(fakeDB.execQueries) != 1 { + t.Fatalf("expected one sequential exec call, got execCalls=%d queries=%#v", fakeDB.execCalls, fakeDB.execQueries) + } + if fakeDB.execQueries[0] != wantExecuted { + t.Fatalf("expected slash delimiter with semicolon to be skipped, got %q", fakeDB.execQueries[0]) + } +} + func TestDBQueryMultiKeepsOraclePackageSpecAndBodyTogether(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc t.Cleanup(func() { diff --git a/internal/app/sql_split.go b/internal/app/sql_split.go index 6b36a1d..c0be9ca 100644 --- a/internal/app/sql_split.go +++ b/internal/app/sql_split.go @@ -281,10 +281,15 @@ func scanSQLStandaloneSlashLineSuffix(text string, pos int) (lineEnd int, standa if pos < 0 || pos >= len(text) || text[pos] != '/' { return 0, false, true } + seenOptionalSemicolon := false for i := pos + 1; i < len(text); i++ { if text[i] == '\n' { return i, true, true } + if text[i] == ';' && !seenOptionalSemicolon { + seenOptionalSemicolon = true + continue + } if text[i] == '-' { if i+1 >= len(text) { return len(text), true, false diff --git a/internal/app/sql_split_test.go b/internal/app/sql_split_test.go index e7eb630..8b4f622 100644 --- a/internal/app/sql_split_test.go +++ b/internal/app/sql_split_test.go @@ -343,6 +343,48 @@ END cproc_tzhssr_order2sale_A1;`, } } +func TestSplitSQLStatements_OracleCreateProcedureSkipsSemicolonAfterSqlPlusSlashDelimiter(t *testing.T) { + input := `-- 修改函数/存储过程:H2.cproc_tzhssr_order2sale_A1 +-- 请确认语法兼容当前数据库后执行 +CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1( + p_sourceid IN VARCHAR2, + p_msg_out OUT NVARCHAR2 +) AS + v_ecnt NUMBER; +BEGIN + SELECT COUNT(*) INTO v_ecnt FROM dual; + p_msg_out := ''; +EXCEPTION + WHEN OTHERS THEN + p_msg_out := substr('订单核销失败,错误信息:' || SQLERRM || ',错误位置:' || + dbms_utility.format_error_backtrace, 1, 1000); +END cproc_tzhssr_order2sale_A1; +/; +SELECT 1 FROM dual;` + got := splitSQLStatements(input) + want := []string{ + `-- 修改函数/存储过程:H2.cproc_tzhssr_order2sale_A1 +-- 请确认语法兼容当前数据库后执行 +CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_A1( + p_sourceid IN VARCHAR2, + p_msg_out OUT NVARCHAR2 +) AS + v_ecnt NUMBER; +BEGIN + SELECT COUNT(*) INTO v_ecnt FROM dual; + p_msg_out := ''; +EXCEPTION + WHEN OTHERS THEN + p_msg_out := substr('订单核销失败,错误信息:' || SQLERRM || ',错误位置:' || + dbms_utility.format_error_backtrace, 1, 1000); +END cproc_tzhssr_order2sale_A1;`, + "SELECT 1 FROM dual", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("splitSQLStatements(%q) = %#v, want %#v", input, got, want) + } +} + func TestSplitSQLStatements_OraclePackageSpecAndBodyStayWhole(t *testing.T) { input := `CREATE OR REPLACE PACKAGE pkg_order AS PROCEDURE sync_order(p_id IN NUMBER);