Skip to content

Commit c54f3ce

Browse files
committed
Support DDL SQL syntax validation
1 parent e912c58 commit c54f3ce

4 files changed

Lines changed: 179 additions & 22 deletions

File tree

src/common/diag_cmd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1369,7 +1369,7 @@ class ObdiagToolSqlSyntaxCommand(ObdiagOriginCommand):
13691369
def __init__(self):
13701370
super(ObdiagToolSqlSyntaxCommand, self).__init__(
13711371
'sql_syntax',
1372-
'obdiag tool sql_syntax. Validate SQL against a live OceanBase instance using EXPLAIN (no execution of the original statement)',
1372+
'obdiag tool sql_syntax. Validate SQL against a live OceanBase instance using EXPLAIN/PREPARE (no execution of the original statement)',
13731373
)
13741374
self.parser.add_option('--sql', type='string', help='SQL statement to validate (single statement only)')
13751375
self.parser.add_option('--env', action='append', type='string', help='Connection override: --env key=value (host, port, user, password/pwd, database/db)')

src/handler/agent/toolsets/obdiag.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def truncate_for_agent(
100100
"rca_run": "执行根因分析(RCA)",
101101
"rca_list": "列出 RCA 场景",
102102
"tool_io_performance": "检查节点磁盘 IO",
103-
"tool_sql_syntax": "用 EXPLAIN 验证 SQL 语法/语义(不执行)",
103+
"tool_sql_syntax": "用 EXPLAIN/PREPARE 验证 SQL 语法/语义(不执行)",
104104
"list_obdiag_clusters": "列出 obdiag 集群配置",
105105
"show_current_cluster": "显示当前会话集群与配置路径",
106106
"db_query": "对集群执行只读 SQL",
@@ -771,7 +771,7 @@ def tool_sql_syntax(
771771
env: Optional[List[str]] = None,
772772
cluster_config_path: Optional[str] = None,
773773
) -> str:
774-
"""Validate SQL syntax/semantics using EXPLAIN — does not execute the statement (obdiag tool sql_syntax).
774+
"""Validate SQL syntax/semantics using EXPLAIN/PREPARE — does not execute the statement (obdiag tool sql_syntax).
775775
776776
Args:
777777
sql: Single SQL statement to check

src/handler/tools/sql_syntax_handler.py

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
@time: 2026/03/27
1515
@file: sql_syntax_handler.py
1616
@desc: Validate SQL syntax/semantics against a live OceanBase instance
17-
using EXPLAIN — without executing the SQL.
17+
using EXPLAIN or PREPARE — without executing the SQL.
1818
See https://github.com/oceanbase/obdiag/issues/1181
1919
"""
2020

@@ -45,6 +45,32 @@ def normalize_sql_for_syntax_check(sql):
4545
return s, None
4646

4747

48+
def strip_leading_block_comments(sql):
49+
pos = 0
50+
length = len(sql)
51+
while pos < length:
52+
while pos < length and sql[pos].isspace():
53+
pos += 1
54+
if not sql.startswith("/*", pos):
55+
break
56+
end = sql.find("*/", pos + 2)
57+
if end == -1:
58+
return ""
59+
pos = end + 2
60+
return sql[pos:]
61+
62+
63+
def is_ddl_statement(sql):
64+
matched = re.match(r'(\w+)', strip_leading_block_comments(sql))
65+
if not matched:
66+
return False
67+
return matched.group(1).upper() in ("ALTER", "CREATE", "DROP", "RENAME", "TRUNCATE")
68+
69+
70+
def quote_sql_literal(value):
71+
return str(value).replace("\\", "\\\\").replace("'", "''")
72+
73+
4874
class SqlSyntaxHandler:
4975
def __init__(self, context):
5076
self.context = context
@@ -131,33 +157,62 @@ def _resolve_connection(self):
131157
return host, int(port), user, password, database
132158

133159
def _check_syntax(self, connector, sql):
134-
"""Run EXPLAIN against the SQL and interpret the result."""
160+
if is_ddl_statement(sql):
161+
return self._check_ddl_syntax(connector, sql)
162+
return self._check_explain_syntax(connector, sql)
163+
164+
def _check_explain_syntax(self, connector, sql):
165+
"""Run EXPLAIN against DML SQL and interpret the result."""
135166
explain_sql = "EXPLAIN {0}".format(sql)
136167
self.stdio.verbose("[sql-syntax] exec: {0}".format(explain_sql))
137168

138169
try:
139170
connector.execute_sql(explain_sql)
140171
self.stdio.print("Result: VALID")
141172
return ObdiagResult(ObdiagResult.SUCCESS_CODE, data={"result": "VALID", "sql": sql})
142-
143173
except mysql.Error as e:
144-
error_code = e.args[0] if e.args else None
145-
error_msg = e.args[1] if len(e.args) > 1 else str(e)
146-
147-
if error_code == 1064:
148-
self.stdio.print("Result: SYNTAX ERROR")
149-
self.stdio.print("Detail: {0}".format(error_msg))
150-
return ObdiagResult(
151-
ObdiagResult.SUCCESS_CODE,
152-
data={"result": "SYNTAX_ERROR", "error_code": error_code, "detail": error_msg},
153-
)
154-
else:
155-
self.stdio.print("Result: VALID (syntax OK, but semantic error [{0}]: {1})".format(error_code, error_msg))
156-
return ObdiagResult(
157-
ObdiagResult.SUCCESS_CODE,
158-
data={"result": "SEMANTIC_ERROR", "error_code": error_code, "detail": error_msg},
159-
)
174+
return self._handle_mysql_syntax_error(e)
175+
except Exception as e:
176+
self.stdio.error("Unexpected error during SQL syntax check: {0}".format(e))
177+
return ObdiagResult(ObdiagResult.SERVER_ERROR_CODE, error_data=str(e))
160178

179+
def _check_ddl_syntax(self, connector, sql):
180+
"""Use PREPARE to validate DDL syntax without executing the DDL statement."""
181+
stmt_name = "obdiag_sql_syntax_stmt"
182+
prepare_sql = "PREPARE {0} FROM '{1}'".format(stmt_name, quote_sql_literal(sql))
183+
prepared = False
184+
self.stdio.verbose("[sql-syntax] exec: {0}".format(prepare_sql))
185+
try:
186+
connector.execute_sql(prepare_sql)
187+
prepared = True
188+
self.stdio.print("Result: VALID")
189+
return ObdiagResult(ObdiagResult.SUCCESS_CODE, data={"result": "VALID", "sql": sql, "method": "PREPARE"})
190+
except mysql.Error as e:
191+
return self._handle_mysql_syntax_error(e)
161192
except Exception as e:
162193
self.stdio.error("Unexpected error during SQL syntax check: {0}".format(e))
163194
return ObdiagResult(ObdiagResult.SERVER_ERROR_CODE, error_data=str(e))
195+
finally:
196+
if prepared:
197+
try:
198+
connector.execute_sql("DEALLOCATE PREPARE {0}".format(stmt_name))
199+
except Exception as e:
200+
self.stdio.warn("Failed to deallocate prepared statement {0}: {1}".format(stmt_name, e))
201+
202+
def _handle_mysql_syntax_error(self, error):
203+
error_code = error.args[0] if error.args else None
204+
error_msg = error.args[1] if len(error.args) > 1 else str(error)
205+
206+
if error_code == 1064:
207+
self.stdio.print("Result: SYNTAX ERROR")
208+
self.stdio.print("Detail: {0}".format(error_msg))
209+
return ObdiagResult(
210+
ObdiagResult.SUCCESS_CODE,
211+
data={"result": "SYNTAX_ERROR", "error_code": error_code, "detail": error_msg},
212+
)
213+
214+
self.stdio.print("Result: VALID (syntax OK, but semantic error [{0}]: {1})".format(error_code, error_msg))
215+
return ObdiagResult(
216+
ObdiagResult.SUCCESS_CODE,
217+
data={"result": "SEMANTIC_ERROR", "error_code": error_code, "detail": error_msg},
218+
)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env python
2+
# -*- coding: UTF-8 -*-
3+
# Copyright (c) 2022 OceanBase
4+
# OceanBase Diagnostic Tool is licensed under Mulan PSL v2.
5+
# You can use this software according to the terms and conditions of the Mulan PSL v2.
6+
# You may obtain a copy of Mulan PSL v2 at:
7+
# http://license.coscl.org.cn/MulanPSL2
8+
# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
9+
# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
10+
# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
11+
# See the Mulan PSL v2 for more details.
12+
13+
"""
14+
@time: 2026/6/8
15+
@file: test_sql_syntax_handler.py
16+
@desc:
17+
"""
18+
19+
import unittest
20+
from unittest.mock import MagicMock
21+
22+
import pymysql as mysql
23+
24+
from src.common.result_type import ObdiagResult
25+
from src.handler.tools.sql_syntax_handler import SqlSyntaxHandler, is_ddl_statement, normalize_sql_for_syntax_check, quote_sql_literal
26+
27+
28+
class _Stdio:
29+
def print(self, *args, **kwargs):
30+
pass
31+
32+
def verbose(self, *args, **kwargs):
33+
pass
34+
35+
def warn(self, *args, **kwargs):
36+
pass
37+
38+
def error(self, *args, **kwargs):
39+
pass
40+
41+
42+
class TestSqlSyntaxHandler(unittest.TestCase):
43+
def setUp(self):
44+
context = MagicMock()
45+
context.stdio = _Stdio()
46+
context.options = {}
47+
self.handler = SqlSyntaxHandler(context)
48+
49+
def test_normalize_accepts_single_statement(self):
50+
sql, err = normalize_sql_for_syntax_check(" SELECT 1;; ")
51+
self.assertIsNone(err)
52+
self.assertEqual(sql, "SELECT 1")
53+
54+
def test_normalize_rejects_multiple_statements(self):
55+
sql, err = normalize_sql_for_syntax_check("SELECT 1; SELECT 2")
56+
self.assertIsNotNone(err)
57+
self.assertIsNone(sql)
58+
59+
def test_detects_ddl_statement(self):
60+
self.assertTrue(is_ddl_statement("CREATE TABLE t1(id int)"))
61+
self.assertTrue(is_ddl_statement("/* comment */ ALTER TABLE t1 ADD c1 int"))
62+
self.assertTrue(is_ddl_statement(" /* a */ /* b */ DROP TABLE t1"))
63+
self.assertFalse(is_ddl_statement("SELECT * FROM t1"))
64+
self.assertFalse(is_ddl_statement("/* unfinished comment CREATE TABLE t1(id int)"))
65+
66+
def test_quote_sql_literal_escapes_backslash_and_quote(self):
67+
self.assertEqual(quote_sql_literal("CREATE TABLE `a\\b` (c varchar(10) default 'x')"), "CREATE TABLE `a\\\\b` (c varchar(10) default ''x'')")
68+
69+
def test_dml_uses_explain(self):
70+
connector = MagicMock()
71+
result = self.handler._check_syntax(connector, "SELECT * FROM t1")
72+
self.assertEqual(result.code, ObdiagResult.SUCCESS_CODE)
73+
self.assertEqual(result.data["result"], "VALID")
74+
connector.execute_sql.assert_called_once_with("EXPLAIN SELECT * FROM t1")
75+
76+
def test_ddl_uses_prepare_without_execute(self):
77+
connector = MagicMock()
78+
result = self.handler._check_syntax(connector, "CREATE TABLE t1(id int)")
79+
self.assertEqual(result.code, ObdiagResult.SUCCESS_CODE)
80+
self.assertEqual(result.data["result"], "VALID")
81+
self.assertEqual(result.data["method"], "PREPARE")
82+
calls = [call.args[0] for call in connector.execute_sql.call_args_list]
83+
self.assertEqual(calls[0], "PREPARE obdiag_sql_syntax_stmt FROM 'CREATE TABLE t1(id int)'")
84+
self.assertEqual(calls[1], "DEALLOCATE PREPARE obdiag_sql_syntax_stmt")
85+
86+
def test_ddl_syntax_error_does_not_deallocate_unprepared_statement(self):
87+
connector = MagicMock()
88+
connector.execute_sql.side_effect = mysql.Error(1064, "syntax error")
89+
result = self.handler._check_syntax(connector, "CREATE TABLE t1(")
90+
self.assertEqual(result.data["result"], "SYNTAX_ERROR")
91+
connector.execute_sql.assert_called_once()
92+
93+
def test_non_1064_error_is_reported_as_semantic_error(self):
94+
connector = MagicMock()
95+
connector.execute_sql.side_effect = mysql.Error(1146, "table does not exist")
96+
result = self.handler._check_syntax(connector, "SELECT * FROM missing_table")
97+
self.assertEqual(result.data["result"], "SEMANTIC_ERROR")
98+
self.assertEqual(result.data["error_code"], 1146)
99+
100+
101+
if __name__ == '__main__':
102+
unittest.main()

0 commit comments

Comments
 (0)