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