From 1a9d417c0ab38a9f12c2641ab17af3496a0d3f0d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 24 Jun 2026 21:10:59 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=BF=87=E7=A8=8B=E8=84=9A=E6=9C=AC=E6=96=9C=E6=9D=A0?= =?UTF-8?q?=E5=88=86=E9=9A=94=E7=AC=A6=E8=AF=AF=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端语句选择跳过独立 SQL*Plus 斜杠分隔符 - 后端 SQL 拆分和流式文件执行保持过程体完整 - 增加 Oracle 过程脚本执行回归测试 --- .../src/utils/sqlStatementSelection.test.ts | 55 +++++++++++++++++++ frontend/src/utils/sqlStatementSelection.ts | 36 ++++++++++++ internal/app/methods_db_multi_test.go | 46 ++++++++++++++++ .../app/methods_file_sql_execution_test.go | 40 ++++++++++++++ internal/app/sql_split.go | 45 +++++++++++++++ internal/app/sql_split_stream.go | 43 +++++++++++++++ internal/app/sql_split_test.go | 49 +++++++++++++++++ 7 files changed, 314 insertions(+) diff --git a/frontend/src/utils/sqlStatementSelection.test.ts b/frontend/src/utils/sqlStatementSelection.test.ts index fb7788a..4f5579a 100644 --- a/frontend/src/utils/sqlStatementSelection.test.ts +++ b/frontend/src/utils/sqlStatementSelection.test.ts @@ -128,6 +128,61 @@ describe('sqlStatementSelection', () => { }); }); + it('skips standalone SQL*Plus slash delimiters after Oracle CREATE PROCEDURE definitions', () => { + const sql = [ + 'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_new(', + ' p_sourceid IN VARCHAR2', + ') IS', + ' v_memcardno VARCHAR2(40);', + ' v_ecnt NUMBER;', + ' CURSOR cur_ware IS', + ' SELECT d.goodsid, d.goodsqty', + ' FROM t_order_d d', + ' WHERE d.sourceid = p_sourceid;', + 'BEGIN', + ' FOR row_ware IN cur_ware LOOP', + ' v_ecnt := row_ware.goodsqty;', + ' END LOOP;', + 'END;', + '/', + 'SELECT 1 FROM dual;', + ].join('\n'); + + const ranges = findSqlStatementRanges(sql).map((range) => range.text); + + expect(ranges).toEqual([ + [ + 'CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_new(', + ' p_sourceid IN VARCHAR2', + ') IS', + ' v_memcardno VARCHAR2(40);', + ' v_ecnt NUMBER;', + ' CURSOR cur_ware IS', + ' SELECT d.goodsid, d.goodsqty', + ' FROM t_order_d d', + ' WHERE d.sourceid = p_sourceid;', + 'BEGIN', + ' FOR row_ware IN cur_ware LOOP', + ' v_ecnt := row_ware.goodsqty;', + ' END LOOP;', + 'END;', + ].join('\n'), + 'SELECT 1 FROM dual', + ]); + expect(resolveExecutableSql(sql, sql.indexOf('v_memcardno'))).toEqual({ + sql: ranges[0], + source: 'statement', + }); + }); + + it('does not treat a slash operator line as a SQL*Plus delimiter', () => { + const sql = 'SELECT 10\n/\n2 FROM dual;'; + + expect(findSqlStatementRanges(sql).map((range) => range.text)).toEqual([ + 'SELECT 10\n/\n2 FROM dual', + ]); + }); + it('keeps PostgreSQL dollar-quoted CREATE FUNCTION definitions as one executable statement', () => { const sql = [ 'CREATE OR REPLACE FUNCTION refresh_stats() RETURNS void AS $$', diff --git a/frontend/src/utils/sqlStatementSelection.ts b/frontend/src/utils/sqlStatementSelection.ts index 81a2d35..60467b3 100644 --- a/frontend/src/utils/sqlStatementSelection.ts +++ b/frontend/src/utils/sqlStatementSelection.ts @@ -15,6 +15,10 @@ const isWhitespace = (ch: string): boolean => ( ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' ); +const isHorizontalWhitespace = (ch: string): boolean => ( + ch === ' ' || ch === '\t' || ch === '\r' || ch === '\f' +); + const isSqlIdentifierStart = (ch: string): boolean => /^[A-Za-z_]$/.test(ch); const isSqlIdentifierPart = (ch: string): boolean => /^[A-Za-z0-9_$#]$/.test(ch); @@ -59,6 +63,26 @@ const nextSqlSignificantChar = (text: string, position: number): string => { return index >= text.length ? '' : text[index]; }; +const resolveStandaloneSqlSlashLineEnd = (text: string, index: number): number | null => { + if (text[index] !== '/') return null; + + const lineStart = text.lastIndexOf('\n', Math.max(0, index - 1)) + 1; + for (let pos = lineStart; pos < index; pos++) { + if (!isHorizontalWhitespace(text[pos])) { + return null; + } + } + + let lineEnd = index + 1; + while (lineEnd < text.length && text[lineEnd] !== '\n') { + if (!isHorizontalWhitespace(text[lineEnd])) { + return null; + } + lineEnd += 1; + } + return lineEnd; +}; + const shouldEnterPlsqlBeginBlock = (text: string, tokenEnd: number): boolean => { const nextChar = nextSqlSignificantChar(text, tokenEnd); if (!nextChar || nextChar === ';') return false; @@ -194,6 +218,18 @@ export const findSqlStatementRanges = (sql: string): SqlStatementRange[] => { inBlockComment = true; continue; } + if ((justClosedPLSQLBlock || !text.slice(statementStart, index).trim()) && ch === '/') { + const slashLineEnd = resolveStandaloneSqlSlashLineEnd(text, index); + if (slashLineEnd !== null) { + push(index); + statementStart = slashLineEnd < text.length && text[slashLineEnd] === '\n' + ? slashLineEnd + 1 + : slashLineEnd; + index = slashLineEnd; + justClosedPLSQLBlock = false; + continue; + } + } if (ch === '#') { inLineComment = true; continue; diff --git a/internal/app/methods_db_multi_test.go b/internal/app/methods_db_multi_test.go index 2e6179a..ee256e4 100644 --- a/internal/app/methods_db_multi_test.go +++ b/internal/app/methods_db_multi_test.go @@ -435,6 +435,52 @@ END;` } } +func TestDBQueryMultiSkipsOracleSqlPlusSlashDelimiter(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 proc_tally2accept( + p_tallyacceptno IN t_tally_accept_h.acceptno%TYPE +) IS + v_count PLS_INTEGER; +BEGIN + SELECT COUNT(*) INTO v_count FROM t_tally_accept_h WHERE acceptno = p_tallyacceptno; +END; +/` + wantExecuted := `CREATE OR REPLACE PROCEDURE proc_tally2accept( + p_tallyacceptno IN t_tally_accept_h.acceptno%TYPE +) IS + v_count PLS_INTEGER; +BEGIN + SELECT COUNT(*) INTO v_count FROM t_tally_accept_h WHERE acceptno = p_tallyacceptno; +END;` + + result := app.DBQueryMulti(config, "ORCLPDB1", query, "oracle-sqlplus-slash-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 to be skipped, got %q", fakeDB.execQueries[0]) + } +} + var _ db.BatchWriteExecer = (*fakeBatchWriteDB)(nil) var _ db.SessionExecerProvider = (*fakeBatchWriteDB)(nil) var _ db.QueryMessageExecer = (*fakeBatchWriteDB)(nil) diff --git a/internal/app/methods_file_sql_execution_test.go b/internal/app/methods_file_sql_execution_test.go index 4c0be11..f68e2ef 100644 --- a/internal/app/methods_file_sql_execution_test.go +++ b/internal/app/methods_file_sql_execution_test.go @@ -435,3 +435,43 @@ func TestStreamSQLFileKeepsOracleCreateProcedureTogether(t *testing.T) { t.Fatalf("unexpected second statement: %q", statements[1]) } } + +func TestStreamSQLFileSkipsOracleSqlPlusSlashDelimiter(t *testing.T) { + input := strings.Join([]string{ + "CREATE OR REPLACE PROCEDURE proc_tally2accept(", + " p_tallyacceptno IN t_tally_accept_h.acceptno%TYPE", + ") IS", + " v_count PLS_INTEGER;", + "BEGIN", + " SELECT COUNT(*) INTO v_count FROM t_tally_accept_h WHERE acceptno = p_tallyacceptno;", + "END;", + "/", + "SELECT 1 FROM dual;", + }, "\n") + var statements []string + + count, err := streamSQLFile(&chunkedReader{data: []byte(input), step: 2}, func(index int, stmt string) error { + statements = append(statements, stmt) + return nil + }) + if err != nil { + t.Fatalf("streamSQLFile returned error: %v", err) + } + if count != 2 || len(statements) != 2 { + t.Fatalf("expected 2 statements, got count=%d statements=%#v", count, statements) + } + if statements[0] != strings.Join([]string{ + "CREATE OR REPLACE PROCEDURE proc_tally2accept(", + " p_tallyacceptno IN t_tally_accept_h.acceptno%TYPE", + ") IS", + " v_count PLS_INTEGER;", + "BEGIN", + " SELECT COUNT(*) INTO v_count FROM t_tally_accept_h WHERE acceptno = p_tallyacceptno;", + "END;", + }, "\n") { + t.Fatalf("unexpected create procedure statement: %q", statements[0]) + } + if statements[1] != "SELECT 1 FROM dual" { + t.Fatalf("unexpected second statement: %q", statements[1]) + } +} diff --git a/internal/app/sql_split.go b/internal/app/sql_split.go index 3287af8..35c988a 100644 --- a/internal/app/sql_split.go +++ b/internal/app/sql_split.go @@ -157,6 +157,15 @@ func splitSQLStatements(sql string) []string { continue } + if ch == '/' && (justClosedPLSQLBlock || strings.TrimSpace(cur.String()) == "") { + if lineEnd, ok := standaloneSQLSlashLineEnd(text, i); ok { + push() + justClosedPLSQLBlock = false + i = lineEnd + continue + } + } + // 块注释开始 if ch == '/' && next == '*' { inBlockComment = true @@ -224,6 +233,42 @@ func isSQLIdentifierPart(ch byte) bool { return isSQLIdentifierStart(ch) || (ch >= '0' && ch <= '9') || ch == '$' || ch == '#' } +func isSQLHorizontalWhitespace(ch byte) bool { + return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\f' +} + +func standaloneSQLSlashLineEnd(text string, pos int) (int, bool) { + if pos < 0 || pos >= len(text) || text[pos] != '/' { + return 0, false + } + lineStart := strings.LastIndexByte(text[:pos], '\n') + 1 + for i := lineStart; i < pos; i++ { + if !isSQLHorizontalWhitespace(text[i]) { + return 0, false + } + } + lineEnd, standalone, _ := scanSQLStandaloneSlashLineSuffix(text, pos) + if !standalone { + return 0, false + } + return lineEnd, true +} + +func scanSQLStandaloneSlashLineSuffix(text string, pos int) (lineEnd int, standalone bool, complete bool) { + if pos < 0 || pos >= len(text) || text[pos] != '/' { + return 0, false, true + } + for i := pos + 1; i < len(text); i++ { + if text[i] == '\n' { + return i, true, true + } + if !isSQLHorizontalWhitespace(text[i]) { + return 0, false, true + } + } + return len(text), true, false +} + func skipSQLWhitespaceAndComments(text string, pos int) int { i := pos for i < len(text) { diff --git a/internal/app/sql_split_stream.go b/internal/app/sql_split_stream.go index 95c3d01..0ec2291 100644 --- a/internal/app/sql_split_stream.go +++ b/internal/app/sql_split_stream.go @@ -178,6 +178,24 @@ func (s *sqlStreamSplitter) Feed(chunk []byte) []string { continue } + if ch == '/' && (s.closedPLSQL || strings.TrimSpace(s.cur.String()) == "") && sqlStreamCurrentLineWhitespaceOnly(&s.cur) { + lineEnd, standalone, complete := scanSQLStandaloneSlashLineSuffix(text, i) + if standalone { + if !complete { + s.pending = text[i:] + break + } + stmt := strings.TrimSpace(s.cur.String()) + if stmt != "" { + statements = append(statements, stmt) + } + s.cur.Reset() + s.closedPLSQL = false + i = lineEnd + continue + } + } + // 块注释开始 if ch == '/' && i+1 >= len(text) { s.pending = text[i:] @@ -267,14 +285,39 @@ func (s *sqlStreamSplitter) Feed(chunk []byte) []string { // Flush 返回缓冲区中剩余的不完整语句(文件结束时调用)。 func (s *sqlStreamSplitter) Flush() string { if s.pending != "" { + if (s.closedPLSQL || strings.TrimSpace(s.cur.String()) == "") && sqlStreamCurrentLineWhitespaceOnly(&s.cur) { + if _, standalone, _ := scanSQLStandaloneSlashLineSuffix(s.pending, 0); standalone { + s.pending = "" + stmt := strings.TrimSpace(s.cur.String()) + s.cur.Reset() + s.closedPLSQL = false + return stmt + } + } s.cur.WriteString(s.pending) s.pending = "" } stmt := strings.TrimSpace(s.cur.String()) s.cur.Reset() + if stmt == "/" { + return "" + } return stmt } +func sqlStreamCurrentLineWhitespaceOnly(builder *strings.Builder) bool { + text := builder.String() + for i := len(text) - 1; i >= 0; i-- { + if text[i] == '\n' { + return true + } + if !isSQLHorizontalWhitespace(text[i]) { + return false + } + } + return true +} + func isIncompleteSQLDollarTag(s string) bool { if len(s) == 0 || s[0] != '$' { return false diff --git a/internal/app/sql_split_test.go b/internal/app/sql_split_test.go index 0503402..3a0de41 100644 --- a/internal/app/sql_split_test.go +++ b/internal/app/sql_split_test.go @@ -211,6 +211,55 @@ END;`, } } +func TestSplitSQLStatements_OracleCreateProcedureSkipsSqlPlusSlashDelimiter(t *testing.T) { + input := `CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_new( + p_sourceid IN VARCHAR2 +) IS + v_memcardno VARCHAR2(40); + v_ecnt NUMBER; + CURSOR cur_ware IS + SELECT d.goodsid, d.goodsqty + FROM t_order_d d + WHERE d.sourceid = p_sourceid; +BEGIN + FOR row_ware IN cur_ware LOOP + v_ecnt := row_ware.goodsqty; + END LOOP; +END; +/ +SELECT 1 FROM dual;` + got := splitSQLStatements(input) + want := []string{ + `CREATE OR REPLACE PROCEDURE cproc_tzhssr_order2sale_new( + p_sourceid IN VARCHAR2 +) IS + v_memcardno VARCHAR2(40); + v_ecnt NUMBER; + CURSOR cur_ware IS + SELECT d.goodsid, d.goodsqty + FROM t_order_d d + WHERE d.sourceid = p_sourceid; +BEGIN + FOR row_ware IN cur_ware LOOP + v_ecnt := row_ware.goodsqty; + END LOOP; +END;`, + "SELECT 1 FROM dual", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("splitSQLStatements(%q) = %#v, want %#v", input, got, want) + } +} + +func TestSplitSQLStatements_DoesNotTreatSlashOperatorLineAsDelimiter(t *testing.T) { + input := "SELECT 10\n/\n2 FROM dual;" + got := splitSQLStatements(input) + want := []string{"SELECT 10\n/\n2 FROM dual"} + if !reflect.DeepEqual(got, want) { + t.Errorf("splitSQLStatements(%q) = %#v, want %#v", input, got, want) + } +} + func TestSplitSQLStatements_TransactionBeginStillSplits(t *testing.T) { input := "BEGIN; UPDATE accounts SET balance = balance - 1 WHERE id = 1; COMMIT;" got := splitSQLStatements(input)