Skip to main content

rsqlite_parser/
parse.rs

1use sqlparser::dialect::SQLiteDialect;
2use sqlparser::parser::Parser;
3
4use crate::error::ParseError;
5
6pub fn parse_sql(sql: &str) -> Result<Vec<sqlparser::ast::Statement>, ParseError> {
7    let dialect = SQLiteDialect {};
8    let preprocessed = preprocess_update_limit(&preprocess_bitwise_shifts(
9        &preprocess_bitwise_not(&preprocess_is_truth_family(&preprocess_fts5_match(
10            &preprocess_pragma(sql),
11        ))),
12    ));
13    if is_vacuum(&preprocessed) {
14        return Ok(vec![make_pragma_statement("__vacuum", None)]);
15    }
16    if let Some(arg) = strip_keyword(&preprocessed, "REINDEX") {
17        return Ok(vec![make_pragma_statement("__reindex", Some(&arg))]);
18    }
19    if let Some(arg) = strip_keyword(&preprocessed, "ANALYZE") {
20        return Ok(vec![make_pragma_statement("__analyze", Some(&arg))]);
21    }
22    if let Some(stmt) = parse_trigger_statement(&preprocessed) {
23        return Ok(vec![stmt]);
24    }
25    if let Some(stmt) = parse_detach_statement(&preprocessed) {
26        return Ok(vec![stmt]);
27    }
28    if let Some(stmt) = parse_create_virtual_table(&preprocessed) {
29        return Ok(vec![stmt]);
30    }
31    let statements = Parser::parse_sql(&dialect, &preprocessed)?;
32    Ok(statements)
33}
34
35/// Detect `CREATE VIRTUAL TABLE [IF NOT EXISTS] <name> USING <module>(args…)`
36/// and emit a `PRAGMA __create_virtual_table('<name>|<module>|<args>')` so
37/// the planner can route it to the vtab module registry. sqlparser's
38/// SQLiteDialect doesn't accept the syntax natively, and emulating it via a
39/// custom dialect would ripple through too much; the pragma channel matches
40/// what we already do for triggers.
41fn parse_create_virtual_table(sql: &str) -> Option<sqlparser::ast::Statement> {
42    let trimmed = sql.trim().trim_end_matches(';').trim();
43    let upper = trimmed.to_uppercase();
44    let prefix = "CREATE VIRTUAL TABLE";
45    if !upper.starts_with(prefix) {
46        return None;
47    }
48    let rest = trimmed[prefix.len()..].trim_start();
49    let upper_rest = rest.to_uppercase();
50    let (rest, if_not_exists) = if upper_rest.starts_with("IF NOT EXISTS") {
51        (rest["IF NOT EXISTS".len()..].trim_start(), true)
52    } else {
53        (rest, false)
54    };
55
56    // Parse the table name (until whitespace).
57    let name_end = rest
58        .find(char::is_whitespace)
59        .unwrap_or(rest.len());
60    let name = rest[..name_end].trim_matches(|c: char| c == '"' || c == '`' || c == '[' || c == ']');
61    if name.is_empty() {
62        return None;
63    }
64    let after_name = rest[name_end..].trim_start();
65
66    // Expect `USING <module>(args)`.
67    let upper_after = after_name.to_uppercase();
68    if !upper_after.starts_with("USING") {
69        return None;
70    }
71    let after_using = after_name["USING".len()..].trim_start().trim_end_matches(';').trim();
72    // Module-arg parens are optional — modules with no args (e.g.
73    // `kvstore`) write `USING kvstore` and we treat that as args="".
74    let (module, raw_args) = match after_using.find('(') {
75        Some(paren) => {
76            let close = after_using.rfind(')')?;
77            if close <= paren {
78                return None;
79            }
80            (after_using[..paren].trim(), &after_using[paren + 1..close])
81        }
82        None => (after_using, ""),
83    };
84    if module.is_empty() {
85        return None;
86    }
87
88    // Encode as `<if_not_exists>|<name>|<module>|<args>` so the planner
89    // can split on '|'.
90    let encoded = format!(
91        "{}|{}|{}|{}",
92        if if_not_exists { "1" } else { "0" },
93        name,
94        module,
95        raw_args
96    );
97    Some(make_pragma_statement(
98        "__create_virtual_table",
99        Some(&encoded),
100    ))
101}
102
103fn parse_trigger_statement(sql: &str) -> Option<sqlparser::ast::Statement> {
104    let upper = sql.trim().to_uppercase();
105    if upper.starts_with("CREATE TRIGGER") || upper.starts_with("CREATE TRIGGER") {
106        return parse_create_trigger(sql.trim());
107    }
108    if upper.starts_with("DROP TRIGGER") {
109        return parse_drop_trigger(sql.trim());
110    }
111    None
112}
113
114fn parse_create_trigger(sql: &str) -> Option<sqlparser::ast::Statement> {
115    let original_sql = sql.trim().trim_end_matches(';').trim();
116    let upper = original_sql.to_uppercase();
117    let tokens: Vec<&str> = upper.split_whitespace().collect();
118
119    let mut pos = 2; // skip "CREATE TRIGGER"
120    let mut if_not_exists = false;
121    if tokens.get(pos) == Some(&"IF")
122        && tokens.get(pos + 1) == Some(&"NOT")
123        && tokens.get(pos + 2) == Some(&"EXISTS")
124    {
125        if_not_exists = true;
126        pos += 3;
127    }
128
129    let name = tokens.get(pos)?.to_string();
130    pos += 1;
131
132    let timing = match tokens.get(pos).copied()? {
133        "BEFORE" => {
134            pos += 1;
135            "BEFORE"
136        }
137        "AFTER" => {
138            pos += 1;
139            "AFTER"
140        }
141        "INSTEAD" => {
142            if tokens.get(pos + 1) == Some(&"OF") {
143                pos += 2;
144                "INSTEAD OF"
145            } else {
146                return None;
147            }
148        }
149        _ => return None,
150    };
151
152    let event = match tokens.get(pos).copied()? {
153        "INSERT" => {
154            pos += 1;
155            "INSERT"
156        }
157        "UPDATE" => {
158            pos += 1;
159            "UPDATE"
160        }
161        "DELETE" => {
162            pos += 1;
163            "DELETE"
164        }
165        _ => return None,
166    };
167
168    if tokens.get(pos) != Some(&"ON") {
169        return None;
170    }
171    pos += 1;
172
173    let table_name = tokens.get(pos)?.to_string();
174    pos += 1;
175
176    if tokens.get(pos) == Some(&"FOR") {
177        if tokens.get(pos + 1) == Some(&"EACH") && tokens.get(pos + 2) == Some(&"ROW") {
178            pos += 3;
179        }
180    }
181
182    // Find BEGIN in the original (case-preserving) text
183    let upper_sql = original_sql.to_uppercase();
184    let begin_idx = find_keyword_pos(&upper_sql, pos, &tokens, "BEGIN")?;
185
186    let when_condition = if tokens.get(pos) == Some(&"WHEN") {
187        let when_start = find_word_offset(original_sql, &tokens, pos + 1)?;
188        let when_end = begin_idx;
189        let cond = original_sql[when_start..when_end].trim().to_string();
190        Some(cond)
191    } else {
192        None
193    };
194
195    let body_start = begin_idx + "BEGIN".len();
196    let end_idx = upper_sql.rfind("END")?;
197    let body_sql = original_sql[body_start..end_idx].trim().to_string();
198
199    let encoded = format!(
200        "{}|{}|{}|{}|{}|{}|{}",
201        name,
202        table_name,
203        timing,
204        event,
205        if if_not_exists { "1" } else { "0" },
206        when_condition.as_deref().unwrap_or(""),
207        body_sql
208    );
209
210    Some(make_pragma_statement("__create_trigger", Some(&encoded)))
211}
212
213fn find_keyword_pos(
214    upper_sql: &str,
215    _start_token: usize,
216    _tokens: &[&str],
217    keyword: &str,
218) -> Option<usize> {
219    upper_sql.find(keyword)
220}
221
222fn find_word_offset(sql: &str, tokens: &[&str], token_idx: usize) -> Option<usize> {
223    let upper = sql.to_uppercase();
224    let mut search_start = 0;
225    for i in 0..token_idx {
226        let tok = tokens.get(i)?;
227        if let Some(pos) = upper[search_start..].find(tok) {
228            search_start += pos + tok.len();
229        }
230    }
231    while search_start < sql.len() && sql.as_bytes()[search_start] == b' ' {
232        search_start += 1;
233    }
234    Some(search_start)
235}
236
237fn parse_detach_statement(sql: &str) -> Option<sqlparser::ast::Statement> {
238    let trimmed = sql.trim().trim_end_matches(';').trim();
239    let upper = trimmed.to_uppercase();
240    if !upper.starts_with("DETACH") {
241        return None;
242    }
243    let upper_tokens: Vec<&str> = upper.split_whitespace().collect();
244    let orig_tokens: Vec<&str> = trimmed.split_whitespace().collect();
245    let mut pos = 1; // skip DETACH
246    if upper_tokens.get(pos) == Some(&"DATABASE") {
247        pos += 1;
248    }
249    let schema_name = orig_tokens.get(pos)?;
250    Some(make_pragma_statement("__detach", Some(schema_name)))
251}
252
253fn parse_drop_trigger(sql: &str) -> Option<sqlparser::ast::Statement> {
254    let trimmed = sql.trim().trim_end_matches(';').trim();
255    let upper = trimmed.to_uppercase();
256    let tokens: Vec<&str> = upper.split_whitespace().collect();
257
258    let mut pos = 2; // skip "DROP TRIGGER"
259    let mut if_exists = false;
260    if tokens.get(pos) == Some(&"IF") && tokens.get(pos + 1) == Some(&"EXISTS") {
261        if_exists = true;
262        pos += 2;
263    }
264    let name = tokens.get(pos)?.to_string();
265    let encoded = format!("{}|{}", name, if if_exists { "1" } else { "0" });
266    Some(make_pragma_statement("__drop_trigger", Some(&encoded)))
267}
268
269fn is_vacuum(sql: &str) -> bool {
270    let trimmed = sql.trim().trim_end_matches(';').trim();
271    trimmed.eq_ignore_ascii_case("VACUUM")
272}
273
274/// If `sql` starts with `keyword`, return everything after it (trimmed).
275/// `REINDEX` and `ANALYZE` accept an optional table/index name, so the
276/// argument may be empty.
277fn strip_keyword(sql: &str, keyword: &str) -> Option<String> {
278    let trimmed = sql.trim().trim_end_matches(';').trim();
279    let upper = trimmed.to_uppercase();
280    if upper == keyword {
281        return Some(String::new());
282    }
283    let prefix = format!("{keyword} ");
284    if upper.starts_with(&prefix) {
285        return Some(trimmed[prefix.len()..].trim().to_string());
286    }
287    None
288}
289
290fn make_pragma_statement(name: &str, value: Option<&str>) -> sqlparser::ast::Statement {
291    use sqlparser::ast::{Ident, ObjectName, Value};
292    sqlparser::ast::Statement::Pragma {
293        name: ObjectName::from(vec![Ident::new(name)]),
294        value: value.map(|v| Value::SingleQuotedString(v.to_string())),
295        is_eq: value.is_some(),
296    }
297}
298
299/// Translate `<ident> IS [NOT] TRUE/FALSE` into the SQLite-equivalent
300/// boolean form so SQLiteDialect (which rejects the syntax) accepts it:
301///
302/// - `x IS TRUE` → `(x IS NOT NULL AND x <> 0)`
303/// - `x IS FALSE` → `(x IS NOT NULL AND x = 0)`
304/// - `x IS NOT TRUE` → `(x IS NULL OR x = 0)`
305/// - `x IS NOT FALSE` → `(x IS NULL OR x <> 0)`
306///
307/// Only matches when the LHS is a simple identifier (`col`, `t.col`,
308/// `"quoted col"`). For arbitrary expressions on the left (`(a + b) IS
309/// TRUE`), use the function form `is_true(...)` instead.
310///
311/// Skips matches inside string literals to avoid corrupting query text.
312fn preprocess_is_truth_family(sql: &str) -> String {
313    // Fast path: most SQL strings don't contain `IS TRUE` / `IS FALSE`, and
314    // ASCII byte-walking the rare matches is incompatible with multi-byte
315    // UTF-8 string literals like `'é'`. Skip the rewrite entirely when no
316    // candidate keyword is present.
317    let upper_haystack = sql.to_ascii_uppercase();
318    if !upper_haystack.contains("IS TRUE")
319        && !upper_haystack.contains("IS FALSE")
320        && !upper_haystack.contains("IS NOT TRUE")
321        && !upper_haystack.contains("IS NOT FALSE")
322    {
323        return sql.to_string();
324    }
325
326    let bytes = sql.as_bytes();
327    let n = bytes.len();
328    let mut out = String::with_capacity(n);
329    let mut i = 0usize;
330
331    while i < n {
332        let ch = bytes[i] as char;
333        // Skip past string literals untouched.
334        if ch == '\'' || ch == '"' {
335            let quote = ch;
336            out.push(ch);
337            i += 1;
338            while i < n {
339                let c = bytes[i] as char;
340                out.push(c);
341                i += 1;
342                if c == quote {
343                    // SQL doubled-quote escape: '' inside ''.
344                    if i < n && (bytes[i] as char) == quote {
345                        out.push(quote);
346                        i += 1;
347                        continue;
348                    }
349                    break;
350                }
351            }
352            continue;
353        }
354        // Skip past line comments.
355        if ch == '-' && i + 1 < n && bytes[i + 1] as char == '-' {
356            while i < n && bytes[i] as char != '\n' {
357                out.push(bytes[i] as char);
358                i += 1;
359            }
360            continue;
361        }
362
363        // Try to match an identifier or parenthesized expression
364        // optionally followed by IS [NOT] TRUE/FALSE.
365        let id_start = i;
366        let id = if ch == '(' {
367            // Parenthesized LHS: capture the balanced (...) verbatim so
368            // arbitrary expressions like `(a + b)` work.
369            let start = i;
370            let mut depth = 1;
371            i += 1;
372            while i < n && depth > 0 {
373                let c = bytes[i] as char;
374                if c == '(' {
375                    depth += 1;
376                } else if c == ')' {
377                    depth -= 1;
378                }
379                i += 1;
380            }
381            if depth != 0 {
382                out.push(ch);
383                i = start + 1;
384                continue;
385            }
386            String::from_utf8_lossy(&bytes[start..i]).into_owned()
387        } else {
388            scan_identifier(bytes, &mut i)
389        };
390        if id.is_empty() {
391            out.push(ch);
392            i += 1;
393            continue;
394        }
395        // Look for `IS [NOT] (TRUE|FALSE)` after optional whitespace.
396        let saved_i = i;
397        let mut j = i;
398        skip_ws(bytes, &mut j);
399        if !match_keyword(bytes, &mut j, "IS") {
400            // Not the pattern; emit the identifier as-is.
401            out.push_str(&id);
402            continue;
403        }
404        skip_ws(bytes, &mut j);
405        let negated = match_keyword(bytes, &mut j, "NOT");
406        if negated {
407            skip_ws(bytes, &mut j);
408        }
409        let truthy = if match_keyword(bytes, &mut j, "TRUE") {
410            Some(true)
411        } else if match_keyword(bytes, &mut j, "FALSE") {
412            Some(false)
413        } else {
414            None
415        };
416        match truthy {
417            Some(true_or_false) => {
418                // IS TRUE      → (x IS NOT NULL AND x <> 0)
419                // IS FALSE     → (x IS NOT NULL AND x = 0)
420                // IS NOT TRUE  → (x IS NULL OR x = 0)
421                // IS NOT FALSE → (x IS NULL OR x <> 0)
422                let cmp = if true_or_false ^ negated { "<>" } else { "=" };
423                let null_test = if negated { "IS NULL" } else { "IS NOT NULL" };
424                let combiner = if negated { "OR" } else { "AND" };
425                let expanded = format!("({id} {null_test} {combiner} {id} {cmp} 0)");
426                let _ = id_start;
427                out.push_str(&expanded);
428                i = j;
429            }
430            None => {
431                out.push_str(&id);
432                i = saved_i;
433            }
434        }
435    }
436
437    out
438}
439
440fn scan_identifier(bytes: &[u8], i: &mut usize) -> String {
441    let start = *i;
442    let n = bytes.len();
443    if start >= n {
444        return String::new();
445    }
446    let first = bytes[start] as char;
447    // Quoted identifier: "name" or `name`.
448    if first == '"' || first == '`' {
449        let quote = first;
450        *i += 1;
451        while *i < n {
452            let c = bytes[*i] as char;
453            *i += 1;
454            if c == quote {
455                break;
456            }
457        }
458        // Optional `.qualifier` after a quoted identifier.
459        if *i < n && bytes[*i] as char == '.' {
460            *i += 1;
461            scan_unquoted_id_tail(bytes, i);
462        }
463        return String::from_utf8_lossy(&bytes[start..*i]).into_owned();
464    }
465    if !first.is_ascii_alphabetic() && first != '_' {
466        return String::new();
467    }
468    *i += 1;
469    while *i < n {
470        let c = bytes[*i] as char;
471        if c.is_ascii_alphanumeric() || c == '_' {
472            *i += 1;
473        } else {
474            break;
475        }
476    }
477    // Optional `.qualifier`.
478    if *i < n && bytes[*i] as char == '.' {
479        *i += 1;
480        scan_unquoted_id_tail(bytes, i);
481    }
482    String::from_utf8_lossy(&bytes[start..*i]).into_owned()
483}
484
485fn scan_unquoted_id_tail(bytes: &[u8], i: &mut usize) {
486    let n = bytes.len();
487    while *i < n {
488        let c = bytes[*i] as char;
489        if c.is_ascii_alphanumeric() || c == '_' {
490            *i += 1;
491        } else {
492            break;
493        }
494    }
495}
496
497fn skip_ws(bytes: &[u8], i: &mut usize) {
498    let n = bytes.len();
499    while *i < n && (bytes[*i] as char).is_whitespace() {
500        *i += 1;
501    }
502}
503
504fn match_keyword(bytes: &[u8], i: &mut usize, kw: &str) -> bool {
505    let n = bytes.len();
506    let kw_bytes = kw.as_bytes();
507    if *i + kw_bytes.len() > n {
508        return false;
509    }
510    for (k, kb) in kw_bytes.iter().enumerate() {
511        let c = bytes[*i + k];
512        if c.to_ascii_uppercase() != *kb {
513            return false;
514        }
515    }
516    // Make sure the next char isn't an identifier continuation.
517    let after = *i + kw_bytes.len();
518    if after < n {
519        let c = bytes[after] as char;
520        if c.is_ascii_alphanumeric() || c == '_' {
521            return false;
522        }
523    }
524    *i = after;
525    true
526}
527
528/// Translate `<atom> << <atom>` and `<atom> >> <atom>` into the function
529/// forms `__shl(a, b)` / `__shr(a, b)` so SQLiteDialect can parse them.
530///
531/// `<atom>` is deliberately narrow: identifier (possibly qualified),
532/// parenthesized subexpression, integer literal, or a simple function
533/// call `name(args)`. The narrowness avoids the precedence trap where
534/// `a + b << c` (which means `(a+b) << c` per SQL precedence) would be
535/// mis-rewritten to `a + __shl(b, c)`. Anything outside the safe shape
536/// falls through and the user keeps `__shl(...)` / `__shr(...)`.
537///
538/// Shift chains apply right-to-left at the rewriter level, but since
539/// the rewrite is one-pass left-to-right scanning, `a << b << c` becomes
540/// `__shl(a, b) << c` after one pass and then `__shl(__shl(a, b), c)` if
541/// we re-scan. We loop until convergence (capped) so chains resolve.
542fn preprocess_bitwise_shifts(sql: &str) -> String {
543    if !sql.contains("<<") && !sql.contains(">>") {
544        return sql.to_string();
545    }
546
547    let mut current = sql.to_string();
548    for _ in 0..8 {
549        let rewritten = rewrite_shifts_one_pass(&current);
550        if rewritten == current {
551            break;
552        }
553        current = rewritten;
554    }
555    current
556}
557
558fn rewrite_shifts_one_pass(sql: &str) -> String {
559    let bytes = sql.as_bytes();
560    let n = bytes.len();
561    let mut out = String::with_capacity(n);
562    let mut i = 0usize;
563
564    while i < n {
565        let ch = bytes[i] as char;
566        if ch == '\'' || ch == '"' {
567            let quote = ch;
568            out.push(ch);
569            i += 1;
570            while i < n {
571                let c = bytes[i] as char;
572                out.push(c);
573                i += 1;
574                if c == quote {
575                    if i < n && (bytes[i] as char) == quote {
576                        out.push(quote);
577                        i += 1;
578                        continue;
579                    }
580                    break;
581                }
582            }
583            continue;
584        }
585        if ch == '-' && i + 1 < n && bytes[i + 1] as char == '-' {
586            while i < n && bytes[i] as char != '\n' {
587                out.push(bytes[i] as char);
588                i += 1;
589            }
590            continue;
591        }
592        // Look for `atom <<` or `atom >>` anchored at the END of `out`.
593        // We only rewrite when the LHS is a "safe" trailing token in
594        // `out` and the RHS is also a safe atom.
595        if i + 2 <= n {
596            let pair = &sql[i..i + 2];
597            if pair == "<<" || pair == ">>" {
598                if let Some((lhs_start, lhs)) = trailing_atom(&out) {
599                    // Try to consume RHS atom.
600                    let after_op = i + 2;
601                    if let Some((rhs, consumed)) = leading_atom(&sql[after_op..]) {
602                        let func = if pair == "<<" { "__shl" } else { "__shr" };
603                        out.truncate(lhs_start);
604                        out.push_str(&format!("{func}({lhs}, {rhs})"));
605                        i = after_op + consumed;
606                        continue;
607                    }
608                }
609            }
610        }
611        out.push(ch);
612        i += 1;
613    }
614    out
615}
616
617/// Return the trailing safe-atom suffix of `out`, if any. The returned
618/// `start_index` is into the *trimmed* tail (i.e. matches `out` after
619/// dropping trailing whitespace) — callers `out.truncate(start_index)`
620/// to lop off the atom, which incidentally also drops the trailing
621/// whitespace before the operator.
622///
623/// Returns None if the atom is preceded by a higher-precedence binary
624/// operator (`+ - * / % ||`). In SQLite, `<<`/`>>` bind looser than
625/// these — so capturing only the trailing atom (e.g. `b` in `a + b << c`)
626/// would silently produce the wrong tree. Bailing here keeps sqlparser
627/// to surface the original error instead.
628fn trailing_atom(out: &str) -> Option<(usize, String)> {
629    let trimmed = out.trim_end();
630    let bytes = trimmed.as_bytes();
631    let n = bytes.len();
632    if n == 0 {
633        return None;
634    }
635    let last = bytes[n - 1] as char;
636
637    // Parenthesized expression (optionally preceded by a function name).
638    if last == ')' {
639        let mut depth = 0i32;
640        let mut start = n;
641        for k in (0..n).rev() {
642            let c = bytes[k] as char;
643            if c == ')' {
644                depth += 1;
645            } else if c == '(' {
646                depth -= 1;
647                if depth == 0 {
648                    start = k;
649                    break;
650                }
651            }
652        }
653        if depth != 0 {
654            return None;
655        }
656        // Extend back over any identifier characters preceding the `(`,
657        // so `__shl(1, 2)` returns as a single atom rather than just `(1, 2)`.
658        let mut id_start = start;
659        while id_start > 0 {
660            let c = bytes[id_start - 1] as char;
661            if c.is_ascii_alphanumeric() || c == '_' {
662                id_start -= 1;
663            } else {
664                break;
665            }
666        }
667        let text = String::from_utf8_lossy(&bytes[id_start..n]).into_owned();
668        return Some((id_start, text));
669    }
670
671    // Numeric literal or identifier (with optional `.qualifier`).
672    if last.is_ascii_digit() || last.is_ascii_alphabetic() || last == '_' {
673        let mut start = n;
674        while start > 0 {
675            let c = bytes[start - 1] as char;
676            if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
677                start -= 1;
678            } else {
679                break;
680            }
681        }
682        // Bail if a higher-precedence binary operator immediately precedes
683        // the atom — see doc comment above. Walk back through whitespace.
684        let mut k = start;
685        while k > 0 && (bytes[k - 1] as char).is_whitespace() {
686            k -= 1;
687        }
688        if k > 0 {
689            let prev = bytes[k - 1] as char;
690            // Watch for `||` (concat) — both bytes are `|`.
691            let prev2 = if k >= 2 { Some(bytes[k - 2] as char) } else { None };
692            let prev_op_higher_prec = matches!(prev, '+' | '-' | '*' | '/' | '%')
693                || (prev == '|' && prev2 == Some('|'));
694            if prev_op_higher_prec {
695                return None;
696            }
697        }
698        let text = String::from_utf8_lossy(&bytes[start..n]).into_owned();
699        return Some((start, text));
700    }
701
702    None
703}
704
705/// Return the leading safe-atom prefix of `s`, with bytes-consumed.
706fn leading_atom(s: &str) -> Option<(String, usize)> {
707    let bytes = s.as_bytes();
708    let n = bytes.len();
709    let mut i = 0;
710    while i < n && (bytes[i] as char).is_whitespace() {
711        i += 1;
712    }
713    if i >= n {
714        return None;
715    }
716    let start = i;
717    let first = bytes[i] as char;
718
719    // Parenthesized.
720    if first == '(' {
721        let mut depth = 1;
722        i += 1;
723        while i < n && depth > 0 {
724            let c = bytes[i] as char;
725            if c == '(' {
726                depth += 1;
727            } else if c == ')' {
728                depth -= 1;
729            }
730            i += 1;
731        }
732        if depth != 0 {
733            return None;
734        }
735        let text = String::from_utf8_lossy(&bytes[start..i]).into_owned();
736        return Some((text, i));
737    }
738
739    // Number (possibly negative — but we'd need to check the previous
740    // op context, so keep it simple and only match unsigned digits).
741    if first.is_ascii_digit() {
742        while i < n && (bytes[i] as char).is_ascii_digit() {
743            i += 1;
744        }
745        let text = String::from_utf8_lossy(&bytes[start..i]).into_owned();
746        return Some((text, i));
747    }
748
749    // Identifier or function call.
750    if first.is_ascii_alphabetic() || first == '_' {
751        while i < n {
752            let c = bytes[i] as char;
753            if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
754                i += 1;
755            } else {
756                break;
757            }
758        }
759        // Optional `(...)` for a function call.
760        if i < n && (bytes[i] as char) == '(' {
761            let mut depth = 1;
762            i += 1;
763            while i < n && depth > 0 {
764                let c = bytes[i] as char;
765                if c == '(' {
766                    depth += 1;
767                } else if c == ')' {
768                    depth -= 1;
769                }
770                i += 1;
771            }
772            if depth != 0 {
773                return None;
774            }
775        }
776        let text = String::from_utf8_lossy(&bytes[start..i]).into_owned();
777        return Some((text, i));
778    }
779
780    None
781}
782
783/// Translate prefix `~<operand>` (bitwise complement) into `__bnot(<operand>)`
784/// before parsing — sqlparser's SQLiteDialect rejects `~` as a unary token.
785///
786/// The operand can be:
787/// - a simple identifier (`col`, `t.col`, `"quoted col"`)
788/// - a parenthesized expression (`~(a + 1)` → `__bnot((a + 1))`)
789///
790/// Other forms (function calls, longer chains) fall through and the user
791/// uses the function form `__bnot(...)` directly.
792///
793/// Skips matches inside string literals, line comments, and `~` chars used
794/// as a binary operator (the SQLite dialect doesn't expose that anyway,
795/// but Postgres-style `~` regex match isn't our concern here).
796fn preprocess_bitwise_not(sql: &str) -> String {
797    if !sql.contains('~') {
798        return sql.to_string();
799    }
800
801    let bytes = sql.as_bytes();
802    let n = bytes.len();
803    let mut out = String::with_capacity(n);
804    let mut i = 0usize;
805
806    while i < n {
807        let ch = bytes[i] as char;
808        if ch == '\'' || ch == '"' {
809            let quote = ch;
810            out.push(ch);
811            i += 1;
812            while i < n {
813                let c = bytes[i] as char;
814                out.push(c);
815                i += 1;
816                if c == quote {
817                    if i < n && (bytes[i] as char) == quote {
818                        out.push(quote);
819                        i += 1;
820                        continue;
821                    }
822                    break;
823                }
824            }
825            continue;
826        }
827        if ch == '-' && i + 1 < n && bytes[i + 1] as char == '-' {
828            while i < n && bytes[i] as char != '\n' {
829                out.push(bytes[i] as char);
830                i += 1;
831            }
832            continue;
833        }
834        if ch != '~' {
835            out.push(ch);
836            i += 1;
837            continue;
838        }
839
840        // Decide whether this `~` is in a prefix position. It's a prefix
841        // iff the previous *significant* output character is the start of
842        // a fresh expression — i.e. one of: empty, `(`, `,`, operator,
843        // keyword separator. We approximate by walking back through any
844        // trailing whitespace.
845        let prev_significant = out.trim_end().chars().next_back();
846        let is_prefix = match prev_significant {
847            None => true,
848            Some(c) => matches!(
849                c,
850                '(' | ',' | '+' | '-' | '*' | '/' | '%' | '=' | '<' | '>' | '!' | '|' | '&' | '~'
851            ),
852        } || {
853            // Or the preceding token is an SQL keyword that ends an
854            // expression boundary (WHERE, AND, OR, NOT, ON, BY, ...).
855            let trimmed = out.trim_end();
856            let last_word: String = trimmed
857                .chars()
858                .rev()
859                .take_while(|c| c.is_ascii_alphabetic() || *c == '_')
860                .collect::<String>()
861                .chars()
862                .rev()
863                .collect();
864            matches!(
865                last_word.to_ascii_uppercase().as_str(),
866                "WHERE"
867                    | "AND"
868                    | "OR"
869                    | "NOT"
870                    | "ON"
871                    | "BY"
872                    | "CASE"
873                    | "WHEN"
874                    | "THEN"
875                    | "ELSE"
876                    | "SELECT"
877                    | "VALUES"
878                    | "RETURNING"
879                    | "HAVING"
880                    | "LIMIT"
881                    | "OFFSET"
882            )
883        };
884
885        if !is_prefix {
886            out.push(ch);
887            i += 1;
888            continue;
889        }
890
891        // Consume `~` and parse the operand.
892        i += 1;
893        skip_ws(bytes, &mut i);
894        if i >= n {
895            out.push('~');
896            continue;
897        }
898        let next_ch = bytes[i] as char;
899        if next_ch == '(' {
900            // Find matching close paren.
901            let start = i;
902            let mut depth = 1;
903            i += 1;
904            while i < n && depth > 0 {
905                let c = bytes[i] as char;
906                if c == '(' {
907                    depth += 1;
908                } else if c == ')' {
909                    depth -= 1;
910                }
911                i += 1;
912            }
913            let operand = &sql[start..i];
914            out.push_str(&format!("__bnot({operand})"));
915            continue;
916        }
917        // Numeric literal — integer or decimal (no exponent / hex for now,
918        // matching what SQLite would accept after `~`).
919        if next_ch.is_ascii_digit() {
920            let start = i;
921            while i < n {
922                let c = bytes[i] as char;
923                if c.is_ascii_digit() || c == '.' {
924                    i += 1;
925                } else {
926                    break;
927                }
928            }
929            out.push_str(&format!("__bnot({})", &sql[start..i]));
930            continue;
931        }
932        // Identifier (possibly qualified).
933        let id = scan_identifier(bytes, &mut i);
934        if id.is_empty() {
935            out.push('~');
936            continue;
937        }
938        out.push_str(&format!("__bnot({id})"));
939    }
940
941    out
942}
943
944/// Translate `UPDATE t SET ... [WHERE ...] (ORDER BY ... | LIMIT n)` into
945/// the rowid-IN workaround SQLite documents:
946///
947///   UPDATE t SET ... WHERE rowid IN (
948///     SELECT rowid FROM t [WHERE ...] [ORDER BY ...] [LIMIT n]
949///   )
950///
951/// SQLite gates UPDATE-with-LIMIT/ORDER-BY behind a compile-time flag and
952/// `sqlparser`'s SQLiteDialect doesn't accept it, so without this pre-pass
953/// the user has to write the rowid-IN form by hand.
954///
955/// Conservative: only touches single-statement inputs that start with
956/// `UPDATE`. Bails on multi-table UPDATE-FROM (where rewriting requires
957/// also threading the FROM clause), or any case where the table name
958/// can't be cleanly extracted.
959fn preprocess_update_limit(sql: &str) -> String {
960    let upper = sql.to_ascii_uppercase();
961    let trimmed_upper = upper.trim_start();
962    if !trimmed_upper.starts_with("UPDATE ") {
963        return sql.to_string();
964    }
965    if !upper.contains(" ORDER BY ") && !upper.contains(" LIMIT ") {
966        return sql.to_string();
967    }
968
969    let kw_pos = find_top_level_keywords(sql, &["SET", "FROM", "WHERE", "ORDER", "LIMIT"]);
970    let set_pos = match kw_pos.iter().find(|(k, _)| k == "SET") {
971        Some((_, p)) => *p,
972        None => return sql.to_string(),
973    };
974    if kw_pos.iter().any(|(k, _)| k == "FROM") {
975        // UPDATE...FROM is a separate beast — leave it alone.
976        return sql.to_string();
977    }
978    let where_pos = kw_pos.iter().find(|(k, _)| k == "WHERE").map(|(_, p)| *p);
979    let order_pos = kw_pos.iter().find(|(k, _)| k == "ORDER").map(|(_, p)| *p);
980    let limit_pos = kw_pos.iter().find(|(k, _)| k == "LIMIT").map(|(_, p)| *p);
981
982    if order_pos.is_none() && limit_pos.is_none() {
983        return sql.to_string();
984    }
985
986    // Locate UPDATE keyword start (after any leading whitespace).
987    let leading_ws = sql.len() - sql.trim_start().len();
988    let table_section_start = leading_ws + "UPDATE".len();
989    let table_section = sql[table_section_start..set_pos].trim();
990    if table_section.is_empty() {
991        return sql.to_string();
992    }
993    // Strip an optional alias clause for the rowid subquery — keep the
994    // outer UPDATE table name verbatim.
995    let table_for_subquery = table_section
996        .split_whitespace()
997        .next()
998        .unwrap_or(table_section);
999
1000    let assignments_end = where_pos
1001        .or(order_pos)
1002        .or(limit_pos)
1003        .unwrap_or(sql.len());
1004    let assignments = sql[set_pos + "SET".len()..assignments_end].trim();
1005    if assignments.is_empty() {
1006        return sql.to_string();
1007    }
1008
1009    let where_body = where_pos.map(|wp| {
1010        let end = order_pos.or(limit_pos).unwrap_or(sql.len());
1011        sql[wp + "WHERE".len()..end].trim()
1012    });
1013
1014    let order_body = order_pos.map(|op| {
1015        // Skip past "ORDER" then optional whitespace then "BY".
1016        let after = sql[op + "ORDER".len()..].trim_start();
1017        let by_skip = sql[op + "ORDER".len()..].len() - after.len();
1018        let body_start = if after.to_ascii_uppercase().starts_with("BY") {
1019            op + "ORDER".len() + by_skip + "BY".len()
1020        } else {
1021            op + "ORDER".len() + by_skip
1022        };
1023        let end = limit_pos.unwrap_or(sql.len());
1024        sql[body_start..end].trim()
1025    });
1026
1027    let limit_body = limit_pos.map(|lp| {
1028        sql[lp + "LIMIT".len()..]
1029            .trim()
1030            .trim_end_matches(';')
1031            .trim()
1032    });
1033
1034    let mut sub = format!("SELECT rowid FROM {table_for_subquery}");
1035    if let Some(w) = where_body {
1036        sub.push_str(" WHERE ");
1037        sub.push_str(w);
1038    }
1039    if let Some(o) = order_body {
1040        sub.push_str(" ORDER BY ");
1041        sub.push_str(o);
1042    }
1043    if let Some(l) = limit_body {
1044        sub.push_str(" LIMIT ");
1045        sub.push_str(l);
1046    }
1047
1048    format!("UPDATE {table_section} SET {assignments} WHERE rowid IN ({sub})")
1049}
1050
1051/// Find positions of `keywords` that appear at the top level of `sql`
1052/// (i.e. not inside a parenthesized subexpression and not inside a string
1053/// literal). Each match must be at a word boundary on both sides.
1054/// Keywords are matched case-insensitively.
1055fn find_top_level_keywords(sql: &str, keywords: &[&str]) -> Vec<(String, usize)> {
1056    let bytes = sql.as_bytes();
1057    let n = bytes.len();
1058    let mut out: Vec<(String, usize)> = Vec::new();
1059    let mut i = 0usize;
1060    let mut depth = 0i32;
1061
1062    while i < n {
1063        let ch = bytes[i] as char;
1064        // Skip string literals.
1065        if ch == '\'' || ch == '"' {
1066            let quote = ch;
1067            i += 1;
1068            while i < n {
1069                let c = bytes[i] as char;
1070                i += 1;
1071                if c == quote {
1072                    if i < n && (bytes[i] as char) == quote {
1073                        i += 1;
1074                        continue;
1075                    }
1076                    break;
1077                }
1078            }
1079            continue;
1080        }
1081        // Skip line comments.
1082        if ch == '-' && i + 1 < n && bytes[i + 1] as char == '-' {
1083            while i < n && bytes[i] as char != '\n' {
1084                i += 1;
1085            }
1086            continue;
1087        }
1088        if ch == '(' {
1089            depth += 1;
1090            i += 1;
1091            continue;
1092        }
1093        if ch == ')' {
1094            depth -= 1;
1095            i += 1;
1096            continue;
1097        }
1098        if depth != 0 {
1099            i += 1;
1100            continue;
1101        }
1102
1103        let prev_is_word = i > 0 && {
1104            let p = bytes[i - 1] as char;
1105            p.is_ascii_alphanumeric() || p == '_'
1106        };
1107        if !prev_is_word {
1108            for kw in keywords {
1109                let kb = kw.as_bytes();
1110                if i + kb.len() > n {
1111                    continue;
1112                }
1113                let mut ok = true;
1114                for (k, b) in kb.iter().enumerate() {
1115                    if bytes[i + k].to_ascii_uppercase() != *b {
1116                        ok = false;
1117                        break;
1118                    }
1119                }
1120                if !ok {
1121                    continue;
1122                }
1123                if i + kb.len() < n {
1124                    let nc = bytes[i + kb.len()] as char;
1125                    if nc.is_ascii_alphanumeric() || nc == '_' {
1126                        continue;
1127                    }
1128                }
1129                out.push((kw.to_string(), i));
1130                break;
1131            }
1132        }
1133        i += 1;
1134    }
1135    out
1136}
1137
1138fn preprocess_pragma(sql: &str) -> String {
1139    let trimmed = sql.trim();
1140    if !trimmed.to_uppercase().starts_with("PRAGMA ") {
1141        return sql.to_string();
1142    }
1143    let after_pragma = trimmed[7..].trim();
1144    if let Some(paren_start) = after_pragma.find('(') {
1145        if let Some(paren_end) = after_pragma.rfind(')') {
1146            let arg = after_pragma[paren_start + 1..paren_end].trim();
1147            if !arg.starts_with('\'') && !arg.starts_with('"') {
1148                let name = &after_pragma[..paren_start];
1149                let rest = if paren_end + 1 < after_pragma.len() {
1150                    &after_pragma[paren_end + 1..]
1151                } else {
1152                    ""
1153                };
1154                return format!("PRAGMA {name}('{arg}'){rest}");
1155            }
1156        }
1157    }
1158    if let Some(eq_pos) = after_pragma.find('=') {
1159        let name = after_pragma[..eq_pos].trim();
1160        let val = after_pragma[eq_pos + 1..].trim().trim_end_matches(';');
1161        let val = val.trim();
1162        if val.eq_ignore_ascii_case("ON")
1163            || val.eq_ignore_ascii_case("YES")
1164            || val.eq_ignore_ascii_case("TRUE")
1165        {
1166            return format!("PRAGMA {name} = 1;");
1167        }
1168        if val.eq_ignore_ascii_case("OFF")
1169            || val.eq_ignore_ascii_case("NO")
1170            || val.eq_ignore_ascii_case("FALSE")
1171        {
1172            return format!("PRAGMA {name} = 0;");
1173        }
1174        if !val.starts_with('\'') && !val.starts_with('"') && val.parse::<i64>().is_err() {
1175            return format!("PRAGMA {name} = '{val}';");
1176        }
1177    }
1178    sql.to_string()
1179}
1180
1181/// Rewrite `<lhs> MATCH '<query>'` into
1182/// `__fts5_match_token(<lhs>, '<query>')`. SQLiteDialect doesn't accept
1183/// `MATCH` as a binary operator outside of the (rarely-used) FTS3 path,
1184/// so we hoist it to a scalar-function form before parsing. The
1185/// rewritten function is recognized by the executor as the FTS5 entry
1186/// point.
1187///
1188/// Conservative: only matches when the LHS is a simple identifier
1189/// (`col`, `t.col`) and the RHS is a single-quoted string literal.
1190fn preprocess_fts5_match(sql: &str) -> String {
1191    if !contains_keyword_ci(sql, "MATCH") {
1192        return sql.to_string();
1193    }
1194    let bytes = sql.as_bytes();
1195    let n = bytes.len();
1196    let mut out = String::with_capacity(n);
1197    let mut i = 0usize;
1198
1199    while i < n {
1200        let ch = bytes[i] as char;
1201        if ch == '\'' || ch == '"' {
1202            let quote = ch;
1203            out.push(ch);
1204            i += 1;
1205            while i < n {
1206                let c = bytes[i] as char;
1207                out.push(c);
1208                i += 1;
1209                if c == quote {
1210                    if i < n && (bytes[i] as char) == quote {
1211                        out.push(quote);
1212                        i += 1;
1213                        continue;
1214                    }
1215                    break;
1216                }
1217            }
1218            continue;
1219        }
1220        if ch == '-' && i + 1 < n && (bytes[i + 1] as char) == '-' {
1221            while i < n && (bytes[i] as char) != '\n' {
1222                out.push(bytes[i] as char);
1223                i += 1;
1224            }
1225            continue;
1226        }
1227
1228        if (ch == 'M' || ch == 'm') && looks_like_match_kw(bytes, i) {
1229            if let Some((id_start, id)) = trailing_identifier(&out) {
1230                let mut j = i + 5;
1231                let saw_ws = j < n && (bytes[j] as char).is_whitespace();
1232                while j < n && (bytes[j] as char).is_whitespace() {
1233                    j += 1;
1234                }
1235                if saw_ws && j < n && (bytes[j] as char) == '\'' {
1236                    let lit_start = j;
1237                    j += 1;
1238                    while j < n {
1239                        let c = bytes[j] as char;
1240                        if c == '\'' {
1241                            if j + 1 < n && (bytes[j + 1] as char) == '\'' {
1242                                j += 2;
1243                                continue;
1244                            }
1245                            j += 1;
1246                            break;
1247                        }
1248                        j += 1;
1249                    }
1250                    let lit = &sql[lit_start..j];
1251                    let lhs_quoted = id.replace('\'', "''");
1252                    out.truncate(id_start);
1253                    out.push_str(&format!(
1254                        "__fts5_match_token('{lhs_quoted}', {lit})"
1255                    ));
1256                    i = j;
1257                    continue;
1258                }
1259            }
1260        }
1261
1262        out.push(ch);
1263        i += 1;
1264    }
1265    out
1266}
1267
1268fn looks_like_match_kw(bytes: &[u8], i: usize) -> bool {
1269    if i + 5 > bytes.len() {
1270        return false;
1271    }
1272    let head = &bytes[i..i + 5];
1273    if !(head[0].eq_ignore_ascii_case(&b'M')
1274        && head[1].eq_ignore_ascii_case(&b'A')
1275        && head[2].eq_ignore_ascii_case(&b'T')
1276        && head[3].eq_ignore_ascii_case(&b'C')
1277        && head[4].eq_ignore_ascii_case(&b'H'))
1278    {
1279        return false;
1280    }
1281    if i > 0 {
1282        let prev = bytes[i - 1] as char;
1283        if prev.is_ascii_alphanumeric() || prev == '_' {
1284            return false;
1285        }
1286    }
1287    if let Some(b) = bytes.get(i + 5) {
1288        let c = *b as char;
1289        if c.is_ascii_alphanumeric() || c == '_' {
1290            return false;
1291        }
1292    }
1293    true
1294}
1295
1296fn trailing_identifier(out: &str) -> Option<(usize, String)> {
1297    let trimmed = out.trim_end();
1298    let bytes = trimmed.as_bytes();
1299    let n = bytes.len();
1300    if n == 0 {
1301        return None;
1302    }
1303    if bytes[n - 1] == b'"' {
1304        let k = n - 1;
1305        if k == 0 {
1306            return None;
1307        }
1308        let mut start = None;
1309        let mut j = k;
1310        while j > 0 {
1311            j -= 1;
1312            if bytes[j] == b'"' {
1313                start = Some(j);
1314                break;
1315            }
1316        }
1317        let s = start?;
1318        let mut q_start = s;
1319        if q_start > 0 && bytes[q_start - 1] == b'.' {
1320            let mut p = q_start - 1;
1321            while p > 0 {
1322                let c = bytes[p - 1] as char;
1323                if c.is_ascii_alphanumeric() || c == '_' {
1324                    p -= 1;
1325                } else {
1326                    break;
1327                }
1328            }
1329            q_start = p;
1330        }
1331        let text = String::from_utf8_lossy(&bytes[q_start..n]).into_owned();
1332        return Some((q_start, text));
1333    }
1334    let last = bytes[n - 1] as char;
1335    if !(last.is_ascii_alphanumeric() || last == '_') {
1336        return None;
1337    }
1338    let mut start = n;
1339    while start > 0 {
1340        let c = bytes[start - 1] as char;
1341        if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
1342            start -= 1;
1343        } else {
1344            break;
1345        }
1346    }
1347    let text = String::from_utf8_lossy(&bytes[start..n]).into_owned();
1348    Some((start, text))
1349}
1350
1351fn contains_keyword_ci(sql: &str, kw: &str) -> bool {
1352    let lower = sql.to_ascii_lowercase();
1353    let needle = kw.to_ascii_lowercase();
1354    let mut idx = 0;
1355    while let Some(p) = lower[idx..].find(&needle) {
1356        let abs = idx + p;
1357        let before_ok = abs == 0
1358            || !lower.as_bytes()[abs - 1].is_ascii_alphanumeric()
1359                && lower.as_bytes()[abs - 1] != b'_';
1360        let after = abs + needle.len();
1361        let after_ok = after == lower.len()
1362            || (!lower.as_bytes()[after].is_ascii_alphanumeric()
1363                && lower.as_bytes()[after] != b'_');
1364        if before_ok && after_ok {
1365            return true;
1366        }
1367        idx = abs + needle.len();
1368        if idx >= lower.len() {
1369            break;
1370        }
1371    }
1372    false
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377    use super::*;
1378
1379    #[test]
1380    fn parse_simple_select() {
1381        let stmts = parse_sql("SELECT * FROM users").unwrap();
1382        assert_eq!(stmts.len(), 1);
1383    }
1384
1385    #[test]
1386    fn parse_create_table() {
1387        let stmts =
1388            parse_sql("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)").unwrap();
1389        assert_eq!(stmts.len(), 1);
1390    }
1391
1392    #[test]
1393    fn parse_insert() {
1394        let stmts = parse_sql("INSERT INTO users (name) VALUES ('alice')").unwrap();
1395        assert_eq!(stmts.len(), 1);
1396    }
1397
1398    #[test]
1399    fn parse_error() {
1400        assert!(parse_sql("SELECTT * FROM").is_err());
1401    }
1402
1403    /// Helper: assert the SQL parsed into a `__pragma` statement with the
1404    /// given internal name (e.g. "__vacuum", "__reindex", "__detach").
1405    fn assert_pseudo_pragma(sql: &str, expected_name: &str) {
1406        let stmts = parse_sql(sql).unwrap();
1407        assert_eq!(stmts.len(), 1, "expected one statement for {sql:?}");
1408        match &stmts[0] {
1409            sqlparser::ast::Statement::Pragma { name, .. } => {
1410                assert_eq!(
1411                    name.to_string().to_lowercase(),
1412                    expected_name,
1413                    "wrong pseudo-pragma for {sql:?}"
1414                );
1415            }
1416            other => panic!("expected pragma statement for {sql:?}, got {other:?}"),
1417        }
1418    }
1419
1420    // ---- VACUUM / REINDEX / ANALYZE pre-processing ----
1421
1422    #[test]
1423    fn parse_vacuum_bare() {
1424        assert_pseudo_pragma("VACUUM", "__vacuum");
1425        assert_pseudo_pragma("vacuum;", "__vacuum");
1426    }
1427
1428    #[test]
1429    fn parse_reindex_no_arg() {
1430        assert_pseudo_pragma("REINDEX", "__reindex");
1431    }
1432
1433    #[test]
1434    fn parse_reindex_with_target() {
1435        assert_pseudo_pragma("REINDEX my_index", "__reindex");
1436        assert_pseudo_pragma("reindex MY_TABLE;", "__reindex");
1437    }
1438
1439    #[test]
1440    fn parse_analyze_no_arg() {
1441        assert_pseudo_pragma("ANALYZE", "__analyze");
1442        assert_pseudo_pragma("analyze ;", "__analyze");
1443    }
1444
1445    #[test]
1446    fn parse_analyze_with_target() {
1447        assert_pseudo_pragma("ANALYZE users", "__analyze");
1448    }
1449
1450    // ---- DETACH ----
1451
1452    #[test]
1453    fn parse_detach_database() {
1454        assert_pseudo_pragma("DETACH DATABASE aux", "__detach");
1455        assert_pseudo_pragma("DETACH aux;", "__detach");
1456    }
1457
1458    // ---- CREATE TRIGGER (pre-processed into pseudo-pragma) ----
1459
1460    #[test]
1461    fn parse_create_trigger_after_insert() {
1462        assert_pseudo_pragma(
1463            "CREATE TRIGGER tlog AFTER INSERT ON t BEGIN SELECT 1; END",
1464            "__create_trigger",
1465        );
1466    }
1467
1468    #[test]
1469    fn parse_create_trigger_before_update_for_each_row() {
1470        assert_pseudo_pragma(
1471            "CREATE TRIGGER t1 BEFORE UPDATE ON things FOR EACH ROW BEGIN SELECT 1; END",
1472            "__create_trigger",
1473        );
1474    }
1475
1476    #[test]
1477    fn parse_create_trigger_if_not_exists() {
1478        assert_pseudo_pragma(
1479            "CREATE TRIGGER IF NOT EXISTS t1 AFTER DELETE ON x BEGIN SELECT 1; END",
1480            "__create_trigger",
1481        );
1482    }
1483
1484    #[test]
1485    fn parse_drop_trigger() {
1486        assert_pseudo_pragma("DROP TRIGGER tlog", "__drop_trigger");
1487        assert_pseudo_pragma("DROP TRIGGER IF EXISTS tlog", "__drop_trigger");
1488    }
1489
1490    // ---- PRAGMA preprocessing ----
1491
1492    #[test]
1493    fn parse_pragma_paren_form() {
1494        // PRAGMA table_info(users) — bare identifier in parens needs quoting
1495        // for sqlparser; preprocess wraps it.
1496        let stmts = parse_sql("PRAGMA table_info(users)").unwrap();
1497        assert_eq!(stmts.len(), 1);
1498        assert!(matches!(
1499            &stmts[0],
1500            sqlparser::ast::Statement::Pragma { .. }
1501        ));
1502    }
1503
1504    #[test]
1505    fn parse_pragma_equals_form() {
1506        let stmts = parse_sql("PRAGMA foreign_keys = ON").unwrap();
1507        assert_eq!(stmts.len(), 1);
1508        assert!(matches!(
1509            &stmts[0],
1510            sqlparser::ast::Statement::Pragma { .. }
1511        ));
1512    }
1513
1514    #[test]
1515    fn parse_pragma_simple_read_form() {
1516        let stmts = parse_sql("PRAGMA page_size").unwrap();
1517        assert_eq!(stmts.len(), 1);
1518        assert!(matches!(
1519            &stmts[0],
1520            sqlparser::ast::Statement::Pragma { .. }
1521        ));
1522    }
1523
1524    // ---- Multi-statement / generic pass-through ----
1525
1526    #[test]
1527    fn parse_multiple_statements() {
1528        let stmts = parse_sql("SELECT 1; SELECT 2;").unwrap();
1529        assert_eq!(stmts.len(), 2);
1530    }
1531}
1532
1533#[cfg(test)]
1534mod update_limit_tests {
1535    use super::*;
1536
1537    #[test]
1538    fn rewrite_limit_only() {
1539        let out = preprocess_update_limit("UPDATE t SET status = 'new' LIMIT 2");
1540        assert!(out.contains("LIMIT 2"), "out={out}");
1541        assert!(out.contains("rowid IN ("), "out={out}");
1542    }
1543
1544    #[test]
1545    fn rewrite_where_and_limit() {
1546        let out = preprocess_update_limit(
1547            "UPDATE t SET status = 'updated' WHERE status = 'a' LIMIT 2",
1548        );
1549        assert!(out.contains("WHERE status = 'a'"), "out={out}");
1550        assert!(out.contains("LIMIT 2"));
1551        assert!(out.contains("rowid IN ("));
1552    }
1553
1554    #[test]
1555    fn no_op_without_order_by_or_limit() {
1556        let sql = "UPDATE t SET status = 'x' WHERE id = 1";
1557        let out = preprocess_update_limit(sql);
1558        assert_eq!(out, sql);
1559    }
1560
1561    #[test]
1562    fn skips_string_literal_limit() {
1563        let sql = "UPDATE t SET label = 'has LIMIT in it' WHERE id = 1";
1564        let out = preprocess_update_limit(sql);
1565        assert_eq!(out, sql);
1566    }
1567}