mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-02 17:01:24 +08:00
🐛 fix(query-editor): 修复过程脚本斜杠分隔符误执行
- 前端语句选择跳过独立 SQL*Plus 斜杠分隔符 - 后端 SQL 拆分和流式文件执行保持过程体完整 - 增加 Oracle 过程脚本执行回归测试
This commit is contained in:
@@ -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 $$',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user