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);