1mod dialect;
8mod lexer;
9
10use crate::error::DactylError;
11
12pub use dialect::{first_unsupported, Construct, Dialect};
13
14#[derive(Debug, Clone)]
16pub struct Analyzed {
17 pub constructs: Vec<Construct>,
19 pub inline_override: Option<&'static str>,
21 pub rewrite: Rewrite,
24}
25
26#[derive(Debug, Clone)]
28pub enum Rewrite {
29 Identity,
31 Replaced(String),
33}
34
35impl Rewrite {
36 pub fn apply(&self, original: &str) -> String {
38 match self {
39 Rewrite::Identity => original.to_string(),
40 Rewrite::Replaced(s) => s.clone(),
41 }
42 }
43}
44
45#[derive(Debug, Default, Clone, Copy)]
47pub struct QueryAnalyzer;
48
49impl QueryAnalyzer {
50 pub fn new() -> Self {
51 Self
52 }
53
54 pub fn analyze(&self, query: &str) -> Analyzed {
56 let (inline_override, remainder) = lexer::strip_dactyl_directive(query);
57 let tokens = lexer::tokenize(&remainder);
58 let constructs = detect_constructs(&tokens);
59 let rewrite = if constructs.is_empty() && inline_override.is_none() {
60 Rewrite::Identity
61 } else {
62 Rewrite::Replaced(remainder)
63 };
64 Analyzed {
65 constructs,
66 inline_override,
67 rewrite,
68 }
69 }
70
71 pub fn prepare(
78 &self,
79 query: &str,
80 dialect: Dialect,
81 allow_rewrites: bool,
82 ) -> Result<String, DactylError> {
83 let analyzed = self.analyze(query);
84 if let Some(override_ds) = analyzed.inline_override {
85 let override_dialect = dialect_of(override_ds).ok_or_else(|| {
86 DactylError::Routing(format!("unknown inline datastore {override_ds:?}"))
87 })?;
88 if override_dialect != dialect {
89 return Err(DactylError::Routing(format!(
90 "inline datastore {override_ds:?} does not match the active connection"
91 )));
92 }
93 }
94
95 let unsupported = first_unsupported(&analyzed.constructs, dialect);
96 let (_, remainder) = lexer::strip_dactyl_directive(query);
97 if unsupported.is_none() {
98 return Ok(remainder);
99 }
100 if !allow_rewrites {
101 return Err(DactylError::Unsupported {
102 construct: unsupported.expect("checked above"),
103 });
104 }
105
106 rewrite_for_dialect(&remainder, &analyzed.constructs, dialect).ok_or_else(|| {
107 DactylError::Unsupported {
108 construct: unsupported.expect("checked above"),
109 }
110 })
111 }
112}
113
114fn detect_constructs(tokens: &[lexer::Token]) -> Vec<Construct> {
116 let mut out = Vec::new();
117 let mut i = 0;
118 while i < tokens.len() {
119 if let lexer::Token::Word(w) = &tokens[i] {
120 match w.as_str() {
121 "json_each" => out.push(Construct::JsonEach),
122 "json_tree" => out.push(Construct::JsonTree),
123 "without" => {
124 if let Some(lexer::Token::Word(n)) = tokens.get(i + 1) {
125 if n == "rowid" {
126 out.push(Construct::WithoutRowId);
127 i += 1;
128 }
129 }
130 }
131 "strict" => out.push(Construct::Strict),
132 "jsonb" => out.push(Construct::Jsonb),
133 "returning" => out.push(Construct::Returning),
134 "ilike" => out.push(Construct::Ilike),
135 "gen_random_uuid" => out.push(Construct::GenRandomUuid),
136 "now" => out.push(Construct::NowFn),
137 _ => {}
138 }
139 } else if let lexer::Token::Op(o) = &tokens[i] {
140 match o.as_str() {
141 "->>" => out.push(Construct::JsonArrowText),
142 "->" => out.push(Construct::JsonArrow),
143 "@>" => out.push(Construct::JsonContains),
144 "<@" => out.push(Construct::JsonContained),
145 _ => {}
146 }
147 }
148 i += 1;
151 }
152 out
153}
154
155fn rewrite_for_dialect(sql: &str, constructs: &[Construct], dialect: Dialect) -> Option<String> {
159 let mut rewritten = sql.to_string();
160 for &construct in constructs {
161 match (dialect, construct) {
162 (Dialect::Sqlite, Construct::Ilike) => {
163 rewritten = replace_word(&rewritten, "ilike", "like");
164 }
165 (Dialect::Sqlite, Construct::NowFn) => {
166 rewritten = replace_now_function(&rewritten);
167 }
168 (Dialect::Sqlite, Construct::Jsonb) => {
169 rewritten = replace_word(&rewritten, "jsonb", "text");
170 }
171 (Dialect::Postgres, Construct::Strict) => {
172 rewritten = replace_word(&rewritten, "strict", "");
173 }
174 (_, Construct::JsonEach)
175 | (_, Construct::JsonTree)
176 | (_, Construct::WithoutRowId)
177 | (_, Construct::JsonContains)
178 | (_, Construct::JsonContained)
179 | (_, Construct::JsonExists)
180 | (_, Construct::GenRandomUuid) => return None,
181 (_, Construct::JsonArrowText | Construct::JsonArrow | Construct::Returning) => {
182 if !construct.supported_by(dialect) {
183 return None;
184 }
185 }
186 (_, Construct::Ilike | Construct::NowFn | Construct::Jsonb | Construct::Strict) => {
187 if !construct.supported_by(dialect) {
188 return None;
189 }
190 }
191 }
192 }
193 Some(rewritten)
194}
195
196fn replace_word(input: &str, needle: &str, replacement: &str) -> String {
197 rewrite_unquoted(input, |segment| {
198 replace_word_unquoted(segment, needle, replacement)
199 })
200}
201
202fn replace_word_unquoted(input: &str, needle: &str, replacement: &str) -> String {
203 let mut out = String::with_capacity(input.len());
204 let lower = input.to_ascii_lowercase();
205 let bytes = input.as_bytes();
206 let needle_bytes = needle.as_bytes();
207 let mut i = 0;
208 while i < bytes.len() {
209 let end = i + needle_bytes.len();
210 let boundary_before =
211 i == 0 || (!bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_');
212 let boundary_after =
213 end >= bytes.len() || (!bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_');
214 if end <= bytes.len()
215 && boundary_before
216 && boundary_after
217 && &lower.as_bytes()[i..end] == needle_bytes
218 {
219 out.push_str(replacement);
220 i = end;
221 } else {
222 out.push(bytes[i] as char);
223 i += 1;
224 }
225 }
226 out
227}
228
229fn replace_now_function(input: &str) -> String {
230 rewrite_unquoted(input, replace_now_function_unquoted)
231}
232
233fn replace_now_function_unquoted(input: &str) -> String {
234 let bytes = input.as_bytes();
235 let lower = input.to_ascii_lowercase();
236 let mut out = String::with_capacity(input.len());
237 let mut i = 0;
238 while i < bytes.len() {
239 if lower.as_bytes()[i..].starts_with(b"now")
240 && (i == 0 || (!bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_'))
241 && (i + 3 == bytes.len()
242 || (!bytes[i + 3].is_ascii_alphanumeric() && bytes[i + 3] != b'_'))
243 {
244 let mut j = i + 3;
245 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
246 j += 1;
247 }
248 if j + 1 < bytes.len() && bytes[j] == b'(' && bytes[j + 1] == b')' {
249 out.push_str("CURRENT_TIMESTAMP");
250 i = j + 2;
251 continue;
252 }
253 }
254 out.push(bytes[i] as char);
255 i += 1;
256 }
257 out
258}
259
260fn rewrite_unquoted<F>(input: &str, mut transform: F) -> String
264where
265 F: FnMut(&str) -> String,
266{
267 let bytes = input.as_bytes();
268 let mut out = String::with_capacity(input.len());
269 let mut segment_start = 0;
270 let mut i = 0;
271 while i < bytes.len() {
272 let quote = if bytes[i] == b'\'' || bytes[i] == b'"' {
273 Some(bytes[i])
274 } else {
275 None
276 };
277 let comment = (bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-')
278 || (bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*');
279 if quote.is_none() && !comment {
280 i += 1;
281 continue;
282 }
283
284 out.push_str(&transform(&input[segment_start..i]));
285 let protected_start = i;
286 if let Some(delimiter) = quote {
287 i += 1;
288 while i < bytes.len() {
289 if bytes[i] == delimiter {
290 if i + 1 < bytes.len() && bytes[i + 1] == delimiter {
291 i += 2;
292 continue;
293 }
294 i += 1;
295 break;
296 }
297 i += 1;
298 }
299 } else if bytes[i] == b'-' {
300 while i < bytes.len() && bytes[i] != b'\n' {
301 i += 1;
302 }
303 } else {
304 i += 2;
305 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
306 i += 1;
307 }
308 if i + 1 < bytes.len() {
309 i += 2;
310 } else {
311 i = bytes.len();
312 }
313 }
314 out.push_str(&input[protected_start..i]);
315 segment_start = i;
316 }
317 out.push_str(&transform(&input[segment_start..]));
318 out
319}
320
321pub fn dialect_of(datastore: &str) -> Option<Dialect> {
323 match datastore {
324 "sqlite" => Some(Dialect::Sqlite),
325 "neon" | "postgres" | "postgresql" | "pg" => Some(Dialect::Postgres),
326 _ => None,
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn prepare_strips_directive_and_rejects_unsafe_mismatch() {
336 let analyzer = QueryAnalyzer::new();
337 let sql = analyzer
338 .prepare("-- dactyl: sqlite\nselect ?1", Dialect::Sqlite, false)
339 .unwrap();
340 assert_eq!(sql, "select ?1");
341 assert!(matches!(
342 analyzer.prepare("select data @> $1", Dialect::Sqlite, true),
343 Err(DactylError::Unsupported { .. })
344 ));
345 }
346
347 #[test]
348 fn safe_rewrites_are_explicit() {
349 let analyzer = QueryAnalyzer::new();
350 let sql = analyzer
351 .prepare(
352 "select now() where name ilike $1 and note = 'now() ilike'",
353 Dialect::Sqlite,
354 true,
355 )
356 .unwrap();
357 assert!(sql.contains("CURRENT_TIMESTAMP"));
358 assert!(sql.contains("like"));
359 assert!(sql.contains("'now() ilike'"));
360 }
361
362 #[test]
363 fn question_placeholders_are_not_json_operators() {
364 let analyzed = QueryAnalyzer::new().analyze("select ?1, ? from values");
365 assert!(analyzed.constructs.is_empty());
366 }
367}