Skip to main content

dre_core/
sqlsplit.rs

1//! Statement splitting: lexing `;`-separated SQL, not parsing it.
2//!
3//! Understands single/double/backtick quotes, `--` and nested `/* */` comments, and Postgres
4//! dollar-quoted blocks (`$$ ... $$`, `$tag$ ... $tag$`). Shared by `dre validate`'s best-effort
5//! unmanaged-report check and by the run path.
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct Statement {
9    /// The statement text, trimmed, without its terminating `;`.
10    pub text: String,
11    /// 1-based line where the statement's first non-whitespace character sits.
12    pub line: usize,
13}
14
15pub fn split(sql: &str) -> Vec<Statement> {
16    let b = sql.as_bytes();
17    let mut out = Vec::new();
18    let mut start = 0;
19    let mut i = 0;
20    while i < b.len() {
21        match b[i] {
22            b'\'' | b'"' | b'`' => i = skip_quoted(b, i),
23            b'-' if b.get(i + 1) == Some(&b'-') => {
24                while i < b.len() && b[i] != b'\n' {
25                    i += 1;
26                }
27            }
28            b'/' if b.get(i + 1) == Some(&b'*') => i = skip_block_comment(b, i),
29            b'$' => i = skip_dollar_quoted(sql, i),
30            b';' => {
31                push_statement(sql, start, i, &mut out);
32                i += 1;
33                start = i;
34            }
35            _ => i += 1,
36        }
37    }
38    push_statement(sql, start, b.len(), &mut out);
39    out
40}
41
42fn push_statement(sql: &str, start: usize, end: usize, out: &mut Vec<Statement>) {
43    let raw = &sql[start..end];
44    let text = raw.trim();
45    if text.is_empty() || strip_leading_comments(text).is_empty() {
46        return;
47    }
48    let lead = raw.len() - raw.trim_start().len();
49    let line = sql[..start + lead].matches('\n').count() + 1;
50    out.push(Statement {
51        text: text.to_string(),
52        line,
53    });
54}
55
56fn skip_quoted(b: &[u8], i: usize) -> usize {
57    let q = b[i];
58    let mut j = i + 1;
59    while j < b.len() {
60        if b[j] == q {
61            // A doubled quote is an escaped quote.
62            if b.get(j + 1) == Some(&q) {
63                j += 2;
64                continue;
65            }
66            return j + 1;
67        }
68        j += 1;
69    }
70    b.len()
71}
72
73fn skip_block_comment(b: &[u8], i: usize) -> usize {
74    let mut depth = 0;
75    let mut j = i;
76    while j < b.len() {
77        if b[j] == b'/' && b.get(j + 1) == Some(&b'*') {
78            depth += 1;
79            j += 2;
80        } else if b[j] == b'*' && b.get(j + 1) == Some(&b'/') {
81            depth -= 1;
82            j += 2;
83            if depth == 0 {
84                return j;
85            }
86        } else {
87            j += 1;
88        }
89    }
90    b.len()
91}
92
93fn skip_dollar_quoted(sql: &str, i: usize) -> usize {
94    let b = sql.as_bytes();
95    // A tag is `$` [A-Za-z_][A-Za-z0-9_]* `$`, or just `$$`. `$1` parameters are not tags.
96    let mut j = i + 1;
97    if j < b.len() && (b[j].is_ascii_alphabetic() || b[j] == b'_') {
98        while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_') {
99            j += 1;
100        }
101    }
102    if b.get(j) != Some(&b'$') {
103        return i + 1;
104    }
105    let tag = &sql[i..=j];
106    match sql[j + 1..].find(tag) {
107        Some(pos) => j + 1 + pos + tag.len(),
108        None => b.len(),
109    }
110}
111
112/// Remove leading whitespace and comments, returning the rest.
113pub fn strip_leading_comments(mut s: &str) -> &str {
114    loop {
115        s = s.trim_start();
116        if let Some(rest) = s.strip_prefix("--") {
117            s = rest.find('\n').map_or("", |p| &rest[p..]);
118        } else if s.starts_with("/*") {
119            let end = skip_block_comment(s.as_bytes(), 0);
120            s = &s[end..];
121        } else {
122            return s;
123        }
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum StatementKind {
129    /// `SELECT` or `WITH`.
130    Read,
131    /// `CREATE [OR REPLACE] TEMP|TEMPORARY TABLE|VIEW`.
132    TempCreate,
133    /// Anything else.
134    Other,
135}
136
137impl StatementKind {
138    /// Whether an unmanaged report may run this statement.
139    pub fn is_read_only_safe(self) -> bool {
140        matches!(self, StatementKind::Read | StatementKind::TempCreate)
141    }
142}
143
144/// Conservative keyword classification. A false rejection is fixed by adding a YAML; a false
145/// acceptance would run a side effect, so anything unrecognised is `Other`.
146pub fn classify(statement: &str) -> StatementKind {
147    let s = strip_leading_comments(statement).trim_start_matches(|c: char| c == '(' || c.is_whitespace());
148    let words: Vec<String> = s
149        .split_whitespace()
150        .take(5)
151        .map(|w| w.trim_matches('(').to_ascii_lowercase())
152        .collect();
153    let w = |n: usize| words.get(n).map(String::as_str).unwrap_or("");
154    match w(0) {
155        "select" | "with" => StatementKind::Read,
156        "create" => {
157            let mut k = 1;
158            if w(1) == "or" && w(2) == "replace" {
159                k = 3;
160            }
161            if matches!(w(k), "temp" | "temporary") && matches!(w(k + 1), "table" | "view") {
162                StatementKind::TempCreate
163            } else {
164                StatementKind::Other
165            }
166        }
167        _ => StatementKind::Other,
168    }
169}