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#[derive(Debug, Clone)]
13pub struct Query(pub(crate) String);
14
15impl Query {
16 pub fn new(sql: String) -> Self {
18 Self(sql)
19 }
20
21 pub fn sql(&self) -> &str {
23 &self.0
24 }
25
26 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 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 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 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 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 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 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 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}