🐛 fix(query-editor): 修复过程脚本斜杠分隔符误执行

- 前端语句选择跳过独立 SQL*Plus 斜杠分隔符

- 后端 SQL 拆分和流式文件执行保持过程体完整

- 增加 Oracle 过程脚本执行回归测试
This commit is contained in:
Syngnat
2026-06-24 21:10:59 +08:00
parent 8a552c4cb3
commit 1a9d417c0a
7 changed files with 314 additions and 0 deletions

View File

@@ -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 $$',

View File

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

View File

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

View File

@@ -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])
}
}

View File

@@ -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) {

View File

@@ -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

View File

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