Skip to main content

powdb_query/
sql.rs

1//! SQL frontend for PowDB.
2//!
3//! This module intentionally keeps SQL as a frontend: it parses a supported
4//! SQL subset, lowers it to PowDB's existing statement AST, and records the
5//! equivalent canonical PowQL text so plan-cache entries are shared with the
6//! native PowQL spelling.
7
8use crate::ast::{AggregateMode, Expr, QueryExpr, Statement};
9use crate::parser::{self, ParseError};
10
11#[derive(Debug, Clone)]
12pub struct ParsedSql {
13    pub statement: Statement,
14    pub canonical_powql: String,
15}
16
17pub fn parse_sql(input: &str) -> Result<Statement, ParseError> {
18    parse_sql_with_canonical(input).map(|p| p.statement)
19}
20
21pub fn parse_sql_with_canonical(input: &str) -> Result<ParsedSql, ParseError> {
22    let (toks, spans) = lex_sql_with_spans(input)?;
23    let mut p = SqlParser {
24        toks,
25        pos: 0,
26        depth: 0,
27        qual_ctx: QualCtx::None,
28    };
29    // One boundary attaches the failing token's char offset to any
30    // position-free UnexpectedToken/Syntax a production raised, mirroring
31    // the PowQL parser's `attach_failing_position`.
32    let canonical_powql = match p.statement() {
33        Ok(q) => q,
34        Err(e) => return Err(attach_sql_failing_position(e, &p, &spans)),
35    };
36    if !p.at_end() {
37        return Err(attach_sql_failing_position(
38            ParseError::Syntax {
39                message: format!(
40                    "unexpected trailing SQL token: {}",
41                    p.peek()
42                        .map(|t| t.display())
43                        .unwrap_or_else(|| "<eof>".into())
44                ),
45                position: None,
46            },
47            &p,
48            &spans,
49        ));
50    }
51    let mut statement = parser::parse(&canonical_powql)?;
52    mark_sql_statement_raw(&mut statement);
53    Ok(ParsedSql {
54        statement,
55        canonical_powql,
56    })
57}
58
59/// True when `input` carries no SQL statement for the engine to run: it is
60/// empty, whitespace, or nothing but comments.
61///
62/// The CLI needs this to decide whether a `--exec` / `--exec-file` segment is
63/// skippable, and it cannot reuse the PowQL blank check for SQL. PowQL's
64/// comment introducer is `#`; SQL's is `--`, which the PowQL lexer reads as two
65/// subtractions. So a dump ending in `-- end of dump` looked like a real
66/// statement, reached the engine, and failed with `expected SQL statement, got
67/// <eof>` *after* every real statement had already committed, which aborts a
68/// `set -e` deploy script that had in fact succeeded.
69///
70/// This asks the real SQL lexer rather than scanning for `--`, so the answer
71/// cannot drift from the dialect: `--` inside a string literal is not a
72/// comment, and `/* ... */` blocks are handled for free. `lex_sql` itself stays
73/// private because its token type is an implementation detail; this predicate
74/// is the minimum public surface that answers the CLI's question.
75///
76/// A lex error is deliberately *not* blank: that input is a real statement with
77/// a real problem, and the engine should be the one to report it. The single
78/// exception is a `#` comment. `#` is not SQL's comment character (see
79/// `docs/SQL.md`), so `lex_sql` rejects it outright, but the CLI shares one
80/// REPL across both dialects and has always skipped `#`-only lines. Flipping
81/// those to exit 1 purely because the session is in SQL mode would be a
82/// regression, so they are stripped and re-offered to the same lexer.
83pub fn sql_is_effectively_blank(input: &str) -> bool {
84    match lex_sql(input) {
85        Ok(toks) => toks.is_empty(),
86        // Only reachable once the input has already failed to lex as SQL, so
87        // this cannot reinterpret a `#` that lives inside a string literal:
88        // such an input lexes cleanly on the first attempt and never gets here.
89        Err(_) => {
90            let stripped = input
91                .lines()
92                .map(|line| line.split('#').next().unwrap_or(""))
93                .collect::<Vec<_>>()
94                .join("\n");
95            matches!(lex_sql(&stripped), Ok(toks) if toks.is_empty())
96        }
97    }
98}
99
100/// Split SQL input into statements on `;`, using SQL's own lexical rules.
101///
102/// The PowQL splitter (`lexer::split_statements`) knows `"` strings and `#`
103/// comments and nothing about `--` or `/* */`, so it splits on a `;` that is
104/// *inside* a SQL comment. That is dangerous rather than merely wrong: given
105/// `-- cleanup; DELETE FROM t`, the PowQL splitter yields `-- cleanup` and
106/// `DELETE FROM t`, and the second fragment is a live statement the user
107/// believed was commented out. A splitter that does not share the dialect's
108/// idea of a comment cannot be used to decide what runs.
109///
110/// The rules here mirror `lex_sql` exactly: `--` to end of line, `/* ... */`
111/// blocks, `'` and `"` quoting with `''` doubling inside `'`, and a backslash
112/// escaping the next character inside either quote.
113pub fn split_statements_sql(input: &str) -> Vec<&str> {
114    let mut out = Vec::new();
115    let mut start = 0usize;
116    let bytes = input.as_bytes();
117    let mut i = 0usize;
118
119    while i < bytes.len() {
120        match bytes[i] {
121            b'-' if bytes.get(i + 1) == Some(&b'-') => {
122                i += 2;
123                while i < bytes.len() && bytes[i] != b'\n' {
124                    i += 1;
125                }
126            }
127            b'/' if bytes.get(i + 1) == Some(&b'*') => {
128                i += 2;
129                while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
130                    i += 1;
131                }
132                // An unterminated block runs to EOF; the lexer reports it.
133                i = (i + 2).min(bytes.len());
134            }
135            q @ (b'\'' | b'"') => {
136                i += 1;
137                while i < bytes.len() {
138                    if bytes[i] == b'\\' && i + 1 < bytes.len() {
139                        i += 2;
140                        continue;
141                    }
142                    if bytes[i] == q {
143                        // `''` inside a single-quoted string is one quote.
144                        if q == b'\'' && bytes.get(i + 1) == Some(&b'\'') {
145                            i += 2;
146                            continue;
147                        }
148                        i += 1;
149                        break;
150                    }
151                    i += 1;
152                }
153            }
154            b';' => {
155                let seg = input[start..i].trim();
156                if !seg.is_empty() {
157                    out.push(seg);
158                }
159                start = i + 1;
160                i += 1;
161            }
162            _ => i += 1,
163        }
164    }
165
166    let seg = input[start..].trim();
167    if !seg.is_empty() {
168        out.push(seg);
169    }
170    out
171}
172
173fn mark_sql_statement_raw(statement: &mut Statement) {
174    match statement {
175        Statement::Query(query) => mark_sql_query_raw(query),
176        Statement::Union(union) => {
177            mark_sql_statement_raw(&mut union.left);
178            mark_sql_statement_raw(&mut union.right);
179        }
180        Statement::Explain(inner) => mark_sql_statement_raw(inner),
181        // Dead arm: the SQL frontend has no CREATE VIEW production (see
182        // `create`, which only builds TABLE and INDEX), so `parse_sql` never
183        // yields a `CreateView`. It is kept only so this match stays total over
184        // the shared AST. WARNING: if SQL views are ever added, a stored view's
185        // canonical PowQL text must spell aggregates `raw` (this is what marks
186        // them so). Dropping this marking would silently flip a stored view's
187        // aggregation semantics on refresh. See the CREATE VIEW rejection test.
188        Statement::CreateView(view) => mark_sql_query_raw(&mut view.query),
189        _ => {}
190    }
191}
192
193fn mark_sql_query_raw(query: &mut QueryExpr) {
194    if let Some(aggregate) = &mut query.aggregation {
195        aggregate.mode = AggregateMode::Raw;
196        if let Some(argument) = &mut aggregate.argument {
197            mark_sql_expr_raw(argument);
198        }
199    }
200    for join in &mut query.joins {
201        if let Some(on) = &mut join.on {
202            mark_sql_expr_raw(on);
203        }
204    }
205    if let Some(filter) = &mut query.filter {
206        mark_sql_expr_raw(filter);
207    }
208    if let Some(order) = &mut query.order {
209        for key in &mut order.keys {
210            mark_sql_expr_raw(&mut key.expr);
211        }
212    }
213    if let Some(projection) = &mut query.projection {
214        for field in projection {
215            mark_sql_expr_raw(&mut field.expr);
216        }
217    }
218    if let Some(group) = &mut query.group_by {
219        for key in &mut group.keys {
220            mark_sql_expr_raw(&mut key.expr);
221        }
222        if let Some(having) = &mut group.having {
223            mark_sql_expr_raw(having);
224        }
225    }
226}
227
228fn mark_sql_expr_raw(expr: &mut Expr) {
229    match expr {
230        Expr::FunctionCall(_, argument, mode) => {
231            *mode = AggregateMode::Raw;
232            mark_sql_expr_raw(argument);
233        }
234        Expr::Window {
235            args,
236            mode,
237            partition_by,
238            order_by,
239            ..
240        } => {
241            *mode = AggregateMode::Raw;
242            for expr in args.iter_mut().chain(partition_by.iter_mut()) {
243                mark_sql_expr_raw(expr);
244            }
245            for key in order_by {
246                mark_sql_expr_raw(&mut key.expr);
247            }
248        }
249        Expr::BinaryOp(left, _, right) | Expr::Coalesce(left, right) => {
250            mark_sql_expr_raw(left);
251            mark_sql_expr_raw(right);
252        }
253        Expr::UnaryOp(_, inner) | Expr::Cast(inner, _) | Expr::JsonPath { base: inner, .. } => {
254            mark_sql_expr_raw(inner);
255        }
256        Expr::ScalarFunc(_, args) => {
257            for expr in args {
258                mark_sql_expr_raw(expr);
259            }
260        }
261        Expr::InList { expr, list, .. } => {
262            mark_sql_expr_raw(expr);
263            for item in list {
264                mark_sql_expr_raw(item);
265            }
266        }
267        Expr::InSubquery { expr, subquery, .. } => {
268            mark_sql_expr_raw(expr);
269            mark_sql_query_raw(subquery);
270        }
271        Expr::ExistsSubquery { subquery, .. } => mark_sql_query_raw(subquery),
272        Expr::Case { whens, else_expr } => {
273            for (condition, result) in whens {
274                mark_sql_expr_raw(condition);
275                mark_sql_expr_raw(result);
276            }
277            if let Some(expr) = else_expr {
278                mark_sql_expr_raw(expr);
279            }
280        }
281        _ => {}
282    }
283}
284
285pub(crate) fn statement_has_aggregate(statement: &Statement) -> bool {
286    match statement {
287        Statement::Query(query) => query_has_aggregate(query),
288        Statement::Union(union) => {
289            statement_has_aggregate(&union.left) || statement_has_aggregate(&union.right)
290        }
291        Statement::Explain(inner) => statement_has_aggregate(inner),
292        Statement::CreateView(view) => query_has_aggregate(&view.query),
293        _ => false,
294    }
295}
296
297fn query_has_aggregate(query: &QueryExpr) -> bool {
298    query.aggregation.is_some()
299        || query
300            .joins
301            .iter()
302            .filter_map(|join| join.on.as_ref())
303            .any(expr_has_aggregate)
304        || query.filter.as_ref().is_some_and(expr_has_aggregate)
305        || query
306            .order
307            .as_ref()
308            .is_some_and(|order| order.keys.iter().any(|key| expr_has_aggregate(&key.expr)))
309        || query.projection.as_ref().is_some_and(|projection| {
310            projection
311                .iter()
312                .any(|field| expr_has_aggregate(&field.expr))
313        })
314        || query.group_by.as_ref().is_some_and(|group| {
315            group.keys.iter().any(|key| expr_has_aggregate(&key.expr))
316                || group.having.as_ref().is_some_and(expr_has_aggregate)
317        })
318}
319
320fn expr_has_aggregate(expr: &Expr) -> bool {
321    match expr {
322        Expr::FunctionCall(..) | Expr::Window { .. } => true,
323        Expr::BinaryOp(left, _, right) | Expr::Coalesce(left, right) => {
324            expr_has_aggregate(left) || expr_has_aggregate(right)
325        }
326        Expr::UnaryOp(_, inner) | Expr::Cast(inner, _) | Expr::JsonPath { base: inner, .. } => {
327            expr_has_aggregate(inner)
328        }
329        Expr::ScalarFunc(_, args) => args.iter().any(expr_has_aggregate),
330        Expr::InList { expr, list, .. } => {
331            expr_has_aggregate(expr) || list.iter().any(expr_has_aggregate)
332        }
333        Expr::InSubquery { expr, subquery, .. } => {
334            expr_has_aggregate(expr) || query_has_aggregate(subquery)
335        }
336        Expr::ExistsSubquery { subquery, .. } => query_has_aggregate(subquery),
337        Expr::Case { whens, else_expr } => {
338            whens.iter().any(|(condition, result)| {
339                expr_has_aggregate(condition) || expr_has_aggregate(result)
340            }) || else_expr.as_deref().is_some_and(expr_has_aggregate)
341        }
342        _ => false,
343    }
344}
345
346#[derive(Debug, Clone, PartialEq)]
347enum SqlTok {
348    Word(String),
349    Number(String),
350    String(String),
351    Symbol(char),
352    Op(String),
353    Param(String),
354}
355
356impl SqlTok {
357    fn display(&self) -> String {
358        match self {
359            SqlTok::Word(s) => s.clone(),
360            SqlTok::Number(s) => s.clone(),
361            SqlTok::String(s) => format!("'{s}'"),
362            SqlTok::Symbol(c) => c.to_string(),
363            SqlTok::Op(s) => s.clone(),
364            SqlTok::Param(s) => format!("${s}"),
365        }
366    }
367}
368
369fn lex_sql(input: &str) -> Result<Vec<SqlTok>, ParseError> {
370    lex_sql_with_spans(input).map(|(toks, _)| toks)
371}
372
373/// [`lex_sql`], additionally returning each token's char offset in `input`
374/// (the same `at position` coordinate the PowQL lexer uses), so SQL parse
375/// errors can say where they happened.
376fn lex_sql_with_spans(input: &str) -> Result<(Vec<SqlTok>, Vec<usize>), ParseError> {
377    let mut out = Vec::new();
378    let mut spans: Vec<usize> = Vec::new();
379    let chars: Vec<char> = input.chars().collect();
380    let mut i = 0usize;
381    while i < chars.len() {
382        let c = chars[i];
383        if c.is_whitespace() {
384            i += 1;
385            continue;
386        }
387        if c == '-' && chars.get(i + 1) == Some(&'-') {
388            i += 2;
389            while i < chars.len() && chars[i] != '\n' {
390                i += 1;
391            }
392            continue;
393        }
394        if c == '/' && chars.get(i + 1) == Some(&'*') {
395            i += 2;
396            while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
397                i += 1;
398            }
399            if i + 1 >= chars.len() {
400                return Err(ParseError::Lex {
401                    message: "unterminated block comment".into(),
402                    position: i,
403                });
404            }
405            i += 2;
406            continue;
407        }
408        // Every recognizer below starts at the current char, so this is the
409        // span recorded for whatever token this iteration emits.
410        let tok_start = i;
411        if c == '\'' || c == '"' {
412            let quote = c;
413            i += 1;
414            let mut s = String::new();
415            while i < chars.len() {
416                if chars[i] == quote {
417                    if quote == '\'' && chars.get(i + 1) == Some(&'\'') {
418                        s.push('\'');
419                        i += 2;
420                        continue;
421                    }
422                    i += 1;
423                    break;
424                }
425                if chars[i] == '\\' && i + 1 < chars.len() {
426                    // The same escape set the PowQL lexer accepts, and the
427                    // same refusal. Collapsing an unrecognised `\X` to `X`
428                    // rewrote the user's own text on the way in, and it made
429                    // the two frontends disagree about one source text:
430                    // PowQL refused `"back\slash"` while SQL read it as
431                    // `backslash`.
432                    match chars[i + 1] {
433                        '\'' => {
434                            s.push('\'');
435                            i += 2;
436                        }
437                        '"' => {
438                            s.push('"');
439                            i += 2;
440                        }
441                        '\\' => {
442                            s.push('\\');
443                            i += 2;
444                        }
445                        'n' => {
446                            s.push('\n');
447                            i += 2;
448                        }
449                        't' => {
450                            s.push('\t');
451                            i += 2;
452                        }
453                        'r' => {
454                            s.push('\r');
455                            i += 2;
456                        }
457                        '0' => {
458                            s.push('\0');
459                            i += 2;
460                        }
461                        'u' => {
462                            let digits: String = chars
463                                .get(i + 2..i + 6)
464                                .map(|hex| hex.iter().collect())
465                                .unwrap_or_default();
466                            let scalar = (digits.len() == 4)
467                                .then(|| u32::from_str_radix(&digits, 16).ok())
468                                .flatten()
469                                .and_then(char::from_u32);
470                            match scalar {
471                                Some(c) => {
472                                    s.push(c);
473                                    i += 6;
474                                }
475                                None => {
476                                    return Err(ParseError::Lex {
477                                        message: format!(
478                                            "invalid \\u escape in string literal: expected four \
479                                             hex digits naming a Unicode character, got \"{digits}\""
480                                        ),
481                                        position: i,
482                                    });
483                                }
484                            }
485                        }
486                        other => {
487                            return Err(ParseError::Lex {
488                                message: format!(
489                                    "unknown escape '\\{other}' in string literal; SQL \
490                                     supports \\' \\\" \\\\ \\n \\t \\r \\0 and \\uXXXX"
491                                ),
492                                position: i,
493                            });
494                        }
495                    }
496                } else {
497                    s.push(chars[i]);
498                    i += 1;
499                }
500            }
501            if i > chars.len() || chars.get(i.saturating_sub(1)) != Some(&quote) {
502                return Err(ParseError::Lex {
503                    message: if quote == '"' {
504                        "unterminated quoted identifier".into()
505                    } else {
506                        "unterminated string".into()
507                    },
508                    position: i,
509                });
510            }
511            if quote == '"' {
512                // In SQL, double quotes delimit an *identifier*; single quotes
513                // delimit a string. Treating both as strings meant
514                // `SELECT "name" FROM t` silently returned the literal text
515                // "name" once per row instead of the column, and `FROM "t"`
516                // failed outright -- so every ORM, which quotes identifiers as
517                // a matter of course, was broken in both directions.
518                //
519                // Re-emit as a Word already wrapped in PowQL's own backtick
520                // quoting. That reuses the escape hatch PowQL already has, and
521                // it bypasses every keyword check downstream for free: a
522                // quoted `"limit"` is a column named limit, not the LIMIT
523                // keyword, and `w.eq_ignore_ascii_case("from")` cannot match
524                // "`from`".
525                if s.is_empty() {
526                    return Err(ParseError::Syntax {
527                        message: "empty quoted identifier".into(),
528                        position: None,
529                    });
530                }
531                if s.contains('`') {
532                    return Err(ParseError::Unsupported {
533                        feature: format!(
534                            "quoted identifier `{s}` contains a backtick, which PowQL uses to \
535                             quote identifiers and cannot escape"
536                        ),
537                    });
538                }
539                out.push(SqlTok::Word(format!("`{s}`")));
540                spans.push(tok_start);
541                continue;
542            }
543            out.push(SqlTok::String(s));
544            spans.push(tok_start);
545            continue;
546        }
547        if c == '$' {
548            i += 1;
549            let start = i;
550            while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
551                i += 1;
552            }
553            out.push(SqlTok::Param(chars[start..i].iter().collect()));
554            spans.push(tok_start);
555            continue;
556        }
557        // Longest token first: `->>` must not be split into `->` plus `>`.
558        if c == '-' && chars.get(i + 1) == Some(&'>') {
559            if chars.get(i + 2) == Some(&'>') {
560                out.push(SqlTok::Op("->>".into()));
561                spans.push(tok_start);
562                i += 3;
563            } else {
564                out.push(SqlTok::Op("->".into()));
565                spans.push(tok_start);
566                i += 2;
567            }
568            continue;
569        }
570        if c.is_ascii_digit() || (c == '-' && chars.get(i + 1).is_some_and(|n| n.is_ascii_digit()))
571        {
572            let start = i;
573            i += 1;
574            while i < chars.len() && chars[i].is_ascii_digit() {
575                i += 1;
576            }
577            if i < chars.len()
578                && chars[i] == '.'
579                && chars.get(i + 1).is_some_and(|n| n.is_ascii_digit())
580            {
581                i += 1;
582                while i < chars.len() && chars[i].is_ascii_digit() {
583                    i += 1;
584                }
585            }
586            // An exponent, on the same terms as the PowQL lexer: `e`/`E`, an
587            // optional sign, at least one digit. The token text is handed
588            // straight to PowQL, which reads the same spelling.
589            if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
590                let mut look = i + 1;
591                if chars.get(look).is_some_and(|c| *c == '+' || *c == '-') {
592                    look += 1;
593                }
594                if chars.get(look).is_some_and(char::is_ascii_digit) {
595                    i = look;
596                    while i < chars.len() && chars[i].is_ascii_digit() {
597                        i += 1;
598                    }
599                }
600            }
601            out.push(SqlTok::Number(chars[start..i].iter().collect()));
602            spans.push(tok_start);
603            continue;
604        }
605        if c.is_alphabetic() || c == '_' {
606            let start = i;
607            i += 1;
608            while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
609                i += 1;
610            }
611            out.push(SqlTok::Word(chars[start..i].iter().collect()));
612            spans.push(tok_start);
613            continue;
614        }
615        if matches!(c, '(' | ')' | ',' | '*' | '.') {
616            out.push(SqlTok::Symbol(c));
617            spans.push(tok_start);
618            i += 1;
619            continue;
620        }
621        if matches!(c, '=' | '<' | '>' | '!') {
622            let mut op = String::new();
623            op.push(c);
624            if matches!(chars.get(i + 1), Some('=') | Some('>')) {
625                op.push(chars[i + 1]);
626                i += 2;
627            } else {
628                i += 1;
629            }
630            if op == "<>" {
631                op = "!=".into();
632            }
633            out.push(SqlTok::Op(op));
634            spans.push(tok_start);
635            continue;
636        }
637        if matches!(c, '+' | '-' | '/') {
638            out.push(SqlTok::Op(c.to_string()));
639            spans.push(tok_start);
640            i += 1;
641            continue;
642        }
643        return Err(ParseError::Lex {
644            message: format!("unexpected SQL character `{c}`"),
645            position: i,
646        });
647    }
648    Ok((out, spans))
649}
650
651/// Bound on the nesting the SQL frontend may produce. The from-scratch SQL
652/// pre-parser recurses on parentheses / `NOT` / operator right-hand sides
653/// before the canonical text is handed to the PowQL parser, so its own guard
654/// must match PowQL's `MAX_NESTING_DEPTH` (64). Without it, a deeply nested SQL
655/// string arriving over the wire overflows the stack and, with panic=abort,
656/// aborts the whole server process.
657///
658/// The infix loop in `expr_bp` needs the same bound even though it recurses
659/// only on right-hand sides: it appends left-associatively to a flat string
660/// (`a AND b AND c ...`), and the PowQL parse of that canonical text builds one
661/// AST level per appended operator. Counting loop iterations here keeps the
662/// produced tree bounded (and stops the O(n^2) string rebuild) instead of
663/// leaving the whole load on PowQL's own chain guard.
664const MAX_SQL_NESTING_DEPTH: usize = 64;
665
666/// Attach the char offset of the token the SQL parser stopped on to an
667/// error that carries none; a deliberate position set in a production wins.
668/// A parser standing past the last token (an EOF failure) points at the end
669/// of the last token's offset — close enough to be actionable, and the only
670/// coordinate the span table still has.
671fn attach_sql_failing_position(
672    error: ParseError,
673    parser: &SqlParser,
674    spans: &[usize],
675) -> ParseError {
676    let offset = spans
677        .get(parser.pos.min(spans.len().saturating_sub(1)))
678        .copied();
679    match error {
680        ParseError::UnexpectedToken {
681            expected,
682            got,
683            position: None,
684        } => ParseError::UnexpectedToken {
685            expected,
686            got,
687            position: offset,
688        },
689        ParseError::Syntax {
690            message,
691            position: None,
692        } => ParseError::Syntax {
693            message,
694            position: offset,
695        },
696        other => other,
697    }
698}
699
700struct SqlParser {
701    toks: Vec<SqlTok>,
702    pos: usize,
703    depth: usize,
704    /// How qualified column references (`t.col`) resolve in the statement
705    /// currently being parsed. Set by SELECT/UPDATE/DELETE before their
706    /// expressions are parsed.
707    qual_ctx: QualCtx,
708}
709
710/// Resolution context for qualified column references.
711///
712/// PowQL only understands the `alias.field` form inside joins; in a
713/// single-table statement it must not reach the PowQL parser (the executor
714/// would resolve it to Empty, silently corrupting projections and filters).
715/// The SQL frontend therefore resolves single-table qualifiers itself.
716#[derive(Clone, PartialEq)]
717enum QualCtx {
718    /// No table in scope (e.g. INSERT ... VALUES): qualified refs are errors.
719    None,
720    /// Single-table statement: a qualifier naming the table (or its alias,
721    /// which per SQL hides the table name) lowers to a bare `.col`; any other
722    /// qualifier is a hard error, matching SQLite's "no such column: x.y".
723    Single { visible_name: String },
724    /// Query with joins: qualifiers pass through for PowQL join resolution.
725    Joined,
726}
727
728/// One item in a SELECT projection, after lowering to canonical PowQL text.
729struct Projection {
730    /// Canonical PowQL for this item, e.g. `count(*)`, `sum(.x)`, `n: .x + 1`.
731    /// Used for the row/grouped projection path (`Table { ... }`).
732    text: String,
733    /// Set when the whole item is a single aggregate call. Drives the rewrite
734    /// of an ungrouped aggregate SELECT into PowQL's aggregate form
735    /// (`count(Table filter ...)`), which the row-projection path can't express.
736    agg: Option<AggCall>,
737}
738
739/// A standalone aggregate call in a projection (`count(*)`, `sum(x)`, ...).
740struct AggCall {
741    /// Lowercased function name: `count` | `sum` | `avg` | `min` | `max`.
742    func: String,
743    arg: AggArg,
744}
745
746enum AggArg {
747    /// `count(*)`.
748    Star,
749    /// `sum(x)` etc. — the lowered PowQL field reference (e.g. `.x`).
750    Field(String),
751}
752
753impl AggCall {
754    /// Canonical PowQL text for the grouped/row projection path.
755    fn canonical(&self) -> String {
756        match &self.arg {
757            AggArg::Star => format!("{}(*)", self.func),
758            AggArg::Field(f) => format!("{}({f})", self.func),
759        }
760    }
761
762    /// The SQL spelling of this call, which is the column name SQL gives an
763    /// aggregate that was written without `AS`. PowQL's own name for it is the
764    /// internal `__agg_N`, so without this the header of
765    /// `SELECT s, count(n) FROM t GROUP BY s` read `__agg_0`.
766    fn sql_spelling(&self) -> String {
767        match &self.arg {
768            AggArg::Star => format!("{}(*)", self.func),
769            AggArg::Field(f) => format!("{}({})", self.func, f.strip_prefix('.').unwrap_or(f)),
770        }
771    }
772}
773
774/// Lower a single ungrouped aggregate over `inner` (an already-lowered PowQL
775/// source pipeline, e.g. `T filter .x > 3`) into PowQL's aggregate form. Every
776/// aggregate carries its column in a trailing PowQL projection
777/// (`sum(T { .x })`); only `COUNT(*)` has no column and counts rows.
778/// `COUNT(col)` counts non-null values, like the grouped path and like SQL.
779fn build_ungrouped_aggregate(agg: &AggCall, inner: &str) -> Result<String, ParseError> {
780    match agg.func.as_str() {
781        "count" if matches!(agg.arg, AggArg::Star) => Ok(format!("count({inner})")),
782        "count" | "sum" | "avg" | "min" | "max" => match &agg.arg {
783            AggArg::Field(f) => Ok(format!("{}({inner} {{ {f} }})", agg.func)),
784            AggArg::Star => Err(ParseError::Unsupported {
785                feature: format!("{0}(*) is not valid; {0}() needs a column", agg.func),
786            }),
787        },
788        // try_aggregate only constructs the five names above.
789        other => Err(ParseError::Syntax {
790            message: format!("unknown aggregate function `{other}`"),
791            position: None,
792        }),
793    }
794}
795
796impl SqlParser {
797    fn at_end(&self) -> bool {
798        self.pos >= self.toks.len()
799    }
800    fn peek(&self) -> Option<&SqlTok> {
801        self.toks.get(self.pos)
802    }
803    fn bump(&mut self) -> Option<SqlTok> {
804        let t = self.toks.get(self.pos).cloned();
805        if t.is_some() {
806            self.pos += 1;
807        }
808        t
809    }
810    fn is_kw(&self, kw: &str) -> bool {
811        matches!(self.peek(), Some(SqlTok::Word(w)) if w.eq_ignore_ascii_case(kw))
812    }
813    fn eat_kw(&mut self, kw: &str) -> bool {
814        if self.is_kw(kw) {
815            self.pos += 1;
816            true
817        } else {
818            false
819        }
820    }
821    fn expect_kw(&mut self, kw: &str) -> Result<(), ParseError> {
822        if self.eat_kw(kw) {
823            Ok(())
824        } else {
825            Err(ParseError::UnexpectedToken {
826                expected: kw.into(),
827                got: self
828                    .peek()
829                    .map(|t| t.display())
830                    .unwrap_or_else(|| "<eof>".into()),
831                position: None,
832            })
833        }
834    }
835    fn eat_sym(&mut self, c: char) -> bool {
836        if matches!(self.peek(), Some(SqlTok::Symbol(got)) if *got == c) {
837            self.pos += 1;
838            true
839        } else {
840            false
841        }
842    }
843    fn expect_sym(&mut self, c: char) -> Result<(), ParseError> {
844        if self.eat_sym(c) {
845            Ok(())
846        } else {
847            Err(ParseError::UnexpectedToken {
848                expected: c.to_string(),
849                got: self
850                    .peek()
851                    .map(|t| t.display())
852                    .unwrap_or_else(|| "<eof>".into()),
853                position: None,
854            })
855        }
856    }
857    fn expect_ident(&mut self, what: &str) -> Result<String, ParseError> {
858        match self.bump() {
859            Some(SqlTok::Word(w)) if !is_reserved_identifier(&w) => Ok(w),
860            Some(SqlTok::Word(w)) => Err(ParseError::Syntax {
861                message: format!("expected {what}, got reserved word `{w}`"),
862                position: None,
863            }),
864            Some(t) => Err(ParseError::UnexpectedToken {
865                expected: what.into(),
866                got: t.display(),
867                position: None,
868            }),
869            None => Err(ParseError::UnexpectedToken {
870                expected: what.into(),
871                got: "<eof>".into(),
872                position: None,
873            }),
874        }
875    }
876
877    fn statement(&mut self) -> Result<String, ParseError> {
878        if self.is_kw("select") {
879            self.select()
880        } else if self.is_kw("insert") {
881            self.insert()
882        } else if self.is_kw("update") {
883            self.update()
884        } else if self.is_kw("delete") {
885            self.delete()
886        } else if self.is_kw("create") {
887            self.create()
888        } else if self.is_kw("drop") {
889            self.drop_stmt()
890        } else if self.is_kw("alter") {
891            self.alter()
892        } else if self.eat_kw("begin") {
893            let _ = self.eat_kw("transaction");
894            Ok("begin".into())
895        } else if self.eat_kw("commit") {
896            Ok("commit".into())
897        } else if self.eat_kw("rollback") {
898            Ok("rollback".into())
899        } else {
900            Err(ParseError::UnexpectedToken {
901                expected: "SQL statement".into(),
902                got: self
903                    .peek()
904                    .map(|t| t.display())
905                    .unwrap_or_else(|| "<eof>".into()),
906                position: None,
907            })
908        }
909    }
910
911    /// Establish the qualified-reference context for a SELECT before its
912    /// projection list is parsed. The projection precedes FROM in the token
913    /// stream, so this scans ahead (at paren depth 0; subqueries are
914    /// parenthesized and rejected elsewhere anyway) for the FROM table, its
915    /// optional alias, and whether any join clause follows.
916    fn scan_select_qual_ctx(&self) -> QualCtx {
917        let mut i = self.pos;
918        let mut depth = 0usize;
919        loop {
920            match self.toks.get(i) {
921                None => return QualCtx::None,
922                Some(SqlTok::Symbol('(')) => depth += 1,
923                Some(SqlTok::Symbol(')')) => depth = depth.saturating_sub(1),
924                Some(SqlTok::Word(w)) if depth == 0 && w.eq_ignore_ascii_case("from") => break,
925                Some(_) => {}
926            }
927            i += 1;
928        }
929        let Some(SqlTok::Word(table)) = self.toks.get(i + 1) else {
930            // Malformed FROM; let the main parse produce the error.
931            return QualCtx::None;
932        };
933        let mut visible = table.clone();
934        let mut j = i + 2;
935        // Mirror `table_ref`: an alias is either `AS ident` or a bare word
936        // that is not a clause keyword or join modifier.
937        match self.toks.get(j) {
938            Some(SqlTok::Word(w)) if w.eq_ignore_ascii_case("as") => {
939                if let Some(SqlTok::Word(a)) = self.toks.get(j + 1) {
940                    visible = a.clone();
941                    j += 2;
942                }
943            }
944            Some(SqlTok::Word(w)) if !is_clause_kw(w) && !is_join_modifier(w) => {
945                visible = w.clone();
946                j += 1;
947            }
948            _ => {}
949        }
950        match self.toks.get(j) {
951            Some(SqlTok::Word(w)) if is_join_modifier(w) => QualCtx::Joined,
952            _ => QualCtx::Single {
953                visible_name: visible,
954            },
955        }
956    }
957
958    fn select(&mut self) -> Result<String, ParseError> {
959        self.expect_kw("select")?;
960        self.qual_ctx = self.scan_select_qual_ctx();
961        let distinct = self.eat_kw("distinct");
962        let projection = self.projection_list()?;
963        self.expect_kw("from")?;
964        let source = self.table_ref()?;
965        let mut joins = Vec::new();
966        while self.starts_join() {
967            joins.push(self.join_clause()?);
968        }
969        let filter = if self.eat_kw("where") {
970            Some(self.expr_until(&["group", "having", "order", "limit", "offset"])?)
971        } else {
972            None
973        };
974        let group = if self.eat_kw("group") {
975            self.expect_kw("by")?;
976            Some(self.expression_list_until(&["having", "order", "limit", "offset"])?)
977        } else {
978            None
979        };
980        let having = if self.eat_kw("having") {
981            Some(self.expr_until(&["order", "limit", "offset"])?)
982        } else {
983            None
984        };
985        let order = if self.eat_kw("order") {
986            self.expect_kw("by")?;
987            Some(self.order_list_until(&["limit", "offset"])?)
988        } else {
989            None
990        };
991        let limit = if self.eat_kw("limit") {
992            Some(self.expr_until(&["offset"])?)
993        } else {
994            None
995        };
996        let offset = if self.eat_kw("offset") {
997            Some(self.expr_until(&[])?)
998        } else {
999            None
1000        };
1001
1002        let has_group = group.is_some();
1003
1004        let mut out = source;
1005        for j in joins {
1006            out.push(' ');
1007            out.push_str(&j);
1008        }
1009        if distinct {
1010            out.push_str(" distinct");
1011        }
1012        if let Some(f) = filter {
1013            out.push_str(" filter ");
1014            out.push_str(&f);
1015        }
1016        if let Some(keys) = group {
1017            out.push_str(" group ");
1018            out.push_str(&keys.join(", "));
1019            if let Some(h) = having {
1020                out.push_str(" having ");
1021                out.push_str(&h);
1022            }
1023        } else if having.is_some() {
1024            return Err(ParseError::Syntax {
1025                message: "HAVING requires GROUP BY".into(),
1026                position: None,
1027            });
1028        }
1029        if let Some(o) = order {
1030            out.push_str(" order ");
1031            out.push_str(&o);
1032        }
1033        if let Some(l) = limit {
1034            out.push_str(" limit ");
1035            out.push_str(&l);
1036        }
1037        if let Some(o) = offset {
1038            out.push_str(" offset ");
1039            out.push_str(&o);
1040        }
1041        if let Some(items) = projection {
1042            // An ungrouped aggregate (`SELECT count(*) FROM t`) is not a row
1043            // projection — PowQL expresses it as `count(t filter ...)`, which
1044            // yields a scalar. Without this the SQL frontend lowered it to
1045            // `t { count(*) }` and returned one null row per source row.
1046            if !has_group && items.iter().any(|p| p.agg.is_some()) {
1047                if distinct {
1048                    return Err(ParseError::Unsupported {
1049                        feature: "aggregates with DISTINCT and no GROUP BY are not supported by the SQL frontend".into(),
1050                    });
1051                }
1052                if items.len() != 1 {
1053                    return Err(ParseError::Unsupported {
1054                        feature: "multiple aggregates, or an aggregate mixed with plain columns, without GROUP BY are not supported; aggregate a single expression or add GROUP BY".into(),
1055                    });
1056                }
1057                // Invariant: len == 1 and the item is an aggregate (any() above).
1058                let agg = items.into_iter().next().unwrap().agg.unwrap();
1059                return build_ungrouped_aggregate(&agg, &out);
1060            }
1061            out.push_str(" { ");
1062            out.push_str(
1063                &items
1064                    .iter()
1065                    .map(|p| p.text.as_str())
1066                    .collect::<Vec<_>>()
1067                    .join(", "),
1068            );
1069            out.push_str(" }");
1070        }
1071        Ok(out)
1072    }
1073
1074    fn projection_list(&mut self) -> Result<Option<Vec<Projection>>, ParseError> {
1075        if self.eat_sym('*') {
1076            return Ok(None);
1077        }
1078        let mut fields = Vec::new();
1079        loop {
1080            // Detect a standalone aggregate (`count(*)`, `sum(x)`) so an
1081            // ungrouped aggregate SELECT can be rewritten into PowQL's aggregate
1082            // form. Anything else (incl. an aggregate inside a larger
1083            // expression) falls through to the generic expression lowering.
1084            let (expr, agg) = match self.try_aggregate()? {
1085                Some(a) => (a.canonical(), Some(a)),
1086                None => (self.expr_until(&["from", "as"])?, None),
1087            };
1088            let text = if self.eat_kw("as") {
1089                let alias = self.expect_ident("projection alias")?;
1090                format!("{alias}: {expr}")
1091            } else if let Some(call) = &agg {
1092                // A backtick alias, because the SQL name of an unaliased
1093                // aggregate (`count(n)`) is not a PowQL identifier.
1094                format!("`{}`: {expr}", call.sql_spelling())
1095            } else {
1096                expr
1097            };
1098            fields.push(Projection { text, agg });
1099            if !self.eat_sym(',') {
1100                break;
1101            }
1102        }
1103        Ok(Some(fields))
1104    }
1105
1106    /// Parse a standalone aggregate call (`count(*)`, `count(x)`, `sum(x)`, ...)
1107    /// when it is the *entire* projection item. Returns `None` (restoring the
1108    /// cursor) for non-aggregates, `count(distinct ...)`, or an aggregate that
1109    /// is only part of a larger expression — those take the generic path.
1110    fn try_aggregate(&mut self) -> Result<Option<AggCall>, ParseError> {
1111        let Some(SqlTok::Word(w)) = self.peek().cloned() else {
1112            return Ok(None);
1113        };
1114        let func = w.to_ascii_lowercase();
1115        if !matches!(func.as_str(), "count" | "sum" | "avg" | "min" | "max") {
1116            return Ok(None);
1117        }
1118        let save = self.pos;
1119        self.pos += 1; // consume the function name
1120        if !self.eat_sym('(') {
1121            self.pos = save;
1122            return Ok(None);
1123        }
1124        let arg = if func == "count" && self.eat_sym('*') {
1125            AggArg::Star
1126        } else if func == "count" && self.is_kw("distinct") {
1127            // count(distinct ...) has different semantics and is not part of the
1128            // SQL subset. Restore and let the generic expression path reject it
1129            // with the named unsupported-feature error (see `primary_expr`).
1130            self.pos = save;
1131            return Ok(None);
1132        } else {
1133            AggArg::Field(self.expr_bp(0, &[])?)
1134        };
1135        // Only an aggregate that fills the whole projection item is rewritable;
1136        // otherwise (e.g. `count(*) + 1`) restore and reparse as an expression.
1137        if self.eat_sym(')')
1138            && (matches!(self.peek(), Some(SqlTok::Symbol(',')))
1139                || self.is_kw("as")
1140                || self.is_kw("from"))
1141        {
1142            Ok(Some(AggCall { func, arg }))
1143        } else {
1144            self.pos = save;
1145            Ok(None)
1146        }
1147    }
1148
1149    fn table_ref(&mut self) -> Result<String, ParseError> {
1150        let table = self.expect_ident("table name")?;
1151        let has_alias = self.eat_kw("as")
1152            || matches!(self.peek(), Some(SqlTok::Word(w)) if !is_clause_kw(w) && !is_join_modifier(w));
1153        if has_alias {
1154            let alias = self.expect_ident("table alias")?;
1155            Ok(format!("{table} as {alias}"))
1156        } else {
1157            Ok(table)
1158        }
1159    }
1160
1161    fn starts_join(&self) -> bool {
1162        self.is_kw("join")
1163            || self.is_kw("inner")
1164            || self.is_kw("left")
1165            || self.is_kw("right")
1166            || self.is_kw("cross")
1167    }
1168
1169    fn join_clause(&mut self) -> Result<String, ParseError> {
1170        let kind = if self.eat_kw("inner") {
1171            self.expect_kw("join")?;
1172            "inner join"
1173        } else if self.eat_kw("left") {
1174            let _ = self.eat_kw("outer");
1175            self.expect_kw("join")?;
1176            "left join"
1177        } else if self.eat_kw("right") {
1178            let _ = self.eat_kw("outer");
1179            self.expect_kw("join")?;
1180            "right join"
1181        } else if self.eat_kw("cross") {
1182            self.expect_kw("join")?;
1183            "cross join"
1184        } else {
1185            self.expect_kw("join")?;
1186            "inner join"
1187        };
1188        let table = self.table_ref()?;
1189        if kind == "cross join" {
1190            return Ok(format!("{kind} {table}"));
1191        }
1192        self.expect_kw("on")?;
1193        let on = self.expr_until(&[
1194            "join", "inner", "left", "right", "cross", "where", "group", "having", "order",
1195            "limit", "offset",
1196        ])?;
1197        Ok(format!("{kind} {table} on {on}"))
1198    }
1199
1200    fn insert(&mut self) -> Result<String, ParseError> {
1201        self.expect_kw("insert")?;
1202        self.expect_kw("into")?;
1203        let table = self.expect_ident("table name")?;
1204        self.expect_sym('(')?;
1205        let mut cols = Vec::new();
1206        loop {
1207            cols.push(self.expect_ident("column name")?);
1208            if !self.eat_sym(',') {
1209                break;
1210            }
1211        }
1212        self.expect_sym(')')?;
1213        self.expect_kw("values")?;
1214        let mut rows = Vec::new();
1215        loop {
1216            self.expect_sym('(')?;
1217            let mut vals = Vec::new();
1218            loop {
1219                vals.push(self.expr_until(&[])?);
1220                if !self.eat_sym(',') {
1221                    break;
1222                }
1223            }
1224            self.expect_sym(')')?;
1225            if vals.len() != cols.len() {
1226                return Err(ParseError::Syntax {
1227                    message: format!(
1228                        "INSERT has {} column(s) but {} value(s)",
1229                        cols.len(),
1230                        vals.len()
1231                    ),
1232                    position: None,
1233                });
1234            }
1235            let assigns = cols
1236                .iter()
1237                .zip(vals)
1238                .map(|(c, v)| format!("{c} := {v}"))
1239                .collect::<Vec<_>>();
1240            rows.push(format!("{{ {} }}", assigns.join(", ")));
1241            if !self.eat_sym(',') {
1242                break;
1243            }
1244        }
1245        let mut out = format!("insert {table} {}", rows.join(", "));
1246        if self.returning_clause()? {
1247            out.push_str(" returning");
1248        }
1249        Ok(out)
1250    }
1251
1252    fn update(&mut self) -> Result<String, ParseError> {
1253        self.expect_kw("update")?;
1254        let table = self.expect_ident("table name")?;
1255        self.qual_ctx = QualCtx::Single {
1256            visible_name: table.clone(),
1257        };
1258        self.expect_kw("set")?;
1259        let assigns = self.assignment_list_until(&["where", "returning"])?;
1260        let filter = if self.eat_kw("where") {
1261            Some(self.expr_until(&["returning"])?)
1262        } else {
1263            None
1264        };
1265        let mut out = table;
1266        if let Some(f) = filter {
1267            out.push_str(" filter ");
1268            out.push_str(&f);
1269        }
1270        out.push_str(" update { ");
1271        out.push_str(&assigns.join(", "));
1272        out.push_str(" }");
1273        if self.returning_clause()? {
1274            out.push_str(" returning");
1275        }
1276        Ok(out)
1277    }
1278
1279    fn delete(&mut self) -> Result<String, ParseError> {
1280        self.expect_kw("delete")?;
1281        self.expect_kw("from")?;
1282        let table = self.expect_ident("table name")?;
1283        self.qual_ctx = QualCtx::Single {
1284            visible_name: table.clone(),
1285        };
1286        let filter = if self.eat_kw("where") {
1287            Some(self.expr_until(&["returning"])?)
1288        } else {
1289            None
1290        };
1291        let mut out = table;
1292        if let Some(f) = filter {
1293            out.push_str(" filter ");
1294            out.push_str(&f);
1295        }
1296        out.push_str(" delete");
1297        if self.returning_clause()? {
1298            out.push_str(" returning");
1299        }
1300        Ok(out)
1301    }
1302
1303    /// Parse a single literal following a column `DEFAULT`, rendered as PowQL
1304    /// literal text. Only scalar literals are accepted (no expression
1305    /// defaults), matching the PowQL `default` modifier.
1306    fn default_literal(&mut self) -> Result<String, ParseError> {
1307        match self.bump() {
1308            Some(SqlTok::Number(n)) => Ok(n),
1309            Some(SqlTok::String(s)) => Ok(quote_powql_string(&s)),
1310            Some(SqlTok::Word(w))
1311                if w.eq_ignore_ascii_case("true") || w.eq_ignore_ascii_case("false") =>
1312            {
1313                Ok(w.to_ascii_lowercase())
1314            }
1315            other => Err(ParseError::Syntax {
1316                message: format!(
1317                    "DEFAULT requires a literal value, got {}",
1318                    other.map(|t| t.display()).unwrap_or_else(|| "<eof>".into())
1319                ),
1320                position: None,
1321            }),
1322        }
1323    }
1324
1325    /// Parse an optional trailing `RETURNING *`, returning whether it was
1326    /// present. PowQL's `returning` clause always yields every column, so a
1327    /// projected `RETURNING a, b` is rejected rather than silently widened to
1328    /// all columns.
1329    fn returning_clause(&mut self) -> Result<bool, ParseError> {
1330        if !self.eat_kw("returning") {
1331            return Ok(false);
1332        }
1333        if !self.eat_sym('*') {
1334            return Err(ParseError::Syntax {
1335                message: "RETURNING currently supports only `RETURNING *` \
1336                          (column projection is not yet supported)"
1337                    .into(),
1338                position: None,
1339            });
1340        }
1341        Ok(true)
1342    }
1343
1344    fn create(&mut self) -> Result<String, ParseError> {
1345        self.expect_kw("create")?;
1346        if self.eat_kw("table") {
1347            let table = self.expect_ident("table name")?;
1348            self.expect_sym('(')?;
1349            let mut fields = Vec::new();
1350            while !self.eat_sym(')') {
1351                // A table-level constraint sits where a column name is expected.
1352                // `UNIQUE` and `CHECK` are also *column* constraints, but those
1353                // follow the type, so reaching them here can only be the table
1354                // form. A column genuinely named `unique` has to be quoted, and
1355                // a quoted identifier never matches `is_kw`.
1356                if self.is_kw("primary") {
1357                    return Err(ParseError::Unsupported {
1358                        feature: "PRIMARY KEY is supported only on the column it keys; \
1359                                  write it after the column's type, as `id int PRIMARY KEY`"
1360                            .into(),
1361                    });
1362                }
1363                if self.is_kw("foreign")
1364                    || self.is_kw("constraint")
1365                    || self.is_kw("unique")
1366                    || self.is_kw("check")
1367                {
1368                    return Err(ParseError::Unsupported { feature: "SQL table constraints are not supported; declare UNIQUE columns or add indexes explicitly".into() });
1369                }
1370                let name = self.expect_ident("column name")?;
1371                let ty = self.sql_type()?;
1372                let mut required = false;
1373                let mut unique = false;
1374                let mut auto = false;
1375                let mut default: Option<String> = None;
1376                loop {
1377                    if self.eat_kw("not") {
1378                        self.expect_kw("null")?;
1379                        required = true;
1380                    } else if self.eat_kw("unique") {
1381                        unique = true;
1382                    } else if self.eat_kw("primary") {
1383                        // A single-column PRIMARY KEY is exactly PowDB's
1384                        // `required unique`. It is the first line of nearly
1385                        // every CREATE TABLE an ORM emits, and it used to be
1386                        // refused with a message about table constraints.
1387                        self.expect_kw("key")?;
1388                        required = true;
1389                        unique = true;
1390                    } else if self.eat_kw("autoincrement") || self.eat_kw("auto_increment") {
1391                        auto = true;
1392                    } else if self.eat_kw("default") {
1393                        default = Some(self.default_literal()?);
1394                    } else if self.eat_kw("null") {
1395                    } else {
1396                        break;
1397                    }
1398                }
1399                let mut mods = Vec::new();
1400                if required {
1401                    mods.push("required");
1402                }
1403                if unique {
1404                    mods.push("unique");
1405                }
1406                if auto {
1407                    mods.push("auto");
1408                }
1409                let prefix = if mods.is_empty() {
1410                    String::new()
1411                } else {
1412                    format!("{} ", mods.join(" "))
1413                };
1414                let suffix = match default {
1415                    Some(lit) => format!(" default {lit}"),
1416                    None => String::new(),
1417                };
1418                fields.push(format!("{prefix}{name}: {ty}{suffix}"));
1419                let _ = self.eat_sym(',');
1420            }
1421            return Ok(format!("type {table} {{ {} }}", fields.join(", ")));
1422        }
1423        let unique = self.eat_kw("unique");
1424        self.expect_kw("index")?;
1425        let _idx = self.expect_ident("index name")?;
1426        self.expect_kw("on")?;
1427        let table = self.expect_ident("table name")?;
1428        self.expect_sym('(')?;
1429        let expression_parenthesized = self.eat_sym('(');
1430        if !matches!(self.peek(), Some(SqlTok::Word(_))) {
1431            return Err(ParseError::Unsupported {
1432                feature: "SQL expression indexes are not supported; use PowQL `alter <table> add index (.<json-column>-><path>)`"
1433                    .into(),
1434            });
1435        }
1436        let col = self.expect_ident("column name")?;
1437        let mut path = format!(".{col}");
1438        let mut has_json_path = false;
1439        loop {
1440            match self.peek() {
1441                Some(SqlTok::Op(operator)) if operator == "->" => {
1442                    self.bump();
1443                    has_json_path = true;
1444                    match self.bump() {
1445                        Some(SqlTok::String(key)) => {
1446                            path.push_str("->");
1447                            path.push_str(&quote_powql_string(&key));
1448                        }
1449                        Some(SqlTok::Number(index))
1450                            if !index.starts_with('-')
1451                                && !index.contains('.')
1452                                && index.parse::<u32>().is_ok() =>
1453                        {
1454                            path.push_str("->");
1455                            path.push_str(&index);
1456                        }
1457                        Some(segment) => {
1458                            return Err(ParseError::Unsupported {
1459                                feature: format!(
1460                                    "SQL JSON expression indexes require string keys or non-negative integer path segments after ->, got {}",
1461                                    segment.display()
1462                                ),
1463                            });
1464                        }
1465                        None => {
1466                            return Err(ParseError::UnexpectedToken {
1467                                expected: "JSON path segment after ->".into(),
1468                                got: "<eof>".into(),
1469                                position: None,
1470                            });
1471                        }
1472                    }
1473                }
1474                Some(SqlTok::Op(operator)) if operator == "->>" => {
1475                    return Err(ParseError::Unsupported {
1476                        feature:
1477                            "SQL ->> text expressions cannot be indexed; use a direct JSON -> path"
1478                                .into(),
1479                    });
1480                }
1481                _ => break,
1482            }
1483        }
1484        if expression_parenthesized && !has_json_path {
1485            return Err(ParseError::Unsupported {
1486                feature: "SQL expression indexes support only direct JSON -> paths; use a plain column without extra parentheses"
1487                    .into(),
1488            });
1489        }
1490        if expression_parenthesized && !self.eat_sym(')') {
1491            return Err(ParseError::Unsupported {
1492                feature: "SQL expression indexes support only direct JSON -> paths".into(),
1493            });
1494        }
1495        if !self.eat_sym(')') {
1496            return Err(ParseError::Unsupported {
1497                feature: "SQL expression and multi-column indexes are not supported; use PowQL `alter <table> add index (.<json-column>-><path>)` for a JSON path"
1498                    .into(),
1499            });
1500        }
1501        Ok(if unique {
1502            if has_json_path {
1503                format!("alter {table} add unique ({path})")
1504            } else {
1505                format!("alter {table} add unique .{col}")
1506            }
1507        } else {
1508            if has_json_path {
1509                format!("alter {table} add index ({path})")
1510            } else {
1511                format!("alter {table} add index .{col}")
1512            }
1513        })
1514    }
1515
1516    fn drop_stmt(&mut self) -> Result<String, ParseError> {
1517        self.expect_kw("drop")?;
1518        if self.eat_kw("table") {
1519            let table = self.expect_ident("table name")?;
1520            Ok(format!("drop {table}"))
1521        } else if self.eat_kw("view") {
1522            let view = self.expect_ident("view name")?;
1523            Ok(format!("drop view {view}"))
1524        } else {
1525            Err(ParseError::UnexpectedToken {
1526                expected: "TABLE or VIEW".into(),
1527                got: self
1528                    .peek()
1529                    .map(|t| t.display())
1530                    .unwrap_or_else(|| "<eof>".into()),
1531                position: None,
1532            })
1533        }
1534    }
1535
1536    fn alter(&mut self) -> Result<String, ParseError> {
1537        self.expect_kw("alter")?;
1538        self.expect_kw("table")?;
1539        let table = self.expect_ident("table name")?;
1540        if self.eat_kw("add") {
1541            let _ = self.eat_kw("column");
1542            let name = self.expect_ident("column name")?;
1543            let ty = self.sql_type()?;
1544            let mut required = false;
1545            if self.eat_kw("not") {
1546                self.expect_kw("null")?;
1547                required = true;
1548            }
1549            let prefix = if required { "required " } else { "" };
1550            Ok(format!("alter {table} add column {prefix}{name}: {ty}"))
1551        } else if self.eat_kw("drop") {
1552            let _ = self.eat_kw("column");
1553            let name = self.expect_ident("column name")?;
1554            Ok(format!("alter {table} drop column {name}"))
1555        } else {
1556            Err(ParseError::UnexpectedToken {
1557                expected: "ADD or DROP".into(),
1558                got: self
1559                    .peek()
1560                    .map(|t| t.display())
1561                    .unwrap_or_else(|| "<eof>".into()),
1562                position: None,
1563            })
1564        }
1565    }
1566
1567    fn sql_type(&mut self) -> Result<String, ParseError> {
1568        let raw = self.expect_ident("type name")?;
1569        // Ignore VARCHAR(255)-style length specifiers.
1570        if self.eat_sym('(') {
1571            while !self.eat_sym(')') {
1572                if self.at_end() {
1573                    return Err(ParseError::Syntax {
1574                        message: "unterminated SQL type length".into(),
1575                        position: None,
1576                    });
1577                }
1578                self.bump();
1579            }
1580        }
1581        let ty = match raw.to_ascii_lowercase().as_str() {
1582            "text" | "varchar" | "char" | "string" | "str" => "str",
1583            "int" | "integer" | "bigint" | "smallint" => "int",
1584            "real" | "double" | "float" | "decimal" | "numeric" => "float",
1585            "bool" | "boolean" => "bool",
1586            "datetime" | "timestamp" => "datetime",
1587            "uuid" => "uuid",
1588            "blob" | "bytes" | "bytea" => "bytes",
1589            // PowDB stores JSON natively (PJ1), and `jsonb` is the spelling
1590            // Postgres-shaped DDL uses for the same thing.
1591            "json" | "jsonb" => "json",
1592            other => {
1593                return Err(ParseError::Unsupported {
1594                    feature: format!("unsupported SQL type `{other}`"),
1595                })
1596            }
1597        };
1598        Ok(ty.into())
1599    }
1600
1601    fn assignment_list_until(&mut self, stop: &[&str]) -> Result<Vec<String>, ParseError> {
1602        let mut out = Vec::new();
1603        loop {
1604            let name = self.expect_ident("column name")?;
1605            match self.bump() {
1606                Some(SqlTok::Op(op)) if op == "=" => {}
1607                // A JSON path target (`SET data->'x' = ...`) reads a column name
1608                // and then a `->`/`->>` where `=` is expected. Report the
1609                // unsupported position precisely instead of a generic
1610                // "expected '='" so the user knows path mutation (json_set) is
1611                // not yet available and can write the whole JSON column instead.
1612                Some(SqlTok::Op(op)) if op == "->" || op == "->>" => {
1613                    return Err(ParseError::Unsupported {
1614                        feature: format!(
1615                            "cannot assign to a JSON path target `{name}{op}...`: JSON path \
1616                             assignment targets are not supported; write the whole JSON column \
1617                             instead (path mutation such as json_set is not yet available)"
1618                        ),
1619                    })
1620                }
1621                Some(t) => {
1622                    return Err(ParseError::UnexpectedToken {
1623                        expected: "=".into(),
1624                        got: t.display(),
1625                        position: None,
1626                    })
1627                }
1628                None => {
1629                    return Err(ParseError::UnexpectedToken {
1630                        expected: "=".into(),
1631                        got: "<eof>".into(),
1632                        position: None,
1633                    })
1634                }
1635            }
1636            let v = self.expr_until(stop)?;
1637            out.push(format!("{name} := {v}"));
1638            if !self.eat_sym(',') {
1639                break;
1640            }
1641        }
1642        Ok(out)
1643    }
1644
1645    fn expression_list_until(&mut self, stop: &[&str]) -> Result<Vec<String>, ParseError> {
1646        let mut expressions = Vec::new();
1647        loop {
1648            expressions.push(self.expr_until(stop)?);
1649            if !self.eat_sym(',') || self.next_is_stop(stop) {
1650                break;
1651            }
1652        }
1653        Ok(expressions)
1654    }
1655
1656    fn order_list_until(&mut self, stop: &[&str]) -> Result<String, ParseError> {
1657        let mut parts = Vec::new();
1658        let mut expression_stop = Vec::with_capacity(stop.len() + 2);
1659        expression_stop.extend_from_slice(stop);
1660        expression_stop.extend_from_slice(&["asc", "desc"]);
1661        loop {
1662            let mut p = self.expr_until(&expression_stop)?;
1663            if self.eat_kw("desc") {
1664                p.push_str(" desc");
1665            } else if self.eat_kw("asc") {
1666                p.push_str(" asc");
1667            }
1668            parts.push(p);
1669            if !self.eat_sym(',') || self.next_is_stop(stop) {
1670                break;
1671            }
1672        }
1673        Ok(parts.join(", "))
1674    }
1675
1676    fn expr_until(&mut self, stop: &[&str]) -> Result<String, ParseError> {
1677        self.expr_bp(0, stop)
1678    }
1679
1680    fn expr_bp(&mut self, min_bp: u8, stop: &[&str]) -> Result<String, ParseError> {
1681        // Guard the recursive descent against stack overflow. Error paths below
1682        // abort the whole parse, so only the success path needs to restore the
1683        // counter (done right before the final `Ok`).
1684        self.depth += 1;
1685        if self.depth > MAX_SQL_NESTING_DEPTH {
1686            return Err(ParseError::NestingDepthExceeded {
1687                max: MAX_SQL_NESTING_DEPTH,
1688            });
1689        }
1690        let mut lhs = if self.eat_kw("not") {
1691            // Standard SQL: `NOT` binds looser than comparison, so `NOT x = 1`
1692            // is `NOT (x = 1)`. Parse the comparison (min_bp 5 admits `=`/`<`/…
1693            // but stops before AND/OR) and parenthesize it so the canonical
1694            // PowQL re-parse is unambiguous regardless of PowQL's own NOT
1695            // precedence.
1696            format!("not ({})", self.expr_bp(5, stop)?)
1697        } else if self.eat_kw("exists") {
1698            if self.eat_sym('(') {
1699                if self.is_kw("select") {
1700                    return Err(ParseError::Unsupported {
1701                        feature:
1702                            "SQL EXISTS subqueries are not supported yet; use PowQL EXISTS for now"
1703                                .into(),
1704                    });
1705                }
1706                return Err(ParseError::Syntax {
1707                    message: "expected subquery after EXISTS".into(),
1708                    position: None,
1709                });
1710            }
1711            return Err(ParseError::Syntax {
1712                message: "expected EXISTS (...)".into(),
1713                position: None,
1714            });
1715        } else if self.eat_sym('(') {
1716            if self.is_kw("select") {
1717                return Err(ParseError::Unsupported {
1718                    feature:
1719                        "SQL scalar subqueries are not supported yet; use PowQL subqueries for now"
1720                            .into(),
1721                });
1722            }
1723            let inner = self.expr_bp(0, stop)?;
1724            self.expect_sym(')')?;
1725            format!("({inner})")
1726        } else {
1727            self.primary_expr()?
1728        };
1729
1730        // Every iteration below appends one more level to `lhs`, which becomes
1731        // one more AST level once the canonical text is re-parsed as PowQL.
1732        let mut chain = 0usize;
1733        loop {
1734            if self.next_is_stop(stop)
1735                || self.at_end()
1736                || matches!(self.peek(), Some(SqlTok::Symbol(')' | ',')))
1737            {
1738                break;
1739            }
1740            chain += 1;
1741            if self.depth + chain > MAX_SQL_NESTING_DEPTH {
1742                return Err(ParseError::NestingDepthExceeded {
1743                    max: MAX_SQL_NESTING_DEPTH,
1744                });
1745            }
1746            if matches!(self.peek(), Some(SqlTok::Op(op)) if op == "->" || op == "->>") {
1747                let text = matches!(self.bump(), Some(SqlTok::Op(op)) if op == "->>");
1748                let segment = match self.bump() {
1749                    Some(SqlTok::String(key)) => quote_powql_string(&key),
1750                    Some(SqlTok::Number(index))
1751                        if !index.starts_with('-') && !index.contains('.') =>
1752                    {
1753                        index
1754                    }
1755                    Some(token) => {
1756                        return Err(ParseError::Syntax {
1757                            message: format!(
1758                                "SQL JSON arrows require a string key or non-negative integer index, got {}",
1759                                token.display()
1760                            ),
1761                            position: None,
1762                        });
1763                    }
1764                    None => {
1765                        return Err(ParseError::UnexpectedToken {
1766                            expected: "JSON object key or array index".into(),
1767                            got: "<eof>".into(),
1768                            position: None,
1769                        });
1770                    }
1771                };
1772                let path = format!("{lhs}->{segment}");
1773                lhs = if text {
1774                    format!("json_text({path})")
1775                } else {
1776                    path
1777                };
1778                continue;
1779            }
1780            if self.eat_kw("is") {
1781                let not = self.eat_kw("not");
1782                self.expect_kw("null")?;
1783                lhs = if not {
1784                    format!("{lhs} != null")
1785                } else {
1786                    format!("{lhs} = null")
1787                };
1788                continue;
1789            }
1790            if self.eat_kw("not") {
1791                if self.eat_kw("in") {
1792                    return Err(ParseError::Unsupported {
1793                        feature:
1794                            "SQL IN lists/subqueries are not supported yet in the SQL frontend"
1795                                .into(),
1796                    });
1797                }
1798                if self.eat_kw("like") {
1799                    let rhs = self.expr_bp(6, stop)?;
1800                    lhs = format!("{lhs} not like {rhs}");
1801                    continue;
1802                }
1803                if self.eat_kw("between") {
1804                    return Err(ParseError::Unsupported {
1805                        feature: "SQL BETWEEN is not supported yet in the SQL frontend".into(),
1806                    });
1807                }
1808                return Err(ParseError::UnexpectedToken {
1809                    expected: "IN, LIKE, or BETWEEN after NOT".into(),
1810                    got: self
1811                        .peek()
1812                        .map(|t| t.display())
1813                        .unwrap_or_else(|| "<eof>".into()),
1814                    position: None,
1815                });
1816            }
1817            if self.eat_kw("in") {
1818                return Err(ParseError::Unsupported {
1819                    feature: "SQL IN lists/subqueries are not supported yet in the SQL frontend"
1820                        .into(),
1821                });
1822            }
1823            if self.eat_kw("between") {
1824                return Err(ParseError::Unsupported {
1825                    feature: "SQL BETWEEN is not supported yet in the SQL frontend".into(),
1826                });
1827            }
1828            // A window call parses its function part fine and then leaves
1829            // `OVER` sitting where a clause keyword should be, so the failure
1830            // surfaced at the clause boundary ("expected from, got OVER")
1831            // rather than at the feature. `is_kw` (not `eat_kw`) keeps the
1832            // cursor on OVER; the error is terminal either way.
1833            if self.is_kw("over") {
1834                return Err(ParseError::Unsupported {
1835                    feature: "SQL window functions (OVER) are not supported yet in the SQL \
1836                              frontend; PowQL has them: row_number() over (order .col)"
1837                        .into(),
1838                });
1839            }
1840            if self.eat_kw("like") {
1841                let (l_bp, r_bp) = (5, 6);
1842                if l_bp < min_bp {
1843                    self.pos -= 1;
1844                    break;
1845                }
1846                let rhs = self.expr_bp(r_bp, stop)?;
1847                lhs = format!("{lhs} like {rhs}");
1848                continue;
1849            }
1850
1851            let op = if self.eat_kw("or") {
1852                "or".to_string()
1853            } else if self.eat_kw("and") {
1854                "and".to_string()
1855            } else if let Some(SqlTok::Op(op)) = self.peek().cloned() {
1856                self.pos += 1;
1857                op
1858            } else if self.eat_sym('*') {
1859                "*".into()
1860            } else {
1861                break;
1862            };
1863            let (l_bp, r_bp) = infix_bp(&op).ok_or_else(|| ParseError::Syntax {
1864                message: format!("unsupported SQL operator `{op}`"),
1865                position: None,
1866            })?;
1867            if l_bp < min_bp {
1868                self.pos -= 1;
1869                break;
1870            }
1871            let rhs = self.expr_bp(r_bp, stop)?;
1872            // SQL text must mean SQL. PowQL deliberately desugars `x = null`
1873            // to `x is null` as a convenience, but in SQL a comparison against
1874            // NULL is UNKNOWN, so `WHERE x = NULL` and `WHERE x <> NULL` both
1875            // select no rows in every other engine. Emitting the PowQL
1876            // spelling here would silently hand back the `IS NULL` rows
1877            // (the opposite row set), so lower it to a constant-false
1878            // predicate instead.
1879            //
1880            // The `IS NULL` / `IS NOT NULL` path above is unaffected: it sets
1881            // `lhs` directly and never reaches this operator loop.
1882            //
1883            // Corner: PowDB filters are two-valued, so `NOT (x = NULL)`
1884            // yields every row where SQL's three-valued logic would yield
1885            // none. That is the already-documented 2VL divergence, not a new
1886            // one.
1887            if rhs == "null" && matches!(op.as_str(), "=" | "!=" | "<>") {
1888                lhs = "false".to_string();
1889                continue;
1890            }
1891            lhs = format!("{lhs} {op} {rhs}");
1892        }
1893        self.depth -= 1;
1894        Ok(lhs)
1895    }
1896
1897    fn primary_expr(&mut self) -> Result<String, ParseError> {
1898        match self.bump() {
1899            Some(SqlTok::Word(w)) if w.eq_ignore_ascii_case("null") => Ok("null".into()),
1900            Some(SqlTok::Word(w))
1901                if w.eq_ignore_ascii_case("true") || w.eq_ignore_ascii_case("false") =>
1902            {
1903                Ok(w.to_ascii_lowercase())
1904            }
1905            // `CASE WHEN ... THEN ... END` otherwise lowers to a bare `.CASE`
1906            // field and dies at the *next* clause boundary ("expected from, got
1907            // WHEN"), which reads exactly like a user typo. Name the gap here.
1908            // A quoted `"case"` lexes as the backticked Word `` `case` `` and so
1909            // never reaches this arm: it stays a column named case.
1910            Some(SqlTok::Word(w)) if w.eq_ignore_ascii_case("case") => {
1911                Err(ParseError::Unsupported {
1912                    feature: "SQL CASE/WHEN is not supported yet in the SQL frontend; \
1913                              PowQL has it: case when <cond> then <value> else <value> end"
1914                        .into(),
1915                })
1916            }
1917            Some(SqlTok::Word(w)) => {
1918                if self.eat_sym('(') {
1919                    let func = w.to_ascii_lowercase();
1920                    if func == "count" && self.eat_sym('*') {
1921                        self.expect_sym(')')?;
1922                        return Ok("count(*)".into());
1923                    }
1924                    // Every refusal below is here because the construct would
1925                    // otherwise lower to a syntactically valid but *wrong*
1926                    // canonical PowQL call (`count(.DISTINCT, .k)`,
1927                    // `cast(.id, .AS, .INT)`, `coalesce(.k, "none")`) and fail
1928                    // in the PowQL re-parse with a low-level message
1929                    // ("expected ')', got ','") that names neither SQL nor the
1930                    // feature. Refuse in the frontend instead, in the same
1931                    // shape as the BETWEEN/IN refusals below.
1932                    if func == "coalesce" {
1933                        return Err(ParseError::Unsupported {
1934                            feature: "SQL COALESCE is not supported yet in the SQL frontend; \
1935                                      PowQL spells it with the ?? operator: .a ?? .b"
1936                                .into(),
1937                        });
1938                    }
1939                    if self.is_kw("distinct") {
1940                        // PowQL only has `count(distinct ...)`, so only claim
1941                        // that workaround for COUNT.
1942                        let hint = if func == "count" {
1943                            "; PowQL spells it count(distinct T { .col })"
1944                        } else {
1945                            ""
1946                        };
1947                        return Err(ParseError::Unsupported {
1948                            feature: format!(
1949                                "SQL {}(DISTINCT ...) is not supported yet in the SQL \
1950                                 frontend{hint}",
1951                                func.to_ascii_uppercase()
1952                            ),
1953                        });
1954                    }
1955                    let mut args = Vec::new();
1956                    while !self.eat_sym(')') {
1957                        args.push(self.expr_bp(0, &[])?);
1958                        // `CAST(x AS TYPE)`: the argument parse stops on the
1959                        // bare `AS` keyword, which no other supported function
1960                        // call can be followed by inside its own parentheses.
1961                        if func == "cast" && self.is_kw("as") {
1962                            return Err(ParseError::Unsupported {
1963                                feature: "SQL CAST(x AS TYPE) is not supported yet in the SQL \
1964                                          frontend; PowDB spells a cast cast(x, 'int') with the \
1965                                          target type as a string argument"
1966                                    .into(),
1967                            });
1968                        }
1969                        let _ = self.eat_sym(',');
1970                    }
1971                    return Ok(format!("{}({})", func, args.join(", ")));
1972                }
1973                if self.eat_sym('.') {
1974                    let f = self.expect_ident("qualified column name")?;
1975                    match &self.qual_ctx {
1976                        QualCtx::Joined => Ok(format!("{w}.{f}")),
1977                        QualCtx::Single { visible_name } => {
1978                            if w.eq_ignore_ascii_case(visible_name) {
1979                                Ok(format!(".{f}"))
1980                            } else {
1981                                Err(ParseError::Syntax {
1982                                    message: format!(
1983                                        "no such column: {w}.{f} (the only table in this \
1984                                         statement is `{visible_name}`)"
1985                                    ),
1986                                    position: None,
1987                                })
1988                            }
1989                        }
1990                        QualCtx::None => Err(ParseError::Syntax {
1991                            message: format!(
1992                                "qualified column reference `{w}.{f}` is not allowed here"
1993                            ),
1994                            position: None,
1995                        }),
1996                    }
1997                } else {
1998                    Ok(format!(".{w}"))
1999                }
2000            }
2001            Some(SqlTok::Number(n)) => Ok(n),
2002            Some(SqlTok::String(s)) => Ok(quote_powql_string(&s)),
2003            Some(SqlTok::Param(p)) => Ok(format!("${p}")),
2004            Some(SqlTok::Symbol('*')) => Ok("*".into()),
2005            Some(t) => Err(ParseError::Syntax {
2006                message: format!("unexpected SQL token in expression: {}", t.display()),
2007                position: None,
2008            }),
2009            None => Err(ParseError::UnexpectedToken {
2010                expected: "expression".into(),
2011                got: "<eof>".into(),
2012                position: None,
2013            }),
2014        }
2015    }
2016
2017    fn next_is_stop(&self, stop: &[&str]) -> bool {
2018        matches!(self.peek(), Some(SqlTok::Word(w)) if stop.iter().any(|kw| w.eq_ignore_ascii_case(kw)))
2019    }
2020}
2021
2022fn infix_bp(op: &str) -> Option<(u8, u8)> {
2023    Some(match op.to_ascii_lowercase().as_str() {
2024        "or" => (1, 2),
2025        "and" => (3, 4),
2026        "=" | "!=" | "<" | ">" | "<=" | ">=" => (5, 6),
2027        "+" | "-" => (7, 8),
2028        "*" | "/" => (9, 10),
2029        _ => return None,
2030    })
2031}
2032
2033fn quote_powql_string(s: &str) -> String {
2034    format!(
2035        "\"{}\"",
2036        s.replace('\\', "\\\\")
2037            .replace('"', "\\\"")
2038            .replace('\n', "\\n")
2039            .replace('\t', "\\t")
2040    )
2041}
2042
2043fn is_clause_kw(w: &str) -> bool {
2044    matches!(
2045        w.to_ascii_lowercase().as_str(),
2046        "where"
2047            | "group"
2048            | "having"
2049            | "order"
2050            | "limit"
2051            | "offset"
2052            | "join"
2053            | "inner"
2054            | "left"
2055            | "right"
2056            | "cross"
2057            | "on"
2058            | "values"
2059            | "set"
2060    )
2061}
2062fn is_join_modifier(w: &str) -> bool {
2063    matches!(
2064        w.to_ascii_lowercase().as_str(),
2065        "join" | "inner" | "left" | "right" | "cross" | "outer"
2066    )
2067}
2068fn is_reserved_identifier(w: &str) -> bool {
2069    matches!(
2070        w.to_ascii_lowercase().as_str(),
2071        "select"
2072            | "from"
2073            | "where"
2074            | "insert"
2075            | "into"
2076            | "values"
2077            | "update"
2078            | "set"
2079            | "delete"
2080            | "create"
2081            | "table"
2082            | "drop"
2083            | "alter"
2084    )
2085}
2086
2087#[cfg(test)]
2088mod tests {
2089    use super::*;
2090    use crate::ast::{AlterAction, IndexTarget};
2091
2092    #[test]
2093    fn a_comment_only_sql_segment_is_effectively_blank() {
2094        // A SQL dump ending in `-- end of dump` used to exit 1 after every
2095        // real statement had committed, because the CLI asked the *PowQL*
2096        // lexer, which reads `--` as two subtractions.
2097        for blank in [
2098            "",
2099            "   ",
2100            "\n\t \n",
2101            "-- comment only",
2102            "--comment only",
2103            "-- a\n-- b\n",
2104            "# comment only",
2105            "  # comment only  ",
2106            "# a\n# b",
2107            "/* block */",
2108            "/* multi\nline */\n-- and a line comment\n",
2109            "-- end of dump\n",
2110        ] {
2111            assert!(
2112                sql_is_effectively_blank(blank),
2113                "{blank:?} must be effectively blank"
2114            );
2115        }
2116
2117        for real in [
2118            "SELECT 1",
2119            // A comment AFTER a real statement must not blank the whole input.
2120            "SELECT 1 -- trailing",
2121            "SELECT 1\n-- trailing",
2122            "-- leading\nSELECT 1",
2123            "# leading\nSELECT 1",
2124            "/* leading */ SELECT 1",
2125            // The obvious way this class of fix goes wrong: `--` inside a
2126            // string literal is not a comment. Asking the real lexer is what
2127            // makes this correct for free.
2128            "SELECT '-- not a comment'",
2129            "SELECT '# not a comment'",
2130            "SELECT '/* not a comment */'",
2131            // A lex error is a real statement with a real problem, not blank.
2132            "SELECT @",
2133            "SELECT 'unterminated",
2134        ] {
2135            assert!(
2136                !sql_is_effectively_blank(real),
2137                "{real:?} must not be treated as blank"
2138            );
2139        }
2140    }
2141
2142    #[test]
2143    fn sql_frontend_rejects_create_view() {
2144        // The SQL frontend has no CREATE VIEW production, so the dead
2145        // `Statement::CreateView` arm in `mark_sql_statement_raw` is truly
2146        // unreachable. If this ever starts parsing, that arm (and its
2147        // `raw`-marking warning) must be revisited before views can round-trip.
2148        let result = parse_sql("CREATE VIEW v AS SELECT id FROM Post");
2149        assert!(
2150            result.is_err(),
2151            "CREATE VIEW must be rejected by the SQL frontend, got {result:?}"
2152        );
2153    }
2154
2155    #[test]
2156    fn json_path_update_target_is_targeted_unsupported() {
2157        // `UPDATE t SET data->'x' = 5` must not die with a generic
2158        // "expected '='": it must name the unsupported feature and the
2159        // whole-column alternative.
2160        for stmt in [
2161            "UPDATE Doc SET data->'x' = 5",
2162            "UPDATE Doc SET data->>'x' = 5",
2163        ] {
2164            let err = parse_sql(stmt).unwrap_err();
2165            assert!(
2166                matches!(err, ParseError::Unsupported { .. }),
2167                "{stmt}: expected Unsupported, got {err:?}"
2168            );
2169            let msg = err.to_string();
2170            assert!(
2171                msg.contains("JSON path assignment targets are not supported"),
2172                "{stmt}: message must state the unsupported feature: {msg}"
2173            );
2174            assert!(
2175                msg.contains("json_set"),
2176                "{stmt}: message must point at the whole-column alternative: {msg}"
2177            );
2178        }
2179        // A normal whole-column update still parses.
2180        assert!(parse_sql("UPDATE Doc SET data = '{}'").is_ok());
2181    }
2182
2183    #[test]
2184    fn json_arrows_lex_longest_token_and_lower_to_powql_paths() {
2185        assert_eq!(
2186            lex_sql("data->>'name'").unwrap(),
2187            vec![
2188                SqlTok::Word("data".into()),
2189                SqlTok::Op("->>".into()),
2190                SqlTok::String("name".into()),
2191            ]
2192        );
2193        let parsed = parse_sql_with_canonical(
2194            "SELECT data -> 'author' ->> 'name' AS name, data -> 'tags' -> 0 AS first FROM Post WHERE data ->> 'state' = 'ready'",
2195        )
2196        .unwrap();
2197        assert_eq!(
2198            parsed.canonical_powql,
2199            "Post filter json_text(.data->\"state\") = \"ready\" { name: json_text(.data->\"author\"->\"name\"), first: .data->\"tags\"->0 }"
2200        );
2201        let raw = parse_sql_with_canonical("SELECT data -> 'name' FROM Post").unwrap();
2202        let text = parse_sql_with_canonical("SELECT data ->> 'name' FROM Post").unwrap();
2203        assert_ne!(
2204            crate::canonicalize::canonicalize(&raw.canonical_powql)
2205                .unwrap()
2206                .0,
2207            crate::canonicalize::canonicalize(&text.canonical_powql)
2208                .unwrap()
2209                .0,
2210            "-> and ->> must never share a cached plan"
2211        );
2212
2213        let ordered = parse_sql_with_canonical(
2214            "SELECT id FROM Post ORDER BY data ->> 'rank' DESC, data -> 'tie' ASC",
2215        )
2216        .unwrap();
2217        assert_eq!(
2218            ordered.canonical_powql,
2219            "Post order json_text(.data->\"rank\") desc, .data->\"tie\" asc { .id }"
2220        );
2221
2222        let grouped = parse_sql_with_canonical(
2223            "SELECT data ->> 'kind' AS kind, COUNT(*) AS n FROM Post GROUP BY data ->> 'kind'",
2224        )
2225        .unwrap();
2226        assert_eq!(
2227            grouped.canonical_powql,
2228            "Post group json_text(.data->\"kind\") { kind: json_text(.data->\"kind\"), n: count(*) }"
2229        );
2230    }
2231
2232    #[test]
2233    fn json_arrows_reject_invalid_path_segments() {
2234        for sql in [
2235            "SELECT data -> other FROM Post",
2236            "SELECT data -> -1 FROM Post",
2237            "SELECT data -> 1.5 FROM Post",
2238        ] {
2239            let err = parse_sql_with_canonical(sql).unwrap_err();
2240            assert!(
2241                err.to_string()
2242                    .contains("string key or non-negative integer index"),
2243                "unexpected error for `{sql}`: {err}"
2244            );
2245        }
2246    }
2247
2248    #[test]
2249    fn select_lowers_to_powql_ast() {
2250        let sql = parse_sql_with_canonical(
2251            "SELECT name, age FROM User WHERE age > 25 ORDER BY age DESC LIMIT 10",
2252        )
2253        .unwrap();
2254        assert_eq!(
2255            sql.canonical_powql,
2256            "User filter .age > 25 order .age desc limit 10 { .name, .age }"
2257        );
2258        assert_eq!(
2259            sql.statement,
2260            parser::parse("User filter .age > 25 order .age desc limit 10 { .name, .age }")
2261                .unwrap()
2262        );
2263    }
2264
2265    #[test]
2266    fn insert_update_delete_and_ddl_lower_to_existing_ast() {
2267        assert!(matches!(
2268            parse_sql("CREATE TABLE User (id INTEGER NOT NULL UNIQUE, name TEXT)").unwrap(),
2269            Statement::CreateType(_)
2270        ));
2271        assert!(matches!(
2272            parse_sql("INSERT INTO User (id, name) VALUES (1, 'Ada')").unwrap(),
2273            Statement::Insert(_)
2274        ));
2275        assert!(matches!(
2276            parse_sql("UPDATE User SET name = 'Grace' WHERE id = 1").unwrap(),
2277            Statement::UpdateQuery(_)
2278        ));
2279        assert!(matches!(
2280            parse_sql("DELETE FROM User WHERE id = 1").unwrap(),
2281            Statement::DeleteQuery(_)
2282        ));
2283    }
2284
2285    #[test]
2286    fn unsupported_sql_gets_explicit_error() {
2287        let err = parse_sql("SELECT name FROM User WHERE id IN (SELECT user_id FROM Orders)")
2288            .unwrap_err();
2289        assert!(err.to_string().contains("SQL IN"));
2290    }
2291
2292    #[test]
2293    fn sql_expression_index_has_targeted_powql_guidance() {
2294        assert!(matches!(
2295            parse_sql("CREATE INDEX post_slug ON Post (slug)").unwrap(),
2296            Statement::AlterTable(_)
2297        ));
2298        for sql in [
2299            "CREATE INDEX post_age ON Post ((data -> 'age'))",
2300            "CREATE INDEX post_first ON Post (data -> 'scores' -> 0)",
2301            "CREATE UNIQUE INDEX post_code ON Post ((data -> 'code'))",
2302        ] {
2303            let Statement::AlterTable(alter) = parse_sql(sql).unwrap() else {
2304                panic!("expected expression-index ALTER lowering for `{sql}`");
2305            };
2306            let target = match alter.action {
2307                AlterAction::AddIndex { target, .. } | AlterAction::AddUnique { target, .. } => {
2308                    target
2309                }
2310                action => panic!("expected add-index action, got {action:?}"),
2311            };
2312            assert!(matches!(target, IndexTarget::JsonPath(_)), "{sql}");
2313        }
2314        let error = parse_sql("CREATE INDEX post_age ON Post (data->>'age')")
2315            .expect_err("SQL text extraction is not an indexable path")
2316            .to_string();
2317        assert!(error.contains("->>"));
2318        let error = parse_sql("CREATE INDEX post_age ON Post ((data + 1))")
2319            .expect_err("arbitrary SQL expressions remain unsupported")
2320            .to_string();
2321        assert!(error.contains("only direct JSON -> paths"));
2322    }
2323
2324    #[test]
2325    fn sql_aggregates_lower_with_raw_mode() {
2326        let Statement::Query(query) =
2327            parse_sql("SELECT dept, SUM(balance) AS total FROM Account GROUP BY dept").unwrap()
2328        else {
2329            panic!("expected query");
2330        };
2331        let projection = query.projection.expect("projection");
2332        assert!(matches!(
2333            projection[1].expr,
2334            Expr::FunctionCall(_, _, AggregateMode::Raw)
2335        ));
2336    }
2337
2338    #[test]
2339    fn ungrouped_join_aggregate_lowers_to_raw_powql_aggregate() {
2340        let lowered = parse_sql_with_canonical(
2341            "SELECT AVG(a.balance) FROM Account a JOIN Entry e ON a.id = e.account_id",
2342        )
2343        .unwrap();
2344        assert_eq!(
2345            lowered.canonical_powql,
2346            "avg(Account as a inner join Entry as e on a.id = e.account_id { a.balance })"
2347        );
2348        let Statement::Query(query) = lowered.statement else {
2349            panic!("expected query");
2350        };
2351        assert_eq!(
2352            query.aggregation.expect("aggregate").mode,
2353            AggregateMode::Raw
2354        );
2355    }
2356}
2357
2358#[cfg(test)]
2359mod split_tests {
2360    use super::split_statements_sql;
2361
2362    /// The dangerous case: a `;` inside a `--` comment is not a boundary. The
2363    /// PowQL splitter cuts `-- cleanup; DELETE FROM t` in two, which turns a
2364    /// commented-out statement into a live one.
2365    ///
2366    /// Splitting and blankness are separate jobs: the splitter must keep the
2367    /// comment whole (one segment, not two), and `sql_is_effectively_blank`
2368    /// then decides that segment runs nothing. Asserting the count is the
2369    /// property that matters, because two segments is what deletes data.
2370    #[test]
2371    fn a_semicolon_inside_a_comment_is_not_a_boundary() {
2372        let segs = split_statements_sql("-- cleanup; DELETE FROM t");
2373        assert_eq!(segs, vec!["-- cleanup; DELETE FROM t"]);
2374        assert!(super::sql_is_effectively_blank(segs[0]));
2375
2376        assert_eq!(
2377            split_statements_sql("SELECT 1 FROM t;\n-- trailing; DELETE FROM t\n"),
2378            vec!["SELECT 1 FROM t", "-- trailing; DELETE FROM t"]
2379        );
2380        assert_eq!(
2381            split_statements_sql("/* drop it; DELETE FROM t */ SELECT 1 FROM t"),
2382            vec!["/* drop it; DELETE FROM t */ SELECT 1 FROM t"]
2383        );
2384    }
2385
2386    /// A `;` inside a string literal is data, in both quote styles, including
2387    /// the `''` doubling and backslash-escape forms the SQL lexer accepts.
2388    #[test]
2389    fn a_semicolon_inside_a_string_is_not_a_boundary() {
2390        assert_eq!(
2391            split_statements_sql("INSERT INTO t VALUES ('a;b')"),
2392            vec!["INSERT INTO t VALUES ('a;b')"]
2393        );
2394        assert_eq!(
2395            split_statements_sql(r#"INSERT INTO t VALUES ("a;b")"#),
2396            vec![r#"INSERT INTO t VALUES ("a;b")"#]
2397        );
2398        assert_eq!(
2399            split_statements_sql("INSERT INTO t VALUES ('it''s; here')"),
2400            vec!["INSERT INTO t VALUES ('it''s; here')"]
2401        );
2402        assert_eq!(
2403            split_statements_sql(r#"INSERT INTO t VALUES ('a\';b')"#),
2404            vec![r#"INSERT INTO t VALUES ('a\';b')"#]
2405        );
2406    }
2407
2408    /// Ordinary splitting still works, and empty segments are dropped.
2409    #[test]
2410    fn real_boundaries_still_split() {
2411        assert_eq!(
2412            split_statements_sql("SELECT 1 FROM t; SELECT 2 FROM t;"),
2413            vec!["SELECT 1 FROM t", "SELECT 2 FROM t"]
2414        );
2415        assert_eq!(split_statements_sql(";;  ;"), Vec::<&str>::new());
2416        assert_eq!(split_statements_sql(""), Vec::<&str>::new());
2417    }
2418}