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