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