sz_orm_explain/parsers/
mssql.rs1use crate::{ExplainError, ExplainParser, ExplainPlan, ScanType};
15
16#[derive(Debug)]
18pub struct MssqlParser;
19
20impl ExplainParser for MssqlParser {
21 fn parse(&self, raw: &str) -> Result<ExplainPlan, ExplainError> {
22 let mut best: Option<(ScanType, String, Option<String>)> = None;
23 let rank = |s: ScanType| match s {
25 ScanType::FullTable => 4,
26 ScanType::IndexRange => 3,
27 ScanType::UniqueLookup => 2,
28 _ => 1,
29 };
30
31 for line in raw.lines() {
32 let line = line.trim();
33 if line.is_empty() || line.starts_with("StmtText") || line.starts_with("----") {
34 continue;
35 }
36 let body = line.strip_prefix("|--").unwrap_or(line).trim();
38 let upper = body.to_ascii_uppercase();
39
40 let candidate: Option<(ScanType, String, Option<String>)> =
41 if upper.starts_with("TABLE SCAN(") || upper.starts_with("CLUSTERED INDEX SCAN(") {
42 Some((ScanType::FullTable, extract_object(body), None))
43 } else if upper.starts_with("INDEX SCAN(") {
44 Some((
45 ScanType::IndexRange,
46 "<unknown>".into(),
47 Some(extract_object(body)),
48 ))
49 } else if upper.starts_with("INDEX SEEK(")
50 || upper.starts_with("CLUSTERED INDEX SEEK(")
51 {
52 Some((
53 ScanType::UniqueLookup,
54 "<unknown>".into(),
55 Some(extract_object(body)),
56 ))
57 } else {
58 None
59 };
60 if let Some(c) = candidate {
61 if best.is_none() || rank(c.0) > rank(best.as_ref().unwrap().0) {
62 best = Some(c);
63 }
64 }
65 }
66 let (scan_type, table, index) = best.ok_or_else(|| ExplainError::Unparseable {
67 reason: "no recognizable plan operator".into(),
68 })?;
69 Ok(ExplainPlan {
70 scan_type,
71 table,
72 index,
73 rows: 0,
74 extra: Vec::new(),
75 })
76 }
77}
78
79fn extract_object(body: &str) -> String {
81 let upper = body.to_ascii_uppercase();
82 if let Some(pos) = upper.find("OBJECT:(") {
83 let rest = &body[pos + 8..];
84 let inner = rest.split(')').next().unwrap_or("");
85 let trimmed = inner.trim_start_matches('[').trim_end_matches(']');
86 trimmed
88 .split("].[")
89 .last()
90 .map(|s| s.trim_matches('[').trim_matches(']').to_string())
91 .unwrap_or_else(|| trimmed.to_string())
92 } else {
93 "<unknown>".into()
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 const TABLE_SCAN: &str = "\
102|--Table Scan(OBJECT:([szdb].[dbo].[users]), WHERE:([age]>(18)))
103";
104
105 const INDEX_SEEK: &str = "\
106|--Index Seek(OBJECT:([szdb].[dbo].[users].[PK__users__3214EC07]), SEEK:([id]=@1) ORDERED FORWARD)
107";
108
109 const INDEX_SCAN: &str = "\
110|--Index Scan(OBJECT:([szdb].[dbo].[orders].[idx_orders_user]))
111";
112
113 const JOIN_PLAN: &str = "\
114 |--Nested Loops(Inner Join, OUTER REFERENCES:([o].[user_id]))
115 |--Index Seek(OBJECT:([szdb].[dbo].[users].[PK__users__3214EC07]), SEEK:([u].[id]=[o].[user_id]) ORDERED FORWARD)
116 |--Table Scan(OBJECT:([szdb].[dbo].[orders]))
117";
118
119 #[test]
120 fn parses_table_scan() {
121 let plan = MssqlParser.parse(TABLE_SCAN).expect("parse ok");
122 assert_eq!(plan.scan_type, ScanType::FullTable);
123 assert_eq!(plan.table, "users");
124 assert!(plan.index.is_none());
125 assert_eq!(plan.rows, 0);
126 assert!(plan.missing_index(0));
127 }
128
129 #[test]
130 fn parses_index_seek() {
131 let plan = MssqlParser.parse(INDEX_SEEK).expect("parse ok");
132 assert_eq!(plan.scan_type, ScanType::UniqueLookup);
133 assert!(plan.index.as_deref().unwrap_or("").contains("PK__users"));
134 }
135
136 #[test]
137 fn parses_index_scan() {
138 let plan = MssqlParser.parse(INDEX_SCAN).expect("parse ok");
139 assert_eq!(plan.scan_type, ScanType::IndexRange);
140 assert!(plan
141 .index
142 .as_deref()
143 .unwrap_or("")
144 .contains("idx_orders_user"));
145 }
146
147 #[test]
148 fn full_scan_beats_seek_in_join() {
149 let plan = MssqlParser.parse(JOIN_PLAN).expect("parse ok");
151 assert_eq!(plan.scan_type, ScanType::FullTable);
152 assert_eq!(plan.table, "orders");
153 }
154
155 #[test]
156 fn rejects_unknown_input() {
157 let err = MssqlParser.parse("nothing here").unwrap_err();
158 assert!(matches!(err, ExplainError::Unparseable { .. }));
159 }
160}