Skip to main content

tern_core/query/
mod.rs

1use regex::Regex;
2use std::fmt::Write;
3use std::io::Error as IoError;
4use std::sync::OnceLock;
5
6use crate::error::{Error, TernResult};
7
8mod split;
9use split::{Parser, SqlDialect};
10
11/// A SQL query.
12#[derive(Debug, Clone)]
13pub struct Query(pub(crate) String);
14
15impl Query {
16    /// New `Query` from a string.
17    pub fn new(sql: String) -> Self {
18        Self(sql)
19    }
20
21    /// Return the underlying query text.
22    pub fn sql(&self) -> &str {
23        &self.0
24    }
25
26    /// Add another query to the end of this one.
27    pub fn append(&mut self, other: Self) -> TernResult<()> {
28        let mut buf = String::new();
29        writeln!(buf, "{}", self.0)?;
30        writeln!(buf, "{}", other.0)?;
31        self.0 = buf;
32        Ok(())
33    }
34
35    /// Split a query comprised of multiple statements into a collection of
36    /// the atomic, single statements.
37    ///
38    /// This is necessary to honor the contract of "tern:noTransaction", since
39    /// sending a query with multiple statements is treated as one prepared
40    /// statement and ran in a transaction automatically.
41    pub fn split_statements(&self) -> TernResult<Vec<String>> {
42        let sql = self.0.as_bytes();
43        let dialect = self.detect_dialect().unwrap_or(SqlDialect::Postgres);
44
45        let mut parser = Parser::with_dialect(sql, sql.len(), dialect);
46        let mut stats = Vec::new();
47
48        while let Some(stat_bytes) =
49            parser.read_statement().map_err(Error::split_err(stats.len()))?
50        {
51            let raw = String::from_utf8(stat_bytes)
52                .map_err(IoError::other)
53                .map_err(Error::split_err(stats.len()))?;
54
55            // Drop "queries" that are only whitespace, like a trailing newline
56            // at the end of the file, or newlines between queries.
57            let stat = raw.trim();
58            if !stat.is_empty() {
59                stats.push(stat.to_string());
60            }
61        }
62
63        Ok(stats)
64    }
65
66    fn detect_dialect(&self) -> Option<SqlDialect> {
67        let mut first = self.0.lines().take(1);
68        let l = first.next()?;
69        let re = dialect_re();
70        let caps = re.captures(l)?;
71        Some(match caps.get(1)?.as_str() {
72            "sqlite" => SqlDialect::Sqlite,
73            "mysql" => SqlDialect::MySql,
74            "postgres" => SqlDialect::Postgres,
75            _ => return None,
76        })
77    }
78}
79
80impl std::fmt::Display for Query {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        self.0.fmt(f)
83    }
84}
85
86fn dialect_re() -> &'static Regex {
87    static RE: OnceLock<Regex> = OnceLock::new();
88    RE.get_or_init(|| Regex::new(r".*tern:noTransaction,?([a-z]*)").unwrap())
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn detects_dialect() {
97        let x = "-- tern:noTransaction";
98        let y = "-- tern:noTransaction,sqlite";
99        let z = "-- Comment with tern:noTransaction,postgres in the middle";
100        let t = "-- tern:noTransaction,dynamodb";
101        let xx = Query::new(x.into());
102        let yy = Query::new(y.into());
103        let zz = Query::new(z.into());
104        let tt = Query::new(t.into());
105        assert_eq!(xx.detect_dialect(), None);
106        assert_eq!(yy.detect_dialect(), Some(SqlDialect::Sqlite));
107        assert_eq!(zz.detect_dialect(), Some(SqlDialect::Postgres));
108        assert_eq!(tt.detect_dialect(), None);
109    }
110
111    #[test]
112    fn handles_single() {
113        const SQL: &str = "
114SELECT
115 blah,
116 whatever, -- this column is way totally
117 region_id,
118 prod_discount_region_start_date_early,
119 prod_discount_region_start_date,
120 prod_discount_region_end_date
121FROM
122 prod_discount_date
123WHERE
124 whatever = 'way;totally' -- Way; very totally
125 AND region_id = 5;
126";
127        let query = Query::new(SQL.into());
128        let res = query.split_statements();
129        assert!(res.is_ok());
130        let mut stats = res.unwrap();
131        assert_eq!(stats.len(), 1);
132        let stat = stats.pop().unwrap();
133        assert_eq!(&stat, SQL.trim());
134    }
135
136    #[test]
137    fn empty_input() {
138        let query = Query::new("".into());
139        let res = query.split_statements().unwrap();
140        assert!(res.is_empty());
141    }
142
143    #[test]
144    fn no_trailing_semicolon() {
145        let query = Query::new("SELECT 1".into());
146        let res = query.split_statements().unwrap();
147        assert_eq!(res.len(), 1);
148        assert_eq!(res[0], "SELECT 1");
149    }
150
151    #[test]
152    fn whitespace_only_between_statements() {
153        // Blank lines between statements should not produce empty entries.
154        const SQL: &str = "SELECT 1;\n\n\nSELECT 2;";
155        let res = Query::new(SQL.into()).split_statements().unwrap();
156        assert_eq!(res.len(), 2);
157    }
158
159    #[test]
160    fn semicolon_in_line_comment_not_a_terminator() {
161        // A semicolon after -- should not split the statement.
162        const SQL: &str = "SELECT 1 -- ignore; this\n, 2;";
163        let res = Query::new(SQL.into()).split_statements().unwrap();
164        assert_eq!(res.len(), 1);
165    }
166
167    #[test]
168    fn dollar_quoted_postgres() {
169        // Semicolons inside $$ bodies must not split the statement.
170        const SQL: &str = "-- tern:noTransaction,postgres
171CREATE FUNCTION add(a int, b int) RETURNS int AS $$
172BEGIN
173  RETURN a + b; -- semicolon inside dollar body
174END;
175$$ LANGUAGE plpgsql;";
176        let res = Query::new(SQL.into()).split_statements().unwrap();
177        assert_eq!(res.len(), 1);
178    }
179
180    #[test]
181    fn dollar_quoted_with_tag_postgres() {
182        // Named dollar-quote tag: $body$...$body$.
183        const SQL: &str = "-- tern:noTransaction,postgres
184DO $body$
185BEGIN
186  RAISE NOTICE 'step; one';
187END;
188$body$;
189
190SELECT 1;";
191        let res = Query::new(SQL.into()).split_statements().unwrap();
192        assert_eq!(res.len(), 2);
193    }
194
195    #[test]
196    fn mysql_backslash_escape() {
197        // Backslash-escaped quote inside a string must not end the string early.
198        const SQL: &str = "-- tern:noTransaction,mysql
199INSERT INTO t (col) VALUES ('it\\'s fine');
200
201SELECT 1;";
202        let res = Query::new(SQL.into()).split_statements().unwrap();
203        assert_eq!(res.len(), 2);
204    }
205
206    #[test]
207    fn semicolon_in_block_comment_not_a_terminator() {
208        const SQL: &str = "SELECT 1 /* this; is ignored */;";
209        let res = Query::new(SQL.into()).split_statements().unwrap();
210        assert_eq!(res.len(), 1);
211    }
212
213    #[test]
214    fn handles_multiple() {
215        const SQL: &str = r#"
216-- tern:noTransaction,postgres
217SELECT
218  column1 AS "asdf;lkh",
219  column2
220FROM
221  the_table as a
222/* Why
223would anyone do this;
224it's absurd
225*/
226JOIN
227  the_other_table as b
228USING (column3);
229
230SELECT * INTO the_table_recent
231FROM the_table
232WHERE
233  column1 != 'string--with--special/*characters*/and--terminator;'
234  AND recent = true;
235"#;
236        let query = Query::new(SQL.into());
237        let res = query.split_statements();
238        assert!(res.is_ok_and(|ss| ss.len() == 2));
239    }
240}