sql_cli/sql/
script_parser.rs1use anyhow::Result;
5
6#[derive(Debug, Clone, PartialEq)]
8pub enum ScriptDirective {
9 Skip,
11}
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum ScriptStatementType {
16 Query(String),
18 Exit(Option<i32>),
21}
22
23#[derive(Debug, Clone)]
25pub struct ScriptStatement {
26 pub statement_type: ScriptStatementType,
28 pub directives: Vec<ScriptDirective>,
30}
31
32impl ScriptStatement {
33 pub fn should_skip(&self) -> bool {
35 self.directives.contains(&ScriptDirective::Skip)
36 }
37
38 pub fn is_exit(&self) -> bool {
40 matches!(self.statement_type, ScriptStatementType::Exit(_))
41 }
42
43 pub fn get_exit_code(&self) -> Option<i32> {
45 match &self.statement_type {
46 ScriptStatementType::Exit(code) => Some(code.unwrap_or(0)),
47 _ => None,
48 }
49 }
50
51 pub fn get_query(&self) -> Option<&str> {
53 match &self.statement_type {
54 ScriptStatementType::Query(sql) => Some(sql),
55 ScriptStatementType::Exit(_) => None,
56 }
57 }
58}
59
60pub struct ScriptParser {
62 content: String,
63 data_file_hint: Option<String>,
64}
65
66impl ScriptParser {
67 pub fn new(content: &str) -> Self {
69 let data_file_hint = Self::extract_data_file_hint(content);
70 Self {
71 content: content.to_string(),
72 data_file_hint,
73 }
74 }
75
76 fn extract_data_file_hint(content: &str) -> Option<String> {
82 for line in content.lines() {
83 let trimmed = line.trim();
84
85 if !trimmed.starts_with("--") {
87 continue;
88 }
89
90 let comment_content = trimmed.strip_prefix("--").unwrap().trim();
92
93 if let Some(path) = comment_content.strip_prefix("#!data:") {
95 return Some(path.trim().to_string());
96 }
97 if let Some(path) = comment_content.strip_prefix("#!datafile:") {
98 return Some(path.trim().to_string());
99 }
100 if let Some(path) = comment_content.strip_prefix("#!") {
101 let path = path.trim();
102 if path.contains('.') || path.contains('/') || path.contains('\\') {
104 return Some(path.to_string());
105 }
106 }
107 }
108 None
109 }
110
111 pub fn data_file_hint(&self) -> Option<&str> {
113 self.data_file_hint.as_deref()
114 }
115
116 fn parse_directives(comment_lines: &[String]) -> Vec<ScriptDirective> {
119 let mut directives = Vec::new();
120
121 for line in comment_lines {
122 let trimmed = line.trim();
123 if !trimmed.starts_with("--") {
124 continue;
125 }
126
127 let comment_content = trimmed.strip_prefix("--").unwrap().trim();
128
129 if comment_content.eq_ignore_ascii_case("[skip]")
131 || comment_content.eq_ignore_ascii_case("[ignore]")
132 {
133 directives.push(ScriptDirective::Skip);
134 }
135 }
136
137 directives
138 }
139
140 fn split_on_semicolons(batch: &str) -> Vec<String> {
157 let mut out = Vec::new();
158 let mut current = String::new();
159 let mut chars = batch.chars().peekable();
160 let mut in_single = false;
161 let mut in_double = false;
162 let mut in_line_comment = false;
163 let mut in_block_comment = false;
164
165 while let Some(ch) = chars.next() {
166 if in_line_comment {
167 current.push(ch);
168 if ch == '\n' {
169 in_line_comment = false;
170 }
171 continue;
172 }
173 if in_block_comment {
174 current.push(ch);
175 if ch == '*' && chars.peek() == Some(&'/') {
176 current.push(chars.next().unwrap());
177 in_block_comment = false;
178 }
179 continue;
180 }
181 if in_single || in_double {
182 let quote = if in_single { '\'' } else { '"' };
183 current.push(ch);
184 if ch == quote {
185 if chars.peek() == Some("e) {
186 current.push(chars.next().unwrap()); } else if in_single {
188 in_single = false;
189 } else {
190 in_double = false;
191 }
192 }
193 continue;
194 }
195
196 match ch {
197 '\'' => {
198 in_single = true;
199 current.push(ch);
200 }
201 '"' => {
202 in_double = true;
203 current.push(ch);
204 }
205 '-' if chars.peek() == Some(&'-') => {
206 in_line_comment = true;
207 current.push(ch);
208 }
209 '/' if chars.peek() == Some(&'*') => {
210 in_block_comment = true;
211 current.push(ch);
212 }
213 ';' => {
214 let stmt = current.trim().to_string();
215 if !stmt.is_empty() {
216 out.push(stmt);
217 }
218 current.clear();
219 }
220 _ => current.push(ch),
221 }
222 }
223
224 let last = current.trim().to_string();
225 if !last.is_empty() {
226 out.push(last);
227 }
228 out
229 }
230
231 #[must_use]
238 pub fn is_multi_statement(sql: &str) -> bool {
239 Self::split_on_semicolons(sql)
240 .iter()
241 .filter(|s| !Self::is_comment_only(s))
242 .count()
243 > 1
244 }
245
246 fn push_batch(batch: &str, pending_comments: &[String], statements: &mut Vec<ScriptStatement>) {
249 let batch = batch.trim();
250 if batch.is_empty() || Self::is_comment_only(batch) {
251 return;
252 }
253
254 let directives = Self::parse_directives(pending_comments);
255
256 for stmt in Self::split_on_semicolons(batch) {
257 if Self::is_comment_only(&stmt) {
258 continue;
259 }
260 let statement_type =
261 Self::parse_exit_statement(&stmt).unwrap_or(ScriptStatementType::Query(stmt));
262 statements.push(ScriptStatement {
263 statement_type,
264 directives: directives.clone(),
265 });
266 }
267 }
268
269 pub fn parse_script_statements(&self) -> Vec<ScriptStatement> {
272 let mut statements = Vec::new();
273 let mut current_statement = String::new();
274 let mut pending_comments = Vec::new();
275
276 for line in self.content.lines() {
277 let trimmed = line.trim();
278
279 if trimmed.eq_ignore_ascii_case("go") {
281 Self::push_batch(¤t_statement, &pending_comments, &mut statements);
282 current_statement.clear();
283 pending_comments.clear();
284 } else if trimmed.starts_with("--") {
285 pending_comments.push(line.to_string());
287 if !current_statement.is_empty() {
289 current_statement.push('\n');
290 }
291 current_statement.push_str(line);
292 } else {
293 if !current_statement.is_empty() {
295 current_statement.push('\n');
296 }
297 current_statement.push_str(line);
298 }
299 }
300
301 Self::push_batch(¤t_statement, &pending_comments, &mut statements);
303
304 statements
305 }
306
307 fn parse_exit_statement(statement: &str) -> Option<ScriptStatementType> {
311 let mut non_comment_lines = Vec::new();
313 for line in statement.lines() {
314 let trimmed = line.trim();
315 if !trimmed.is_empty() && !trimmed.starts_with("--") {
316 non_comment_lines.push(trimmed);
317 }
318 }
319
320 if non_comment_lines.is_empty() {
321 return None;
322 }
323
324 let content = non_comment_lines.join(" ");
326 let trimmed = content.trim().trim_end_matches(';').trim();
327
328 if trimmed.eq_ignore_ascii_case("exit") {
329 return Some(ScriptStatementType::Exit(None));
330 }
331
332 let parts: Vec<&str> = trimmed.split_whitespace().collect();
334 if parts.len() == 2 && parts[0].eq_ignore_ascii_case("exit") {
335 if let Ok(code) = parts[1].parse::<i32>() {
336 return Some(ScriptStatementType::Exit(Some(code)));
337 }
338 }
339
340 None
341 }
342
343 pub fn parse_statements(&self) -> Vec<String> {
347 self.parse_script_statements()
348 .into_iter()
349 .filter_map(|stmt| match stmt.statement_type {
350 ScriptStatementType::Query(sql) => Some(sql),
351 ScriptStatementType::Exit(_) => None,
352 })
353 .collect()
354 }
355
356 fn is_comment_only(statement: &str) -> bool {
358 for line in statement.lines() {
359 let trimmed = line.trim();
360 if trimmed.is_empty() || trimmed.starts_with("--") {
362 continue;
363 }
364 return false;
366 }
367 true
369 }
370
371 pub fn parse_and_validate(&self) -> Result<Vec<String>> {
374 let statements = self.parse_statements();
375
376 if statements.is_empty() {
377 anyhow::bail!("No SQL statements found in script");
378 }
379
380 for (i, stmt) in statements.iter().enumerate() {
382 if stmt.trim().is_empty() {
383 anyhow::bail!("Empty statement at position {}", i + 1);
384 }
385 }
386
387 Ok(statements)
388 }
389}
390
391#[derive(Debug)]
393pub struct StatementResult {
394 pub statement_number: usize,
395 pub sql: String,
396 pub success: bool,
397 pub rows_affected: usize,
398 pub error_message: Option<String>,
399 pub execution_time_ms: f64,
400}
401
402#[derive(Debug)]
404pub struct ScriptResult {
405 pub total_statements: usize,
406 pub successful_statements: usize,
407 pub failed_statements: usize,
408 pub total_execution_time_ms: f64,
409 pub statement_results: Vec<StatementResult>,
410}
411
412impl ScriptResult {
413 pub fn new() -> Self {
414 Self {
415 total_statements: 0,
416 successful_statements: 0,
417 failed_statements: 0,
418 total_execution_time_ms: 0.0,
419 statement_results: Vec::new(),
420 }
421 }
422
423 pub fn add_success(&mut self, statement_number: usize, sql: String, rows: usize, time_ms: f64) {
424 self.total_statements += 1;
425 self.successful_statements += 1;
426 self.total_execution_time_ms += time_ms;
427
428 self.statement_results.push(StatementResult {
429 statement_number,
430 sql,
431 success: true,
432 rows_affected: rows,
433 error_message: None,
434 execution_time_ms: time_ms,
435 });
436 }
437
438 pub fn add_failure(
439 &mut self,
440 statement_number: usize,
441 sql: String,
442 error: String,
443 time_ms: f64,
444 ) {
445 self.total_statements += 1;
446 self.failed_statements += 1;
447 self.total_execution_time_ms += time_ms;
448
449 self.statement_results.push(StatementResult {
450 statement_number,
451 sql,
452 success: false,
453 rows_affected: 0,
454 error_message: Some(error),
455 execution_time_ms: time_ms,
456 });
457 }
458
459 pub fn all_successful(&self) -> bool {
460 self.failed_statements == 0
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn test_parse_single_statement() {
470 let script = "SELECT * FROM users";
471 let parser = ScriptParser::new(script);
472 let statements = parser.parse_statements();
473
474 assert_eq!(statements.len(), 1);
475 assert_eq!(statements[0], "SELECT * FROM users");
476 }
477
478 #[test]
479 fn test_parse_multiple_statements_with_go() {
480 let script = r"
481SELECT * FROM users
482GO
483SELECT * FROM orders
484GO
485SELECT * FROM products
486";
487 let parser = ScriptParser::new(script);
488 let statements = parser.parse_statements();
489
490 assert_eq!(statements.len(), 3);
491 assert_eq!(statements[0].trim(), "SELECT * FROM users");
492 assert_eq!(statements[1].trim(), "SELECT * FROM orders");
493 assert_eq!(statements[2].trim(), "SELECT * FROM products");
494 }
495
496 #[test]
497 fn test_go_case_insensitive() {
498 let script = r"
499SELECT 1
500go
501SELECT 2
502Go
503SELECT 3
504GO
505";
506 let parser = ScriptParser::new(script);
507 let statements = parser.parse_statements();
508
509 assert_eq!(statements.len(), 3);
510 }
511
512 #[test]
513 fn test_go_in_string_not_separator() {
514 let script = r"
515SELECT 'This string contains GO but should not split' as test
516GO
517SELECT 'Another statement' as test2
518";
519 let parser = ScriptParser::new(script);
520 let statements = parser.parse_statements();
521
522 assert_eq!(statements.len(), 2);
523 assert!(statements[0].contains("GO but should not split"));
524 }
525
526 #[test]
527 fn test_multiline_statements() {
528 let script = r"
529SELECT
530 id,
531 name,
532 email
533FROM users
534WHERE active = true
535GO
536SELECT COUNT(*)
537FROM orders
538";
539 let parser = ScriptParser::new(script);
540 let statements = parser.parse_statements();
541
542 assert_eq!(statements.len(), 2);
543 assert!(statements[0].contains("WHERE active = true"));
544 }
545
546 #[test]
547 fn test_empty_statements_filtered() {
548 let script = r"
549GO
550SELECT 1
551GO
552GO
553SELECT 2
554GO
555";
556 let parser = ScriptParser::new(script);
557 let statements = parser.parse_statements();
558
559 assert_eq!(statements.len(), 2);
560 assert_eq!(statements[0].trim(), "SELECT 1");
561 assert_eq!(statements[1].trim(), "SELECT 2");
562 }
563}