feat(sqlserver): add execution plan support

This commit is contained in:
zipg authored and GitHub committed 2026-07-16 16:32:52 +08:00
1 parent da7eedb49c
commit e4c56e6165
16 files changed
+636 -23

No files matched your search

@@ -53,6 +53,9 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
if !is_safe_explain_sql(&source) {
return explain_err("unsafe");
}
if options.database_type == Some(DatabaseType::SqlServer) && crate::sql::split_sql_batches(&source).len() != 1 {
return explain_err("unsafe");
}
let sql = match options.database_type {
Some(DatabaseType::Postgres | DatabaseType::MongoDb) => {
@@ -62,6 +65,9 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
format!("EXPLAIN {source}")
}
Some(DatabaseType::Oracle) => format!("EXPLAIN PLAN FOR {source}"),
Some(DatabaseType::SqlServer) => {
format!("SET SHOWPLAN_XML ON;\nGO\n{source}\nGO\nSET SHOWPLAN_XML OFF;")
}
Some(DatabaseType::Mysql) if options.format == Some(ExplainFormat::Standard) => {
// MySQL 8.0.32+ may otherwise inherit TREE or JSON from the session-level explain_format.
format!("EXPLAIN FORMAT=TRADITIONAL {source}")
@@ -99,6 +105,7 @@ pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> bool {
| DatabaseType::Questdb
| DatabaseType::Dameng
| DatabaseType::Oracle
| DatabaseType::SqlServer
)
)
}
@@ -202,6 +209,9 @@ pub fn is_write_sql_for_database(sql: &str, database_type: DatabaseType) -> bool
}
fn is_write_sql_with_database_type(sql: &str, database_type: Option<DatabaseType>) -> bool {
if database_type == Some(DatabaseType::SqlServer) && is_sqlserver_showplan_xml_set(sql) {
return false;
}
if database_type.is_some_and(|database_type| has_dialect_specific_write(sql, database_type)) {
return true;
}
@@ -220,6 +230,14 @@ fn is_write_sql_with_database_type(sql: &str, database_type: Option<DatabaseType
.any(|statement| is_write_sql_statement(statement, detect_mysql_executable_comments, detect_select_into))
}
fn is_sqlserver_showplan_xml_set(sql: &str) -> bool {
let normalized = strip_sql_comments(sql)
.split_whitespace()
.map(|part| part.trim_end_matches(';').to_ascii_uppercase())
.collect::<Vec<_>>();
matches!(normalized.as_slice(), [set, showplan, value] if set == "SET" && showplan == "SHOWPLAN_XML" && matches!(value.as_str(), "ON" | "OFF"))
}
fn is_mysql_compatible_database(database_type: DatabaseType) -> bool {
matches!(
database_type,
@@ -716,6 +734,44 @@ mod tests {
);
}
#[test]
fn builds_sqlserver_showplan_xml_batches() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
sql: "WITH rows AS (SELECT 1 AS id) SELECT * FROM rows;".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some(
"SET SHOWPLAN_XML ON;\nGO\nWITH rows AS (SELECT 1 AS id) SELECT * FROM rows\nGO\nSET SHOWPLAN_XML OFF;"
.to_string()
),
reason: None,
}
);
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
sql: "SELECT 1\nGO\nSELECT 2".to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
);
assert!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
sql: "SELECT 'first line\nGO\nlast line' AS text".to_string(),
})
.ok
);
}
#[test]
fn validates_dameng_autotrace_sql_safety() {
assert!(is_safe_dameng_autotrace_sql("SELECT * FROM t WHERE name = 'delete';"));
@@ -1144,6 +1200,16 @@ mod tests {
assert!(show_create_err.unwrap_err().contains("Write operation"));
}
#[test]
fn check_read_only_allows_only_sqlserver_showplan_xml_session_switches() {
for sql in ["SET SHOWPLAN_XML ON;", "-- explain\nSET SHOWPLAN_XML OFF"] {
assert_eq!(check_read_only(sql, "readonly", DatabaseType::SqlServer), Ok(()));
}
assert!(check_read_only("SET SHOWPLAN_ALL ON", "readonly", DatabaseType::SqlServer).is_err());
assert!(check_read_only("SET SHOWPLAN_XML ON; SELECT 1", "readonly", DatabaseType::SqlServer).is_err());
assert!(check_read_only("SET SHOWPLAN_XML OFF; DROP TABLE users", "readonly", DatabaseType::SqlServer).is_err());
}
#[test]
fn check_read_only_only_treats_executable_comments_as_writes_for_mysql_compatible_connections() {
let mysql_executable_comment = "SELECT 3156 /*!50000 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */";