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