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