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