Skip to main content

spg_sql/
parser.rs

1//! Recursive-descent parser with a Pratt (precedence-climbing) sub-parser for
2//! expressions.
3//!
4//! Precedence (lowest → highest binding):
5//! `OR` (1) `<` `AND` (2) `<` `NOT` unary (3) `<`
6//! comparisons `=` `<>` `<` `<=` `>` `>=` (4) `<`
7//! `+` `-` (5) `<` `*` `/` (6) `<` unary `-` (7) `<` parens / atom.
8//!
9//! This matches PG's behaviour for the operators we support — e.g. `NOT a = b`
10//! parses as `NOT (a = b)` and `-a * b` as `(-a) * b`.
11
12use alloc::boxed::Box;
13use alloc::format;
14use alloc::string::{String, ToString};
15use alloc::vec;
16use alloc::vec::Vec;
17use core::fmt;
18use core::mem;
19
20use crate::ast::{
21    AssignTarget, BinOp, CastTarget, ColumnDef, ColumnName, ColumnTypeName,
22    CreateFunctionStatement, CreateIndexStatement, CreatePublicationStatement,
23    CreateSubscriptionStatement, CreateTableStatement, CreateTriggerStatement, Expr, ExtractField,
24    FkAction, ForeignKeyConstraint, FrameBound, FrameKind, FromClause, FromJoin, FunctionArg,
25    FunctionArgMode, FunctionArgType, FunctionBody, FunctionReturn, IndexMethod, InsertStatement,
26    JoinKind, Literal, NullTreatment, OrderBy, PlPgSqlBlock, PlPgSqlDeclare, PlPgSqlStmt,
27    PublicationScope, RaiseLevel, ReturnTarget, SelectItem, SelectStatement, Statement, TableRef,
28    TriggerEvent, TriggerForEach, TriggerTiming, UnOp, UnionKind, VecEncoding, WindowFrame,
29};
30use crate::lexer::{self, LexError, Token};
31
32/// v7.14.0 — true when the leading keyword of a top-level
33/// statement is one of the dump-emitted DDL forms SPG accepts
34/// as a no-op (no behavioural effect on the single-schema /
35/// single-database model). These statements are consumed up to
36/// the next `;` / EOF and returned as `Statement::Empty`.
37fn is_dump_noise_statement(lc: &str) -> bool {
38    matches!(
39        lc,
40        // Object comments / privileges / ownership — none of
41        // these change schema semantics on SPG.
42        "comment"
43            | "grant"
44            | "revoke"
45            // MySQL bulk-load brackets.
46            | "lock"
47            | "unlock"
48            // MySQL OPTIMIZE / ANALYZE TABLE / CHECK TABLE
49            // diagnostics that pg_dump-style tools also emit
50            // post-restore.
51            | "optimize"
52            | "check"
53            | "use"
54            // PG psql backslash meta-commands that newer
55            // pg_dump versions emit unescaped (\restrict /
56            // \unrestrict). Real psql intercepts these; SPG's
57            // PG-wire sees them as raw text.
58            | "\\restrict"
59            | "\\unrestrict"
60    )
61}
62
63/// v7.9.22 — recognise pgvector / SPG vector-index opclass names
64/// in CREATE INDEX. SPG's HNSW already routes by query operator;
65/// the opclass is accepted for `pg_dump` compatibility (mailrs
66/// migration follow-up G5).
67/// v7.13.0 — extended to recognise PG built-in / pg_trgm opclasses
68/// (mailrs round-5 G5). These are tokens-only acceptance — SPG
69/// doesn't change index behaviour based on them.
70fn is_vector_opclass_name(name: &str) -> bool {
71    let lc = name.to_ascii_lowercase();
72    matches!(
73        lc.as_str(),
74        "vector_cosine_ops"
75            | "vector_l2_ops"
76            | "vector_ip_ops"
77            | "halfvec_cosine_ops"
78            | "halfvec_l2_ops"
79            | "halfvec_ip_ops"
80            | "sq8_cosine_ops"
81            | "sq8_l2_ops"
82            | "sq8_ip_ops"
83            // pg_trgm — trigram operator class. SPG's GIN index
84            // already uses tsvector tokens; trigram-style LIKE
85            // pattern matching still routes through a sequential
86            // scan, but the opclass name is accepted so PG schemas
87            // load.
88            | "gin_trgm_ops"
89            | "gist_trgm_ops"
90            // PG built-in btree opclasses occasionally appear in
91            // pg_dump output for column types with multiple
92            // sort orders (text_pattern_ops, varchar_pattern_ops,
93            // bpchar_pattern_ops).
94            | "text_pattern_ops"
95            | "varchar_pattern_ops"
96            | "bpchar_pattern_ops"
97            | "int4_ops"
98            | "int8_ops"
99            | "text_ops"
100    )
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct ParseError {
105    pub message: String,
106    /// Index into the token stream where parsing tripped. Not a byte offset.
107    pub token_pos: usize,
108}
109
110impl fmt::Display for ParseError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(
113            f,
114            "parse error at token #{}: {}",
115            self.token_pos, self.message
116        )
117    }
118}
119
120impl From<LexError> for ParseError {
121    fn from(e: LexError) -> Self {
122        Self {
123            message: format!("lex: {e}"),
124            token_pos: 0,
125        }
126    }
127}
128
129/// v7.9.30 — parse a single expression (no trailing junk). Used by
130/// the engine to re-hydrate stored partial-index / unique-index
131/// predicates from their canonical Display form. The same Pratt
132/// parser the statement path uses; this entry point just skips the
133/// statement dispatch.
134pub fn parse_expression(input: &str) -> Result<Expr, ParseError> {
135    let tokens = lexer::tokenize(input)?;
136    let mut p = Parser::new(tokens);
137    let expr = p.parse_expr(0)?;
138    p.expect_eof()?;
139    Ok(expr)
140}
141
142/// Parse exactly one statement, swallow an optional trailing `;`, and require
143/// the token stream to end there.
144pub fn parse_statement(input: &str) -> Result<Statement, ParseError> {
145    let tokens = lexer::tokenize(input)?;
146    let mut p = Parser::new(tokens);
147    let stmt = p.parse_one_statement()?;
148    if matches!(p.peek(), Token::Semicolon) {
149        p.advance();
150    }
151    p.expect_eof()?;
152    Ok(stmt)
153}
154
155struct Parser {
156    tokens: Vec<Token>,
157    pos: usize,
158}
159
160impl Parser {
161    fn new(tokens: Vec<Token>) -> Self {
162        Self { tokens, pos: 0 }
163    }
164
165    fn peek(&self) -> &Token {
166        // tokens always ends with Eof; pos is clamped in advance().
167        &self.tokens[self.pos]
168    }
169
170    fn advance(&mut self) -> Token {
171        let t = mem::replace(&mut self.tokens[self.pos], Token::Eof);
172        if self.pos + 1 < self.tokens.len() {
173            self.pos += 1;
174        }
175        t
176    }
177
178    fn err(&self, message: String) -> ParseError {
179        ParseError {
180            message,
181            token_pos: self.pos,
182        }
183    }
184
185    fn expect_eof(&self) -> Result<(), ParseError> {
186        if matches!(self.peek(), Token::Eof) {
187            Ok(())
188        } else {
189            Err(self.err(format!("expected end of input, got {:?}", self.peek())))
190        }
191    }
192
193    /// v7.14.0 — swallow every token up to (but not including) the
194    /// next semicolon / EOF. Used by the dump-noise dispatcher
195    /// to consume `COMMENT ON …`, `GRANT …`, `LOCK TABLES …`,
196    /// etc. without modeling each grammar.
197    fn consume_until_statement_boundary(&mut self) {
198        loop {
199            match self.peek() {
200                Token::Semicolon | Token::Eof => return,
201                _ => self.advance(),
202            };
203        }
204    }
205
206    fn expect_ident_like(&mut self) -> Result<String, ParseError> {
207        let first = match self.advance() {
208            Token::Ident(s) | Token::QuotedIdent(s) => s,
209            other => {
210                return Err(ParseError {
211                    message: format!("expected identifier, got {other:?}"),
212                    token_pos: self.pos.saturating_sub(1),
213                });
214            }
215        };
216        // v7.14.0 — strip optional `<schema>.` prefix. PG dumps
217        // qualify every name with `public.` (and pg_catalog.* for
218        // functions); SPG is single-schema so we discard the
219        // prefix and return only the trailing ident. Same shape
220        // also handles MySQL `db.tbl` cross-database refs (SPG
221        // ignores the db part).
222        if matches!(self.peek(), Token::Dot) {
223            self.advance();
224            match self.advance() {
225                Token::Ident(s) | Token::QuotedIdent(s) => return Ok(s),
226                other => {
227                    return Err(ParseError {
228                        message: format!("expected identifier after '{first}.', got {other:?}"),
229                        token_pos: self.pos.saturating_sub(1),
230                    });
231                }
232            }
233        }
234        Ok(first)
235    }
236
237    #[allow(clippy::too_many_lines)]
238    fn parse_one_statement(&mut self) -> Result<Statement, ParseError> {
239        // v7.14.0 — empty / comment-only / semicolon-only input
240        // (after the lexer strips line + block + MySQL
241        // conditional comments) lands as Statement::Empty.
242        // pg_dump and mysqldump emit several wrappers that
243        // collapse to nothing after stripping (`/*!40101 SET …
244        // */;`, blank lines between statements); the engine
245        // returns CommandOk no-op so the dump loads cleanly.
246        if matches!(self.peek(), Token::Eof | Token::Semicolon) {
247            return Ok(Statement::Empty);
248        }
249        // v7.14.0 — pg_dump / mysqldump "noise" statements:
250        // catalog / metadata DDL that has no behavioural effect
251        // on SPG's single-schema, single-database, single-user
252        // model. Consume the whole statement up to the next
253        // semicolon / EOF and return Empty. This is broader than
254        // the per-keyword DROP / SET / COMMENT arms but lets the
255        // long tail of `LOCK TABLES`, `UNLOCK TABLES`, `GRANT`,
256        // `REVOKE`, `ALTER OWNER TO`, `\restrict`, `\unrestrict`,
257        // `BEGIN; COMMIT;` wrappers, etc. all pass through.
258        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
259            let lc = s.to_ascii_lowercase();
260            if is_dump_noise_statement(&lc) {
261                self.consume_until_statement_boundary();
262                return Ok(Statement::Empty);
263            }
264        }
265        match self.peek() {
266            Token::Select => self.parse_select_stmt(),
267            // v7.9.27 — `DO $$ … $$ [LANGUAGE plpgsql]`. The
268            // body is a dollar-quoted plpgsql block (lexer already
269            // collapsed `$$…$$` into a single Token::String).
270            // v7.16.2 — mailrs round-10 A.2: parse the body as a
271            // real PlPgSqlBlock so the engine can EXECUTE it at
272            // top level instead of silently swallowing. Pre-
273            // v7.16.2 the parser threw the body away and the
274            // engine returned CommandOk for the entire DO; that
275            // turned `DO BEGIN … IF EXISTS ... THEN ALTER …; END
276            // $$` into a SEV-1 silent no-op (the IF + the rename
277            // were both invisible — mailrs's migrate-042 didn't
278            // actually run). Now the body parses + executes;
279            // EmbeddedSql inside the block runs immediately
280            // against the engine (not deferred — we're at top
281            // level, not inside a trigger row-write loop).
282            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
283                self.advance();
284                let body_text = match self.advance() {
285                    Token::String(s) => s,
286                    other => {
287                        return Err(self.err(alloc::format!(
288                            "expected dollar-quoted body after DO, got {other:?}"
289                        )));
290                    }
291                };
292                // Optional `LANGUAGE <name>` trailer (idents only).
293                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("language")) {
294                    self.advance();
295                    let _ = self.expect_ident_like()?;
296                }
297                // Parse the body — same shape CREATE FUNCTION
298                // uses for trigger function bodies. If the body
299                // doesn't parse cleanly we surface the error
300                // (better than silent no-op).
301                let block = parse_plpgsql_body(&body_text)?;
302                Ok(Statement::DoBlock(block))
303            }
304            // v4.11: `WITH name AS (SELECT ...) [, ...] SELECT ...`.
305            // WITH isn't a reserved token in our lexer — comes through
306            // as `Token::Ident("with")` (case-insensitive).
307            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with") => {
308                self.advance();
309                self.parse_with_cte_then_select()
310            }
311            // v4.26: `EXPLAIN [ANALYZE] <select>`. Comes through as
312            // an identifier — not a reserved keyword.
313            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("explain") => {
314                self.advance();
315                let mut analyze = false;
316                let mut suggest = false;
317                // v6.8.3 — `EXPLAIN (SUGGEST)` opt-in.
318                if matches!(self.peek(), Token::LParen) {
319                    self.advance();
320                    let opt = match self.peek().clone() {
321                        Token::Ident(s) | Token::QuotedIdent(s) => s,
322                        other => {
323                            return Err(self.err(format!(
324                                "expected option keyword inside EXPLAIN (…), got {other:?}"
325                            )));
326                        }
327                    };
328                    if !opt.eq_ignore_ascii_case("suggest") {
329                        return Err(self.err(format!(
330                            "unknown EXPLAIN option {opt:?}; v6.8.3 supports SUGGEST"
331                        )));
332                    }
333                    self.advance();
334                    if !matches!(self.peek(), Token::RParen) {
335                        return Err(self.err(format!(
336                            "expected ')' after EXPLAIN option, got {:?}",
337                            self.peek()
338                        )));
339                    }
340                    self.advance();
341                    suggest = true;
342                } else if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
343                    && (s.eq_ignore_ascii_case("analyze") || s.eq_ignore_ascii_case("analyse"))
344                {
345                    self.advance();
346                    analyze = true;
347                }
348                let inner = self.parse_select_stmt()?;
349                let Statement::Select(s) = inner else {
350                    return Err(self.err(format!("EXPLAIN body must be a SELECT, got {inner:?}")));
351                };
352                Ok(Statement::Explain(crate::ast::ExplainStatement {
353                    analyze,
354                    inner: Box::new(s),
355                    suggest,
356                }))
357            }
358            Token::Create => self.parse_create_stmt(),
359            Token::Insert => self.parse_insert_stmt(),
360            Token::Begin => {
361                self.advance();
362                Ok(Statement::Begin)
363            }
364            Token::Commit => {
365                self.advance();
366                Ok(Statement::Commit)
367            }
368            Token::Rollback => {
369                self.advance();
370                // `ROLLBACK TO [SAVEPOINT] <name>` returns to that
371                // savepoint without ending the transaction. Bare
372                // `ROLLBACK` drops the whole TX.
373                if matches!(self.peek(), Token::To) {
374                    self.advance();
375                    if matches!(self.peek(), Token::Savepoint) {
376                        self.advance();
377                    }
378                    let name = self.expect_ident_like()?;
379                    Ok(Statement::RollbackToSavepoint(name))
380                } else {
381                    Ok(Statement::Rollback)
382                }
383            }
384            Token::Savepoint => {
385                self.advance();
386                let name = self.expect_ident_like()?;
387                Ok(Statement::Savepoint(name))
388            }
389            Token::Release => {
390                self.advance();
391                // `RELEASE [SAVEPOINT] <name>` — the `SAVEPOINT` keyword
392                // is optional in standard SQL.
393                if matches!(self.peek(), Token::Savepoint) {
394                    self.advance();
395                }
396                let name = self.expect_ident_like()?;
397                Ok(Statement::ReleaseSavepoint(name))
398            }
399            Token::Show => {
400                self.advance();
401                // `SHOW TABLES` / `SHOW USERS` / `SHOW COLUMNS FROM <table>`.
402                // v6.1.2 promoted TABLES to a reserved keyword (for
403                // `CREATE PUBLICATION … FOR ALL TABLES`), so it now
404                // arrives as `Token::Tables` rather than a bare ident.
405                // USERS / COLUMNS remain bare idents.
406                let target = match self.advance() {
407                    Token::Tables => "tables".to_string(),
408                    Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
409                    other => {
410                        return Err(self.err(format!(
411                            "expected SHOW target, got {other:?}"
412                        )));
413                    }
414                };
415                match target.as_str() {
416                    "tables" => Ok(Statement::ShowTables),
417                    "users" => Ok(Statement::ShowUsers),
418                    // v6.1.3 — PUBLICATIONS plural is NOT a reserved
419                    // keyword on its own; it lands here as a bare
420                    // ident. Returning all publications + their
421                    // scope summary.
422                    "publications" => Ok(Statement::ShowPublications),
423                    // v6.1.4 — same shape for SUBSCRIPTIONS plural.
424                    "subscriptions" => Ok(Statement::ShowSubscriptions),
425                    "columns" => {
426                        if !matches!(self.peek(), Token::From) {
427                            return Err(self.err(format!(
428                                "expected FROM after SHOW COLUMNS, got {:?}",
429                                self.peek()
430                            )));
431                        }
432                        self.advance();
433                        let table = self.expect_ident_like()?;
434                        Ok(Statement::ShowColumns(table))
435                    }
436                    other => Err(self.err(format!(
437                        "unknown SHOW target {other:?}; supported: TABLES, COLUMNS, USERS, PUBLICATIONS"
438                    ))),
439                }
440            }
441            // v6.1.2: `DROP` is now a reserved keyword (it dispatches
442            // to DROP USER and DROP PUBLICATION today; DROP TABLE /
443            // DROP INDEX are still SHOW-shaped admin ops). Pre-6.1.2
444            // arrived as a bare ident; tokenising it dedicatedly
445            // keeps the dispatch tree small.
446            Token::Drop => {
447                self.advance();
448                match self.peek() {
449                    Token::Publication => {
450                        self.advance();
451                        let name = self.expect_ident_or_string()?;
452                        Ok(Statement::DropPublication(name))
453                    }
454                    Token::Subscription => {
455                        self.advance();
456                        let name = self.expect_ident_or_string()?;
457                        Ok(Statement::DropSubscription(name))
458                    }
459                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
460                        self.advance();
461                        let name = self.expect_ident_or_string()?;
462                        Ok(Statement::DropUser(name))
463                    }
464                    // v7.12.4 — DROP TRIGGER [IF EXISTS] name ON table.
465                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
466                        self.advance();
467                        let if_exists = self.consume_if_exists();
468                        let name = self.expect_ident_like()?;
469                        // ON <table>
470                        if !matches!(self.peek(), Token::On) {
471                            return Err(self.err(alloc::format!(
472                                "expected ON <table> after DROP TRIGGER {name:?}, got {:?}",
473                                self.peek()
474                            )));
475                        }
476                        self.advance();
477                        let table = self.expect_ident_like()?;
478                        Ok(Statement::DropTrigger {
479                            name,
480                            table,
481                            if_exists,
482                        })
483                    }
484                    // v7.12.4 — DROP FUNCTION [IF EXISTS] name [(args)].
485                    // v7.12.4 ignores any optional arg-list (signature-
486                    // based overload disambiguation lands in v7.12.5+).
487                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
488                        self.advance();
489                        let if_exists = self.consume_if_exists();
490                        let name = self.expect_ident_like()?;
491                        // Optional `()` — consume + discard.
492                        if matches!(self.peek(), Token::LParen) {
493                            self.advance();
494                            // Skip until matching RParen, accepting any tokens (typed args we don't model yet).
495                            let mut depth = 1usize;
496                            while depth > 0 {
497                                match self.peek() {
498                                    Token::LParen => depth += 1,
499                                    Token::RParen => depth -= 1,
500                                    Token::Eof => {
501                                        return Err(self.err(alloc::format!(
502                                            "unterminated arg list in DROP FUNCTION {name:?}"
503                                        )));
504                                    }
505                                    _ => {}
506                                }
507                                self.advance();
508                            }
509                        }
510                        Ok(Statement::DropFunction { name, if_exists })
511                    }
512                    // v7.14.0 — DROP TABLE [IF EXISTS] name [, name…]
513                    // [CASCADE|RESTRICT]. pg_dump and mysqldump both
514                    // emit DROP TABLE IF EXISTS at the head of every
515                    // CREATE TABLE block so re-importing a dump
516                    // overwrites prior state. SPG accepts and removes
517                    // matching tables; CASCADE/RESTRICT trailers
518                    // accepted silently.
519                    Token::Table => {
520                        self.advance();
521                        let if_exists = self.consume_if_exists();
522                        let mut names: Vec<String> = Vec::new();
523                        loop {
524                            names.push(self.expect_ident_like()?);
525                            if matches!(self.peek(), Token::Comma) {
526                                self.advance();
527                                continue;
528                            }
529                            break;
530                        }
531                        if matches!(
532                            self.peek(),
533                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
534                                || s.eq_ignore_ascii_case("restrict")
535                        ) {
536                            self.advance();
537                        }
538                        Ok(Statement::DropTable { names, if_exists })
539                    }
540                    // v7.14.0 — DROP INDEX [IF EXISTS] name
541                    // [CASCADE|RESTRICT]. PG / mysqldump emit this
542                    // for partial-index renames and pgvector
543                    // migrations. SPG removes the matching index;
544                    // IF EXISTS makes the drop idempotent.
545                    Token::Index => {
546                        self.advance();
547                        let if_exists = self.consume_if_exists();
548                        let name = self.expect_ident_like()?;
549                        if matches!(
550                            self.peek(),
551                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
552                                || s.eq_ignore_ascii_case("restrict")
553                        ) {
554                            self.advance();
555                        }
556                        Ok(Statement::DropIndex { name, if_exists })
557                    }
558                    // v7.14.0 — DROP SCHEMA [IF EXISTS] name
559                    // [CASCADE|RESTRICT]. SPG is single-database;
560                    // schemas are accepted as no-ops (any name
561                    // resolves to the single catalog).
562                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
563                        self.advance();
564                        let _ = self.consume_if_exists();
565                        let _ = self.expect_ident_like()?;
566                        if matches!(
567                            self.peek(),
568                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
569                                || s.eq_ignore_ascii_case("restrict")
570                        ) {
571                            self.advance();
572                        }
573                        Ok(Statement::Empty)
574                    }
575                    // v7.14.0 — DROP SEQUENCE [IF EXISTS] name
576                    // [CASCADE|RESTRICT]. SPG has no separate
577                    // sequence object — SERIAL/BIGSERIAL is column-
578                    // local AUTO_INCREMENT — so DROP SEQUENCE
579                    // resolves as a no-op.
580                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
581                        self.advance();
582                        let _ = self.consume_if_exists();
583                        let _ = self.expect_ident_like()?;
584                        if matches!(
585                            self.peek(),
586                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
587                                || s.eq_ignore_ascii_case("restrict")
588                        ) {
589                            self.advance();
590                        }
591                        Ok(Statement::Empty)
592                    }
593                    other => Err(self.err(format!(
594                        "expected TABLE / INDEX / SCHEMA / SEQUENCE / USER / PUBLICATION / \
595                         SUBSCRIPTION / TRIGGER / FUNCTION after DROP, got {other:?}"
596                    ))),
597                }
598            }
599            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
600                self.advance();
601                self.parse_update_after_keyword()
602            }
603            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
604                self.advance();
605                self.parse_delete_after_keyword()
606            }
607            // v6.0.4: ALTER INDEX <name> REBUILD [WITH (encoding = ...)].
608            // ALTER is not a reserved keyword in the lexer — handled
609            // as a bare ident here.
610            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("alter") => {
611                self.advance();
612                self.parse_alter_after_keyword()
613            }
614            // v6.1.7: WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>].
615            // WAIT / POSITION / TIMEOUT are bare idents — no lexer
616            // additions needed.
617            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("wait") => {
618                self.advance();
619                self.parse_wait_after_keyword()
620            }
621            // v6.2.0: ANALYZE [<table>]. ANALYZE is a bare ident.
622            // Bare ANALYZE → analyse every user table; ANALYZE
623            // <name> → re-stats one. The argument is an optional
624            // ident (or quoted ident); anything else is a parse
625            // error.
626            // v6.7.3 — `COMPACT COLD SEGMENTS`. No arguments, no
627            // `WHERE` filter (carved out per V6_7_DESIGN.md
628            // STABILITY). Lex order: identifier "compact" → "cold"
629            // → "segments". Anything else after `COMPACT` is a
630            // parse error.
631            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("compact") => {
632                self.advance();
633                let next = self.peek().clone();
634                let cold = match next {
635                    Token::Ident(s) | Token::QuotedIdent(s) => s,
636                    _ => {
637                        return Err(
638                            self.err(format!("expected COLD after COMPACT, got {:?}", self.peek()))
639                        );
640                    }
641                };
642                if !cold.eq_ignore_ascii_case("cold") {
643                    return Err(self.err(format!("expected COLD after COMPACT, got {cold:?}")));
644                }
645                self.advance();
646                let next = self.peek().clone();
647                let segments = match next {
648                    Token::Ident(s) | Token::QuotedIdent(s) => s,
649                    _ => {
650                        return Err(self.err(format!(
651                            "expected SEGMENTS after COMPACT COLD, got {:?}",
652                            self.peek()
653                        )));
654                    }
655                };
656                if !segments.eq_ignore_ascii_case("segments") {
657                    return Err(self.err(format!(
658                        "expected SEGMENTS after COMPACT COLD, got {segments:?}"
659                    )));
660                }
661                self.advance();
662                Ok(Statement::CompactColdSegments)
663            }
664            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("analyze") => {
665                self.advance();
666                let target = match self.peek() {
667                    Token::Eof | Token::Semicolon => None,
668                    Token::Ident(_) | Token::QuotedIdent(_) => {
669                        Some(self.expect_ident_like()?)
670                    }
671                    other => {
672                        return Err(self.err(format!(
673                            "expected table name or end of statement after ANALYZE, got {other:?}"
674                        )));
675                    }
676                };
677                Ok(Statement::Analyze(target))
678            }
679            // v7.12.1 — `SET <name> [TO|=] <value>`. The
680            // `default_text_search_config` parameter is consumed
681            // by the FTS function dispatcher; other parameter
682            // names are recorded but treated as a no-op so PG
683            // dump output loads.
684            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {
685                self.advance();
686                // PG allows `SET LOCAL` / `SET SESSION` qualifiers
687                // — accept and ignore. MySQL adds `SET GLOBAL` too
688                // (and the alias `SET @@global.name = …` which the
689                // SessionVar path handles).
690                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("local") || s.eq_ignore_ascii_case("session") || s.eq_ignore_ascii_case("global"))
691                {
692                    self.advance();
693                }
694                // v7.14.0 — MySQL `SET NAMES <charset> [COLLATE
695                // <collation>]` — change the connection client
696                // charset. SPG stores UTF-8 always and orders
697                // bytewise; accept as a no-op.
698                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("names"))
699                {
700                    self.advance();
701                    // Charset ident-or-string.
702                    if matches!(
703                        self.peek(),
704                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
705                    ) {
706                        self.advance();
707                    }
708                    // Optional `COLLATE <name>`.
709                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate"))
710                    {
711                        self.advance();
712                        if matches!(
713                            self.peek(),
714                            Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
715                        ) {
716                            self.advance();
717                        }
718                    }
719                    return Ok(Statement::Empty);
720                }
721                // v7.16.2 — PG `SET [SESSION] AUTHORIZATION
722                // { DEFAULT | '<role>' | <ident> }` (mailrs
723                // round-10 A.1). pg_dump preamble emits the
724                // `DEFAULT` form to reset session authorization;
725                // SPG has no role system so this is a strict
726                // no-op. PG also accepts `RESET SESSION
727                // AUTHORIZATION` (handled by the RESET parser
728                // elsewhere). Reference:
729                // <https://www.postgresql.org/docs/current/sql-set-session-authorization.html>
730                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("authorization"))
731                {
732                    self.advance(); // AUTHORIZATION
733                    match self.peek().clone() {
734                        Token::Default => {
735                            self.advance();
736                        }
737                        Token::String(_)
738                        | Token::Ident(_)
739                        | Token::QuotedIdent(_) => {
740                            self.advance();
741                        }
742                        other => {
743                            return Err(self.err(alloc::format!(
744                                "expected DEFAULT / '<role>' / <ident> after SET SESSION AUTHORIZATION, got {other:?}"
745                            )));
746                        }
747                    }
748                    return Ok(Statement::Empty);
749                }
750                // v7.14.0 — MySQL `SET CHARACTER SET <charset>`
751                // alias — same accept-as-no-op as SET NAMES.
752                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
753                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
754                {
755                    self.advance(); // CHARACTER
756                    self.advance(); // SET
757                    if matches!(
758                        self.peek(),
759                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
760                    ) {
761                        self.advance();
762                    }
763                    return Ok(Statement::Empty);
764                }
765                // v7.14.0 — multi-assignment form
766                // `SET a = 1, b = 2, …`. Single-assignment is the
767                // 1-element case. Each LHS may be a regular ident
768                // or a SessionVar (`@VAR` / `@@VAR`).
769                let mut pairs: Vec<(String, crate::ast::SetValue)> = Vec::new();
770                loop {
771                    let lhs = match self.peek().clone() {
772                        Token::SessionVar(s) => {
773                            self.advance();
774                            s
775                        }
776                        Token::Ident(_) | Token::QuotedIdent(_) => self.parse_set_param_name()?,
777                        other => {
778                            return Err(self.err(format!(
779                                "expected parameter name after SET, got {other:?}"
780                            )));
781                        }
782                    };
783                    // Accept either `=` or the bare `TO` keyword.
784                    match self.peek() {
785                        Token::Eq => {
786                            self.advance();
787                        }
788                        Token::To => {
789                            self.advance();
790                        }
791                        other => {
792                            return Err(self.err(format!(
793                                "expected `=` or TO after SET {lhs}, got {other:?}"
794                            )));
795                        }
796                    }
797                    let value = self.parse_set_value()?;
798                    pairs.push((lhs, value));
799                    if matches!(self.peek(), Token::Comma) {
800                        self.advance();
801                        continue;
802                    }
803                    break;
804                }
805                if pairs.len() == 1 {
806                    let (name, value) = pairs.into_iter().next().unwrap();
807                    Ok(Statement::SetParameter { name, value })
808                } else {
809                    Ok(Statement::SetParameterList(pairs))
810                }
811            }
812            // v7.12.1 — `RESET <name>` / `RESET ALL`.
813            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("reset") => {
814                self.advance();
815                match self.peek().clone() {
816                    Token::All => {
817                        self.advance();
818                        Ok(Statement::ResetParameter(None))
819                    }
820                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("all") => {
821                        self.advance();
822                        Ok(Statement::ResetParameter(None))
823                    }
824                    _ => {
825                        let name = self.parse_set_param_name()?;
826                        Ok(Statement::ResetParameter(Some(name)))
827                    }
828                }
829            }
830            other => Err(self.err(format!(
831                "expected SELECT / CREATE / DROP / INSERT / UPDATE / DELETE / ALTER / BEGIN / COMMIT / \
832                 ROLLBACK / SAVEPOINT / RELEASE / SHOW at start of statement, got {other:?}"
833            ))),
834        }
835    }
836
837    fn parse_create_stmt(&mut self) -> Result<Statement, ParseError> {
838        debug_assert!(matches!(self.peek(), Token::Create));
839        self.advance();
840        match self.peek() {
841            Token::Table => self.parse_create_table_stmt_after_create(),
842            Token::Index => self.parse_create_index_stmt_after_create(false),
843            // v7.9.29 — `CREATE UNIQUE INDEX … [WHERE pred]`.
844            // The `UNIQUE` modifier turns a partial index into a
845            // partial-uniqueness invariant (only rows matching the
846            // WHERE predicate are checked for duplicates). mailrs
847            // K1 (3 hits: email_templates default, calendar_events
848            // master, calendar_events instance).
849            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unique") => {
850                self.advance();
851                if !matches!(self.peek(), Token::Index) {
852                    return Err(self.err(alloc::format!(
853                        "expected INDEX after CREATE UNIQUE, got {:?}",
854                        self.peek()
855                    )));
856                }
857                self.parse_create_index_stmt_after_create(true)
858            }
859            Token::Publication => {
860                self.advance();
861                self.parse_create_publication_after_keyword()
862            }
863            Token::Subscription => {
864                self.advance();
865                self.parse_create_subscription_after_keyword()
866            }
867            // v4.1: CREATE USER 'name' WITH PASSWORD 'pw' [ROLE 'role'].
868            // USER isn't a reserved keyword — we look for the bare
869            // identifier so the lexer doesn't have to grow a token.
870            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
871                self.advance();
872                self.parse_create_user_after_keyword()
873            }
874            // v7.9.15 — `CREATE EXTENSION [IF NOT EXISTS] <name>
875            // [WITH SCHEMA …] [VERSION '…'] [CASCADE]` as a
876            // no-op. mailrs follow-up F3.
877            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("extension") => {
878                self.advance();
879                self.parse_create_extension_after_keyword()
880            }
881            // v7.12.4 — `CREATE [OR REPLACE] FUNCTION …` and
882            // `CREATE [OR REPLACE] TRIGGER …`. `OR REPLACE` is
883            // optional; absorb it here and forward to the
884            // per-kind parsers with the flag. OR is a reserved
885            // keyword token.
886            Token::Or => {
887                self.advance();
888                let next = self.peek();
889                let (Token::Ident(s2) | Token::QuotedIdent(s2)) = next else {
890                    return Err(self.err(alloc::format!(
891                        "expected REPLACE after CREATE OR, got {next:?}"
892                    )));
893                };
894                if !s2.eq_ignore_ascii_case("replace") {
895                    return Err(self.err(alloc::format!(
896                        "expected REPLACE after CREATE OR, got {s2:?}"
897                    )));
898                }
899                self.advance();
900                self.parse_create_function_or_trigger_after_or_replace(true)
901            }
902            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
903                self.advance();
904                self.parse_create_function_after_keyword(false)
905            }
906            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
907                self.advance();
908                self.parse_create_trigger_after_keyword(false)
909            }
910            // v7.14.0 — pg_dump / mysqldump emit
911            // `CREATE SEQUENCE / SCHEMA / VIEW / MATERIALIZED VIEW
912            // / TYPE / DOMAIN / DATABASE / ROLE / POLICY / OPERATOR`.
913            // SPG is single-schema / single-database; these have
914            // no behavioural effect, so consume + return Empty.
915            Token::Ident(s) | Token::QuotedIdent(s)
916                if matches!(
917                    s.to_ascii_lowercase().as_str(),
918                    "sequence"
919                        | "schema"
920                        | "view"
921                        | "materialized"
922                        | "type"
923                        | "domain"
924                        | "database"
925                        | "role"
926                        | "policy"
927                        | "operator"
928                        | "cast"
929                        | "rule"
930                        | "aggregate"
931                        | "language"
932                        | "collation"
933                        | "conversion"
934                ) =>
935            {
936                self.consume_until_statement_boundary();
937                Ok(Statement::Empty)
938            }
939            other => Err(self.err(format!(
940                "expected TABLE / INDEX / USER / EXTENSION / PUBLICATION / SUBSCRIPTION / FUNCTION / TRIGGER / SEQUENCE / SCHEMA / VIEW / TYPE / DOMAIN [OR REPLACE …] after CREATE, got {other:?}"
941            ))),
942        }
943    }
944
945    /// v7.12.4 — `CREATE OR REPLACE` already consumed; the next
946    /// keyword decides whether we parse a function or trigger
947    /// body. PG accepts other `OR REPLACE`-able objects (VIEW,
948    /// PROCEDURE) — those land in later releases.
949    fn parse_create_function_or_trigger_after_or_replace(
950        &mut self,
951        or_replace: bool,
952    ) -> Result<Statement, ParseError> {
953        let tok = self.peek();
954        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
955            return Err(self.err(alloc::format!(
956                "expected FUNCTION / TRIGGER after CREATE OR REPLACE, got {tok:?}"
957            )));
958        };
959        if s.eq_ignore_ascii_case("function") {
960            self.advance();
961            self.parse_create_function_after_keyword(or_replace)
962        } else if s.eq_ignore_ascii_case("trigger") {
963            self.advance();
964            self.parse_create_trigger_after_keyword(or_replace)
965        } else {
966            Err(self.err(alloc::format!(
967                "expected FUNCTION / TRIGGER after CREATE OR REPLACE, got {s:?}"
968            )))
969        }
970    }
971
972    /// v7.9.15 — accept and discard `CREATE EXTENSION` DDL.
973    /// SPG doesn't have a registry; pgvector / similar are
974    /// either builtin (VECTOR(N) ↔ pgvector) or n/a. Parsing
975    /// the syntax lets dual-target schemas keep the line.
976    fn parse_create_extension_after_keyword(&mut self) -> Result<Statement, ParseError> {
977        // Optional `IF NOT EXISTS`.
978        self.consume_if_not_exists();
979        let name = self.expect_ident_like()?;
980        // Drain optional WITH SCHEMA <ident> / VERSION '<v>' /
981        // CASCADE / FROM '<v>' clauses; we don't model them.
982        loop {
983            match self.peek() {
984                Token::Ident(s) if s.eq_ignore_ascii_case("with") => {
985                    self.advance();
986                    continue;
987                }
988                Token::Ident(s) if s.eq_ignore_ascii_case("schema") => {
989                    self.advance();
990                    let _ = self.expect_ident_like()?;
991                    continue;
992                }
993                Token::Ident(s) if s.eq_ignore_ascii_case("version") => {
994                    self.advance();
995                    // String or ident literal.
996                    let _ = self.advance();
997                    continue;
998                }
999                Token::Ident(s) if s.eq_ignore_ascii_case("from") => {
1000                    self.advance();
1001                    let _ = self.advance();
1002                    continue;
1003                }
1004                Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => {
1005                    self.advance();
1006                    continue;
1007                }
1008                _ => break,
1009            }
1010        }
1011        Ok(Statement::CreateExtension(name))
1012    }
1013
1014    /// v7.12.4 — body of `CREATE [OR REPLACE] FUNCTION`. The
1015    /// `[OR REPLACE]` flag (and the `FUNCTION` keyword) have
1016    /// already been consumed by the caller. Grammar accepted:
1017    ///
1018    ///   name `(` arg-list `)`
1019    ///   `RETURNS` return-type
1020    ///   [ `LANGUAGE` ident ]
1021    ///   `AS` $$ body $$
1022    ///   [ `LANGUAGE` ident ]
1023    ///
1024    /// Either `LANGUAGE` position is allowed; PG accepts both.
1025    fn parse_create_function_after_keyword(
1026        &mut self,
1027        or_replace: bool,
1028    ) -> Result<Statement, ParseError> {
1029        let name = self.expect_ident_like()?;
1030        // Argument list. v7.12.4 commonly sees the empty `()`
1031        // (trigger functions); typed args parse and round-trip
1032        // but the executor only invokes nullary functions.
1033        if !matches!(self.peek(), Token::LParen) {
1034            return Err(self.err(alloc::format!(
1035                "expected '(' after function name {name:?}, got {:?}",
1036                self.peek()
1037            )));
1038        }
1039        self.advance();
1040        let args = self.parse_function_arg_list()?;
1041        // RETURNS clause.
1042        let tok = self.peek();
1043        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1044            return Err(self.err(alloc::format!(
1045                "expected RETURNS after function arg list, got {tok:?}"
1046            )));
1047        };
1048        if !s.eq_ignore_ascii_case("returns") {
1049            return Err(self.err(alloc::format!(
1050                "expected RETURNS after function arg list, got {s:?}"
1051            )));
1052        }
1053        self.advance();
1054        let returns = self.parse_function_return()?;
1055        // Optional LANGUAGE clause (PG also accepts after AS — we'll
1056        // re-check after the body too).
1057        let mut language: Option<String> = self.parse_optional_language()?;
1058        // `AS` followed by a $$-quoted body (lexer already
1059        // collapses both `$$…$$` and `$tag$…$tag$` to a single
1060        // Token::String). AS is a reserved keyword (Token::As).
1061        if !matches!(self.peek(), Token::As) {
1062            return Err(self.err(alloc::format!(
1063                "expected AS before function body, got {:?}",
1064                self.peek()
1065            )));
1066        }
1067        self.advance();
1068        let body_text = match self.peek() {
1069            Token::String(s) => {
1070                let body = s.clone();
1071                self.advance();
1072                body
1073            }
1074            other => {
1075                return Err(self.err(alloc::format!(
1076                    "expected $$-quoted function body after AS, got {other:?}"
1077                )));
1078            }
1079        };
1080        // Trailing optional LANGUAGE clause (the other PG position).
1081        if language.is_none() {
1082            language = self.parse_optional_language()?;
1083        }
1084        let language = language.unwrap_or_else(|| String::from("sql"));
1085        // PL/pgSQL bodies get structure-parsed. Other languages
1086        // (or PL/pgSQL bodies the v7.12.4 parser doesn't yet
1087        // recognise) round-trip as Raw text — the executor errors
1088        // when invoked with a clear unsupported message.
1089        let body = if language.eq_ignore_ascii_case("plpgsql") {
1090            match parse_plpgsql_body(&body_text) {
1091                Ok(block) => FunctionBody::PlPgSql(block),
1092                // Best-effort: if the body parser doesn't yet
1093                // support a construct used inside, fall back to
1094                // raw — keeps `CREATE FUNCTION` itself working
1095                // (catalogue accepts), executor errors on
1096                // invocation only.
1097                Err(_) => FunctionBody::Raw(body_text),
1098            }
1099        } else {
1100            FunctionBody::Raw(body_text)
1101        };
1102        Ok(Statement::CreateFunction(CreateFunctionStatement {
1103            name,
1104            or_replace,
1105            args,
1106            returns,
1107            language,
1108            body,
1109        }))
1110    }
1111
1112    /// Closing `)`-terminated argument list. v7.12.4 commonly
1113    /// sees the empty `()`; typed args round-trip but the
1114    /// executor (yet) doesn't invoke them.
1115    fn parse_function_arg_list(&mut self) -> Result<Vec<FunctionArg>, ParseError> {
1116        let mut args: Vec<FunctionArg> = Vec::new();
1117        if matches!(self.peek(), Token::RParen) {
1118            self.advance();
1119            return Ok(args);
1120        }
1121        loop {
1122            // Optional `IN` / `OUT` / `INOUT` mode keyword. IN is
1123            // a reserved token; OUT / INOUT are bare idents.
1124            let mode = if matches!(self.peek(), Token::In) {
1125                self.advance();
1126                FunctionArgMode::In
1127            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("out"))
1128            {
1129                self.advance();
1130                FunctionArgMode::Out
1131            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("inout"))
1132            {
1133                self.advance();
1134                FunctionArgMode::InOut
1135            } else {
1136                FunctionArgMode::In
1137            };
1138            // Optional name. The next token is either a name
1139            // (followed by a type ident) or the type itself.
1140            // Disambiguate by peeking ahead: if the token after
1141            // the next ident is also an ident, we treat the
1142            // first as the name.
1143            let (name, ty_token) = {
1144                let first = self.expect_ident_like()?;
1145                // Peek next: if it's an ident (i.e. a type
1146                // name) the `first` was the arg name.
1147                match self.peek() {
1148                    Token::Ident(_) | Token::QuotedIdent(_) => {
1149                        let ty = self.expect_ident_like()?;
1150                        (Some(first), ty)
1151                    }
1152                    _ => (None, first),
1153                }
1154            };
1155            // Type — try to map to ColumnTypeName, else Raw.
1156            let ty = match map_type_ident_to_column_type_name(&ty_token) {
1157                Some(t) => FunctionArgType::Typed(t),
1158                None => FunctionArgType::Raw(ty_token),
1159            };
1160            args.push(FunctionArg { mode, name, ty });
1161            match self.peek() {
1162                Token::Comma => {
1163                    self.advance();
1164                    continue;
1165                }
1166                Token::RParen => {
1167                    self.advance();
1168                    return Ok(args);
1169                }
1170                other => {
1171                    return Err(self.err(alloc::format!(
1172                        "expected , or ) in function arg list, got {other:?}"
1173                    )));
1174                }
1175            }
1176        }
1177    }
1178
1179    fn parse_function_return(&mut self) -> Result<FunctionReturn, ParseError> {
1180        let ident = self.expect_ident_like()?;
1181        if ident.eq_ignore_ascii_case("trigger") {
1182            return Ok(FunctionReturn::Trigger);
1183        }
1184        if ident.eq_ignore_ascii_case("void") {
1185            return Ok(FunctionReturn::Void);
1186        }
1187        match map_type_ident_to_column_type_name(&ident) {
1188            Some(t) => Ok(FunctionReturn::Type(t)),
1189            None => Ok(FunctionReturn::Other(ident)),
1190        }
1191    }
1192
1193    fn parse_optional_language(&mut self) -> Result<Option<String>, ParseError> {
1194        match self.peek() {
1195            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("language") => {
1196                self.advance();
1197                let lang = self.expect_ident_like()?;
1198                Ok(Some(lang.to_ascii_lowercase()))
1199            }
1200            _ => Ok(None),
1201        }
1202    }
1203
1204    /// v7.12.4 — body of `CREATE [OR REPLACE] TRIGGER`. The
1205    /// `[OR REPLACE]` flag and the `TRIGGER` keyword have already
1206    /// been consumed.
1207    fn parse_create_trigger_after_keyword(
1208        &mut self,
1209        or_replace: bool,
1210    ) -> Result<Statement, ParseError> {
1211        let name = self.expect_ident_like()?;
1212        let timing = {
1213            let ident = self.expect_ident_like()?;
1214            if ident.eq_ignore_ascii_case("before") {
1215                TriggerTiming::Before
1216            } else if ident.eq_ignore_ascii_case("after") {
1217                TriggerTiming::After
1218            } else if ident.eq_ignore_ascii_case("instead") {
1219                let next = self.expect_ident_like()?;
1220                if !next.eq_ignore_ascii_case("of") {
1221                    return Err(self.err(alloc::format!(
1222                        "expected OF after INSTEAD in trigger timing, got {next:?}"
1223                    )));
1224                }
1225                TriggerTiming::InsteadOf
1226            } else {
1227                return Err(self.err(alloc::format!(
1228                    "expected BEFORE / AFTER / INSTEAD OF in trigger timing, got {ident:?}"
1229                )));
1230            }
1231        };
1232        // Events: INSERT [ OR UPDATE [ OR DELETE [ OR TRUNCATE ] ] ].
1233        // OR is a reserved keyword token (Token::Or), not an Ident.
1234        // v7.13.0 — after an UPDATE event we may optionally see
1235        // `OF col, col, …` (mailrs round-5 G7). Columns are
1236        // captured into `update_columns` once across the whole
1237        // events list; multiple `UPDATE OF` clauses are rejected.
1238        let mut events: Vec<TriggerEvent> = Vec::new();
1239        let mut update_columns: Vec<String> = Vec::new();
1240        let (first_ev, first_cols) = self.parse_trigger_event_with_optional_of()?;
1241        events.push(first_ev);
1242        if !first_cols.is_empty() {
1243            update_columns = first_cols;
1244        }
1245        while matches!(self.peek(), Token::Or) {
1246            self.advance();
1247            let (ev, cols) = self.parse_trigger_event_with_optional_of()?;
1248            events.push(ev);
1249            if !cols.is_empty() {
1250                if !update_columns.is_empty() {
1251                    return Err(
1252                        self.err("CREATE TRIGGER: `UPDATE OF cols` may appear at most once".into())
1253                    );
1254                }
1255                update_columns = cols;
1256            }
1257        }
1258        // ON <table>
1259        let tok = self.peek();
1260        let Token::On = tok else {
1261            return Err(self.err(alloc::format!(
1262                "expected ON after trigger events, got {tok:?}"
1263            )));
1264        };
1265        self.advance();
1266        let table = self.expect_ident_like()?;
1267        // FOR EACH ROW / FOR EACH STATEMENT. FOR is a reserved
1268        // keyword (Token::For); EACH / ROW / STATEMENT are bare
1269        // idents.
1270        if !matches!(self.peek(), Token::For) {
1271            return Err(self.err(alloc::format!(
1272                "expected FOR EACH ROW / STATEMENT, got {:?}",
1273                self.peek()
1274            )));
1275        }
1276        self.advance();
1277        let for_each = {
1278            let e = self.expect_ident_like()?;
1279            if !e.eq_ignore_ascii_case("each") {
1280                return Err(self.err(alloc::format!("expected EACH after FOR, got {e:?}")));
1281            }
1282            let unit = self.expect_ident_like()?;
1283            if unit.eq_ignore_ascii_case("row") {
1284                TriggerForEach::Row
1285            } else if unit.eq_ignore_ascii_case("statement") {
1286                TriggerForEach::Statement
1287            } else {
1288                return Err(self.err(alloc::format!(
1289                    "expected ROW / STATEMENT after FOR EACH, got {unit:?}"
1290                )));
1291            }
1292        };
1293        // EXECUTE FUNCTION/PROCEDURE name(...)
1294        let exec = self.expect_ident_like()?;
1295        if !exec.eq_ignore_ascii_case("execute") {
1296            return Err(self.err(alloc::format!(
1297                "expected EXECUTE FUNCTION/PROCEDURE in CREATE TRIGGER, got {exec:?}"
1298            )));
1299        }
1300        let fn_or_proc = self.expect_ident_like()?;
1301        if !(fn_or_proc.eq_ignore_ascii_case("function")
1302            || fn_or_proc.eq_ignore_ascii_case("procedure"))
1303        {
1304            return Err(self.err(alloc::format!(
1305                "expected FUNCTION / PROCEDURE after EXECUTE, got {fn_or_proc:?}"
1306            )));
1307        }
1308        let function = self.expect_ident_like()?;
1309        // Optional empty arg list `()`.
1310        if matches!(self.peek(), Token::LParen) {
1311            self.advance();
1312            if !matches!(self.peek(), Token::RParen) {
1313                return Err(self.err(alloc::format!(
1314                    "v7.12.4 trigger function calls take no args; got {:?}",
1315                    self.peek()
1316                )));
1317            }
1318            self.advance();
1319        }
1320        Ok(Statement::CreateTrigger(CreateTriggerStatement {
1321            name,
1322            or_replace,
1323            timing,
1324            events,
1325            table,
1326            for_each,
1327            function,
1328            update_columns,
1329        }))
1330    }
1331
1332    /// v7.13.0 — parse one trigger event, then optionally consume
1333    /// `OF col, col, …` after `UPDATE` (mailrs round-5 G7). Other
1334    /// events (INSERT/DELETE/TRUNCATE) don't accept the OF tail.
1335    fn parse_trigger_event_with_optional_of(
1336        &mut self,
1337    ) -> Result<(TriggerEvent, Vec<String>), ParseError> {
1338        let ev = self.parse_trigger_event()?;
1339        if !matches!(ev, TriggerEvent::Update) {
1340            return Ok((ev, Vec::new()));
1341        }
1342        // `OF` is a bare ident.
1343        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("of")) {
1344            return Ok((ev, Vec::new()));
1345        }
1346        self.advance(); // OF
1347        let mut cols: Vec<String> = Vec::new();
1348        loop {
1349            cols.push(self.expect_ident_like()?);
1350            if matches!(self.peek(), Token::Comma) {
1351                self.advance();
1352                continue;
1353            }
1354            break;
1355        }
1356        if cols.is_empty() {
1357            return Err(
1358                self.err("CREATE TRIGGER: `UPDATE OF` requires at least one column name".into())
1359            );
1360        }
1361        Ok((ev, cols))
1362    }
1363
1364    /// v7.12.4 — `BEGIN stmt; stmt; … END[;]` PL/pgSQL block.
1365    /// v7.12.6 — optional `DECLARE var TYPE [:= init];` prelude
1366    /// before `BEGIN`, and IF / RAISE / embedded SQL statements
1367    /// inside the body.
1368    /// Called by [`parse_plpgsql_body`] after the body's tokens
1369    /// have been lexed into this temporary parser.
1370    pub(crate) fn parse_plpgsql_block(&mut self) -> Result<PlPgSqlBlock, ParseError> {
1371        // v7.12.6 — optional DECLARE prelude.
1372        let declarations = if matches!(
1373            self.peek(),
1374            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("declare")
1375        ) {
1376            self.advance();
1377            self.parse_plpgsql_declare_block()?
1378        } else {
1379            Vec::new()
1380        };
1381        // BEGIN keyword (PL/pgSQL — distinct from the SQL
1382        // `BEGIN` transaction-start, but we can reuse the
1383        // reserved Token::Begin since the body is a separate
1384        // lex/parse context).
1385        if !matches!(self.peek(), Token::Begin) {
1386            return Err(self.err(alloc::format!(
1387                "expected BEGIN at start of plpgsql block, got {:?}",
1388                self.peek()
1389            )));
1390        }
1391        self.advance();
1392        let statements = self.parse_plpgsql_stmt_list_until_end()?;
1393        Ok(PlPgSqlBlock {
1394            declarations,
1395            statements,
1396        })
1397    }
1398
1399    /// v7.12.6 — parse the `DECLARE ... [var TYPE [:= init];]+`
1400    /// prelude. Caller has already consumed `DECLARE`. We stop
1401    /// reading entries when we hit `BEGIN`.
1402    fn parse_plpgsql_declare_block(&mut self) -> Result<Vec<PlPgSqlDeclare>, ParseError> {
1403        let mut out: Vec<PlPgSqlDeclare> = Vec::new();
1404        loop {
1405            if matches!(self.peek(), Token::Begin) {
1406                return Ok(out);
1407            }
1408            let name = self.expect_ident_like()?;
1409            let ty_token = self.expect_ident_like()?;
1410            let ty = match map_type_ident_to_column_type_name(&ty_token) {
1411                Some(t) => FunctionArgType::Typed(t),
1412                None => FunctionArgType::Raw(ty_token),
1413            };
1414            let default = match self.peek() {
1415                Token::ColonEq => {
1416                    self.advance();
1417                    Some(self.parse_expr(0)?)
1418                }
1419                Token::Eq => {
1420                    // PL/pgSQL also accepts `=` for the
1421                    // DECLARE default (PG treats them the same
1422                    // in this position).
1423                    self.advance();
1424                    Some(self.parse_expr(0)?)
1425                }
1426                _ => None,
1427            };
1428            // Mandatory `;` between declarations.
1429            if !matches!(self.peek(), Token::Semicolon) {
1430                return Err(self.err(alloc::format!(
1431                    "expected ; after DECLARE entry for {name:?}, got {:?}",
1432                    self.peek()
1433                )));
1434            }
1435            self.advance();
1436            out.push(PlPgSqlDeclare { name, ty, default });
1437        }
1438    }
1439
1440    /// v7.12.6 — parse PL/pgSQL statements up to (and consuming)
1441    /// the terminating `END;` (or `END IF;` etc — handled by the
1442    /// per-construct sub-parsers). Used by both the outer block
1443    /// and the IF/ELSE branch bodies.
1444    fn parse_plpgsql_stmt_list_until_end(&mut self) -> Result<Vec<PlPgSqlStmt>, ParseError> {
1445        let mut statements: Vec<PlPgSqlStmt> = Vec::new();
1446        loop {
1447            // Allow trailing semicolons + END.
1448            while matches!(self.peek(), Token::Semicolon) {
1449                self.advance();
1450            }
1451            // END / ELSE / ELSIF — handled by the caller.
1452            if matches!(
1453                self.peek(),
1454                Token::Ident(s) | Token::QuotedIdent(s)
1455                    if s.eq_ignore_ascii_case("end")
1456                        || s.eq_ignore_ascii_case("else")
1457                        || s.eq_ignore_ascii_case("elsif")
1458                        || s.eq_ignore_ascii_case("elseif")
1459            ) {
1460                return Ok(statements);
1461            }
1462            // Otherwise: one statement, then expect `;` or
1463            // a block-terminator keyword.
1464            let stmt = self.parse_plpgsql_stmt()?;
1465            statements.push(stmt);
1466            match self.peek() {
1467                Token::Semicolon => {
1468                    self.advance();
1469                }
1470                Token::Ident(s) | Token::QuotedIdent(s)
1471                    if s.eq_ignore_ascii_case("end")
1472                        || s.eq_ignore_ascii_case("else")
1473                        || s.eq_ignore_ascii_case("elsif")
1474                        || s.eq_ignore_ascii_case("elseif") =>
1475                {
1476                    // Final statement of the block without `;`.
1477                }
1478                other => {
1479                    return Err(self.err(alloc::format!(
1480                        "expected ; or END/ELSE/ELSIF after plpgsql statement, got {other:?}"
1481                    )));
1482                }
1483            }
1484        }
1485    }
1486
1487    fn parse_plpgsql_stmt(&mut self) -> Result<PlPgSqlStmt, ParseError> {
1488        // RETURN keyword?
1489        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("return"))
1490        {
1491            self.advance();
1492            return self.parse_plpgsql_return();
1493        }
1494        // v7.12.6 — IF block.
1495        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
1496        {
1497            self.advance();
1498            return self.parse_plpgsql_if();
1499        }
1500        // v7.12.6 — RAISE.
1501        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("raise"))
1502        {
1503            self.advance();
1504            return self.parse_plpgsql_raise();
1505        }
1506        // v7.16.2 — `SELECT <projection> INTO <var> [FROM …]`
1507        // plpgsql-specific shape (mailrs round-10 migrate-042).
1508        // PG's SELECT INTO at top-level SQL would CREATE a new
1509        // table; inside plpgsql it ASSIGNS the query result to
1510        // a local variable. We detect the INTO at paren-depth
1511        // 0 between SELECT and the statement boundary; if
1512        // found, split the token stream into "pre-INTO
1513        // projection" + "var" + "post-INTO FROM/WHERE…" and
1514        // rebuild as a SelectInto with a regular SELECT body
1515        // (no INTO clause).
1516        if matches!(self.peek(), Token::Select)
1517            && let Some((select_body, var_name)) = self.try_parse_plpgsql_select_into()?
1518        {
1519            return Ok(PlPgSqlStmt::SelectInto {
1520                var: var_name,
1521                body: Box::new(select_body),
1522            });
1523        }
1524        // v7.12.6 — embedded SQL statements. INSERT/UPDATE/DELETE/
1525        // SELECT can appear directly inside a trigger body; we
1526        // recurse into the regular Statement parser, which will
1527        // stop at the trailing `;` (which our caller then
1528        // consumes).
1529        // v7.16.2 — top-level DO blocks (mailrs round-10 A.2)
1530        // also embed ALTER / CREATE / DROP statements; route
1531        // those through the same parser so the DO body parses
1532        // cleanly.
1533        if matches!(self.peek(), Token::Insert)
1534            || matches!(self.peek(), Token::Select)
1535            || matches!(self.peek(), Token::Create)
1536            || matches!(self.peek(), Token::Drop)
1537            || matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1538                if s.eq_ignore_ascii_case("update")
1539                    || s.eq_ignore_ascii_case("delete")
1540                    || s.eq_ignore_ascii_case("alter"))
1541        {
1542            let stmt = self.parse_one_statement()?;
1543            return Ok(PlPgSqlStmt::EmbeddedSql(Box::new(stmt)));
1544        }
1545        // Otherwise: assignment. `NEW.col` / `OLD.col` / `var`
1546        // followed by `:=` and an expression.
1547        let target = self.parse_plpgsql_assign_target()?;
1548        // PL/pgSQL assignment uses `:=`. The lexer represents
1549        // this as a colon followed by `=`; check both shapes.
1550        match self.peek() {
1551            Token::ColonEq => {
1552                self.advance();
1553            }
1554            Token::Colon => {
1555                self.advance();
1556                if !matches!(self.peek(), Token::Eq) {
1557                    return Err(self.err(alloc::format!(
1558                        "expected := after plpgsql assign target, got `:` then {:?}",
1559                        self.peek()
1560                    )));
1561                }
1562                self.advance();
1563            }
1564            other => {
1565                return Err(self.err(alloc::format!(
1566                    "expected := after plpgsql assign target, got {other:?}"
1567                )));
1568            }
1569        }
1570        let value = self.parse_expr(0)?;
1571        Ok(PlPgSqlStmt::Assign { target, value })
1572    }
1573
1574    /// v7.12.6 — `IF cond THEN body [ELSIF cond THEN body]*
1575    /// [ELSE body] END IF`. `IF` keyword already consumed.
1576    fn parse_plpgsql_if(&mut self) -> Result<PlPgSqlStmt, ParseError> {
1577        let mut branches: Vec<(Expr, Vec<PlPgSqlStmt>)> = Vec::new();
1578        let mut else_branch: Vec<PlPgSqlStmt> = Vec::new();
1579        loop {
1580            // <expr> THEN
1581            let cond = self.parse_expr(0)?;
1582            let then_kw = self.expect_ident_like()?;
1583            if !then_kw.eq_ignore_ascii_case("then") {
1584                return Err(self.err(alloc::format!(
1585                    "expected THEN after IF/ELSIF condition, got {then_kw:?}"
1586                )));
1587            }
1588            let body = self.parse_plpgsql_stmt_list_until_end()?;
1589            branches.push((cond, body));
1590            // Look at terminator: ELSIF/ELSEIF, ELSE, or END IF.
1591            match self.peek() {
1592                Token::Ident(s) | Token::QuotedIdent(s)
1593                    if s.eq_ignore_ascii_case("elsif") || s.eq_ignore_ascii_case("elseif") =>
1594                {
1595                    self.advance();
1596                    continue;
1597                }
1598                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("else") => {
1599                    self.advance();
1600                    else_branch = self.parse_plpgsql_stmt_list_until_end()?;
1601                    break;
1602                }
1603                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
1604                    break;
1605                }
1606                other => {
1607                    return Err(self.err(alloc::format!(
1608                        "expected ELSIF / ELSE / END after IF branch body, got {other:?}"
1609                    )));
1610                }
1611            }
1612        }
1613        // Expect `END IF` (the END keyword is the one we're
1614        // looking at right now).
1615        let end_kw = self.expect_ident_like()?;
1616        if !end_kw.eq_ignore_ascii_case("end") {
1617            return Err(self.err(alloc::format!("expected END IF, got {end_kw:?}")));
1618        }
1619        let if_kw = self.expect_ident_like()?;
1620        if !if_kw.eq_ignore_ascii_case("if") {
1621            return Err(self.err(alloc::format!("expected END IF, got END {if_kw:?}")));
1622        }
1623        Ok(PlPgSqlStmt::If {
1624            branches,
1625            else_branch,
1626        })
1627    }
1628
1629    /// v7.12.6 — `RAISE { NOTICE | WARNING | INFO | LOG | DEBUG
1630    /// | EXCEPTION } '<message>' [, args]*`. The `RAISE` keyword
1631    /// is already consumed.
1632    fn parse_plpgsql_raise(&mut self) -> Result<PlPgSqlStmt, ParseError> {
1633        let lvl_ident = self.expect_ident_like()?;
1634        let level = match lvl_ident.to_ascii_lowercase().as_str() {
1635            "notice" => RaiseLevel::Notice,
1636            "warning" => RaiseLevel::Warning,
1637            "info" => RaiseLevel::Info,
1638            "log" => RaiseLevel::Log,
1639            "debug" => RaiseLevel::Debug,
1640            "exception" => RaiseLevel::Exception,
1641            other => {
1642                return Err(self.err(alloc::format!(
1643                    "expected RAISE level (NOTICE/WARNING/INFO/LOG/DEBUG/EXCEPTION), got {other:?}"
1644                )));
1645            }
1646        };
1647        // Message: required for v7.12.6. PG accepts a bare
1648        // RAISE-rethrow form (no message), reserved for future
1649        // RAISE-no-args support.
1650        let Token::String(msg) = self.peek() else {
1651            return Err(self.err(alloc::format!(
1652                "expected RAISE message string, got {:?}",
1653                self.peek()
1654            )));
1655        };
1656        let message = msg.clone();
1657        self.advance();
1658        // Optional comma-separated args (PG `%` format substitution).
1659        let mut args: Vec<Expr> = Vec::new();
1660        while matches!(self.peek(), Token::Comma) {
1661            self.advance();
1662            args.push(self.parse_expr(0)?);
1663        }
1664        Ok(PlPgSqlStmt::Raise {
1665            level,
1666            message,
1667            args,
1668        })
1669    }
1670
1671    /// v7.16.2 — scan ahead for a plpgsql-flavoured `SELECT
1672    /// <projection> INTO <var> [FROM …]` (mailrs round-10
1673    /// migrate-042). Returns `(rebuilt_select_without_into,
1674    /// var_name)` when the pattern matches; `None` for
1675    /// regular SELECTs (those go through the embedded-SQL
1676    /// path). Token-stream surgery so the rebuilt SELECT
1677    /// parses through the regular `parse_select_stmt`.
1678    #[allow(clippy::too_many_lines)]
1679    fn try_parse_plpgsql_select_into(
1680        &mut self,
1681    ) -> Result<Option<(SelectStatement, String)>, ParseError> {
1682        // Scan forward from `self.pos + 1` (past Token::Select)
1683        // for Token::Into at paren-depth 0, stopping at the
1684        // first `;`, `END`, `ELSE`, `ELSIF` keyword that would
1685        // end the plpgsql statement.
1686        let start = self.pos;
1687        let mut into_pos: Option<usize> = None;
1688        let mut depth: i32 = 0;
1689        let mut i = start + 1;
1690        while i < self.tokens.len() {
1691            match &self.tokens[i] {
1692                Token::LParen => depth += 1,
1693                Token::RParen => depth -= 1,
1694                Token::Semicolon if depth == 0 => break,
1695                Token::Ident(s)
1696                    if depth == 0
1697                        && (s.eq_ignore_ascii_case("end")
1698                            || s.eq_ignore_ascii_case("else")
1699                            || s.eq_ignore_ascii_case("elsif")) =>
1700                {
1701                    break;
1702                }
1703                Token::Into if depth == 0 => {
1704                    into_pos = Some(i);
1705                    break;
1706                }
1707                _ => {}
1708            }
1709            i += 1;
1710        }
1711        let Some(into_at) = into_pos else {
1712            return Ok(None);
1713        };
1714        // The token immediately after INTO must be the target
1715        // var ident; anything else (e.g. INSERT INTO table)
1716        // ruled out by the depth-0 check above. Capture it.
1717        let var = match self.tokens.get(into_at + 1) {
1718            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
1719            other => {
1720                return Err(self.err(alloc::format!(
1721                    "expected variable name after SELECT … INTO, got {other:?}"
1722                )));
1723            }
1724        };
1725        // Find the end of the plpgsql SELECT INTO statement —
1726        // same boundary rules as the depth-0 scan above.
1727        let mut end = into_at + 2;
1728        let mut depth2: i32 = 0;
1729        while end < self.tokens.len() {
1730            match &self.tokens[end] {
1731                Token::LParen => depth2 += 1,
1732                Token::RParen => depth2 -= 1,
1733                Token::Semicolon if depth2 == 0 => break,
1734                Token::Ident(s)
1735                    if depth2 == 0
1736                        && (s.eq_ignore_ascii_case("end")
1737                            || s.eq_ignore_ascii_case("else")
1738                            || s.eq_ignore_ascii_case("elsif")) =>
1739                {
1740                    break;
1741                }
1742                _ => {}
1743            }
1744            end += 1;
1745        }
1746        // Rebuild a token stream that represents the SELECT
1747        // WITHOUT the INTO clause: [SELECT .. up-to-INTO] + [
1748        // post-var tokens up to statement end]. Run the
1749        // regular `parse_select_stmt` against it.
1750        let mut rebuilt: Vec<Token> = Vec::with_capacity(end - start);
1751        for j in start..into_at {
1752            rebuilt.push(self.tokens[j].clone());
1753        }
1754        for j in (into_at + 2)..end {
1755            rebuilt.push(self.tokens[j].clone());
1756        }
1757        rebuilt.push(Token::Eof);
1758        let saved_pos = self.pos;
1759        let saved_tokens = core::mem::replace(&mut self.tokens, rebuilt);
1760        self.pos = 0;
1761        // parse_select_stmt → parse_bare_select consumes Token::Select itself.
1762        if !matches!(self.peek(), Token::Select) {
1763            self.tokens = saved_tokens;
1764            self.pos = saved_pos;
1765            return Err(self.err("plpgsql SELECT … INTO: rebuilt stream missing SELECT".into()));
1766        }
1767        let sel = self.parse_select_stmt();
1768        self.tokens = saved_tokens;
1769        self.pos = end;
1770        let sel = sel?;
1771        let Statement::Select(body) = sel else {
1772            return Err(self.err(alloc::format!(
1773                "plpgsql SELECT … INTO: rebuilt SELECT did not produce a Select node, got {sel:?}"
1774            )));
1775        };
1776        Ok(Some((body, var)))
1777    }
1778
1779    fn parse_plpgsql_assign_target(&mut self) -> Result<AssignTarget, ParseError> {
1780        // v7.16.1 — read the head token DIRECTLY rather than
1781        // via `expect_ident_like`. The v7.14.0 schema-qualifier
1782        // strip (`public.t` → `t`) inside `expect_ident_like`
1783        // greedily consumes any `ident . ident` pair, which
1784        // silently turned every `NEW.col := …` /
1785        // `OLD.col := …` plpgsql assignment into a Local("col")
1786        // assignment — the head "new"/"old" was eaten as if it
1787        // were a schema name and the Dot was consumed too, so
1788        // this function's own `peek() == Token::Dot` check
1789        // below never fired. Every BEFORE trigger that rewrote
1790        // a NEW cell was a silent no-op for two major releases
1791        // (v7.14.0 + v7.15.0) until the e2e_trigger workspace-
1792        // gate failures were investigated as v7.16.1 backlog.
1793        let head = match self.advance() {
1794            Token::Ident(s) | Token::QuotedIdent(s) => s,
1795            other => {
1796                return Err(self.err(alloc::format!(
1797                    "expected NEW / OLD / <local_var> as plpgsql assign target, got {other:?}"
1798                )));
1799            }
1800        };
1801        if matches!(self.peek(), Token::Dot) {
1802            self.advance();
1803            let col = self.expect_ident_like()?;
1804            if head.eq_ignore_ascii_case("new") {
1805                return Ok(AssignTarget::NewColumn(col));
1806            }
1807            if head.eq_ignore_ascii_case("old") {
1808                return Ok(AssignTarget::OldColumn(col));
1809            }
1810            return Err(self.err(alloc::format!(
1811                "plpgsql assign target must be NEW.<col> / OLD.<col> / <local_var>; \
1812                 got {head:?}.<col>"
1813            )));
1814        }
1815        Ok(AssignTarget::Local(head))
1816    }
1817
1818    fn parse_plpgsql_return(&mut self) -> Result<PlPgSqlStmt, ParseError> {
1819        // RETURN NEW / OLD / NULL — bare-ident forms.
1820        match self.peek() {
1821            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("new") => {
1822                self.advance();
1823                return Ok(PlPgSqlStmt::Return(ReturnTarget::New));
1824            }
1825            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("old") => {
1826                self.advance();
1827                return Ok(PlPgSqlStmt::Return(ReturnTarget::Old));
1828            }
1829            Token::Null => {
1830                self.advance();
1831                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
1832            }
1833            // Bare `RETURN;` (no value) — treated as `RETURN NULL`
1834            // per PL/pgSQL convention.
1835            Token::Semicolon => {
1836                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
1837            }
1838            _ => {}
1839        }
1840        // Fall through: parse a full expression.
1841        let e = self.parse_expr(0)?;
1842        Ok(PlPgSqlStmt::Return(ReturnTarget::Expr(e)))
1843    }
1844
1845    fn parse_trigger_event(&mut self) -> Result<TriggerEvent, ParseError> {
1846        // INSERT is a reserved Token; UPDATE / DELETE / TRUNCATE
1847        // are ident-shaped (the parser keys off case-insensitive
1848        // match — same shape used by the top-level Update / Delete
1849        // dispatchers at parse_one_statement).
1850        if matches!(self.peek(), Token::Insert) {
1851            self.advance();
1852            return Ok(TriggerEvent::Insert);
1853        }
1854        match self.peek() {
1855            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
1856                self.advance();
1857                Ok(TriggerEvent::Update)
1858            }
1859            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
1860                self.advance();
1861                Ok(TriggerEvent::Delete)
1862            }
1863            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("truncate") => {
1864                self.advance();
1865                Ok(TriggerEvent::Truncate)
1866            }
1867            other => Err(self.err(alloc::format!(
1868                "expected INSERT / UPDATE / DELETE / TRUNCATE in trigger event list, got {other:?}"
1869            ))),
1870        }
1871    }
1872
1873    /// v6.1.2 → v6.1.3 — `CREATE PUBLICATION <name>` body. Accepts:
1874    ///   - (no clause) → implicit `FOR ALL TABLES`
1875    ///   - `FOR ALL TABLES`
1876    ///   - `FOR ALL TABLES EXCEPT t1, t2, …` (v6.1.3)
1877    ///   - `FOR TABLE t1, t2, …` (v6.1.3) — `FOR TABLES …` also
1878    ///     accepted (PG accepts both forms in PG 19).
1879    fn parse_create_publication_after_keyword(&mut self) -> Result<Statement, ParseError> {
1880        let name = self.expect_ident_or_string()?;
1881        // Bare DDL maps to FOR ALL TABLES — matches the v6.1.2
1882        // shape so existing publications keep parsing identically.
1883        let scope = if matches!(self.peek(), Token::For) {
1884            self.advance();
1885            if matches!(self.peek(), Token::All) {
1886                self.advance();
1887                if !matches!(self.peek(), Token::Tables) {
1888                    return Err(self.err(format!(
1889                        "expected TABLES after FOR ALL, got {:?}",
1890                        self.peek()
1891                    )));
1892                }
1893                self.advance();
1894                if matches!(self.peek(), Token::Except) {
1895                    self.advance();
1896                    let tables = self.parse_publication_table_list()?;
1897                    PublicationScope::AllTablesExcept(tables)
1898                } else {
1899                    PublicationScope::AllTables
1900                }
1901            } else if matches!(self.peek(), Token::Table | Token::Tables) {
1902                // PG 19 accepts both `FOR TABLE …` (singular) and
1903                // `FOR TABLES …` (plural); SPG matches.
1904                self.advance();
1905                let tables = self.parse_publication_table_list()?;
1906                PublicationScope::ForTables(tables)
1907            } else {
1908                return Err(self.err(format!(
1909                    "expected ALL TABLES or TABLE <list> after FOR, got {:?}",
1910                    self.peek()
1911                )));
1912            }
1913        } else {
1914            PublicationScope::AllTables
1915        };
1916        Ok(Statement::CreatePublication(CreatePublicationStatement {
1917            name,
1918            scope,
1919        }))
1920    }
1921
1922    /// v6.1.3 — Comma-separated identifier list for the publication
1923    /// FOR-clause. Requires at least one entry; empty list is a
1924    /// parse error (PG behaviour). Quoted idents are accepted; the
1925    /// names round-trip through `Display` as `quote_ident(name)`.
1926    fn parse_publication_table_list(&mut self) -> Result<Vec<String>, ParseError> {
1927        let first = self.expect_ident_like()?;
1928        let mut out = alloc::vec![first];
1929        while matches!(self.peek(), Token::Comma) {
1930            self.advance();
1931            out.push(self.expect_ident_like()?);
1932        }
1933        Ok(out)
1934    }
1935
1936    /// v6.1.4 — `CREATE SUBSCRIPTION <name>
1937    ///                 CONNECTION '<conn>'
1938    ///                 PUBLICATION <pub> [, <pub> ...]`.
1939    ///
1940    /// The clause order is fixed (CONNECTION first, then
1941    /// PUBLICATION) to match PG. No WITH-options accepted in
1942    /// v6.1.4 — `enabled` defaults to true, no other knobs ship.
1943    fn parse_create_subscription_after_keyword(&mut self) -> Result<Statement, ParseError> {
1944        let name = self.expect_ident_or_string()?;
1945        if !matches!(self.peek(), Token::Connection) {
1946            return Err(self.err(format!(
1947                "expected CONNECTION after CREATE SUBSCRIPTION <name>, got {:?}",
1948                self.peek()
1949            )));
1950        }
1951        self.advance();
1952        let conn_str = self.expect_string_literal()?;
1953        if !matches!(self.peek(), Token::Publication) {
1954            return Err(self.err(format!(
1955                "expected PUBLICATION after CONNECTION '<conn>', got {:?}",
1956                self.peek()
1957            )));
1958        }
1959        self.advance();
1960        // Reuse the publication FOR-list parser shape: at least one
1961        // identifier, comma-separated.
1962        let first = self.expect_ident_like()?;
1963        let mut publications = alloc::vec![first];
1964        while matches!(self.peek(), Token::Comma) {
1965            self.advance();
1966            publications.push(self.expect_ident_like()?);
1967        }
1968        Ok(Statement::CreateSubscription(CreateSubscriptionStatement {
1969            name,
1970            conn_str,
1971            publications,
1972        }))
1973    }
1974
1975    /// v6.1.7 — `WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>]`.
1976    /// All keywords after `WAIT` are bare idents in v6.1.x; no
1977    /// lexer churn. Both `<pos>` and `<ms>` are positive integers
1978    /// that fit `u64`.
1979    /// v7.12.1 — parameter name in `SET <name>` may be dotted
1980    /// (`pg_catalog.default_text_search_config` etc).
1981    fn parse_set_param_name(&mut self) -> Result<String, ParseError> {
1982        let mut name = self.expect_ident_like()?;
1983        while matches!(self.peek(), Token::Dot) {
1984            self.advance();
1985            let next = self.expect_ident_like()?;
1986            name.push('.');
1987            name.push_str(&next);
1988        }
1989        Ok(name.to_ascii_lowercase())
1990    }
1991
1992    fn parse_set_value(&mut self) -> Result<crate::ast::SetValue, ParseError> {
1993        match self.advance() {
1994            Token::String(s) => Ok(crate::ast::SetValue::String(s)),
1995            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("default") => {
1996                Ok(crate::ast::SetValue::Default)
1997            }
1998            Token::Ident(s) | Token::QuotedIdent(s) => {
1999                let mut accum = s;
2000                while matches!(self.peek(), Token::Dot) {
2001                    self.advance();
2002                    let next = self.expect_ident_like()?;
2003                    accum.push('.');
2004                    accum.push_str(&next);
2005                }
2006                Ok(crate::ast::SetValue::Ident(accum))
2007            }
2008            Token::Integer(n) => Ok(crate::ast::SetValue::Number(n.to_string())),
2009            Token::Float(f) => Ok(crate::ast::SetValue::Number(f.to_string())),
2010            // v7.14.0 — MySQL session/user variable RHS
2011            // (e.g. `SET OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS`).
2012            // Wrap as Ident so the SET handler can record it; the
2013            // engine treats `@VAR` / `@@VAR` values as opaque
2014            // strings.
2015            Token::SessionVar(s) => Ok(crate::ast::SetValue::Ident(s)),
2016            // v7.14.0 — `SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO,STRICT_TRANS_TABLES'`
2017            // is the common MySQL preamble shape. Allow a `+` or
2018            // `-` prefix on negative numerics for parity with PG
2019            // (some param defaults are negative).
2020            Token::Minus => match self.advance() {
2021                Token::Integer(n) => Ok(crate::ast::SetValue::Number(alloc::format!("-{n}"))),
2022                Token::Float(f) => Ok(crate::ast::SetValue::Number(alloc::format!("-{f}"))),
2023                other => Err(self.err(format!(
2024                    "expected numeric after `-` in SET value, got {other:?}"
2025                ))),
2026            },
2027            other => Err(self.err(format!(
2028                "expected literal, identifier, or DEFAULT after `=` in SET, got {other:?}"
2029            ))),
2030        }
2031    }
2032
2033    fn parse_wait_after_keyword(&mut self) -> Result<Statement, ParseError> {
2034        // FOR is a v6.1.2-reserved keyword (Token::For). The
2035        // other two are bare idents — they've never needed lexer
2036        // support and we keep it that way.
2037        if !matches!(self.peek(), Token::For) {
2038            return Err(self.err(format!("expected FOR after WAIT, got {:?}", self.peek())));
2039        }
2040        self.advance();
2041        self.expect_keyword_ident("wal")?;
2042        self.expect_keyword_ident("position")?;
2043        let pos = self.expect_u64_literal()?;
2044        let timeout_ms = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with"))
2045        {
2046            self.advance();
2047            self.expect_keyword_ident("timeout")?;
2048            Some(self.expect_u64_literal()?)
2049        } else {
2050            None
2051        };
2052        Ok(Statement::WaitForWalPosition { pos, timeout_ms })
2053    }
2054
2055    /// v6.1.7 helper — consume a `Token::Integer` and check it
2056    /// fits `u64`. WAL positions and millisecond timeouts are
2057    /// non-negative.
2058    fn expect_u64_literal(&mut self) -> Result<u64, ParseError> {
2059        match self.advance() {
2060            Token::Integer(n) if n >= 0 => Ok(n as u64),
2061            Token::Integer(n) => Err(ParseError {
2062                message: format!("expected non-negative integer, got {n}"),
2063                token_pos: self.pos.saturating_sub(1),
2064            }),
2065            other => Err(ParseError {
2066                message: format!("expected integer literal, got {other:?}"),
2067                token_pos: self.pos.saturating_sub(1),
2068            }),
2069        }
2070    }
2071
2072    /// `CREATE USER` body — name + WITH PASSWORD '<pw>' + optional
2073    /// ROLE '<role>' (defaults to readonly). All string slots accept
2074    /// either a quoted ident or a quoted string literal.
2075    fn parse_create_user_after_keyword(&mut self) -> Result<Statement, ParseError> {
2076        let name = self.expect_ident_or_string()?;
2077        self.expect_keyword_ident("with")?;
2078        self.expect_keyword_ident("password")?;
2079        let password = self.expect_string_literal()?;
2080        let role = if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
2081            && s.eq_ignore_ascii_case("role")
2082        {
2083            self.advance();
2084            self.expect_string_literal()?
2085        } else {
2086            "readonly".to_string()
2087        };
2088        Ok(Statement::CreateUser(crate::ast::CreateUserStatement {
2089            name,
2090            password,
2091            role,
2092        }))
2093    }
2094
2095    /// v4.4 `UPDATE <table> SET col = expr [, col = expr]* [WHERE cond]`.
2096    /// Caller already consumed the leading `UPDATE` ident.
2097    fn parse_update_after_keyword(&mut self) -> Result<Statement, ParseError> {
2098        let table = self.expect_ident_like()?;
2099        self.expect_keyword_ident("set")?;
2100        let mut assignments = Vec::new();
2101        loop {
2102            let col = self.expect_ident_like()?;
2103            if !matches!(self.peek(), Token::Eq) {
2104                return Err(self.err(format!(
2105                    "expected `=` after column name in UPDATE SET, got {:?}",
2106                    self.peek()
2107                )));
2108            }
2109            self.advance();
2110            let value = self.parse_expr(0)?;
2111            assignments.push((col, value));
2112            if matches!(self.peek(), Token::Comma) {
2113                self.advance();
2114                continue;
2115            }
2116            break;
2117        }
2118        let where_ = if matches!(self.peek(), Token::Where) {
2119            self.advance();
2120            Some(self.parse_expr(0)?)
2121        } else {
2122            None
2123        };
2124        let returning = self.parse_optional_returning()?;
2125        Ok(Statement::Update(crate::ast::UpdateStatement {
2126            table,
2127            assignments,
2128            where_,
2129            returning,
2130        }))
2131    }
2132
2133    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Caller already consumed
2134    /// the leading `DELETE` ident.
2135    fn parse_delete_after_keyword(&mut self) -> Result<Statement, ParseError> {
2136        if !matches!(self.peek(), Token::From) {
2137            return Err(self.err(format!("expected FROM after DELETE, got {:?}", self.peek())));
2138        }
2139        self.advance();
2140        let table = self.expect_ident_like()?;
2141        let where_ = if matches!(self.peek(), Token::Where) {
2142            self.advance();
2143            Some(self.parse_expr(0)?)
2144        } else {
2145            None
2146        };
2147        let returning = self.parse_optional_returning()?;
2148        Ok(Statement::Delete(crate::ast::DeleteStatement {
2149            table,
2150            where_,
2151            returning,
2152        }))
2153    }
2154
2155    /// v7.9.4 — parse the optional trailing `RETURNING <projection>`
2156    /// clause on INSERT / UPDATE / DELETE. Same projection grammar
2157    /// as SELECT, so `RETURNING *`, `RETURNING col`,
2158    /// `RETURNING expr AS alias`, and `RETURNING a, b, c` all work.
2159    fn parse_optional_returning(
2160        &mut self,
2161    ) -> Result<Option<Vec<crate::ast::SelectItem>>, ParseError> {
2162        let is_returning_kw = matches!(
2163            self.peek(),
2164            Token::Ident(s) if s.eq_ignore_ascii_case("returning")
2165        );
2166        if !is_returning_kw {
2167            return Ok(None);
2168        }
2169        self.advance();
2170        let mut items = Vec::new();
2171        loop {
2172            items.push(self.parse_select_item()?);
2173            if matches!(self.peek(), Token::Comma) {
2174                self.advance();
2175                continue;
2176            }
2177            break;
2178        }
2179        Ok(Some(items))
2180    }
2181
2182    /// v6.0.4 — parse the tail of an ALTER statement after the
2183    /// leading `ALTER` keyword has been consumed. Only one form is
2184    /// supported in v6.0.4:
2185    ///
2186    /// ```text
2187    /// ALTER INDEX <name> REBUILD [WITH (encoding = <enc>)]
2188    /// ```
2189    fn parse_alter_after_keyword(&mut self) -> Result<Statement, ParseError> {
2190        // ALTER INDEX <name> ... | ALTER TABLE <name> SET hot_tier_bytes = <n>
2191        // v7.14.0 — `ALTER TABLE ONLY` modifier (PG partition-
2192        // exclusion) is accepted by stripping the `ONLY` keyword
2193        // before the table parse.
2194        // v7.14.0 — `ALTER SEQUENCE / ALTER VIEW / ALTER OWNER`
2195        // and the long PG-dump tail are accepted as no-ops.
2196        match self.advance() {
2197            Token::Index => {}
2198            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("index") => {}
2199            // v6.7.2 — ALTER TABLE t SET hot_tier_bytes = X
2200            // v7.14.0 — ALTER TABLE ONLY t … strip the `ONLY`.
2201            Token::Table => {
2202                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
2203                    self.advance();
2204                }
2205                return self.parse_alter_table_after_keyword();
2206            }
2207            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("table") => {
2208                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
2209                    self.advance();
2210                }
2211                return self.parse_alter_table_after_keyword();
2212            }
2213            // v7.14.0 — ALTER SEQUENCE / ALTER VIEW / ALTER
2214            // FUNCTION / ALTER TYPE / ALTER DOMAIN / ALTER
2215            // DATABASE / ALTER USER / ALTER ROLE / ALTER SCHEMA
2216            // / ALTER OWNER / ALTER DEFAULT PRIVILEGES — accept
2217            // as no-op so pg_dump's tail loads.
2218            Token::Ident(s) | Token::QuotedIdent(s)
2219                if matches!(
2220                    s.to_ascii_lowercase().as_str(),
2221                    "sequence"
2222                        | "view"
2223                        | "function"
2224                        | "type"
2225                        | "domain"
2226                        | "database"
2227                        | "role"
2228                        | "schema"
2229                        | "owner"
2230                        | "default"
2231                        | "extension"
2232                        | "materialized"
2233                        | "policy"
2234                        | "publication"
2235                        | "subscription"
2236                ) =>
2237            {
2238                self.consume_until_statement_boundary();
2239                return Ok(Statement::Empty);
2240            }
2241            other => {
2242                return Err(self.err(format!(
2243                    "expected INDEX / TABLE / SEQUENCE / VIEW / FUNCTION / TYPE / OWNER / etc \
2244                     after ALTER, got {other:?}"
2245                )));
2246            }
2247        }
2248        // v7.16.2 — optional `IF EXISTS` after ALTER INDEX
2249        // (mailrs migrate-042 ships these). The presence of an
2250        // IF EXISTS makes the subsequent name lookup tolerate
2251        // a missing index — engine returns CommandOk no-op.
2252        let if_exists = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
2253            let next = self.tokens.get(self.pos + 1);
2254            if matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
2255                self.advance();
2256                self.advance();
2257                true
2258            } else {
2259                false
2260            }
2261        } else {
2262            false
2263        };
2264        let name = self.expect_ident_like()?;
2265        // v7.16.2 — RENAME TO new_name shape (mailrs migrate-042).
2266        // Detect BEFORE the REBUILD path so the existing REBUILD
2267        // arm stays untouched.
2268        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("rename")) {
2269            self.advance();
2270            if matches!(self.peek(), Token::To) {
2271                self.advance();
2272            } else {
2273                self.expect_keyword_ident("to")?;
2274            }
2275            let new = self.expect_ident_like()?;
2276            return Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
2277                name,
2278                target: crate::ast::AlterIndexTarget::Rename { new, if_exists },
2279            }));
2280        }
2281        // REBUILD
2282        self.expect_keyword_ident("rebuild")?;
2283        // Optional: WITH (encoding = <enc>)
2284        let encoding = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
2285            self.advance();
2286            if !matches!(self.peek(), Token::LParen) {
2287                return Err(self.err(format!(
2288                    "expected '(' after WITH in ALTER INDEX REBUILD, got {:?}",
2289                    self.peek()
2290                )));
2291            }
2292            self.advance();
2293            self.expect_keyword_ident("encoding")?;
2294            if !matches!(self.peek(), Token::Eq) {
2295                return Err(self.err(format!(
2296                    "expected '=' after encoding in ALTER INDEX REBUILD, got {:?}",
2297                    self.peek()
2298                )));
2299            }
2300            self.advance();
2301            let enc_ident = match self.advance() {
2302                Token::Ident(s) | Token::QuotedIdent(s) => s,
2303                other => {
2304                    return Err(self.err(format!("expected encoding name after =, got {other:?}")));
2305                }
2306            };
2307            let enc = match enc_ident.to_ascii_lowercase().as_str() {
2308                "f32" => VecEncoding::F32,
2309                "sq8" => VecEncoding::Sq8,
2310                "half" => VecEncoding::F16,
2311                other => {
2312                    return Err(self.err(format!(
2313                        "unknown vector encoding {other:?} in ALTER INDEX REBUILD; supported: F32, SQ8, HALF"
2314                    )));
2315                }
2316            };
2317            if !matches!(self.peek(), Token::RParen) {
2318                return Err(self.err(format!(
2319                    "expected ')' after encoding value, got {:?}",
2320                    self.peek()
2321                )));
2322            }
2323            self.advance();
2324            Some(enc)
2325        } else {
2326            None
2327        };
2328        Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
2329            name,
2330            target: crate::ast::AlterIndexTarget::Rebuild { encoding },
2331        }))
2332    }
2333
2334    /// v6.7.2 — `ALTER TABLE <name> SET hot_tier_bytes = <n>`. The
2335    /// only `SET` form currently supported; future v6.7.x can add
2336    /// more SET subjects without changing the dispatch shape.
2337    /// v7.13.2 — mailrs round-6 S1: accepts comma-separated
2338    /// subactions. Single-subaction shape stays a 1-element vec.
2339    fn parse_alter_table_after_keyword(&mut self) -> Result<Statement, ParseError> {
2340        let table_name = self.expect_ident_like()?;
2341        let mut targets: Vec<crate::ast::AlterTableTarget> = Vec::new();
2342        loop {
2343            let subaction = self.parse_alter_table_subaction()?;
2344            // ADD COLUMN with inline REFERENCES emits both an
2345            // AddColumn and an AddForeignKey subaction; the
2346            // helper returns 1 or 2 items.
2347            targets.extend(subaction);
2348            if matches!(self.peek(), Token::Comma) {
2349                self.advance();
2350                continue;
2351            }
2352            break;
2353        }
2354        Ok(Statement::AlterTable(crate::ast::AlterTableStatement {
2355            name: table_name,
2356            targets,
2357        }))
2358    }
2359
2360    /// Parse one ALTER TABLE subaction. Returns a Vec because
2361    /// inline `REFERENCES` on `ADD COLUMN` produces both an
2362    /// AddColumn and an AddForeignKey entry (mailrs round-6 S3).
2363    fn parse_alter_table_subaction(
2364        &mut self,
2365    ) -> Result<Vec<crate::ast::AlterTableTarget>, ParseError> {
2366        match self.peek() {
2367            Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
2368                self.advance();
2369                let setting = self.expect_ident_like()?;
2370                if !setting.eq_ignore_ascii_case("hot_tier_bytes") {
2371                    return Err(self.err(alloc::format!(
2372                        "ALTER TABLE SET: unknown setting {setting:?}; supported: hot_tier_bytes"
2373                    )));
2374                }
2375                if !matches!(self.peek(), Token::Eq) {
2376                    return Err(self.err(alloc::format!(
2377                        "expected '=' after hot_tier_bytes, got {:?}",
2378                        self.peek()
2379                    )));
2380                }
2381                self.advance();
2382                let n = self.expect_u64_literal()?;
2383                Ok(alloc::vec![crate::ast::AlterTableTarget::SetHotTierBytes(n)])
2384            }
2385            Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
2386                self.advance();
2387                // v7.14.0 — ADD CONSTRAINT <name> { FOREIGN KEY |
2388                // PRIMARY KEY | UNIQUE | CHECK }. pg_dump emits
2389                // PRIMARY KEY this way; mysqldump emits both.
2390                // Peek-only dispatch (no advance) — `advance()`
2391                // destructively replaces consumed tokens with Eof,
2392                // so saved-pos restore would land on Eofs.
2393                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint"))
2394                {
2395                    // The next-but-one ident is the constraint
2396                    // name; the one after THAT is the kind.
2397                    let kind_pos = self.pos + 2;
2398                    let kind = self.tokens.get(kind_pos).cloned();
2399                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("foreign"))
2400                    {
2401                        let fk = self.parse_table_level_fk()?;
2402                        return Ok(alloc::vec![
2403                            crate::ast::AlterTableTarget::AddForeignKey(fk)
2404                        ]);
2405                    }
2406                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary"))
2407                    {
2408                        self.advance(); // CONSTRAINT
2409                        let _name = self.expect_ident_like()?;
2410                        self.advance(); // PRIMARY
2411                        self.expect_keyword_ident("key")?;
2412                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
2413                        return Ok(alloc::vec![
2414                            crate::ast::AlterTableTarget::AddTableConstraint(
2415                                crate::ast::TableConstraint::PrimaryKey {
2416                                    name: None,
2417                                    columns: cols,
2418                                }
2419                            )
2420                        ]);
2421                    }
2422                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique"))
2423                    {
2424                        self.advance(); // CONSTRAINT
2425                        let _name = self.expect_ident_like()?;
2426                        self.advance(); // UNIQUE
2427                        let cols = self.parse_paren_ident_list("UNIQUE")?;
2428                        return Ok(alloc::vec![
2429                            crate::ast::AlterTableTarget::AddTableConstraint(
2430                                crate::ast::TableConstraint::Unique {
2431                                    name: None,
2432                                    columns: cols,
2433                                    nulls_not_distinct: false,
2434                                }
2435                            )
2436                        ]);
2437                    }
2438                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check"))
2439                    {
2440                        self.advance(); // CONSTRAINT
2441                        let _name = self.expect_ident_like()?;
2442                        self.advance(); // CHECK
2443                        if !matches!(self.peek(), Token::LParen) {
2444                            return Err(self.err(alloc::format!(
2445                                "expected '(' after CHECK, got {:?}", self.peek()
2446                            )));
2447                        }
2448                        self.advance();
2449                        let expr = self.parse_expr(0)?;
2450                        if matches!(self.peek(), Token::RParen) {
2451                            self.advance();
2452                        }
2453                        return Ok(alloc::vec![
2454                            crate::ast::AlterTableTarget::AddTableConstraint(
2455                                crate::ast::TableConstraint::Check { name: None, expr }
2456                            )
2457                        ]);
2458                    }
2459                    // Unknown kind — fall through to FK path which
2460                    // produces a descriptive parse error.
2461                }
2462                let is_fk = matches!(
2463                    self.peek(),
2464                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
2465                        || s.eq_ignore_ascii_case("foreign")
2466                );
2467                if is_fk {
2468                    let fk = self.parse_table_level_fk()?;
2469                    return Ok(alloc::vec![crate::ast::AlterTableTarget::AddForeignKey(fk)]);
2470                }
2471                // v7.14.0 — bare ADD PRIMARY KEY / UNIQUE / CHECK
2472                // (no CONSTRAINT prefix) — same dispatch.
2473                match self.peek().clone() {
2474                    Token::Ident(s) if s.eq_ignore_ascii_case("primary") => {
2475                        self.advance();
2476                        self.expect_keyword_ident("key")?;
2477                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
2478                        return Ok(alloc::vec![
2479                            crate::ast::AlterTableTarget::AddTableConstraint(
2480                                crate::ast::TableConstraint::PrimaryKey {
2481                                    name: None,
2482                                    columns: cols,
2483                                }
2484                            )
2485                        ]);
2486                    }
2487                    Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
2488                        self.advance();
2489                        let cols = self.parse_paren_ident_list("UNIQUE")?;
2490                        return Ok(alloc::vec![
2491                            crate::ast::AlterTableTarget::AddTableConstraint(
2492                                crate::ast::TableConstraint::Unique {
2493                                    name: None,
2494                                    columns: cols,
2495                                    nulls_not_distinct: false,
2496                                }
2497                            )
2498                        ]);
2499                    }
2500                    _ => {}
2501                }
2502                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
2503                    self.advance();
2504                }
2505                let mut if_not_exists = false;
2506                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
2507                    self.advance();
2508                    if !matches!(self.peek(), Token::Not) {
2509                        return Err(self.err(alloc::format!(
2510                            "expected NOT after IF in ALTER TABLE ADD COLUMN, got {:?}",
2511                            self.peek()
2512                        )));
2513                    }
2514                    self.advance();
2515                    if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("exists")) {
2516                        return Err(self.err(alloc::format!(
2517                            "expected EXISTS after IF NOT in ALTER TABLE ADD COLUMN, got {:?}",
2518                            self.peek()
2519                        )));
2520                    }
2521                    self.advance();
2522                    if_not_exists = true;
2523                }
2524                // v7.13.2 — mailrs round-6 S3: `ADD COLUMN col TYPE
2525                // REFERENCES other(col) [ON DELETE …]`. parse_column_def
2526                // returns ColumnDef + an optional inline FK.
2527                let (column, col_level_fk) = self.parse_column_def_with_fk()?;
2528                let col_name = column.name.clone();
2529                let mut out = alloc::vec![crate::ast::AlterTableTarget::AddColumn {
2530                    column,
2531                    if_not_exists,
2532                }];
2533                if let Some(mut fk) = col_level_fk {
2534                    if fk.columns.is_empty() {
2535                        fk.columns.push(col_name);
2536                    }
2537                    out.push(crate::ast::AlterTableTarget::AddForeignKey(fk));
2538                }
2539                Ok(out)
2540            }
2541            Token::Drop => {
2542                self.advance();
2543                // v7.13.3 — dispatch on the next token. mailrs round-7
2544                // S8 closed DROP COLUMN; round-6 S7 closed
2545                // DROP CONSTRAINT. Both share IF EXISTS / CASCADE /
2546                // RESTRICT modifiers.
2547                //   DROP CONSTRAINT [IF EXISTS] <name> [CASCADE|RESTRICT]
2548                //   DROP [COLUMN] [IF EXISTS] <col> [CASCADE|RESTRICT]
2549                let subject = match self.peek() {
2550                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint") => {
2551                        self.advance();
2552                        "constraint"
2553                    }
2554                    Token::Ident(s) if s.eq_ignore_ascii_case("column") => {
2555                        self.advance();
2556                        "column"
2557                    }
2558                    // PG-canonical bare `DROP <col>` without COLUMN
2559                    // keyword is also valid; treat any other ident
2560                    // as the column name.
2561                    Token::Ident(_) | Token::QuotedIdent(_) => "column",
2562                    other => {
2563                        return Err(self.err(alloc::format!(
2564                            "expected COLUMN / CONSTRAINT after DROP in ALTER TABLE, got {other:?}"
2565                        )));
2566                    }
2567                };
2568                let mut if_exists = false;
2569                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
2570                    let n1 = self.tokens.get(self.pos + 1);
2571                    if matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
2572                        self.advance();
2573                        self.advance();
2574                        if_exists = true;
2575                    }
2576                }
2577                let name = self.expect_ident_like()?;
2578                let mut cascade = false;
2579                if matches!(
2580                    self.peek(),
2581                    Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
2582                        || s.eq_ignore_ascii_case("restrict")
2583                ) {
2584                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("cascade"))
2585                    {
2586                        cascade = true;
2587                    }
2588                    self.advance();
2589                }
2590                if subject == "constraint" {
2591                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropForeignKey {
2592                        name,
2593                        if_exists,
2594                    }])
2595                } else {
2596                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropColumn {
2597                        column: name,
2598                        if_exists,
2599                        cascade,
2600                    }])
2601                }
2602            }
2603            Token::Ident(s) if s.eq_ignore_ascii_case("alter") => {
2604                self.advance();
2605                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
2606                    self.advance();
2607                }
2608                let col_name = self.expect_ident_like()?;
2609                match self.peek() {
2610                    Token::Ident(s) if s.eq_ignore_ascii_case("type") => {
2611                        self.advance();
2612                    }
2613                    // v7.14.0 — pg_dump emits BIGSERIAL via
2614                    // `ALTER TABLE … ALTER COLUMN id SET DEFAULT
2615                    // nextval('seq')` (the sequence is created
2616                    // separately). SPG's BIGSERIAL already uses
2617                    // AUTO_INCREMENT; accept SET DEFAULT / DROP
2618                    // DEFAULT / SET NOT NULL / DROP NOT NULL as
2619                    // engine no-ops by consuming the tail.
2620                    Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
2621                        // ALTER COLUMN col SET DEFAULT … / SET NOT
2622                        // NULL — accept as a no-op on SPG (BIGSERIAL
2623                        // already auto-increments; nullability change
2624                        // would need row scan — deferred).
2625                        self.consume_until_statement_boundary();
2626                        return Ok(Vec::new());
2627                    }
2628                    Token::Ident(s) if s.eq_ignore_ascii_case("drop") => {
2629                        // ALTER COLUMN col DROP DEFAULT / DROP NOT NULL.
2630                        self.consume_until_statement_boundary();
2631                        return Ok(Vec::new());
2632                    }
2633                    other => {
2634                        return Err(self.err(alloc::format!(
2635                            "expected TYPE / SET / DROP after ALTER COLUMN <name>, got {other:?}"
2636                        )));
2637                    }
2638                }
2639                let new_type = self.parse_column_type_name()?;
2640                let using = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using"))
2641                {
2642                    self.advance();
2643                    Some(self.parse_expr(0)?)
2644                } else {
2645                    None
2646                };
2647                Ok(alloc::vec![crate::ast::AlterTableTarget::AlterColumnType {
2648                    column: col_name,
2649                    new_type,
2650                    using,
2651                }])
2652            }
2653            // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO new`.
2654            // PG also supports `RENAME TO new_table` for table-name
2655            // rename; that surface is deferred (pg_dump never emits
2656            // it). If the first post-RENAME ident is `TO`, the user
2657            // is asking for table rename — error with a clear
2658            // message rather than misparsing `TO` as a column name.
2659            Token::Ident(s) if s.eq_ignore_ascii_case("rename") => {
2660                self.advance();
2661                // v7.16.2 — `ALTER TABLE t RENAME TO new_table`
2662                // table-name rename (mailrs round-10 A.5 — used
2663                // by migrate-042's `RENAME TO email_contacts`).
2664                // `TO` lexes as Token::To.
2665                if matches!(self.peek(), Token::To)
2666                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("to"))
2667                {
2668                    self.advance();
2669                    let new = self.expect_ident_like()?;
2670                    return Ok(alloc::vec![crate::ast::AlterTableTarget::RenameTable {
2671                        new,
2672                    }]);
2673                }
2674                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
2675                    self.advance();
2676                }
2677                let old = self.expect_ident_like()?;
2678                // `TO` is a reserved keyword token; accept both
2679                // Token::To and Token::Ident("to") for consistency.
2680                if matches!(self.peek(), Token::To) {
2681                    self.advance();
2682                } else {
2683                    self.expect_keyword_ident("to")?;
2684                }
2685                let new = self.expect_ident_like()?;
2686                Ok(alloc::vec![crate::ast::AlterTableTarget::RenameColumn {
2687                    old,
2688                    new,
2689                }])
2690            }
2691            // v7.16.1 — `ALTER TABLE t { ENABLE | DISABLE } TRIGGER
2692            // { ALL | <name> }`. pg_dump --disable-triggers wraps
2693            // every data block with these. Real disable semantics —
2694            // not no-op — because reload correctness assumes the
2695            // triggers don't fire (rows already carry their
2696            // computed values from prod).
2697            Token::Ident(s)
2698                if s.eq_ignore_ascii_case("enable") || s.eq_ignore_ascii_case("disable") =>
2699            {
2700                let enabled = s.eq_ignore_ascii_case("enable");
2701                self.advance();
2702                // PG also accepts ENABLE/DISABLE { REPLICA | ALWAYS }
2703                // TRIGGER … and ENABLE/DISABLE RULE / ROW LEVEL
2704                // SECURITY. v7.16.1 only matches TRIGGER (mailrs's
2705                // pg_dump output) — anything else falls through to
2706                // the catch-all error below.
2707                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("trigger")) {
2708                    return Err(self.err(alloc::format!(
2709                        "expected TRIGGER after {}, got {:?}",
2710                        if enabled { "ENABLE" } else { "DISABLE" },
2711                        self.peek()
2712                    )));
2713                }
2714                self.advance();
2715                // `ALL` lexes as Token::All (reserved); also
2716                // accept Token::Ident("all") for symmetry.
2717                let which = if matches!(self.peek(), Token::All)
2718                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("all"))
2719                {
2720                    self.advance();
2721                    crate::ast::TriggerSelector::All
2722                } else {
2723                    let name = self.expect_ident_like()?;
2724                    crate::ast::TriggerSelector::Named(name)
2725                };
2726                Ok(alloc::vec![crate::ast::AlterTableTarget::SetTriggerEnabled {
2727                    which,
2728                    enabled,
2729                }])
2730            }
2731            other => Err(self.err(alloc::format!(
2732                "expected SET / ADD / DROP / ALTER / RENAME / ENABLE / DISABLE in ALTER TABLE, got {other:?}"
2733            ))),
2734        }
2735    }
2736
2737    /// v7.16.2 — peek for `information_schema.<tbl>` /
2738    /// `pg_catalog.<tbl>` triples and, if matched, consume all
2739    /// three tokens + return a synthetic table name the engine's
2740    /// SELECT path recognises as a virtual view. Returns `None`
2741    /// when the head doesn't look like a meta-qualified name.
2742    /// Used by `parse_table_ref` to bypass the
2743    /// `expect_ident_like` schema-strip for these specific PG
2744    /// meta schemas (mailrs round-10 A.3).
2745    fn try_peek_meta_qualified(&mut self) -> Option<String> {
2746        // Extract the schema name. Must be a plain ident token.
2747        let schema = match self.tokens.get(self.pos) {
2748            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
2749            _ => return None,
2750        };
2751        // Dot.
2752        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
2753            return None;
2754        }
2755        // The table-side ident may lex as a reserved keyword
2756        // (e.g. `Token::Tables`). Tolerate the common ones via a
2757        // helper that reads the trailing token's underlying name.
2758        let tbl = match self.tokens.get(self.pos + 2)? {
2759            Token::Ident(t) | Token::QuotedIdent(t) => t.clone(),
2760            Token::Tables => "tables".to_string(),
2761            // Other PG meta table names that may collide with
2762            // reserved keywords land here as needed.
2763            _ => return None,
2764        };
2765        // Strip the `pg_` prefix from `pg_catalog.pg_class`-style
2766        // names so the synthetic name doesn't double-prefix
2767        // (`__spg_pg_class`, not `__spg_pg_pg_class`).
2768        let (prefix, normalised) = if schema.eq_ignore_ascii_case("information_schema") {
2769            ("__spg_info_", tbl.to_ascii_lowercase())
2770        } else if schema.eq_ignore_ascii_case("pg_catalog") {
2771            let bare = tbl
2772                .to_ascii_lowercase()
2773                .strip_prefix("pg_")
2774                .map(alloc::string::String::from)
2775                .unwrap_or_else(|| tbl.to_ascii_lowercase());
2776            ("__spg_pg_", bare)
2777        } else {
2778            return None;
2779        };
2780        self.advance(); // schema
2781        self.advance(); // dot
2782        self.advance(); // tbl
2783        Some(alloc::format!("{prefix}{normalised}"))
2784    }
2785
2786    /// Consume a bare ident if its lowercase matches `kw`, else err.
2787    fn expect_keyword_ident(&mut self, kw: &str) -> Result<(), ParseError> {
2788        match self.advance() {
2789            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case(kw) => Ok(()),
2790            other => Err(ParseError {
2791                message: format!("expected {kw:?}, got {other:?}"),
2792                token_pos: self.pos.saturating_sub(1),
2793            }),
2794        }
2795    }
2796
2797    /// Accept either a quoted identifier (`"foo"`) or a quoted string
2798    /// literal (`'foo'`) — same shape used by CREATE USER for the
2799    /// username slot.
2800    fn expect_ident_or_string(&mut self) -> Result<String, ParseError> {
2801        match self.advance() {
2802            Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => Ok(s),
2803            other => Err(ParseError {
2804                message: format!("expected identifier or string, got {other:?}"),
2805                token_pos: self.pos.saturating_sub(1),
2806            }),
2807        }
2808    }
2809
2810    fn expect_string_literal(&mut self) -> Result<String, ParseError> {
2811        match self.advance() {
2812            Token::String(s) => Ok(s),
2813            other => Err(ParseError {
2814                message: format!("expected quoted string, got {other:?}"),
2815                token_pos: self.pos.saturating_sub(1),
2816            }),
2817        }
2818    }
2819
2820    fn parse_select_stmt(&mut self) -> Result<Statement, ParseError> {
2821        // Caller dispatches on Token::Select; the inner helper handles
2822        // the rest. ORDER BY / LIMIT bind at this top level; UNION peers
2823        // get a fresh bare-select parse and may not have their own ORDER
2824        // BY / LIMIT.
2825        let mut head = self.parse_bare_select()?;
2826        while matches!(self.peek(), Token::Union) {
2827            self.advance();
2828            let kind = if matches!(self.peek(), Token::All) {
2829                self.advance();
2830                UnionKind::All
2831            } else {
2832                UnionKind::Distinct
2833            };
2834            let peer = self.parse_bare_select()?;
2835            head.unions.push((kind, peer));
2836        }
2837        head.order_by = if matches!(self.peek(), Token::Order) {
2838            self.advance();
2839            if !matches!(self.peek(), Token::By) {
2840                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
2841            }
2842            self.advance();
2843            // v6.4.0 — multi-key ORDER BY. Loop over comma-separated
2844            // `<expr> [ASC|DESC]` items.
2845            let mut keys = Vec::new();
2846            loop {
2847                let expr = self.parse_expr(0)?;
2848                let desc = if matches!(self.peek(), Token::Desc) {
2849                    self.advance();
2850                    true
2851                } else if matches!(self.peek(), Token::Asc) {
2852                    self.advance();
2853                    false
2854                } else {
2855                    false
2856                };
2857                keys.push(OrderBy { expr, desc });
2858                if matches!(self.peek(), Token::Comma) {
2859                    self.advance();
2860                } else {
2861                    break;
2862                }
2863            }
2864            keys
2865        } else {
2866            Vec::new()
2867        };
2868        head.limit = if matches!(self.peek(), Token::Limit) {
2869            self.advance();
2870            Some(self.parse_limit_expr("LIMIT")?)
2871        } else {
2872            None
2873        };
2874        head.offset = if matches!(self.peek(), Token::Offset) {
2875            self.advance();
2876            Some(self.parse_limit_expr("OFFSET")?)
2877        } else {
2878            None
2879        };
2880        Ok(Statement::Select(head))
2881    }
2882
2883    /// v7.9.24 — accept `LIMIT <int>` or `LIMIT $N`. mailrs H2.
2884    /// Bind value gets resolved during prepared-statement Execute;
2885    /// the Pratt expression parser would over-accept here (e.g.
2886    /// `LIMIT 5 + 5`), so we narrowly accept only the two PG forms.
2887    fn parse_limit_expr(&mut self, label: &str) -> Result<crate::ast::LimitExpr, ParseError> {
2888        match self.advance() {
2889            Token::Integer(n) if n >= 0 => u32::try_from(n)
2890                .map(crate::ast::LimitExpr::Literal)
2891                .map_err(|_| ParseError {
2892                    message: alloc::format!("{label} value too large: {n}"),
2893                    token_pos: self.pos.saturating_sub(1),
2894                }),
2895            Token::Placeholder(n) => Ok(crate::ast::LimitExpr::Placeholder(n)),
2896            other => Err(ParseError {
2897                message: alloc::format!(
2898                    "expected non-negative integer or $N placeholder after {label}, got {other:?}"
2899                ),
2900                token_pos: self.pos.saturating_sub(1),
2901            }),
2902        }
2903    }
2904
2905    /// Parse one SELECT block without ORDER BY / LIMIT / UNION chaining —
2906    /// just `[DISTINCT] items [FROM] [WHERE] [GROUP BY]`. Returned with
2907    /// `unions` empty and `order_by` / `limit` `None`; the top-level
2908    /// `parse_select_stmt` is responsible for filling those in.
2909    fn parse_bare_select(&mut self) -> Result<SelectStatement, ParseError> {
2910        if !matches!(self.peek(), Token::Select) {
2911            return Err(self.err(format!(
2912                "expected SELECT to start a query block, got {:?}",
2913                self.peek()
2914            )));
2915        }
2916        self.advance();
2917        let distinct = if matches!(self.peek(), Token::Distinct) {
2918            self.advance();
2919            true
2920        } else {
2921            false
2922        };
2923        let items = self.parse_select_list()?;
2924        let from = if matches!(self.peek(), Token::From) {
2925            self.advance();
2926            Some(self.parse_from_clause()?)
2927        } else {
2928            None
2929        };
2930        let where_ = if matches!(self.peek(), Token::Where) {
2931            self.advance();
2932            Some(self.parse_expr(0)?)
2933        } else {
2934            None
2935        };
2936        let mut group_by_all = false;
2937        let group_by = if matches!(self.peek(), Token::Group) {
2938            self.advance();
2939            if !matches!(self.peek(), Token::By) {
2940                return Err(self.err(format!("expected BY after GROUP, got {:?}", self.peek())));
2941            }
2942            self.advance();
2943            // v6.4.1 — `GROUP BY ALL` shortcut. Planner expands to
2944            // every non-aggregate SELECT-list item later.
2945            if matches!(self.peek(), Token::All) {
2946                self.advance();
2947                group_by_all = true;
2948                None
2949            } else {
2950                let mut groups = Vec::new();
2951                loop {
2952                    groups.push(self.parse_expr(0)?);
2953                    if matches!(self.peek(), Token::Comma) {
2954                        self.advance();
2955                    } else {
2956                        break;
2957                    }
2958                }
2959                Some(groups)
2960            }
2961        } else {
2962            None
2963        };
2964        let having = if matches!(self.peek(), Token::Having) {
2965            self.advance();
2966            Some(self.parse_expr(0)?)
2967        } else {
2968            None
2969        };
2970        Ok(SelectStatement {
2971            ctes: Vec::new(),
2972            distinct,
2973            items,
2974            from,
2975            where_,
2976            group_by,
2977            group_by_all,
2978            having,
2979            unions: Vec::new(),
2980            order_by: Vec::new(),
2981            limit: None,
2982            offset: None,
2983        })
2984    }
2985
2986    fn parse_create_table_stmt_after_create(&mut self) -> Result<Statement, ParseError> {
2987        // Caller already consumed CREATE; we're sitting on TABLE.
2988        debug_assert!(matches!(self.peek(), Token::Table));
2989        self.advance();
2990        let if_not_exists = self.consume_if_not_exists();
2991        let name = self.expect_ident_like()?;
2992        if !matches!(self.peek(), Token::LParen) {
2993            return Err(self.err(format!(
2994                "expected '(' after table name, got {:?}",
2995                self.peek()
2996            )));
2997        }
2998        self.advance();
2999        let mut columns = Vec::new();
3000        let mut foreign_keys: Vec<ForeignKeyConstraint> = Vec::new();
3001        let mut table_constraints: Vec<crate::ast::TableConstraint> = Vec::new();
3002        loop {
3003            // v7.6.0 / v7.9.18 — distinguish table-level constraint
3004            // clauses from column definitions. Constraints start
3005            // with `CONSTRAINT <name> …`, `FOREIGN KEY (…)`,
3006            // `PRIMARY KEY (…)`, or `UNIQUE (…)`. Anything else is
3007            // a column.
3008            if self.peek_table_level_pk_start() {
3009                table_constraints.push(self.parse_table_level_primary_key()?);
3010            } else if self.peek_table_level_unique_start() {
3011                table_constraints.push(self.parse_table_level_unique()?);
3012            } else if self.peek_table_level_check_start() {
3013                // v7.13.0 — table-level CHECK (mailrs round-5 G3).
3014                table_constraints.push(self.parse_table_level_check()?);
3015            } else if self.peek_mysql_inline_key_start() {
3016                // v7.14.0 — mysqldump emits inline `KEY name (cols)`,
3017                // `INDEX name (cols)`, `UNIQUE KEY name (cols)`,
3018                // `FULLTEXT KEY name (cols)`, `SPATIAL KEY name (cols)`
3019                // inside the column list. Skip name + paren list;
3020                // for UNIQUE KEY, register as a UC.
3021                if let Some(uc) = self.parse_mysql_inline_key()? {
3022                    table_constraints.push(uc);
3023                }
3024            } else if self.peek_constraint_or_fk_start() {
3025                foreign_keys.push(self.parse_table_level_fk()?);
3026            } else {
3027                let (col, col_level_fk) = self.parse_column_def_with_fk()?;
3028                // v7.13.0 — fold inline UNIQUE / CHECK column
3029                // constraints into table-level entries so the
3030                // engine path stays uniform.
3031                if col.is_unique {
3032                    table_constraints.push(crate::ast::TableConstraint::Unique {
3033                        name: None,
3034                        columns: alloc::vec![col.name.clone()],
3035                        nulls_not_distinct: false,
3036                    });
3037                }
3038                if let Some(check_expr) = col.check.clone() {
3039                    table_constraints.push(crate::ast::TableConstraint::Check {
3040                        name: None,
3041                        expr: check_expr,
3042                    });
3043                }
3044                columns.push(col);
3045                if let Some(fk) = col_level_fk {
3046                    foreign_keys.push(fk);
3047                }
3048            }
3049            match self.peek() {
3050                Token::Comma => {
3051                    self.advance();
3052                }
3053                Token::RParen => {
3054                    self.advance();
3055                    break;
3056                }
3057                other => {
3058                    return Err(
3059                        self.err(format!("expected ',' or ')' in column list, got {other:?}"))
3060                    );
3061                }
3062            }
3063        }
3064        if columns.is_empty() {
3065            return Err(self.err("CREATE TABLE requires at least one column".into()));
3066        }
3067        // v7.14.0 — consume MySQL/MariaDB table options after the
3068        // closing `)`. mysqldump emits things like
3069        // `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
3070        // AUTO_INCREMENT=42 ROW_FORMAT=DYNAMIC COMMENT='blog posts'`.
3071        // SPG accepts all forms as no-ops (each option is
3072        // `<ident> [=] <ident-or-string>` separated by whitespace).
3073        self.consume_mysql_table_options();
3074        Ok(Statement::CreateTable(CreateTableStatement {
3075            name,
3076            columns,
3077            if_not_exists,
3078            foreign_keys,
3079            table_constraints,
3080        }))
3081    }
3082
3083    /// v7.14.0 — true when the next tokens look like an inline
3084    /// MySQL index declaration: KEY / INDEX / UNIQUE KEY /
3085    /// UNIQUE INDEX / FULLTEXT [KEY|INDEX] / SPATIAL [KEY|INDEX]
3086    /// — each followed by an optional name + `(...)`. Critical:
3087    /// a column NAMED `key` / `index` (PG accepts as ident) must
3088    /// NOT be mistaken for the KEY constraint shape. We disambig
3089    /// by requiring the keyword to be followed by either `(` or
3090    /// `<ident> (`.
3091    fn peek_mysql_inline_key_start(&self) -> bool {
3092        let cur = self.peek();
3093        // Shapes:
3094        //   KEY (cols)
3095        //   KEY name (cols)
3096        //   INDEX (cols)
3097        //   INDEX name (cols)
3098        //   UNIQUE KEY [name] (cols)
3099        //   UNIQUE INDEX [name] (cols)
3100        //   FULLTEXT [KEY|INDEX] [name] (cols)
3101        //   SPATIAL [KEY|INDEX] [name] (cols)
3102        let after_keyword_followed_by_paren_or_ident_paren = |skip: usize| -> bool {
3103            // tokens at skip = the position AFTER the index-form
3104            // keywords (KEY/INDEX) have been consumed.
3105            match self.tokens.get(skip) {
3106                Some(Token::LParen) => true,
3107                Some(Token::Ident(_) | Token::QuotedIdent(_)) => {
3108                    matches!(self.tokens.get(skip + 1), Some(Token::LParen))
3109                }
3110                _ => false,
3111            }
3112        };
3113        // `INDEX` lexes as Token::Index (reserved), not as
3114        // Token::Ident("index"). Both shapes count as a KEY/INDEX
3115        // start; the peek helper below handles either.
3116        let is_key_or_index_tok = |t: &Token| -> bool {
3117            matches!(t, Token::Index)
3118                || matches!(t, Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index"))
3119        };
3120        match cur {
3121            Token::Index => after_keyword_followed_by_paren_or_ident_paren(self.pos + 1),
3122            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
3123                after_keyword_followed_by_paren_or_ident_paren(self.pos + 1)
3124            }
3125            Token::Ident(s)
3126                if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial") =>
3127            {
3128                let nxt = self.tokens.get(self.pos + 1);
3129                let after_after = if nxt.is_some_and(is_key_or_index_tok) {
3130                    self.pos + 2
3131                } else {
3132                    self.pos + 1
3133                };
3134                after_keyword_followed_by_paren_or_ident_paren(after_after)
3135            }
3136            Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
3137                let nxt = self.tokens.get(self.pos + 1);
3138                if !nxt.is_some_and(is_key_or_index_tok) {
3139                    return false;
3140                }
3141                after_keyword_followed_by_paren_or_ident_paren(self.pos + 2)
3142            }
3143            _ => false,
3144        }
3145    }
3146
3147    /// v7.14.0 — parse the MySQL inline KEY/INDEX form. Returns
3148    /// Some(TableConstraint::Unique) for UNIQUE KEY (so SPG
3149    /// enforces uniqueness on INSERT). v7.15.0: plain KEY/INDEX
3150    /// returns Some(TableConstraint::Index) so the engine builds
3151    /// a real BTree index on the leading column (mysqldump
3152    /// `KEY idx_posts_author (author_id)` shape).
3153    /// FULLTEXT / SPATIAL still return None — accepted-as-no-op
3154    /// (the storage layer has no matching AM).
3155    fn parse_mysql_inline_key(
3156        &mut self,
3157    ) -> Result<Option<crate::ast::TableConstraint>, ParseError> {
3158        // Detect UNIQUE prefix.
3159        let is_unique = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unique"))
3160        {
3161            self.advance();
3162            true
3163        } else {
3164            false
3165        };
3166        // Consume FULLTEXT / SPATIAL prefix and record it. SPG
3167        // has no native FULLTEXT / SPATIAL AM, so we still
3168        // accept-as-no-op for those (return None below); plain
3169        // KEY/INDEX builds a real BTree.
3170        let is_fulltext_or_spatial = if matches!(
3171            self.peek(),
3172            Token::Ident(s) if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial")
3173        ) {
3174            self.advance();
3175            true
3176        } else {
3177            false
3178        };
3179        // KEY / INDEX keyword. `INDEX` lexes as Token::Index
3180        // (reserved); accept either token shape.
3181        match self.peek() {
3182            Token::Index => {
3183                self.advance();
3184            }
3185            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
3186                self.advance();
3187            }
3188            other => {
3189                return Err(self.err(alloc::format!(
3190                    "expected KEY/INDEX in inline index declaration, got {other:?}"
3191                )));
3192            }
3193        }
3194        // Optional index name (an ident before the `(`).
3195        // v7.15.0 — capture the name when present so the engine
3196        // builds the secondary index under the user's chosen
3197        // name (matches mysqldump's `KEY idx_x (col)` shape).
3198        let mut idx_name: Option<String> = None;
3199        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_))
3200            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
3201        {
3202            if let Token::Ident(s) | Token::QuotedIdent(s) = self.advance() {
3203                idx_name = Some(s);
3204            }
3205        }
3206        // Optional `USING BTREE` / `USING HASH` (MySQL).
3207        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
3208            self.advance();
3209            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
3210                self.advance();
3211            }
3212        }
3213        // Required column list `(col [, col]*)`.
3214        if !matches!(self.peek(), Token::LParen) {
3215            return Err(self.err(alloc::format!(
3216                "expected '(' in inline KEY/INDEX, got {:?}",
3217                self.peek()
3218            )));
3219        }
3220        self.advance();
3221        let mut cols: Vec<String> = Vec::new();
3222        while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
3223            self.advance();
3224            cols.push(s);
3225            // Skip optional `(length)` per-column prefix.
3226            if matches!(self.peek(), Token::LParen) {
3227                let mut depth = 1usize;
3228                self.advance();
3229                while depth > 0 {
3230                    match self.peek() {
3231                        Token::LParen => depth += 1,
3232                        Token::RParen => depth -= 1,
3233                        Token::Eof => break,
3234                        _ => {}
3235                    }
3236                    self.advance();
3237                }
3238            }
3239            // Skip optional ASC / DESC.
3240            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("asc") || s.eq_ignore_ascii_case("desc"))
3241                || matches!(self.peek(), Token::Asc | Token::Desc)
3242            {
3243                self.advance();
3244            }
3245            if matches!(self.peek(), Token::Comma) {
3246                self.advance();
3247                continue;
3248            }
3249            break;
3250        }
3251        if matches!(self.peek(), Token::RParen) {
3252            self.advance();
3253        }
3254        // Trailing options on the inline index — comment / etc.
3255        // Skip until comma or `)`.
3256        while !matches!(self.peek(), Token::Comma | Token::RParen | Token::Eof) {
3257            self.advance();
3258        }
3259        if cols.is_empty() {
3260            return Ok(None);
3261        }
3262        if is_unique {
3263            // Carry the captured idx_name on UNIQUE too so future
3264            // engine work can name the underlying BTree
3265            // accordingly; today the unique-constraint installer
3266            // synthesises the name itself, but Display round-trip
3267            // benefits from preserving it.
3268            Ok(Some(crate::ast::TableConstraint::Unique {
3269                name: idx_name,
3270                columns: cols,
3271                nulls_not_distinct: false,
3272            }))
3273        } else if is_fulltext_or_spatial {
3274            // SPG has no FULLTEXT / SPATIAL AM. Accept-as-no-op.
3275            Ok(None)
3276        } else {
3277            // v7.15.0 — plain KEY / INDEX builds a real BTree
3278            // secondary index.
3279            Ok(Some(crate::ast::TableConstraint::Index {
3280                name: idx_name,
3281                columns: cols,
3282            }))
3283        }
3284    }
3285
3286    /// v7.14.0 — consume MySQL/MariaDB table-options tail after
3287    /// the closing `)`: ENGINE=..., DEFAULT CHARSET=...,
3288    /// COLLATE=..., AUTO_INCREMENT=N, ROW_FORMAT=..., COMMENT='...'
3289    /// (in any order, separated by whitespace).
3290    fn consume_mysql_table_options(&mut self) {
3291        loop {
3292            // Heuristic: a table option is an ident (or `DEFAULT`
3293            // reserved keyword) followed by `=` and an
3294            // ident / string / integer.
3295            let name_lc = match self.peek().clone() {
3296                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
3297                Token::Default => alloc::string::String::from("default"),
3298                _ => break,
3299            };
3300            let known = matches!(
3301                name_lc.as_str(),
3302                "engine"
3303                    | "default"
3304                    | "charset"
3305                    | "collate"
3306                    | "auto_increment"
3307                    | "row_format"
3308                    | "comment"
3309                    | "pack_keys"
3310                    | "stats_persistent"
3311                    | "stats_auto_recalc"
3312                    | "stats_sample_pages"
3313                    | "key_block_size"
3314                    | "tablespace"
3315                    | "min_rows"
3316                    | "max_rows"
3317                    | "checksum"
3318                    | "delay_key_write"
3319                    | "insert_method"
3320                    | "data"
3321                    | "index"
3322                    | "encryption"
3323                    | "compression"
3324            );
3325            if !known {
3326                break;
3327            }
3328            self.advance(); // option name
3329            // `DEFAULT` optional prefix is followed by `CHARSET` /
3330            // `COLLATE`; consume the next ident too.
3331            if name_lc == "default" {
3332                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
3333                    self.advance();
3334                }
3335            }
3336            if matches!(self.peek(), Token::Eq) {
3337                self.advance();
3338            }
3339            match self.peek() {
3340                Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_) | Token::Integer(_) => {
3341                    self.advance();
3342                }
3343                _ => {}
3344            }
3345        }
3346    }
3347
3348    /// v7.9.18 — true when the next tokens are `PRIMARY KEY (…)`.
3349    /// PRIMARY and KEY are bare idents; we look-ahead 2 to be
3350    /// sure (otherwise a column literally named `primary` would
3351    /// be mistaken).
3352    fn peek_table_level_pk_start(&self) -> bool {
3353        let cur = self.peek();
3354        let nxt = self.tokens.get(self.pos + 1);
3355        let nxt2 = self.tokens.get(self.pos + 2);
3356        let is_primary = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("primary"));
3357        let is_key = matches!(nxt, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("key"));
3358        let is_lparen = matches!(nxt2, Some(Token::LParen));
3359        is_primary && is_key && is_lparen
3360    }
3361
3362    /// v7.9.18 — true when the next tokens are `UNIQUE (…)`.
3363    /// v7.13.0 — also matches `UNIQUE NULLS [NOT] DISTINCT (…)`
3364    /// (mailrs round-5 G10).
3365    fn peek_table_level_unique_start(&self) -> bool {
3366        let cur = self.peek();
3367        let is_unique = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("unique"));
3368        if !is_unique {
3369            return false;
3370        }
3371        let n1 = self.tokens.get(self.pos + 1);
3372        // Plain `UNIQUE (…)`.
3373        if matches!(n1, Some(Token::LParen)) {
3374            return true;
3375        }
3376        // `UNIQUE NULLS [NOT] DISTINCT (…)`.
3377        let is_nulls = matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("nulls"));
3378        if !is_nulls {
3379            return false;
3380        }
3381        let n2 = self.tokens.get(self.pos + 2);
3382        let n3 = self.tokens.get(self.pos + 3);
3383        let n4 = self.tokens.get(self.pos + 4);
3384        // `UNIQUE NULLS DISTINCT (…)` — 4 tokens before `(`.
3385        if matches!(n2, Some(Token::Distinct)) && matches!(n3, Some(Token::LParen)) {
3386            return true;
3387        }
3388        // `UNIQUE NULLS NOT DISTINCT (…)` — 5 tokens before `(`.
3389        if matches!(n2, Some(Token::Not))
3390            && matches!(n3, Some(Token::Distinct))
3391            && matches!(n4, Some(Token::LParen))
3392        {
3393            return true;
3394        }
3395        false
3396    }
3397
3398    fn parse_table_level_primary_key(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
3399        self.advance(); // PRIMARY
3400        self.advance(); // KEY
3401        let columns = self.parse_paren_ident_list("PRIMARY KEY")?;
3402        Ok(crate::ast::TableConstraint::PrimaryKey {
3403            name: None,
3404            columns,
3405        })
3406    }
3407
3408    fn parse_table_level_unique(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
3409        self.advance(); // UNIQUE
3410        // v7.13.0 — optional `NULLS NOT DISTINCT` modifier
3411        // (mailrs round-5 G10, PG 15+ surface). Default behaviour
3412        // is `NULLS DISTINCT` per the SQL standard.
3413        let mut nulls_not_distinct = false;
3414        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
3415            let n1 = self.tokens.get(self.pos + 1);
3416            let n2 = self.tokens.get(self.pos + 2);
3417            let is_not = matches!(n1, Some(Token::Not));
3418            let is_distinct = matches!(n2, Some(Token::Distinct));
3419            if is_not && is_distinct {
3420                self.advance(); // NULLS
3421                self.advance(); // NOT
3422                self.advance(); // DISTINCT
3423                nulls_not_distinct = true;
3424            } else if matches!(n1, Some(Token::Distinct)) {
3425                self.advance(); // NULLS
3426                self.advance(); // DISTINCT
3427            }
3428        }
3429        let columns = self.parse_paren_ident_list("UNIQUE")?;
3430        Ok(crate::ast::TableConstraint::Unique {
3431            name: None,
3432            columns,
3433            nulls_not_distinct,
3434        })
3435    }
3436
3437    /// v7.13.0 — table-level `CHECK (<expr>)` constraint
3438    /// (mailrs round-5 G3). Consumes `CHECK` then a parenthesised
3439    /// expression.
3440    fn parse_table_level_check(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
3441        self.advance(); // CHECK
3442        if !matches!(self.peek(), Token::LParen) {
3443            return Err(self.err(alloc::format!(
3444                "expected '(' after CHECK, got {:?}",
3445                self.peek()
3446            )));
3447        }
3448        self.advance();
3449        let expr = self.parse_expr(0)?;
3450        if !matches!(self.peek(), Token::RParen) {
3451            return Err(self.err(alloc::format!(
3452                "expected ')' to close CHECK predicate, got {:?}",
3453                self.peek()
3454            )));
3455        }
3456        self.advance();
3457        Ok(crate::ast::TableConstraint::Check { name: None, expr })
3458    }
3459
3460    /// v7.13.0 — `true` when the next token is `CHECK` (a bare ident).
3461    fn peek_table_level_check_start(&self) -> bool {
3462        matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("check"))
3463    }
3464
3465    fn parse_paren_ident_list(&mut self, ctx: &str) -> Result<Vec<String>, ParseError> {
3466        if !matches!(self.peek(), Token::LParen) {
3467            return Err(self.err(alloc::format!(
3468                "expected '(' after {ctx}, got {:?}",
3469                self.peek()
3470            )));
3471        }
3472        self.advance();
3473        let mut out = Vec::new();
3474        loop {
3475            out.push(self.expect_ident_like()?);
3476            match self.peek() {
3477                Token::Comma => {
3478                    self.advance();
3479                }
3480                Token::RParen => {
3481                    self.advance();
3482                    break;
3483                }
3484                other => {
3485                    return Err(self.err(alloc::format!(
3486                        "expected ',' or ')' in {ctx} list, got {other:?}"
3487                    )));
3488                }
3489            }
3490        }
3491        if out.is_empty() {
3492            return Err(self.err(alloc::format!("{ctx} requires at least one column")));
3493        }
3494        Ok(out)
3495    }
3496
3497    /// v7.6.0 — true when the next tokens are `CONSTRAINT <name>
3498    /// FOREIGN KEY` or bare `FOREIGN KEY`. Both introduce a
3499    /// table-level FK; a column def never starts with either keyword
3500    /// (column names are not in this reserved set).
3501    fn peek_constraint_or_fk_start(&self) -> bool {
3502        let is_constraint_kw = matches!(
3503            self.peek(),
3504            Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
3505        );
3506        let is_foreign_kw = matches!(
3507            self.peek(),
3508            Token::Ident(s) if s.eq_ignore_ascii_case("foreign")
3509        );
3510        is_constraint_kw || is_foreign_kw
3511    }
3512
3513    /// v7.6.0 — parse a table-level FK clause:
3514    /// `[CONSTRAINT <name>] FOREIGN KEY (<col>[,<col>]*) REFERENCES
3515    /// <tbl> [(<pcol>[,<pcol>]*)] [ON DELETE <action>] [ON UPDATE <action>]`.
3516    fn parse_table_level_fk(&mut self) -> Result<ForeignKeyConstraint, ParseError> {
3517        let mut name: Option<String> = None;
3518        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
3519            self.advance();
3520            name = Some(self.expect_ident_like()?);
3521        }
3522        // `FOREIGN`
3523        match self.advance() {
3524            Token::Ident(s) if s.eq_ignore_ascii_case("foreign") => {}
3525            other => return Err(self.err(format!("expected FOREIGN, got {other:?}"))),
3526        }
3527        // `KEY`
3528        match self.advance() {
3529            Token::Ident(s) if s.eq_ignore_ascii_case("key") => {}
3530            other => return Err(self.err(format!("expected KEY after FOREIGN, got {other:?}"))),
3531        }
3532        // `(col, col, ...)`
3533        if !matches!(self.peek(), Token::LParen) {
3534            return Err(self.err(format!(
3535                "expected '(' after FOREIGN KEY, got {:?}",
3536                self.peek()
3537            )));
3538        }
3539        self.advance();
3540        let mut columns = Vec::new();
3541        loop {
3542            columns.push(self.expect_ident_like()?);
3543            match self.peek() {
3544                Token::Comma => {
3545                    self.advance();
3546                }
3547                Token::RParen => {
3548                    self.advance();
3549                    break;
3550                }
3551                other => {
3552                    return Err(self.err(format!(
3553                        "expected ',' or ')' in FK column list, got {other:?}"
3554                    )));
3555                }
3556            }
3557        }
3558        if columns.is_empty() {
3559            return Err(self.err("FOREIGN KEY requires at least one column".into()));
3560        }
3561        let (parent_table, parent_columns, on_delete, on_update) =
3562            self.parse_references_tail(columns.len())?;
3563        Ok(ForeignKeyConstraint {
3564            name,
3565            columns,
3566            parent_table,
3567            parent_columns,
3568            on_delete,
3569            on_update,
3570        })
3571    }
3572
3573    /// v7.6.0 — parse the tail `REFERENCES <tbl> [(<pcol>...)] [ON
3574    /// DELETE <action>] [ON UPDATE <action>]`. `expected_arity` is
3575    /// the local column count, used to default the parent column
3576    /// list when omitted (SQL spec: parent's PK is implied).
3577    fn parse_references_tail(
3578        &mut self,
3579        expected_arity: usize,
3580    ) -> Result<(String, Vec<String>, FkAction, FkAction), ParseError> {
3581        match self.advance() {
3582            Token::Ident(s) if s.eq_ignore_ascii_case("references") => {}
3583            other => return Err(self.err(format!("expected REFERENCES, got {other:?}"))),
3584        }
3585        let parent_table = self.expect_ident_like()?;
3586        let mut parent_columns: Vec<String> = Vec::new();
3587        if matches!(self.peek(), Token::LParen) {
3588            self.advance();
3589            loop {
3590                parent_columns.push(self.expect_ident_like()?);
3591                match self.peek() {
3592                    Token::Comma => {
3593                        self.advance();
3594                    }
3595                    Token::RParen => {
3596                        self.advance();
3597                        break;
3598                    }
3599                    other => {
3600                        return Err(self.err(format!(
3601                            "expected ',' or ')' in REFERENCES column list, got {other:?}"
3602                        )));
3603                    }
3604                }
3605            }
3606        }
3607        if !parent_columns.is_empty() && parent_columns.len() != expected_arity {
3608            return Err(self.err(format!(
3609                "FK arity mismatch: {} local column(s) vs {} parent column(s)",
3610                expected_arity,
3611                parent_columns.len()
3612            )));
3613        }
3614        // v7.6.7 — accept and reject `[NOT] DEFERRABLE [INITIALLY
3615        // {DEFERRED | IMMEDIATE}]` so existing PG dumps don't fail
3616        // at parse time. SPG's single-writer model has no deferred
3617        // constraint window, so we surface this as a clean
3618        // unsupported-feature error rather than a syntax error.
3619        loop {
3620            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable")) {
3621                return Err(self.err(
3622                    "DEFERRABLE constraints are not supported (SPG is single-writer; \
3623                     constraints are always evaluated immediately at commit)"
3624                        .into(),
3625                ));
3626            }
3627            if matches!(self.peek(), Token::Not) {
3628                let look = self.tokens.get(self.pos + 1);
3629                if matches!(look, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("deferrable")) {
3630                    // NOT DEFERRABLE — accept as the SPG default
3631                    // and consume both tokens silently.
3632                    self.advance();
3633                    self.advance();
3634                    // Optional `INITIALLY IMMEDIATE` clause.
3635                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially"))
3636                    {
3637                        self.advance();
3638                        match self.advance() {
3639                            Token::Ident(s) if s.eq_ignore_ascii_case("immediate") => {}
3640                            other => {
3641                                return Err(self.err(format!(
3642                                    "expected IMMEDIATE after INITIALLY for NOT DEFERRABLE, \
3643                                     got {other:?}"
3644                                )));
3645                            }
3646                        }
3647                    }
3648                    continue;
3649                }
3650                break;
3651            }
3652            break;
3653        }
3654        // Optional `ON DELETE <action>` and `ON UPDATE <action>` in
3655        // either order, each at most once.
3656        let mut on_delete = FkAction::Restrict;
3657        let mut on_update = FkAction::Restrict;
3658        let mut seen_on_delete = false;
3659        let mut seen_on_update = false;
3660        loop {
3661            if !matches!(self.peek(), Token::On) {
3662                break;
3663            }
3664            self.advance();
3665            let which = self.advance();
3666            let action = self.parse_fk_action()?;
3667            match which {
3668                Token::Ident(ref s) if s.eq_ignore_ascii_case("delete") => {
3669                    if seen_on_delete {
3670                        return Err(self.err("ON DELETE specified twice".into()));
3671                    }
3672                    seen_on_delete = true;
3673                    on_delete = action;
3674                }
3675                Token::Ident(ref s) if s.eq_ignore_ascii_case("update") => {
3676                    if seen_on_update {
3677                        return Err(self.err("ON UPDATE specified twice".into()));
3678                    }
3679                    seen_on_update = true;
3680                    on_update = action;
3681                }
3682                other => {
3683                    return Err(
3684                        self.err(format!("expected DELETE or UPDATE after ON, got {other:?}"))
3685                    );
3686                }
3687            }
3688        }
3689        Ok((parent_table, parent_columns, on_delete, on_update))
3690    }
3691
3692    /// v7.6.0 — parse `CASCADE | RESTRICT | SET NULL | SET DEFAULT |
3693    /// NO ACTION`.
3694    fn parse_fk_action(&mut self) -> Result<FkAction, ParseError> {
3695        match self.advance() {
3696            Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => Ok(FkAction::Cascade),
3697            Token::Ident(s) if s.eq_ignore_ascii_case("restrict") => Ok(FkAction::Restrict),
3698            Token::Ident(s) if s.eq_ignore_ascii_case("set") => match self.advance() {
3699                Token::Null => Ok(FkAction::SetNull),
3700                Token::Default => Ok(FkAction::SetDefault),
3701                other => Err(self.err(format!(
3702                    "expected NULL or DEFAULT after SET in FK action, got {other:?}"
3703                ))),
3704            },
3705            Token::Ident(s) if s.eq_ignore_ascii_case("no") => match self.advance() {
3706                Token::Ident(s) if s.eq_ignore_ascii_case("action") => Ok(FkAction::NoAction),
3707                other => Err(self.err(format!(
3708                    "expected ACTION after NO in FK action, got {other:?}"
3709                ))),
3710            },
3711            other => Err(self.err(format!(
3712                "expected CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION, got {other:?}"
3713            ))),
3714        }
3715    }
3716
3717    /// Recognise the optional `IF NOT EXISTS` prefix shared by `CREATE
3718    /// TABLE` and `CREATE INDEX`. Returns `true` if consumed.
3719    fn consume_if_not_exists(&mut self) -> bool {
3720        // `IF` arrives as a bare Ident (we don't reserve it because it
3721        // also appears mid-expression in PG, though we don't support
3722        // those forms yet).
3723        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
3724        if !looks_like_if {
3725            return false;
3726        }
3727        // Peek one ahead before committing: only consume IF when it's
3728        // actually `IF NOT EXISTS`.
3729        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Not)) {
3730            return false;
3731        }
3732        if !matches!(
3733            self.tokens.get(self.pos + 2),
3734            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
3735        ) {
3736            return false;
3737        }
3738        self.advance(); // IF
3739        self.advance(); // NOT
3740        self.advance(); // EXISTS
3741        true
3742    }
3743
3744    /// v7.12.4 — `IF EXISTS` modifier for DROP statements.
3745    /// Consumes IF EXISTS as a pair; returns false otherwise
3746    /// without consuming any tokens.
3747    fn consume_if_exists(&mut self) -> bool {
3748        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
3749        if !looks_like_if {
3750            return false;
3751        }
3752        if !matches!(
3753            self.tokens.get(self.pos + 1),
3754            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
3755        ) {
3756            return false;
3757        }
3758        self.advance(); // IF
3759        self.advance(); // EXISTS
3760        true
3761    }
3762
3763    /// v7.9.14 — consume `ASC | DESC | NULLS FIRST | NULLS LAST`
3764    /// qualifiers after an index column ref. ASC / DESC are
3765    /// reserved tokens; NULLS / FIRST / LAST are bare idents.
3766    /// We accept and discard them since single-column BTree
3767    /// stores rows in natural key order today.
3768    fn consume_optional_index_column_qualifiers(&mut self) {
3769        loop {
3770            match self.peek() {
3771                Token::Asc | Token::Desc => {
3772                    self.advance();
3773                }
3774                Token::Ident(s) if s.eq_ignore_ascii_case("nulls") => {
3775                    let look = self.tokens.get(self.pos + 1);
3776                    if matches!(
3777                        look,
3778                        Some(Token::Ident(k)) if k.eq_ignore_ascii_case("first")
3779                            || k.eq_ignore_ascii_case("last")
3780                    ) {
3781                        self.advance();
3782                        self.advance();
3783                    } else {
3784                        break;
3785                    }
3786                }
3787                _ => break,
3788            }
3789        }
3790    }
3791
3792    fn parse_create_index_stmt_after_create(
3793        &mut self,
3794        is_unique: bool,
3795    ) -> Result<Statement, ParseError> {
3796        // Caller consumed CREATE (and the optional UNIQUE); we're on INDEX.
3797        debug_assert!(matches!(self.peek(), Token::Index));
3798        self.advance();
3799        let if_not_exists = self.consume_if_not_exists();
3800        let name = self.expect_ident_like()?;
3801        if !matches!(self.peek(), Token::On) {
3802            return Err(self.err(format!(
3803                "expected ON after CREATE INDEX <name>, got {:?}",
3804                self.peek()
3805            )));
3806        }
3807        self.advance();
3808        let table = self.expect_ident_like()?;
3809        // Optional `USING <method>` — only recognised method in v2.0 is
3810        // `hnsw` (a single-layer NSW graph for kNN). `USING` is the bare
3811        // ident `using` (we don't promote it to a reserved keyword
3812        // because it isn't reserved anywhere else in our SQL surface).
3813        let method = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
3814            self.advance();
3815            let m = self.expect_ident_like()?;
3816            match m.to_ascii_lowercase().as_str() {
3817                "hnsw" => IndexMethod::Hnsw,
3818                "btree" => IndexMethod::BTree,
3819                "brin" => IndexMethod::Brin,
3820                // v7.12.3 — real GIN inverted index over `tsvector`.
3821                // v7.9.26b's `USING gin` → BTree silent fallback is
3822                // gone; the engine validates that the indexed column
3823                // is `tsvector` at CREATE INDEX time.
3824                "gin" => IndexMethod::Gin,
3825                // v7.9.26b — PG `pg_dump` emits `USING gist` /
3826                // `USING spgist` / `USING hash` for their built-in
3827                // AMs that SPG doesn't have a matching
3828                // implementation for; degrade to BTree on the
3829                // leading column so the schema loads + the index
3830                // catalogue stays consistent. Operator pays the
3831                // planner cost only for the queries that would have
3832                // used the specialised AM.
3833                "gist" | "spgist" | "hash" => IndexMethod::BTree,
3834                // v7.11.3 — pgvector ships both `ivfflat` and
3835                // `hnsw`. Customers shouldn't have to choose
3836                // their on-disk index method based on what SPG
3837                // implements; accept `ivfflat` as a synonym for
3838                // `hnsw` so PG schemas using either method drop
3839                // in. The vector distance op (`<->` / `<#>` /
3840                // `<=>`) at query time still picks the metric.
3841                "ivfflat" => IndexMethod::Hnsw,
3842                other => {
3843                    return Err(self.err(alloc::format!(
3844                        "unknown index method {other:?}; supported: hnsw, btree, brin, gin (gist/spgist/hash accepted as BTree fallback)"
3845                    )));
3846                }
3847            }
3848        } else {
3849            IndexMethod::BTree
3850        };
3851        if !matches!(self.peek(), Token::LParen) {
3852            return Err(self.err(format!(
3853                "expected '(' before indexed column, got {:?}",
3854                self.peek()
3855            )));
3856        }
3857        self.advance();
3858        // v6.8.2 — accept either a bare column ident (legacy) or
3859        // an expression `fn(col, …)` for expression indexes.
3860        // Distinguish by peeking the token *after* the current
3861        // ident: `ident )` is the legacy column-only path;
3862        // anything else triggers the Pratt expression parser.
3863        // (`advance()` uses `mem::replace` to nil out the current
3864        // slot, so we can't save+rewind cleanly — peek-ahead via
3865        // direct index avoids the mutation.)
3866        let mut opclass: Option<String> = None;
3867        let (column, expression): (String, Option<Expr>) = match self.peek().clone() {
3868            // Single column with `)` immediately after — fast path.
3869            // v7.9.29 — also: bare column followed by `,` (the
3870            // multi-column form `(a, b, c)`). Without this branch
3871            // the leading ident gets pulled into `parse_expr`
3872            // which then sets `expression = Some(Column(a))` and
3873            // breaks Display round-trip on the multi-column shape.
3874            Token::Ident(s) | Token::QuotedIdent(s)
3875                if matches!(
3876                    self.tokens.get(self.pos + 1),
3877                    Some(Token::RParen | Token::Comma)
3878                ) =>
3879            {
3880                self.advance();
3881                (s, None)
3882            }
3883            // v7.9.22 — single column followed by a pgvector
3884            // opclass ident: `(col vector_cosine_ops)`. mailrs G5.
3885            // v7.15.0 — capture the opclass instead of discarding
3886            // it so the engine can dispatch (e.g. `gin_trgm_ops`
3887            // → real trigram-shingle GIN over a TEXT column).
3888            // Vector/HNSW opclasses still take their distance
3889            // metric from the query operator (`<->` / `<#>` /
3890            // `<=>`), so for those callers the opclass stays
3891            // informational.
3892            Token::Ident(s) | Token::QuotedIdent(s)
3893                if matches!(
3894                    self.tokens.get(self.pos + 1),
3895                    Some(Token::Ident(op) | Token::QuotedIdent(op))
3896                        if is_vector_opclass_name(op)
3897                ) =>
3898            {
3899                self.advance(); // column name
3900                // Capture the opclass token, lower-cased for
3901                // case-insensitive engine dispatch.
3902                let op_tok = self.advance();
3903                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
3904                    opclass = Some(op.to_ascii_lowercase());
3905                }
3906                (s, None)
3907            }
3908            Token::Ident(_) | Token::QuotedIdent(_) => {
3909                let key_expr = self.parse_expr(0)?;
3910                let primary = extract_first_column(&key_expr).ok_or_else(|| {
3911                    self.err("expression index key must reference at least one column".into())
3912                })?;
3913                (primary, Some(key_expr))
3914            }
3915            other => {
3916                return Err(self.err(format!(
3917                    "expected column ident or expression, got {other:?}"
3918                )));
3919            }
3920        };
3921        // v7.9.14 — accept extra comma-separated columns inside
3922        // the index key parens (`CREATE INDEX … (a, b, c)`).
3923        // mailrs F2. Each extra column may carry an optional
3924        // `ASC` / `DESC` / `NULLS FIRST` / `NULLS LAST` clause
3925        // — parsed and discarded; SPG doesn't honour direction
3926        // on a BTree index today (column ordering is intrinsic
3927        // to the storage). v7.10 will widen to genuine composite
3928        // index keys.
3929        let mut extra_columns: Vec<String> = Vec::new();
3930        // The leading column may also have ASC/DESC after it.
3931        self.consume_optional_index_column_qualifiers();
3932        while matches!(self.peek(), Token::Comma) {
3933            self.advance();
3934            let extra = self.expect_ident_like()?;
3935            self.consume_optional_index_column_qualifiers();
3936            extra_columns.push(extra);
3937        }
3938        if !matches!(self.peek(), Token::RParen) {
3939            return Err(self.err(format!(
3940                "expected ')' after indexed column / expression, got {:?}",
3941                self.peek()
3942            )));
3943        }
3944        self.advance();
3945        // v6.8.0 — optional `INCLUDE (col1, col2, …)` clause for
3946        // index-only-scan annotation. Bare ident (not a reserved
3947        // keyword) so we test by case-insensitive string match.
3948        let included_columns = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("include"))
3949        {
3950            self.advance();
3951            if !matches!(self.peek(), Token::LParen) {
3952                return Err(self.err(format!("expected '(' after INCLUDE, got {:?}", self.peek())));
3953            }
3954            self.advance();
3955            let mut cols = Vec::new();
3956            loop {
3957                cols.push(self.expect_ident_like()?);
3958                match self.peek() {
3959                    Token::Comma => {
3960                        self.advance();
3961                    }
3962                    Token::RParen => {
3963                        self.advance();
3964                        break;
3965                    }
3966                    other => {
3967                        return Err(self.err(format!(
3968                            "expected ',' or ')' in INCLUDE list, got {other:?}"
3969                        )));
3970                    }
3971                }
3972            }
3973            cols
3974        } else {
3975            Vec::new()
3976        };
3977        // v7.11.3 — accept and discard PG `WITH (k = v, ...)` index
3978        // storage parameters. pgvector emits `WITH (lists = N)` for
3979        // ivfflat and `WITH (m = N, ef_construction = M)` for hnsw;
3980        // SPG's HNSW picks its own parameters today (tunable via
3981        // env vars), so the WITH clause is informational and dropped.
3982        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
3983            self.advance();
3984            if !matches!(self.peek(), Token::LParen) {
3985                return Err(self.err(format!(
3986                    "expected '(' after WITH in CREATE INDEX, got {:?}",
3987                    self.peek()
3988                )));
3989            }
3990            self.advance();
3991            loop {
3992                if matches!(self.peek(), Token::RParen) {
3993                    self.advance();
3994                    break;
3995                }
3996                // Drain `key = value` or bare `key` tokens.
3997                let _ = self.advance(); // key
3998                if matches!(self.peek(), Token::Eq) {
3999                    self.advance();
4000                    let _ = self.advance(); // value (int / string / ident)
4001                }
4002                match self.peek() {
4003                    Token::Comma => {
4004                        self.advance();
4005                    }
4006                    Token::RParen => {
4007                        self.advance();
4008                        break;
4009                    }
4010                    other => {
4011                        return Err(self.err(format!(
4012                            "expected ',' or ')' in WITH (…) clause, got {other:?}"
4013                        )));
4014                    }
4015                }
4016            }
4017        }
4018        // v6.8.1 — optional `WHERE <expr>` partial-index predicate.
4019        let partial_predicate = if matches!(self.peek(), Token::Where) {
4020            self.advance();
4021            Some(self.parse_expr(0)?)
4022        } else {
4023            None
4024        };
4025        // v7.9.29 — UNIQUE on a vector index (HNSW) makes no
4026        // sense: uniqueness over an ANN structure has no clean
4027        // semantics. Reject early. (BRIN UNIQUE is similarly
4028        // meaningless — block both.)
4029        if is_unique && !matches!(method, IndexMethod::BTree) {
4030            return Err(self.err(alloc::format!(
4031                "UNIQUE is only supported on BTree indexes, got USING {:?}",
4032                method
4033            )));
4034        }
4035        Ok(Statement::CreateIndex(CreateIndexStatement {
4036            name,
4037            table,
4038            column,
4039            method,
4040            if_not_exists,
4041            included_columns,
4042            partial_predicate,
4043            extra_columns: extra_columns.clone(),
4044            expression,
4045            is_unique,
4046            opclass,
4047        }))
4048    }
4049
4050    /// v7.6.0 — wraps `parse_column_def` and consumes an optional
4051    /// column-level `REFERENCES ...` clause. The trailing FK is
4052    /// normalised into table-level shape (single-element columns +
4053    /// parent_columns) so the engine sees one uniform constraint list.
4054    fn parse_column_def_with_fk(
4055        &mut self,
4056    ) -> Result<(ColumnDef, Option<ForeignKeyConstraint>), ParseError> {
4057        let col = self.parse_column_def()?;
4058        // Inline form: `col INT REFERENCES tbl(pcol) [ON DELETE ...] [ON UPDATE ...]`.
4059        let inline_references = matches!(
4060            self.peek(),
4061            Token::Ident(s) if s.eq_ignore_ascii_case("references")
4062        );
4063        if !inline_references {
4064            return Ok((col, None));
4065        }
4066        let (parent_table, parent_columns, on_delete, on_update) = self.parse_references_tail(1)?;
4067        let fk = ForeignKeyConstraint {
4068            name: None,
4069            columns: vec![col.name.clone()],
4070            parent_table,
4071            parent_columns,
4072            on_delete,
4073            on_update,
4074        };
4075        Ok((col, Some(fk)))
4076    }
4077
4078    /// v7.13.0 — parse a column type (consuming the type ident and
4079    /// any trailing parameters / `[]`), without surrounding column
4080    /// constraints. Used by ALTER COLUMN TYPE (mailrs round-5 G8).
4081    /// Returns the resolved `ColumnTypeName` plus implied
4082    /// `(auto_increment, not_null)` flags from PG SERIAL family
4083    /// shorthands — callers that don't expect those (ALTER COLUMN
4084    /// TYPE) can discard them.
4085    fn parse_column_type_name(&mut self) -> Result<ColumnTypeName, ParseError> {
4086        let (ty, _, _) = self.parse_type_with_implied_flags()?;
4087        Ok(ty)
4088    }
4089
4090    fn parse_type_with_implied_flags(
4091        &mut self,
4092    ) -> Result<(ColumnTypeName, bool, bool), ParseError> {
4093        let ty_ident = match self.advance() {
4094            Token::Ident(s) => s,
4095            other => {
4096                return Err(ParseError {
4097                    message: format!("expected column type, got {other:?}"),
4098                    token_pos: self.pos.saturating_sub(1),
4099                });
4100            }
4101        };
4102        let mut implied_auto_increment = false;
4103        let mut implied_not_null = false;
4104        let mut ty = match ty_ident.as_str() {
4105            // PG SERIAL family. Implies NOT NULL + AUTO_INCREMENT.
4106            "smallserial" | "serial2" => {
4107                implied_auto_increment = true;
4108                implied_not_null = true;
4109                ColumnTypeName::SmallInt
4110            }
4111            "serial" | "serial4" => {
4112                implied_auto_increment = true;
4113                implied_not_null = true;
4114                ColumnTypeName::Int
4115            }
4116            "bigserial" | "serial8" => {
4117                implied_auto_increment = true;
4118                implied_not_null = true;
4119                ColumnTypeName::BigInt
4120            }
4121            // MySQL flavours we accept by aliasing to the closest SPG
4122            // type. TINYINT covers MySQL's i8 — held inside SMALLINT
4123            // since SPG doesn't have a dedicated i8. MEDIUMINT (MySQL
4124            // 24-bit) → INT. UNSIGNED modifiers are consumed below
4125            // without semantic effect.
4126            "smallint" | "tinyint" => {
4127                // v7.14.0 — MySQL display-width on integers
4128                // (`TINYINT(1)`, `INT(11)`, `BIGINT(20)`). The
4129                // parenthesised number is purely cosmetic — it
4130                // doesn't change storage. Accept + discard.
4131                self.consume_optional_paren_size();
4132                ColumnTypeName::SmallInt
4133            }
4134            "int" | "integer" | "mediumint" => {
4135                self.consume_optional_paren_size();
4136                ColumnTypeName::Int
4137            }
4138            "bigint" => {
4139                self.consume_optional_paren_size();
4140                ColumnTypeName::BigInt
4141            }
4142            // DOUBLE / REAL are 64-bit IEEE — same as our FLOAT.
4143            // v7.13.0 — `DOUBLE PRECISION` (PG canonical spelling)
4144            // (mailrs round-5 G6). Consume the optional `PRECISION`
4145            // tail when the type keyword was `double` / `DOUBLE`.
4146            "float" | "double" | "real" => {
4147                if ty_ident.eq_ignore_ascii_case("double")
4148                    && matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("precision"))
4149                {
4150                    self.advance();
4151                }
4152                ColumnTypeName::Float
4153            }
4154            // v7.13.0 — `FLOAT8` (PG short form) maps the same as FLOAT.
4155            "float4" | "float8" => ColumnTypeName::Float,
4156            "text" => ColumnTypeName::Text,
4157            "bool" | "boolean" => ColumnTypeName::Bool,
4158            "varchar" => ColumnTypeName::Varchar(self.parse_paren_size("VARCHAR")?),
4159            "char" => ColumnTypeName::Char(self.parse_paren_size("CHAR")?),
4160            "vector" => {
4161                let dim = self.parse_paren_size("VECTOR")?;
4162                let encoding = self.parse_optional_vector_encoding()?;
4163                ColumnTypeName::Vector { dim, encoding }
4164            }
4165            "numeric" => {
4166                let (precision, scale) = self.parse_optional_numeric_params()?;
4167                ColumnTypeName::Numeric(precision, scale)
4168            }
4169            "date" => ColumnTypeName::Date,
4170            // MySQL's `DATETIME` is the same domain as standard
4171            // `TIMESTAMP` — accept both spellings.
4172            "timestamp" | "datetime" => {
4173                // v7.14.0 — PG canonical `TIMESTAMP WITH TIME ZONE`
4174                // / `TIMESTAMP WITHOUT TIME ZONE`. pg_dump emits
4175                // the full form. SPG canonicalises:
4176                //   - WITH TIME ZONE    → Timestamptz
4177                //   - WITHOUT TIME ZONE → Timestamp
4178                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with"))
4179                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
4180                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
4181                {
4182                    self.advance(); // WITH
4183                    self.advance(); // TIME
4184                    self.advance(); // ZONE
4185                    ColumnTypeName::Timestamptz
4186                } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("without"))
4187                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
4188                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
4189                {
4190                    self.advance(); // WITHOUT
4191                    self.advance(); // TIME
4192                    self.advance(); // ZONE
4193                    ColumnTypeName::Timestamp
4194                } else {
4195                    // Optional `(precision)` parenthesised modifier
4196                    // (PG fractional seconds precision). SPG stores
4197                    // µs always; accept + discard.
4198                    self.consume_optional_paren_size();
4199                    ColumnTypeName::Timestamp
4200                }
4201            }
4202            // v7.9.2 — `TIMESTAMPTZ` and full PG spelling
4203            // `TIMESTAMP WITH TIME ZONE`. Same storage as TIMESTAMP;
4204            // only PG-wire OID differs.
4205            "timestamptz" => ColumnTypeName::Timestamptz,
4206            // v4.9: JSON / JSONB. Stored as raw text — no parse-time
4207            // validation. We accept the JSONB spelling too because
4208            // most PG clients default to it; SPG doesn't distinguish
4209            // the two (no path-operator perf advantage to model).
4210            "json" => ColumnTypeName::Json,
4211            "jsonb" => ColumnTypeName::Jsonb,
4212            // v7.10.4 — PG `BYTEA` and the SPG `BYTES` alias both
4213            // surface here. Same storage shape; mapping happens at
4214            // the engine side via the ColumnTypeName → DataType
4215            // resolver. Literal forms are handled at coerce_value
4216            // time so the lexer stays untouched.
4217            "bytea" | "bytes" => ColumnTypeName::Bytes,
4218            // v7.12.0 — PG full-text search types. mailrs G-CRIT-3.
4219            // The actual `to_tsvector` / `@@` / `ts_rank` surface
4220            // arrives in v7.12.1+; the type itself loads here so
4221            // mailrs's `scripts/init-schema.sql` runs unmodified.
4222            "tsvector" => ColumnTypeName::TsVector,
4223            "tsquery" => ColumnTypeName::TsQuery,
4224            other => {
4225                return Err(ParseError {
4226                    message: format!("unsupported column type {other:?}"),
4227                    token_pos: self.pos.saturating_sub(1),
4228                });
4229            }
4230        };
4231        // MySQL's `UNSIGNED` modifier sits right after the type
4232        // keyword. SPG doesn't carry a separate unsigned variant —
4233        // accepting the keyword keeps existing schemas compatible
4234        // without changing semantics. Drop it silently.
4235        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unsigned")) {
4236            self.advance();
4237        }
4238        // v7.14.0 — mysqldump emits `<type> CHARACTER SET <name>` and
4239        // `<type> COLLATE <name>` post-fixes on text columns. SPG
4240        // stores text as UTF-8 always and orders bytewise; charset /
4241        // collate are accepted as no-ops so PG / MySQL / MariaDB
4242        // dumps load without parser noise.
4243        loop {
4244            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
4245                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
4246            {
4247                self.advance(); // CHARACTER
4248                self.advance(); // SET
4249                if matches!(
4250                    self.peek(),
4251                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
4252                ) {
4253                    self.advance();
4254                }
4255                continue;
4256            }
4257            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate")) {
4258                self.advance(); // COLLATE
4259                if matches!(
4260                    self.peek(),
4261                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
4262                ) {
4263                    self.advance();
4264                }
4265                continue;
4266            }
4267            break;
4268        }
4269        // v7.10.10 — postfix `[]` widens TEXT → TEXT[]. PG accepts
4270        // `TYPE[]` after any base type; v7.10 only models TEXT[]
4271        // so we reject other base types here. mailrs uses TEXT[]
4272        // for labels / addresses / message-on-thread.
4273        if matches!(self.peek(), Token::LBracket) {
4274            self.advance();
4275            if !matches!(self.peek(), Token::RBracket) {
4276                return Err(self.err(alloc::format!(
4277                    "TEXT[] takes no dimension; got {:?}",
4278                    self.peek()
4279                )));
4280            }
4281            self.advance();
4282            // v7.11.13 — widened to INT[] and BIGINT[] in addition
4283            // to TEXT[]. Other base types (BOOL[], NUMERIC[], etc.)
4284            // still error here.
4285            ty = match ty {
4286                ColumnTypeName::Text => ColumnTypeName::TextArray,
4287                ColumnTypeName::Int => ColumnTypeName::IntArray,
4288                ColumnTypeName::BigInt => ColumnTypeName::BigIntArray,
4289                other => {
4290                    return Err(self.err(alloc::format!(
4291                        "v7.11 supports TEXT[] / INT[] / BIGINT[] only; got {other:?}[]"
4292                    )));
4293                }
4294            };
4295        }
4296        Ok((ty, implied_auto_increment, implied_not_null))
4297    }
4298
4299    fn parse_column_def(&mut self) -> Result<ColumnDef, ParseError> {
4300        let name = self.expect_ident_like()?;
4301        let (ty, implied_auto_increment, implied_not_null) =
4302            self.parse_type_with_implied_flags()?;
4303        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
4304        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
4305        // each at most once.
4306        let mut default: Option<Expr> = None;
4307        let mut nullable = !implied_not_null;
4308        let mut nullability_seen = implied_not_null;
4309        let mut auto_increment = implied_auto_increment;
4310        let mut is_primary_key = false;
4311        let mut is_unique = false;
4312        let mut check: Option<Expr> = None;
4313        loop {
4314            if matches!(self.peek(), Token::Default) {
4315                if default.is_some() {
4316                    return Err(self.err("DEFAULT specified twice".into()));
4317                }
4318                self.advance();
4319                default = Some(self.parse_expr(0)?);
4320                continue;
4321            }
4322            if matches!(self.peek(), Token::Not) {
4323                if nullability_seen {
4324                    return Err(self.err("NOT NULL specified twice".into()));
4325                }
4326                self.advance();
4327                if !matches!(self.peek(), Token::Null) {
4328                    return Err(self.err(format!(
4329                        "expected NULL after NOT in column def, got {:?}",
4330                        self.peek()
4331                    )));
4332                }
4333                self.advance();
4334                nullable = false;
4335                nullability_seen = true;
4336                continue;
4337            }
4338            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
4339            // "this column is nullable" marker (the default in
4340            // standard SQL anyway). mysqldump emits it routinely
4341            // (`col TYPE NULL DEFAULT NULL` for nullable
4342            // timestamps etc). Accept + no-op.
4343            if matches!(self.peek(), Token::Null) {
4344                if nullability_seen && !nullable {
4345                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
4346                }
4347                self.advance();
4348                nullable = true;
4349                nullability_seen = true;
4350                continue;
4351            }
4352            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
4353            // arrives as a bare Ident. Match either, case-insensitive.
4354            if let Token::Ident(s) = self.peek()
4355                && (s.eq_ignore_ascii_case("auto_increment")
4356                    || s.eq_ignore_ascii_case("autoincrement"))
4357            {
4358                if auto_increment {
4359                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
4360                }
4361                self.advance();
4362                auto_increment = true;
4363                continue;
4364            }
4365            // v7.9.13 — inline `PRIMARY KEY` column constraint
4366            // (mailrs F1). Implies `NOT NULL`. The engine creates
4367            // a BTree index for the PK column at CREATE TABLE time
4368            // so FK parent-side index lookups resolve.
4369            if let Token::Ident(s) = self.peek()
4370                && s.eq_ignore_ascii_case("primary")
4371            {
4372                if is_primary_key {
4373                    return Err(self.err("PRIMARY KEY specified twice".into()));
4374                }
4375                // Peek-ahead for the required `KEY` token.
4376                let next = self.tokens.get(self.pos + 1);
4377                let next_is_key = matches!(
4378                    next,
4379                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
4380                );
4381                if !next_is_key {
4382                    return Err(self.err(format!(
4383                        "expected KEY after PRIMARY in column def, got {:?}",
4384                        next
4385                    )));
4386                }
4387                self.advance(); // PRIMARY
4388                self.advance(); // KEY
4389                is_primary_key = true;
4390                if nullability_seen && nullable {
4391                    return Err(self.err(
4392                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
4393                    ));
4394                }
4395                nullable = false;
4396                nullability_seen = true;
4397                continue;
4398            }
4399            // v7.13.0 — inline `UNIQUE` column constraint
4400            // (mailrs round-5 G2). Fold into a single-column
4401            // table-level UNIQUE at CREATE TABLE post-process time.
4402            if let Token::Ident(s) = self.peek()
4403                && s.eq_ignore_ascii_case("unique")
4404            {
4405                if is_unique {
4406                    return Err(self.err("UNIQUE specified twice".into()));
4407                }
4408                self.advance();
4409                is_unique = true;
4410                continue;
4411            }
4412            // v7.13.0 — inline `CHECK (<expr>)` column constraint
4413            // (mailrs round-5 G3). PG semantics: column-level
4414            // CHECK is equivalent to a table-level CHECK. Multiple
4415            // inline CHECKs on the same column AND together.
4416            if let Token::Ident(s) = self.peek()
4417                && s.eq_ignore_ascii_case("check")
4418            {
4419                self.advance();
4420                if !matches!(self.peek(), Token::LParen) {
4421                    return Err(self.err(alloc::format!(
4422                        "expected '(' after CHECK in column def, got {:?}",
4423                        self.peek()
4424                    )));
4425                }
4426                self.advance();
4427                let pred = self.parse_expr(0)?;
4428                if !matches!(self.peek(), Token::RParen) {
4429                    return Err(self.err(alloc::format!(
4430                        "expected ')' to close CHECK predicate, got {:?}",
4431                        self.peek()
4432                    )));
4433                }
4434                self.advance();
4435                check = Some(match check.take() {
4436                    Some(prev) => Expr::Binary {
4437                        op: BinOp::And,
4438                        lhs: Box::new(prev),
4439                        rhs: Box::new(pred),
4440                    },
4441                    None => pred,
4442                });
4443                continue;
4444            }
4445            break;
4446        }
4447        Ok(ColumnDef {
4448            name,
4449            ty,
4450            nullable,
4451            default,
4452            auto_increment,
4453            is_primary_key,
4454            is_unique,
4455            check,
4456        })
4457    }
4458
4459    /// `NUMERIC` may appear without parameters, with one (precision
4460    /// only, scale=0), or with both. Returns `(precision, scale)` with
4461    /// 0 = unspecified for the bare form.
4462    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
4463        if !matches!(self.peek(), Token::LParen) {
4464            // Bare `NUMERIC` — PG treats this as "unlimited precision";
4465            // we surface it as precision=0 to mean "unconstrained" so
4466            // the engine doesn't need a separate variant.
4467            return Ok((0, 0));
4468        }
4469        self.advance();
4470        let precision = match self.advance() {
4471            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
4472            other => {
4473                return Err(ParseError {
4474                    message: format!(
4475                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
4476                    ),
4477                    token_pos: self.pos.saturating_sub(1),
4478                });
4479            }
4480        };
4481        let scale = if matches!(self.peek(), Token::Comma) {
4482            self.advance();
4483            match self.advance() {
4484                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
4485                    u8::try_from(n).expect("range-checked")
4486                }
4487                other => {
4488                    return Err(ParseError {
4489                        message: format!(
4490                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
4491                        ),
4492                        token_pos: self.pos.saturating_sub(1),
4493                    });
4494                }
4495            }
4496        } else {
4497            0
4498        };
4499        if !matches!(self.peek(), Token::RParen) {
4500            return Err(self.err(format!(
4501                "expected ')' to close NUMERIC params, got {:?}",
4502                self.peek()
4503            )));
4504        }
4505        self.advance();
4506        Ok((precision, scale))
4507    }
4508
4509    /// Parse `(N)` where `N` is a positive integer literal — used by the
4510    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
4511    /// for the error message.
4512    /// v6.0.1: parse the optional `USING <encoding>` clause that
4513    /// follows `VECTOR(N)` in a column definition. Missing clause
4514    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
4515    /// ident → `ParseError` listing the encodings recognised today.
4516    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
4517        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
4518            return Ok(VecEncoding::F32);
4519        }
4520        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
4521        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
4522        // consume the token when the very next token is a known
4523        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
4524        // `USING` for the caller — it's the rewrite-expression form.
4525        let n1 = self.tokens.get(self.pos + 1);
4526        let next_is_encoding = matches!(
4527            n1,
4528            Some(Token::Ident(s))
4529                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
4530        );
4531        if !next_is_encoding {
4532            return Ok(VecEncoding::F32);
4533        }
4534        self.advance();
4535        let enc_ident = match self.advance() {
4536            Token::Ident(s) => s,
4537            other => {
4538                return Err(self.err(format!(
4539                    "expected vector encoding after USING, got {other:?}"
4540                )));
4541            }
4542        };
4543        match enc_ident.to_ascii_lowercase().as_str() {
4544            "sq8" => Ok(VecEncoding::Sq8),
4545            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
4546            // binary16 per-element storage.
4547            "half" => Ok(VecEncoding::F16),
4548            other => Err(self.err(format!(
4549                "unknown vector encoding {other:?}; supported: SQ8, HALF"
4550            ))),
4551        }
4552    }
4553
4554    /// v7.14.0 — consume an optional MySQL display-width
4555    /// parenthesised number after an integer type, returning
4556    /// nothing. `TINYINT(1)` etc.
4557    fn consume_optional_paren_size(&mut self) {
4558        if !matches!(self.peek(), Token::LParen) {
4559            return;
4560        }
4561        self.advance();
4562        // Skip until matching RParen (allow nested or any tokens).
4563        let mut depth = 1usize;
4564        while depth > 0 {
4565            match self.peek() {
4566                Token::LParen => depth += 1,
4567                Token::RParen => depth -= 1,
4568                Token::Eof => return,
4569                _ => {}
4570            }
4571            self.advance();
4572        }
4573    }
4574
4575    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
4576        if !matches!(self.peek(), Token::LParen) {
4577            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
4578        }
4579        self.advance();
4580        let n = match self.advance() {
4581            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
4582                message: format!("{label} size too large: {n}"),
4583                token_pos: self.pos.saturating_sub(1),
4584            })?,
4585            other => {
4586                return Err(ParseError {
4587                    message: format!("expected positive integer {label} size, got {other:?}"),
4588                    token_pos: self.pos.saturating_sub(1),
4589                });
4590            }
4591        };
4592        if !matches!(self.peek(), Token::RParen) {
4593            return Err(self.err(format!(
4594                "expected ')' after {label} size, got {:?}",
4595                self.peek()
4596            )));
4597        }
4598        self.advance();
4599        Ok(n)
4600    }
4601
4602    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
4603        debug_assert!(matches!(self.peek(), Token::Insert));
4604        self.advance();
4605        if !matches!(self.peek(), Token::Into) {
4606            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
4607        }
4608        self.advance();
4609        let table = self.expect_ident_like()?;
4610        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
4611        let columns = if matches!(self.peek(), Token::LParen) {
4612            self.advance();
4613            let mut names = Vec::new();
4614            loop {
4615                names.push(self.expect_ident_like()?);
4616                match self.peek() {
4617                    Token::Comma => {
4618                        self.advance();
4619                    }
4620                    Token::RParen => {
4621                        self.advance();
4622                        break;
4623                    }
4624                    other => {
4625                        return Err(self.err(format!(
4626                            "expected ',' or ')' in INSERT column list, got {other:?}"
4627                        )));
4628                    }
4629                }
4630            }
4631            Some(names)
4632        } else {
4633            None
4634        };
4635        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
4636        // round-5 G4). Dispatch on VALUES vs SELECT.
4637        if matches!(self.peek(), Token::Select) {
4638            let select_stmt = match self.parse_select_stmt()? {
4639                Statement::Select(s) => s,
4640                other => {
4641                    return Err(self.err(alloc::format!(
4642                        "expected SELECT after INSERT INTO ... target, got {other:?}"
4643                    )));
4644                }
4645            };
4646            let on_conflict = self.parse_optional_on_conflict()?;
4647            let returning = self.parse_optional_returning()?;
4648            return Ok(Statement::Insert(InsertStatement {
4649                table,
4650                columns,
4651                rows: Vec::new(),
4652                select_source: Some(Box::new(select_stmt)),
4653                on_conflict,
4654                returning,
4655            }));
4656        }
4657        if !matches!(self.peek(), Token::Values) {
4658            return Err(self.err(format!(
4659                "expected VALUES or SELECT after table name, got {:?}",
4660                self.peek()
4661            )));
4662        }
4663        self.advance();
4664        if !matches!(self.peek(), Token::LParen) {
4665            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
4666        }
4667        let mut rows = Vec::new();
4668        loop {
4669            // Each iteration consumes one `(expr, expr, …)` tuple.
4670            if !matches!(self.peek(), Token::LParen) {
4671                return Err(self.err(format!(
4672                    "expected '(' for next VALUES tuple, got {:?}",
4673                    self.peek()
4674                )));
4675            }
4676            self.advance();
4677            let mut tuple = Vec::new();
4678            loop {
4679                tuple.push(self.parse_expr(0)?);
4680                match self.peek() {
4681                    Token::Comma => {
4682                        self.advance();
4683                    }
4684                    Token::RParen => {
4685                        self.advance();
4686                        break;
4687                    }
4688                    other => {
4689                        return Err(self.err(format!(
4690                            "expected ',' or ')' in VALUES tuple, got {other:?}"
4691                        )));
4692                    }
4693                }
4694            }
4695            if tuple.is_empty() {
4696                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
4697            }
4698            rows.push(tuple);
4699            // Continue with comma-separated tuples.
4700            if matches!(self.peek(), Token::Comma) {
4701                self.advance();
4702            } else {
4703                break;
4704            }
4705        }
4706        let on_conflict = self.parse_optional_on_conflict()?;
4707        let returning = self.parse_optional_returning()?;
4708        Ok(Statement::Insert(InsertStatement {
4709            table,
4710            columns,
4711            rows,
4712            select_source: None,
4713            on_conflict,
4714            returning,
4715        }))
4716    }
4717
4718    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
4719    /// clause sitting between the INSERT body and the trailing
4720    /// RETURNING. All keywords come in as bare idents; `ON` is
4721    /// a reserved Token though.
4722    fn parse_optional_on_conflict(
4723        &mut self,
4724    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
4725        if !matches!(self.peek(), Token::On) {
4726            return Ok(None);
4727        }
4728        // Peek further: we want exactly "ON CONFLICT ...". If the
4729        // next ident isn't "conflict", let some other parser handle.
4730        let next_is_conflict = matches!(
4731            self.tokens.get(self.pos + 1),
4732            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
4733        );
4734        if !next_is_conflict {
4735            return Ok(None);
4736        }
4737        self.advance(); // ON
4738        self.advance(); // CONFLICT
4739        // Optional `(col [, col]*)` target list.
4740        let mut target_columns: Vec<String> = Vec::new();
4741        if matches!(self.peek(), Token::LParen) {
4742            self.advance();
4743            loop {
4744                target_columns.push(self.expect_ident_like()?);
4745                match self.peek() {
4746                    Token::Comma => {
4747                        self.advance();
4748                    }
4749                    Token::RParen => {
4750                        self.advance();
4751                        break;
4752                    }
4753                    other => {
4754                        return Err(self.err(alloc::format!(
4755                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
4756                        )));
4757                    }
4758                }
4759            }
4760        }
4761        // Required `DO`.
4762        match self.advance() {
4763            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
4764            other => {
4765                return Err(self.err(alloc::format!(
4766                    "expected DO after ON CONFLICT [(…)], got {other:?}"
4767                )));
4768            }
4769        }
4770        // Action: NOTHING | UPDATE SET …
4771        let action = match self.advance() {
4772            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
4773                crate::ast::OnConflictAction::Nothing
4774            }
4775            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
4776                self.parse_on_conflict_update_action()?
4777            }
4778            other => {
4779                return Err(self.err(alloc::format!(
4780                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
4781                )));
4782            }
4783        };
4784        Ok(Some(crate::ast::OnConflictClause {
4785            target_columns,
4786            action,
4787        }))
4788    }
4789
4790    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
4791    /// `SET col = expr [, …] [WHERE cond]`. Caller already
4792    /// consumed `UPDATE`.
4793    fn parse_on_conflict_update_action(
4794        &mut self,
4795    ) -> Result<crate::ast::OnConflictAction, ParseError> {
4796        // `SET`
4797        match self.advance() {
4798            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
4799            other => {
4800                return Err(self.err(alloc::format!(
4801                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
4802                )));
4803            }
4804        }
4805        let mut assignments: Vec<(String, Expr)> = Vec::new();
4806        loop {
4807            let col = self.expect_ident_like()?;
4808            if !matches!(self.peek(), Token::Eq) {
4809                return Err(self.err(alloc::format!(
4810                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
4811                    self.peek()
4812                )));
4813            }
4814            self.advance();
4815            let value = self.parse_expr(0)?;
4816            assignments.push((col, value));
4817            if matches!(self.peek(), Token::Comma) {
4818                self.advance();
4819                continue;
4820            }
4821            break;
4822        }
4823        let where_ = if matches!(self.peek(), Token::Where) {
4824            self.advance();
4825            Some(self.parse_expr(0)?)
4826        } else {
4827            None
4828        };
4829        Ok(crate::ast::OnConflictAction::Update {
4830            assignments,
4831            where_,
4832        })
4833    }
4834
4835    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
4836        let mut items = Vec::new();
4837        loop {
4838            items.push(self.parse_select_item()?);
4839            if matches!(self.peek(), Token::Comma) {
4840                self.advance();
4841            } else {
4842                break;
4843            }
4844        }
4845        Ok(items)
4846    }
4847
4848    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
4849        if matches!(self.peek(), Token::Star) {
4850            self.advance();
4851            return Ok(SelectItem::Wildcard);
4852        }
4853        let expr = self.parse_expr(0)?;
4854        let alias = self.parse_optional_alias();
4855        Ok(SelectItem::Expr { expr, alias })
4856    }
4857
4858    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
4859        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
4860        // source. Detect at the head before the bare-ident fallback;
4861        // unnest is not a reserved token.
4862        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
4863            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
4864        {
4865            self.advance(); // unnest
4866            self.advance(); // (
4867            let expr = self.parse_expr(0)?;
4868            if !matches!(self.peek(), Token::RParen) {
4869                return Err(self.err(alloc::format!(
4870                    "expected ')' after unnest() argument, got {:?}",
4871                    self.peek()
4872                )));
4873            }
4874            self.advance();
4875            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
4876            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
4877            return Ok(TableRef {
4878                name,
4879                alias: alias_ident,
4880                as_of_segment: None,
4881                unnest_expr: Some(Box::new(expr)),
4882                unnest_column_aliases,
4883            });
4884        }
4885        // v7.16.2 — preserve information_schema / pg_catalog
4886        // qualifiers (mailrs round-10 A.3). The generic
4887        // `expect_ident_like` strip silently drops the schema;
4888        // we want the engine to recognise these PG meta tables
4889        // and synthesise rows from the live catalog. Produce a
4890        // synthetic name (`__spg_info_columns` etc.) so the
4891        // engine's SELECT-side router can dispatch without
4892        // clashing with any user-defined `columns` table.
4893        let name = if let Some(synth) = self.try_peek_meta_qualified() {
4894            synth
4895        } else {
4896            self.expect_ident_like()?
4897        };
4898        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
4899        // time-travel clause. Parse BEFORE the alias so the
4900        // alias can still ride at the tail (`tbl AS OF SEGMENT
4901        // '5' alias`). `AS` is a reserved keyword token, while
4902        // `OF` and `SEGMENT` are bare idents.
4903        let as_of_segment = if matches!(self.peek(), Token::As)
4904            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
4905        {
4906            self.advance(); // AS
4907            self.advance(); // OF
4908            let kw = match self.peek().clone() {
4909                Token::Ident(s) | Token::QuotedIdent(s) => s,
4910                other => {
4911                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
4912                }
4913            };
4914            if !kw.eq_ignore_ascii_case("segment") {
4915                return Err(self.err(format!(
4916                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
4917                )));
4918            }
4919            self.advance();
4920            // Segment id literal — accept either a string or
4921            // integer for operator ergonomics.
4922            let id = match self.advance() {
4923                Token::String(s) => s
4924                    .parse::<u32>()
4925                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
4926                Token::Integer(n) => u32::try_from(n)
4927                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
4928                other => {
4929                    return Err(self.err(format!(
4930                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
4931                    )));
4932                }
4933            };
4934            Some(id)
4935        } else {
4936            None
4937        };
4938        let alias = self.parse_optional_alias();
4939        Ok(TableRef {
4940            name,
4941            alias,
4942            as_of_segment,
4943            unnest_expr: None,
4944            unnest_column_aliases: Vec::new(),
4945        })
4946    }
4947
4948    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
4949    /// but also accepts `AS alias(col [, col, …])` — the
4950    /// PG-standard table-function column-list form. The column
4951    /// list is only honoured when paired with `UNNEST(...)` in
4952    /// the parent; other call sites currently discard it.
4953    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
4954        let alias = self.parse_optional_alias();
4955        if alias.is_none() {
4956            return (None, Vec::new());
4957        }
4958        let mut cols: Vec<String> = Vec::new();
4959        if matches!(self.peek(), Token::LParen) {
4960            self.advance();
4961            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
4962                self.advance();
4963                cols.push(s);
4964                if matches!(self.peek(), Token::Comma) {
4965                    self.advance();
4966                    continue;
4967                }
4968                break;
4969            }
4970            if matches!(self.peek(), Token::RParen) {
4971                self.advance();
4972            }
4973        }
4974        (alias, cols)
4975    }
4976
4977    /// FROM-clause: a primary table reference plus zero-or-more joined
4978    /// peers expressed via either `, <table>` (cross-product, no ON) or
4979    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
4980    /// the join list flat (left-associative nested-loop semantics).
4981    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
4982        let primary = self.parse_table_ref()?;
4983        let mut joins = Vec::new();
4984        loop {
4985            // `, <table>` — cross-product with no ON.
4986            if matches!(self.peek(), Token::Comma) {
4987                self.advance();
4988                let table = self.parse_table_ref()?;
4989                joins.push(FromJoin {
4990                    kind: JoinKind::Cross,
4991                    table,
4992                    on: None,
4993                });
4994                continue;
4995            }
4996            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
4997            // CROSS JOIN, and bare JOIN (defaults to INNER).
4998            let kind =
4999                match self.peek() {
5000                    Token::Inner => {
5001                        self.advance();
5002                        if !matches!(self.peek(), Token::Join) {
5003                            return Err(self
5004                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
5005                        }
5006                        self.advance();
5007                        JoinKind::Inner
5008                    }
5009                    Token::Left => {
5010                        self.advance();
5011                        if matches!(self.peek(), Token::Outer) {
5012                            self.advance();
5013                        }
5014                        if !matches!(self.peek(), Token::Join) {
5015                            return Err(self.err(format!(
5016                                "expected JOIN after LEFT [OUTER], got {:?}",
5017                                self.peek()
5018                            )));
5019                        }
5020                        self.advance();
5021                        JoinKind::Left
5022                    }
5023                    Token::Cross => {
5024                        self.advance();
5025                        if !matches!(self.peek(), Token::Join) {
5026                            return Err(self
5027                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
5028                        }
5029                        self.advance();
5030                        JoinKind::Cross
5031                    }
5032                    Token::Join => {
5033                        self.advance();
5034                        JoinKind::Inner
5035                    }
5036                    _ => break,
5037                };
5038            let table = self.parse_table_ref()?;
5039            let on = if matches!(self.peek(), Token::On) {
5040                self.advance();
5041                Some(self.parse_expr(0)?)
5042            } else if kind == JoinKind::Cross {
5043                None
5044            } else {
5045                return Err(self.err(format!(
5046                    "expected ON after {:?} JOIN, got {:?}",
5047                    kind,
5048                    self.peek()
5049                )));
5050            };
5051            joins.push(FromJoin { kind, table, on });
5052        }
5053        Ok(FromClause { primary, joins })
5054    }
5055
5056    /// Optional alias after an expression or table:
5057    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
5058    /// accepted (PG-style implicit alias). Returns `None` if the next token
5059    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
5060    fn parse_optional_alias(&mut self) -> Option<String> {
5061        if matches!(self.peek(), Token::As) {
5062            self.advance();
5063            // After AS, the next token MUST be an identifier-like — if not,
5064            // we still return None and let the caller surface the error on the
5065            // next expectation. v0.2 keeps the alias path forgiving; the
5066            // corpus tests don't exercise the malformed case.
5067            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
5068                return self.expect_ident_like().ok();
5069            }
5070            return None;
5071        }
5072        if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
5073            return self.expect_ident_like().ok();
5074        }
5075        None
5076    }
5077
5078    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
5079    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
5080        let mut lhs = self.parse_unary()?;
5081        while let Some((op, prec)) = binop_from(self.peek()) {
5082            if prec < min_prec {
5083                break;
5084            }
5085            self.advance();
5086            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
5087            // ANY is a bare ident; ALL is a reserved Token. Both
5088            // require an immediate `(` to disambiguate from
5089            // identifier columns named `any` / `all`.
5090            let any_kind = match self.peek() {
5091                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
5092                    Some(false)
5093                }
5094                Token::Ident(s) | Token::QuotedIdent(s)
5095                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
5096                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
5097                {
5098                    Some(s.eq_ignore_ascii_case("any"))
5099                }
5100                _ => None,
5101            };
5102            if let Some(is_any) = any_kind {
5103                self.advance(); // ident
5104                self.advance(); // (
5105                let arr = self.parse_expr(0)?;
5106                if !matches!(self.peek(), Token::RParen) {
5107                    return Err(self.err(alloc::format!(
5108                        "expected ')' after ANY/ALL argument, got {:?}",
5109                        self.peek()
5110                    )));
5111                }
5112                self.advance();
5113                lhs = Expr::AnyAll {
5114                    expr: Box::new(lhs),
5115                    op,
5116                    array: Box::new(arr),
5117                    is_any,
5118                };
5119                continue;
5120            }
5121            let rhs = self.parse_expr(prec + 1)?;
5122            lhs = Expr::Binary {
5123                lhs: Box::new(lhs),
5124                op,
5125                rhs: Box::new(rhs),
5126            };
5127        }
5128        Ok(lhs)
5129    }
5130
5131    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
5132        match self.peek() {
5133            Token::Not => {
5134                self.advance();
5135                // NOT sits between AND (2) and comparisons (4) — bind everything
5136                // ≥3, which leaves AND/OR outside.
5137                let e = self.parse_expr(3)?;
5138                Ok(Expr::Unary {
5139                    op: UnOp::Not,
5140                    expr: Box::new(e),
5141                })
5142            }
5143            Token::Minus => {
5144                self.advance();
5145                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
5146                // `<->` slotted into 5 and arithmetic shifted up).
5147                let e = self.parse_expr(8)?;
5148                Ok(Expr::Unary {
5149                    op: UnOp::Neg,
5150                    expr: Box::new(e),
5151                })
5152            }
5153            _ => self.parse_atom(),
5154        }
5155    }
5156
5157    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
5158        let tok_pos = self.pos;
5159        match self.advance() {
5160            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
5161            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
5162            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
5163            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
5164            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
5165            Token::Null => Ok(Expr::Literal(Literal::Null)),
5166            // v6.1.1 — `$N` placeholder. The actual Value lookup
5167            // happens in the engine eval path against the prepared-
5168            // statement bind buffer.
5169            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
5170            Token::LParen => {
5171                // v4.10: `(SELECT ...)` in expression position is a
5172                // scalar subquery; otherwise it's a parenthesised
5173                // expression. Peek for SELECT keyword to dispatch.
5174                if matches!(self.peek(), Token::Select) {
5175                    let inner = self.parse_select_stmt()?;
5176                    match self.advance() {
5177                        Token::RParen => {
5178                            let Statement::Select(s) = inner else {
5179                                unreachable!("parse_select_stmt returns Select")
5180                            };
5181                            Ok(Expr::ScalarSubquery(Box::new(s)))
5182                        }
5183                        other => Err(ParseError {
5184                            message: format!("expected ')' after scalar subquery, got {other:?}"),
5185                            token_pos: self.pos.saturating_sub(1),
5186                        }),
5187                    }
5188                } else {
5189                    let e = self.parse_expr(0)?;
5190                    match self.advance() {
5191                        Token::RParen => Ok(e),
5192                        other => Err(ParseError {
5193                            message: format!("expected ')', got {other:?}"),
5194                            token_pos: self.pos.saturating_sub(1),
5195                        }),
5196                    }
5197                }
5198            }
5199            Token::LBracket => self.parse_vector_literal_body(),
5200            Token::Extract => self.parse_extract_atom(),
5201            Token::Interval => self.parse_interval_atom(),
5202            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
5203            // token; we match on the bare ident. NOT is a token
5204            // (consumed in the comparison rung), but `EXISTS (...)`
5205            // at the top of an expression starts here.
5206            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
5207                self.parse_exists_atom(false)
5208            }
5209            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
5210            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
5211            // CASE is a bare ident; we dispatch on lowercase match.
5212            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
5213                self.parse_case_atom()
5214            }
5215            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
5216            // is not a reserved token; we match by case-insensitive
5217            // ident. The opening `[` must follow immediately.
5218            Token::Ident(s) | Token::QuotedIdent(s)
5219                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
5220            {
5221                self.advance(); // consume `[`
5222                let mut items: Vec<Expr> = Vec::new();
5223                if !matches!(self.peek(), Token::RBracket) {
5224                    loop {
5225                        items.push(self.parse_expr(0)?);
5226                        match self.peek() {
5227                            Token::Comma => {
5228                                self.advance();
5229                            }
5230                            Token::RBracket => break,
5231                            other => {
5232                                return Err(self.err(alloc::format!(
5233                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
5234                                )));
5235                            }
5236                        }
5237                    }
5238                }
5239                self.advance(); // consume `]`
5240                Ok(Expr::Array(items))
5241            }
5242            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
5243            other => Err(ParseError {
5244                message: format!("unexpected token {other:?} in expression"),
5245                token_pos: tok_pos,
5246            }),
5247        }
5248        // After parsing the atom, fold any postfix `::vector` casts.
5249        .and_then(|atom| self.finish_postfix_casts(atom))
5250    }
5251
5252    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
5253    /// Both bind tighter than any binary op.
5254    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
5255        loop {
5256            if matches!(self.peek(), Token::DoubleColon) {
5257                self.advance();
5258                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
5259                // target set to include INTERVAL (reserved Token),
5260                // TIMESTAMPTZ, and PG catalog regtype / regclass.
5261                // mailrs follow-up H3a + H3b.
5262                let target = match self.advance() {
5263                    Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
5264                        "int" | "integer" | "int4" => {
5265                            if matches!(self.peek(), Token::LBracket)
5266                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
5267                            {
5268                                self.advance();
5269                                self.advance();
5270                                CastTarget::IntArray
5271                            } else {
5272                                CastTarget::Int
5273                            }
5274                        }
5275                        "bigint" | "int8" => {
5276                            if matches!(self.peek(), Token::LBracket)
5277                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
5278                            {
5279                                self.advance();
5280                                self.advance();
5281                                CastTarget::BigIntArray
5282                            } else {
5283                                CastTarget::BigInt
5284                            }
5285                        }
5286                        "float" | "double" | "real" => CastTarget::Float,
5287                        "text" => {
5288                            // v7.10.11 — `::TEXT[]` widens to TextArray.
5289                            if matches!(self.peek(), Token::LBracket)
5290                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
5291                            {
5292                                self.advance();
5293                                self.advance();
5294                                CastTarget::TextArray
5295                            } else {
5296                                CastTarget::Text
5297                            }
5298                        }
5299                        "bool" | "boolean" => CastTarget::Bool,
5300                        "vector" => CastTarget::Vector,
5301                        "date" => CastTarget::Date,
5302                        "timestamp" | "datetime" => CastTarget::Timestamp,
5303                        "timestamptz" => CastTarget::Timestamptz,
5304                        "interval" => CastTarget::Interval,
5305                        "json" => CastTarget::Json,
5306                        "jsonb" => CastTarget::Jsonb,
5307                        "regtype" => CastTarget::RegType,
5308                        "regclass" => CastTarget::RegClass,
5309                        // v7.12.0 — `::tsvector` / `::tsquery`.
5310                        // Engine decodes the LHS text via the PG
5311                        // external form parser.
5312                        "tsvector" => CastTarget::TsVector,
5313                        "tsquery" => CastTarget::TsQuery,
5314                        other => {
5315                            return Err(ParseError {
5316                                message: format!("unsupported cast target `::{other}`"),
5317                                token_pos: self.pos.saturating_sub(1),
5318                            });
5319                        }
5320                    },
5321                    Token::Interval => CastTarget::Interval,
5322                    other => {
5323                        return Err(ParseError {
5324                            message: format!("expected type ident after `::`, got {other:?}"),
5325                            token_pos: self.pos.saturating_sub(1),
5326                        });
5327                    }
5328                };
5329                expr = Expr::Cast {
5330                    expr: Box::new(expr),
5331                    target,
5332                };
5333                continue;
5334            }
5335            if matches!(self.peek(), Token::Is) {
5336                self.advance();
5337                let negated = if matches!(self.peek(), Token::Not) {
5338                    self.advance();
5339                    true
5340                } else {
5341                    false
5342                };
5343                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
5344                // mailrs pg_dump.
5345                if matches!(self.peek(), Token::Distinct) {
5346                    self.advance();
5347                    if !matches!(self.peek(), Token::From) {
5348                        return Err(self.err(format!(
5349                            "expected FROM after IS{} DISTINCT, got {:?}",
5350                            if negated { " NOT" } else { "" },
5351                            self.peek()
5352                        )));
5353                    }
5354                    self.advance();
5355                    // Right-hand side: parse at the same precedence
5356                    // tier as comparison so `x IS DISTINCT FROM a + b`
5357                    // groups as `x IS DISTINCT FROM (a + b)`.
5358                    let rhs = self.parse_expr(20)?;
5359                    let op = if negated {
5360                        BinOp::IsNotDistinctFrom
5361                    } else {
5362                        BinOp::IsDistinctFrom
5363                    };
5364                    expr = Expr::Binary {
5365                        op,
5366                        lhs: Box::new(expr),
5367                        rhs: Box::new(rhs),
5368                    };
5369                    continue;
5370                }
5371                if !matches!(self.peek(), Token::Null) {
5372                    return Err(self.err(format!(
5373                        "expected NULL or DISTINCT after IS{}, got {:?}",
5374                        if negated { " NOT" } else { "" },
5375                        self.peek()
5376                    )));
5377                }
5378                self.advance();
5379                expr = Expr::IsNull {
5380                    expr: Box::new(expr),
5381                    negated,
5382                };
5383                continue;
5384            }
5385            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
5386            // Look one token ahead so a stray `NOT` not followed by any of
5387            // these flows through to the early return below untouched.
5388            let negated = if matches!(self.peek(), Token::Not) {
5389                let next = self.tokens.get(self.pos + 1);
5390                matches!(next, Some(Token::Between | Token::In | Token::Like))
5391            } else {
5392                false
5393            };
5394            if negated {
5395                self.advance();
5396            }
5397            if matches!(self.peek(), Token::Between) {
5398                expr = self.parse_between_tail(expr, negated)?;
5399                continue;
5400            }
5401            if matches!(self.peek(), Token::In) {
5402                expr = self.parse_in_tail(expr, negated)?;
5403                continue;
5404            }
5405            if matches!(self.peek(), Token::Like) {
5406                self.advance();
5407                // Pattern at the same precedence as other comparison RHSes —
5408                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
5409                let pattern = self.parse_expr(5)?;
5410                expr = Expr::Like {
5411                    expr: Box::new(expr),
5412                    pattern: Box::new(pattern),
5413                    negated,
5414                };
5415                continue;
5416            }
5417            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
5418            // returns NULL for out-of-range. Multiple subscripts
5419            // chain: `a[i][j]` parses left-to-right.
5420            if matches!(self.peek(), Token::LBracket) {
5421                self.advance();
5422                let index = self.parse_expr(0)?;
5423                if !matches!(self.peek(), Token::RBracket) {
5424                    return Err(self.err(alloc::format!(
5425                        "expected ']' after array index, got {:?}",
5426                        self.peek()
5427                    )));
5428                }
5429                self.advance();
5430                expr = Expr::ArraySubscript {
5431                    target: Box::new(expr),
5432                    index: Box::new(index),
5433                };
5434                continue;
5435            }
5436            return Ok(expr);
5437        }
5438    }
5439
5440    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
5441    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
5442    /// `AND` is not swallowed.
5443    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
5444        self.advance(); // BETWEEN
5445        let low = self.parse_expr(5)?;
5446        if !matches!(self.peek(), Token::And) {
5447            return Err(self.err(format!(
5448                "expected AND after BETWEEN low bound, got {:?}",
5449                self.peek()
5450            )));
5451        }
5452        self.advance();
5453        let high = self.parse_expr(5)?;
5454        let target = Box::new(expr);
5455        let combined = Expr::Binary {
5456            lhs: Box::new(Expr::Binary {
5457                lhs: target.clone(),
5458                op: BinOp::GtEq,
5459                rhs: Box::new(low),
5460            }),
5461            op: BinOp::And,
5462            rhs: Box::new(Expr::Binary {
5463                lhs: target,
5464                op: BinOp::LtEq,
5465                rhs: Box::new(high),
5466            }),
5467        };
5468        Ok(maybe_not(combined, negated))
5469    }
5470
5471    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
5472    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
5473    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
5474    /// Caller already consumed the leading `WITH` ident.
5475    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
5476        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
5477        // Comes through as an identifier; consume it if present and
5478        // mark every CTE in the clause as recursive (PG semantics —
5479        // the flag is per-WITH, not per-CTE).
5480        let mut recursive = false;
5481        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
5482            && s.eq_ignore_ascii_case("recursive")
5483        {
5484            self.advance();
5485            recursive = true;
5486        }
5487        let mut ctes = Vec::new();
5488        loop {
5489            let name = self.expect_ident_like()?;
5490            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
5491            // PG uses these to rename the body's output columns; we
5492            // do the same below by overriding `columns[i].name`.
5493            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
5494                self.advance();
5495                let mut names = Vec::new();
5496                loop {
5497                    names.push(self.expect_ident_like()?);
5498                    if matches!(self.peek(), Token::Comma) {
5499                        self.advance();
5500                        continue;
5501                    }
5502                    break;
5503                }
5504                if !matches!(self.peek(), Token::RParen) {
5505                    return Err(self.err(format!(
5506                        "expected ')' to close CTE column list, got {:?}",
5507                        self.peek()
5508                    )));
5509                }
5510                self.advance();
5511                names
5512            } else {
5513                Vec::new()
5514            };
5515            // AS is a reserved Token::As (used by SELECT-item / FROM
5516            // aliasing) — handle it specially rather than as a bare
5517            // ident.
5518            if !matches!(self.peek(), Token::As) {
5519                return Err(self.err(format!(
5520                    "expected AS after CTE name {name:?}, got {:?}",
5521                    self.peek()
5522                )));
5523            }
5524            self.advance();
5525            if !matches!(self.peek(), Token::LParen) {
5526                return Err(self.err(format!(
5527                    "expected '(' after AS in WITH clause, got {:?}",
5528                    self.peek()
5529                )));
5530            }
5531            self.advance();
5532            if !matches!(self.peek(), Token::Select) {
5533                return Err(self.err(format!("WITH body must be a SELECT, got {:?}", self.peek())));
5534            }
5535            let inner = self.parse_select_stmt()?;
5536            if !matches!(self.peek(), Token::RParen) {
5537                return Err(self.err(format!(
5538                    "expected ')' after CTE body, got {:?}",
5539                    self.peek()
5540                )));
5541            }
5542            self.advance();
5543            let Statement::Select(body) = inner else {
5544                unreachable!("parse_select_stmt returns Select")
5545            };
5546            ctes.push(crate::ast::Cte {
5547                name,
5548                body,
5549                recursive,
5550                column_overrides,
5551            });
5552            if matches!(self.peek(), Token::Comma) {
5553                self.advance();
5554                continue;
5555            }
5556            break;
5557        }
5558        // The body SELECT follows. Must start with SELECT.
5559        if !matches!(self.peek(), Token::Select) {
5560            return Err(self.err(format!(
5561                "expected SELECT after WITH clause, got {:?}",
5562                self.peek()
5563            )));
5564        }
5565        let body_stmt = self.parse_select_stmt()?;
5566        let Statement::Select(mut body) = body_stmt else {
5567            unreachable!()
5568        };
5569        body.ctes = ctes;
5570        Ok(Statement::Select(body))
5571    }
5572
5573    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
5574    /// already consumed the leading `EXISTS` ident via
5575    /// `self.advance()`.
5576    /// v7.13.0 — parse the rest of a `CASE … END` expression after
5577    /// the leading `CASE` ident has been consumed (mailrs round-5
5578    /// G9). Supports both the searched form
5579    /// (`CASE WHEN cond THEN val …`) and the simple form
5580    /// (`CASE operand WHEN val THEN val …`).
5581    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
5582        // Disambiguate searched vs simple form: if the next token
5583        // is `WHEN`, we're in the searched form. Otherwise the
5584        // intervening expression is the operand.
5585        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
5586            None
5587        } else {
5588            Some(Box::new(self.parse_expr(0)?))
5589        };
5590        let mut branches: Vec<(Expr, Expr)> = Vec::new();
5591        loop {
5592            match self.peek() {
5593                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
5594                    self.advance();
5595                    let cond = self.parse_expr(0)?;
5596                    match self.peek() {
5597                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
5598                            self.advance();
5599                        }
5600                        other => {
5601                            return Err(self.err(alloc::format!(
5602                                "expected THEN after CASE WHEN <expr>, got {other:?}"
5603                            )));
5604                        }
5605                    }
5606                    let value = self.parse_expr(0)?;
5607                    branches.push((cond, value));
5608                }
5609                _ => break,
5610            }
5611        }
5612        if branches.is_empty() {
5613            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
5614        }
5615        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
5616        {
5617            self.advance();
5618            Some(Box::new(self.parse_expr(0)?))
5619        } else {
5620            None
5621        };
5622        match self.peek() {
5623            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
5624                self.advance();
5625            }
5626            other => {
5627                return Err(self.err(alloc::format!(
5628                    "expected END to close CASE expression, got {other:?}"
5629                )));
5630            }
5631        }
5632        Ok(Expr::Case {
5633            operand,
5634            branches,
5635            else_branch,
5636        })
5637    }
5638
5639    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
5640        if !matches!(self.peek(), Token::LParen) {
5641            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
5642        }
5643        self.advance();
5644        let inner = self.parse_select_stmt()?;
5645        if !matches!(self.peek(), Token::RParen) {
5646            return Err(self.err(format!(
5647                "expected ')' after EXISTS-subquery, got {:?}",
5648                self.peek()
5649            )));
5650        }
5651        self.advance();
5652        let Statement::Select(s) = inner else {
5653            unreachable!("parse_select_stmt returns Select")
5654        };
5655        Ok(Expr::Exists {
5656            subquery: Box::new(s),
5657            negated,
5658        })
5659    }
5660
5661    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
5662        self.advance(); // IN
5663        if !matches!(self.peek(), Token::LParen) {
5664            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
5665        }
5666        self.advance();
5667        // v4.10: `IN (SELECT ...)` — subquery branch.
5668        if matches!(self.peek(), Token::Select) {
5669            let inner = self.parse_select_stmt()?;
5670            if !matches!(self.peek(), Token::RParen) {
5671                return Err(self.err(format!(
5672                    "expected ')' after IN-subquery, got {:?}",
5673                    self.peek()
5674                )));
5675            }
5676            self.advance();
5677            let Statement::Select(s) = inner else {
5678                unreachable!("parse_select_stmt always returns Statement::Select")
5679            };
5680            return Ok(Expr::InSubquery {
5681                expr: Box::new(expr),
5682                subquery: Box::new(s),
5683                negated,
5684            });
5685        }
5686        let mut elements = Vec::new();
5687        if !matches!(self.peek(), Token::RParen) {
5688            loop {
5689                elements.push(self.parse_expr(0)?);
5690                match self.peek() {
5691                    Token::Comma => {
5692                        self.advance();
5693                    }
5694                    Token::RParen => break,
5695                    other => {
5696                        return Err(
5697                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
5698                        );
5699                    }
5700                }
5701            }
5702        }
5703        self.advance(); // ')'
5704        let target = Box::new(expr);
5705        let combined = if elements.is_empty() {
5706            Expr::Literal(Literal::Bool(false))
5707        } else {
5708            let mut iter = elements.into_iter();
5709            let first = iter.next().unwrap();
5710            let mut acc = Expr::Binary {
5711                lhs: target.clone(),
5712                op: BinOp::Eq,
5713                rhs: Box::new(first),
5714            };
5715            for elt in iter {
5716                acc = Expr::Binary {
5717                    lhs: Box::new(acc),
5718                    op: BinOp::Or,
5719                    rhs: Box::new(Expr::Binary {
5720                        lhs: target.clone(),
5721                        op: BinOp::Eq,
5722                        rhs: Box::new(elt),
5723                    }),
5724                };
5725            }
5726            acc
5727        };
5728        Ok(maybe_not(combined, negated))
5729    }
5730
5731    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
5732    /// already consumed by the caller. Elements must be numeric literals
5733    /// (with optional unary `-`); any compound expression is rejected at
5734    /// parse time so the runtime never needs to evaluate inside a vector.
5735    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
5736    /// has already consumed the `EXTRACT` token before calling us —
5737    /// we pick up at the opening `(`.
5738    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
5739        if !matches!(self.peek(), Token::LParen) {
5740            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
5741        }
5742        self.advance();
5743        let field_name = self.expect_ident_like()?;
5744        let field = match field_name.to_ascii_lowercase().as_str() {
5745            "year" => ExtractField::Year,
5746            "month" => ExtractField::Month,
5747            "day" => ExtractField::Day,
5748            "hour" => ExtractField::Hour,
5749            "minute" => ExtractField::Minute,
5750            "second" => ExtractField::Second,
5751            "microsecond" | "microseconds" => ExtractField::Microsecond,
5752            other => {
5753                return Err(self.err(format!(
5754                    "unknown EXTRACT field {other:?}; \
5755                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND"
5756                )));
5757            }
5758        };
5759        if !matches!(self.peek(), Token::From) {
5760            return Err(self.err(format!(
5761                "expected FROM after EXTRACT field, got {:?}",
5762                self.peek()
5763            )));
5764        }
5765        self.advance();
5766        let source = self.parse_expr(0)?;
5767        if !matches!(self.peek(), Token::RParen) {
5768            return Err(self.err(format!(
5769                "expected ')' to close EXTRACT, got {:?}",
5770                self.peek()
5771            )));
5772        }
5773        self.advance();
5774        Ok(Expr::Extract {
5775            field,
5776            source: Box::new(source),
5777        })
5778    }
5779
5780    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
5781    /// is already consumed; we expect a single string literal next and
5782    /// resolve it into `Literal::Interval` at parse time so the engine
5783    /// never has to re-tokenise inside the string.
5784    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
5785        let tok = self.advance();
5786        let Token::String(text) = tok else {
5787            return Err(self.err(format!(
5788                "expected string literal after INTERVAL, got {tok:?}"
5789            )));
5790        };
5791        let (months, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
5792            message: format!(
5793                "cannot parse INTERVAL {text:?}; \
5794                     expected `<n> <unit> [<n> <unit> ...]` with units \
5795                     microsecond[s], millisecond[s], second[s], minute[s], \
5796                     hour[s], day[s], week[s], month[s], year[s]"
5797            ),
5798            token_pos: self.pos.saturating_sub(1),
5799        })?;
5800        Ok(Expr::Literal(Literal::Interval {
5801            months,
5802            micros,
5803            text,
5804        }))
5805    }
5806
5807    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
5808        let mut elems = Vec::new();
5809        if matches!(self.peek(), Token::RBracket) {
5810            self.advance();
5811            return Ok(Expr::Literal(Literal::Vector(elems)));
5812        }
5813        loop {
5814            let e = self.parse_expr(0)?;
5815            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
5816                message: format!("vector element must be a numeric literal, got {e:?}"),
5817                token_pos: self.pos,
5818            })?;
5819            elems.push(x);
5820            match self.peek() {
5821                Token::Comma => {
5822                    self.advance();
5823                }
5824                Token::RBracket => {
5825                    self.advance();
5826                    break;
5827                }
5828                other => {
5829                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
5830                }
5831            }
5832        }
5833        Ok(Expr::Literal(Literal::Vector(elems)))
5834    }
5835
5836    /// Atom that started with an identifier: could be `t.col`, `col`, or
5837    /// `func(arg, ...)`. Detect each shape by looking at the next token.
5838    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
5839    /// [, ...])`. Caller has already consumed `OVER`. Either clause
5840    /// is optional; an empty `()` is also legal (PG semantics).
5841    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
5842    /// modifier between `name(args)` and `OVER (...)`. Default is
5843    /// `Respect`. Unrecognised idents leave the stream unchanged.
5844    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
5845        let Token::Ident(s) = self.peek().clone() else {
5846            return NullTreatment::Respect;
5847        };
5848        let is_ignore = s.eq_ignore_ascii_case("ignore");
5849        let is_respect = s.eq_ignore_ascii_case("respect");
5850        if !is_ignore && !is_respect {
5851            return NullTreatment::Respect;
5852        }
5853        // Lookahead for NULLS — only consume both tokens together.
5854        // pos+1 must hold a "nulls" ident.
5855        if self.pos + 1 < self.tokens.len()
5856            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
5857            && s2.eq_ignore_ascii_case("nulls")
5858        {
5859            self.advance();
5860            self.advance();
5861            return if is_ignore {
5862                NullTreatment::Ignore
5863            } else {
5864                NullTreatment::Respect
5865            };
5866        }
5867        NullTreatment::Respect
5868    }
5869
5870    /// No frame clause is supported.
5871    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
5872    fn parse_over_clause(
5873        &mut self,
5874    ) -> Result<(Vec<Expr>, Vec<(Expr, bool)>, Option<WindowFrame>), ParseError> {
5875        if !matches!(self.peek(), Token::LParen) {
5876            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
5877        }
5878        self.advance();
5879        let mut partition_by = Vec::new();
5880        let mut order_by = Vec::new();
5881        // PARTITION BY ?
5882        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
5883            && s.eq_ignore_ascii_case("partition")
5884        {
5885            self.advance();
5886            if !matches!(self.peek(), Token::By) {
5887                return Err(self.err(format!(
5888                    "expected BY after PARTITION, got {:?}",
5889                    self.peek()
5890                )));
5891            }
5892            self.advance();
5893            loop {
5894                partition_by.push(self.parse_expr(0)?);
5895                if matches!(self.peek(), Token::Comma) {
5896                    self.advance();
5897                    continue;
5898                }
5899                break;
5900            }
5901        }
5902        // ORDER BY ?
5903        if matches!(self.peek(), Token::Order) {
5904            self.advance();
5905            if !matches!(self.peek(), Token::By) {
5906                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
5907            }
5908            self.advance();
5909            loop {
5910                let e = self.parse_expr(0)?;
5911                let desc = if matches!(self.peek(), Token::Desc) {
5912                    self.advance();
5913                    true
5914                } else if matches!(self.peek(), Token::Asc) {
5915                    self.advance();
5916                    false
5917                } else {
5918                    false
5919                };
5920                order_by.push((e, desc));
5921                if matches!(self.peek(), Token::Comma) {
5922                    self.advance();
5923                    continue;
5924                }
5925                break;
5926            }
5927        }
5928        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
5929        // Both keywords come through the lexer as identifiers; match
5930        // case-insensitively.
5931        let mut frame: Option<WindowFrame> = None;
5932        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
5933            let kind = if s.eq_ignore_ascii_case("rows") {
5934                Some(FrameKind::Rows)
5935            } else if s.eq_ignore_ascii_case("range") {
5936                Some(FrameKind::Range)
5937            } else {
5938                None
5939            };
5940            if let Some(kind) = kind {
5941                self.advance();
5942                frame = Some(self.parse_frame_tail(kind)?);
5943            }
5944        }
5945        if !matches!(self.peek(), Token::RParen) {
5946            return Err(self.err(format!(
5947                "expected ')' to close OVER clause, got {:?}",
5948                self.peek()
5949            )));
5950        }
5951        self.advance();
5952        Ok((partition_by, order_by, frame))
5953    }
5954
5955    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
5956    /// or `RANGE` keyword was just consumed. Accepts both
5957    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
5958    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
5959    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
5960    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
5961        if matches!(self.peek(), Token::Between) {
5962            self.advance();
5963            let start = self.parse_frame_bound()?;
5964            if !matches!(self.peek(), Token::And) {
5965                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
5966            }
5967            self.advance();
5968            let end = self.parse_frame_bound()?;
5969            Ok(WindowFrame {
5970                kind,
5971                start,
5972                end: Some(end),
5973            })
5974        } else {
5975            let start = self.parse_frame_bound()?;
5976            Ok(WindowFrame {
5977                kind,
5978                start,
5979                end: None,
5980            })
5981        }
5982    }
5983
5984    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
5985    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
5986    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
5987        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
5988        if let Token::Integer(n) = *self.peek() {
5989            self.advance();
5990            let n: u64 = u64::try_from(n).map_err(|_| {
5991                self.err(format!(
5992                    "invalid frame offset {n} — expected non-negative integer"
5993                ))
5994            })?;
5995            let dir = self.expect_ident_like()?;
5996            return if dir.eq_ignore_ascii_case("preceding") {
5997                Ok(FrameBound::OffsetPreceding(n))
5998            } else if dir.eq_ignore_ascii_case("following") {
5999                Ok(FrameBound::OffsetFollowing(n))
6000            } else {
6001                Err(self.err(format!(
6002                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
6003                )))
6004            };
6005        }
6006        let first = self.expect_ident_like()?;
6007        if first.eq_ignore_ascii_case("unbounded") {
6008            let dir = self.expect_ident_like()?;
6009            return if dir.eq_ignore_ascii_case("preceding") {
6010                Ok(FrameBound::UnboundedPreceding)
6011            } else if dir.eq_ignore_ascii_case("following") {
6012                Ok(FrameBound::UnboundedFollowing)
6013            } else {
6014                Err(self.err(format!(
6015                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
6016                )))
6017            };
6018        }
6019        if first.eq_ignore_ascii_case("current") {
6020            let row = self.expect_ident_like()?;
6021            if !row.eq_ignore_ascii_case("row") {
6022                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
6023            }
6024            return Ok(FrameBound::CurrentRow);
6025        }
6026        Err(self.err(format!(
6027            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
6028        )))
6029    }
6030
6031    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
6032        if matches!(self.peek(), Token::Dot) {
6033            self.advance();
6034            let name = self.expect_ident_like()?;
6035            // v7.14.0 — schema-qualified function call
6036            // `<schema>.<fn>(args)`. PG dumps emit
6037            // `pg_catalog.set_config(...)` in the preamble. SPG
6038            // is single-namespace: drop the schema prefix and
6039            // route the dispatch on the bare function name.
6040            if matches!(self.peek(), Token::LParen) {
6041                return self.finish_ident_atom(name);
6042            }
6043            return Ok(Expr::Column(ColumnName {
6044                qualifier: Some(first),
6045                name,
6046            }));
6047        }
6048        if matches!(self.peek(), Token::LParen) {
6049            self.advance();
6050            // `COUNT(*)` — special-cased here because `*` isn't a normal
6051            // expression token. Lower-case match on `first` since the lexer
6052            // folds identifiers.
6053            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
6054                self.advance();
6055                if !matches!(self.peek(), Token::RParen) {
6056                    return Err(self.err(format!(
6057                        "expected ')' after COUNT(*), got {:?}",
6058                        self.peek()
6059                    )));
6060                }
6061                self.advance();
6062                // v4.12: COUNT(*) OVER (...) — same window tail.
6063                let null_treatment = self.parse_null_treatment_modifier();
6064                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
6065                    && s.eq_ignore_ascii_case("over")
6066                {
6067                    self.advance();
6068                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
6069                    return Ok(Expr::WindowFunction {
6070                        name: "count_star".into(),
6071                        args: Vec::new(),
6072                        partition_by,
6073                        order_by,
6074                        frame,
6075                        null_treatment,
6076                    });
6077                }
6078                return Ok(Expr::FunctionCall {
6079                    name: "count_star".into(),
6080                    args: Vec::new(),
6081                });
6082            }
6083            // Function call. PG-style: zero-or-more comma-separated args.
6084            let mut args = Vec::new();
6085            if !matches!(self.peek(), Token::RParen) {
6086                loop {
6087                    args.push(self.parse_expr(0)?);
6088                    match self.peek() {
6089                        Token::Comma => {
6090                            self.advance();
6091                        }
6092                        Token::RParen => break,
6093                        other => {
6094                            return Err(self.err(format!(
6095                                "expected ',' or ')' in function args, got {other:?}"
6096                            )));
6097                        }
6098                    }
6099                }
6100            }
6101            self.advance(); // consume ')'
6102            // v4.12: window-function tail — `name(args) OVER (...)`.
6103            // Promotes the just-parsed FunctionCall into a
6104            // WindowFunction node carrying partition + order.
6105            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
6106            // / `RESPECT NULLS OVER (...)` between the closing paren
6107            // and `OVER`.
6108            let null_treatment = self.parse_null_treatment_modifier();
6109            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
6110                && s.eq_ignore_ascii_case("over")
6111            {
6112                self.advance();
6113                let (partition_by, order_by, frame) = self.parse_over_clause()?;
6114                return Ok(Expr::WindowFunction {
6115                    name: first,
6116                    args,
6117                    partition_by,
6118                    order_by,
6119                    frame,
6120                    null_treatment,
6121                });
6122            }
6123            return Ok(Expr::FunctionCall { name: first, args });
6124        }
6125        // v7.9.20 — SQL-standard parenless keyword expressions
6126        // (PG treats these as functions called without parens).
6127        // Resolve to a synthetic FunctionCall so the engine's
6128        // eval path reuses the existing function-call routing.
6129        // mailrs G3.
6130        let lc = first.to_ascii_lowercase();
6131        if matches!(
6132            lc.as_str(),
6133            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
6134        ) {
6135            return Ok(Expr::FunctionCall {
6136                name: lc,
6137                args: Vec::new(),
6138            });
6139        }
6140        Ok(Expr::Column(ColumnName {
6141            qualifier: None,
6142            name: first,
6143        }))
6144    }
6145}
6146
6147/// v6.8.2 — walk an expression tree and return the first column
6148/// reference's bare name. Used by `parse_create_index_stmt_after_create`
6149/// to derive `CreateIndexStatement.column` from an expression
6150/// key (so downstream planner code resolving a primary column
6151/// position keeps working with expression indexes). Returns
6152/// `None` when the expression has no column ref at all — caller
6153/// surfaces that as a parse error.
6154fn extract_first_column(expr: &Expr) -> Option<String> {
6155    match expr {
6156        Expr::Column(cn) => Some(cn.name.clone()),
6157        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
6158        Expr::Binary { lhs, rhs, .. } => {
6159            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
6160        }
6161        Expr::Unary { expr: e, .. } => extract_first_column(e),
6162        _ => None,
6163    }
6164}
6165
6166fn maybe_not(expr: Expr, negated: bool) -> Expr {
6167    if negated {
6168        Expr::Unary {
6169            op: UnOp::Not,
6170            expr: Box::new(expr),
6171        }
6172    } else {
6173        expr
6174    }
6175}
6176
6177fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
6178    let pair = match tok {
6179        Token::Or => (BinOp::Or, 1),
6180        Token::And => (BinOp::And, 2),
6181        Token::Eq => (BinOp::Eq, 4),
6182        Token::NotEq => (BinOp::NotEq, 4),
6183        Token::Lt => (BinOp::Lt, 4),
6184        Token::LtEq => (BinOp::LtEq, 4),
6185        Token::Gt => (BinOp::Gt, 4),
6186        Token::GtEq => (BinOp::GtEq, 4),
6187        // pgvector distance ops all sit on the same rung — tighter than
6188        // comparisons (4) so `col <-> v < threshold` parses correctly.
6189        Token::L2Distance => (BinOp::L2Distance, 5),
6190        Token::InnerProduct => (BinOp::InnerProduct, 5),
6191        Token::CosineDistance => (BinOp::CosineDistance, 5),
6192        Token::Plus => (BinOp::Add, 6),
6193        Token::Minus => (BinOp::Sub, 6),
6194        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
6195        // by the same level as binary additive arithmetic).
6196        Token::Concat => (BinOp::Concat, 6),
6197        Token::Star => (BinOp::Mul, 7),
6198        Token::Slash => (BinOp::Div, 7),
6199        // v4.14: JSON path ops bind tighter than comparisons (4)
6200        // and additive (6) so `doc->'k' = 'v'` parses correctly.
6201        // Same rung as the multiplicative ops.
6202        Token::JsonGet => (BinOp::JsonGet, 7),
6203        Token::JsonGetText => (BinOp::JsonGetText, 7),
6204        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
6205        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
6206        Token::JsonContains => (BinOp::JsonContains, 7),
6207        // v7.12.2 — `@@` binds at the comparison rung (looser than
6208        // arithmetic, tighter than AND / OR). PG places `@@` at
6209        // the same precedence as `=` / `<`, so we follow.
6210        Token::TsMatch => (BinOp::TsMatch, 4),
6211        _ => return None,
6212    };
6213    Some(pair)
6214}
6215
6216#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
6217// `as f32` here is intentional: vector elements widen / narrow into f32 on
6218// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
6219// past ~15 decimal digits — both are acceptable for a fixed-precision
6220// pgvector column.
6221fn extract_numeric_literal(e: &Expr) -> Option<f32> {
6222    match e {
6223        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
6224        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
6225        Expr::Unary {
6226            op: UnOp::Neg,
6227            expr,
6228        } => extract_numeric_literal(expr).map(|x| -x),
6229        _ => None,
6230    }
6231}
6232
6233/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
6234/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
6235/// negative. Returns `None` if any pair fails to parse or no pair is found.
6236///
6237/// Recognised units (case-insensitive, optional trailing `s`):
6238/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
6239/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
6240pub fn parse_interval_text(s: &str) -> Option<(i32, i64)> {
6241    let parts: Vec<&str> = s.split_whitespace().collect();
6242    if parts.is_empty() || !parts.len().is_multiple_of(2) {
6243        return None;
6244    }
6245    let mut months: i32 = 0;
6246    let mut micros: i64 = 0;
6247    let mut i = 0;
6248    while i < parts.len() {
6249        let n: i64 = parts[i].parse().ok()?;
6250        let unit = parts[i + 1].to_ascii_lowercase();
6251        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
6252        match unit_stripped {
6253            "microsecond" => micros = micros.checked_add(n)?,
6254            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
6255            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
6256            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
6257            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
6258            "day" => micros = micros.checked_add(n.checked_mul(86_400_000_000)?)?,
6259            "week" => micros = micros.checked_add(n.checked_mul(604_800_000_000)?)?,
6260            "month" => {
6261                let n32 = i32::try_from(n).ok()?;
6262                months = months.checked_add(n32)?;
6263            }
6264            "year" => {
6265                let n32 = i32::try_from(n).ok()?;
6266                months = months.checked_add(n32.checked_mul(12)?)?;
6267            }
6268            _ => return None,
6269        }
6270        i += 2;
6271    }
6272    Some((months, micros))
6273}
6274
6275/// v7.12.4 — map a bare type-name identifier (the form that
6276/// appears in a function arg list or RETURNS clause) to a
6277/// [`ColumnTypeName`]. Returns `None` for unknown / extension
6278/// types so the caller can preserve them as
6279/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
6280///
6281/// Subset of the full column-type grammar — we deliberately
6282/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
6283/// here because function-arg types in v7.12.4 are mostly the
6284/// bare form (`text`, `int`, `bytea`, …).
6285fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
6286    Some(match ident.to_ascii_lowercase().as_str() {
6287        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
6288        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
6289        "bigint" => ColumnTypeName::BigInt,
6290        "float" | "double" | "real" => ColumnTypeName::Float,
6291        "text" => ColumnTypeName::Text,
6292        "bool" | "boolean" => ColumnTypeName::Bool,
6293        "date" => ColumnTypeName::Date,
6294        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
6295        "timestamptz" => ColumnTypeName::Timestamptz,
6296        "json" => ColumnTypeName::Json,
6297        "jsonb" => ColumnTypeName::Jsonb,
6298        "bytea" | "bytes" => ColumnTypeName::Bytes,
6299        "tsvector" => ColumnTypeName::TsVector,
6300        "tsquery" => ColumnTypeName::TsQuery,
6301        _ => return None,
6302    })
6303}
6304
6305/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
6306/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
6307///
6308/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
6309/// / embedded SQL land in v7.12.5+):
6310///
6311/// ```text
6312///   body          := [ws] block [ws]
6313///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
6314///   stmt          := assign | return
6315///   assign        := assign_target := expr
6316///   assign_target := ( NEW | OLD ) . ident | ident
6317///   return        := RETURN ( NEW | OLD | NULL | expr )
6318/// ```
6319///
6320/// `expr` is parsed by recursing into the regular `Parser` — so a
6321/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
6322/// NEW.subject || ' ' || NEW.sender)` body shape works without
6323/// the body parser knowing what `to_tsvector` is.
6324///
6325/// Errors here cause the caller to fall back to
6326/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
6327/// successful, but the executor will refuse to invoke the
6328/// function with an "unparseable body" error.
6329/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
6330/// from the crate root as `spg_sql::parse_function_body`.
6331pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
6332    parse_plpgsql_body(body)
6333}
6334
6335fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
6336    // Use the regular lexer on the body text. The trailing
6337    // `END;` may or may not have a semicolon; the lexer treats
6338    // both forms identically.
6339    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
6340        message: alloc::format!("plpgsql body lex error: {e}"),
6341        token_pos: 0,
6342    })?;
6343    let mut parser = Parser::new(tokens);
6344    parser.parse_plpgsql_block()
6345}
6346
6347#[cfg(test)]
6348mod tests {
6349    use super::*;
6350    use alloc::string::ToString;
6351
6352    fn parse(s: &str) -> Statement {
6353        parse_statement(s).expect("parse ok")
6354    }
6355
6356    fn lit_int(n: i64) -> Expr {
6357        Expr::Literal(Literal::Integer(n))
6358    }
6359
6360    fn col(name: &str) -> Expr {
6361        Expr::Column(ColumnName {
6362            qualifier: None,
6363            name: name.into(),
6364        })
6365    }
6366
6367    #[test]
6368    fn select_single_integer() {
6369        let s = parse("SELECT 1");
6370        let Statement::Select(s) = s else {
6371            panic!("expected SELECT")
6372        };
6373        assert_eq!(s.items.len(), 1);
6374        assert!(s.from.is_none());
6375        assert!(s.where_.is_none());
6376    }
6377
6378    #[test]
6379    fn select_multiple_literal_kinds() {
6380        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
6381        let Statement::Select(s) = s else {
6382            panic!("expected SELECT")
6383        };
6384        assert_eq!(s.items.len(), 5);
6385    }
6386
6387    #[test]
6388    fn select_wildcard_from_table() {
6389        let s = parse("SELECT * FROM users");
6390        let Statement::Select(s) = s else {
6391            panic!("expected SELECT")
6392        };
6393        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
6394        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
6395    }
6396
6397    #[test]
6398    fn select_with_table_alias() {
6399        let s = parse("SELECT * FROM users AS u");
6400        let Statement::Select(s) = s else {
6401            panic!("expected SELECT")
6402        };
6403        let t = &s.from.as_ref().unwrap().primary;
6404        assert_eq!(t.name, "users");
6405        assert_eq!(t.alias.as_deref(), Some("u"));
6406    }
6407
6408    #[test]
6409    fn select_with_where_eq() {
6410        let s = parse("SELECT a FROM t WHERE a = 1");
6411        let Statement::Select(s) = s else {
6412            panic!("expected SELECT")
6413        };
6414        let w = s.where_.unwrap();
6415        assert_eq!(
6416            w,
6417            Expr::Binary {
6418                lhs: Box::new(col("a")),
6419                op: BinOp::Eq,
6420                rhs: Box::new(lit_int(1)),
6421            }
6422        );
6423    }
6424
6425    #[test]
6426    fn arithmetic_precedence() {
6427        let s = parse("SELECT 1 + 2 * 3");
6428        let Statement::Select(s) = s else {
6429            panic!("expected SELECT")
6430        };
6431        let SelectItem::Expr { expr, .. } = &s.items[0] else {
6432            panic!("wildcard?")
6433        };
6434        assert_eq!(
6435            expr,
6436            &Expr::Binary {
6437                lhs: Box::new(lit_int(1)),
6438                op: BinOp::Add,
6439                rhs: Box::new(Expr::Binary {
6440                    lhs: Box::new(lit_int(2)),
6441                    op: BinOp::Mul,
6442                    rhs: Box::new(lit_int(3)),
6443                }),
6444            }
6445        );
6446    }
6447
6448    #[test]
6449    fn parentheses_override_precedence() {
6450        let s = parse("SELECT (1 + 2) * 3");
6451        let Statement::Select(s) = s else {
6452            panic!("expected SELECT")
6453        };
6454        let SelectItem::Expr { expr, .. } = &s.items[0] else {
6455            panic!()
6456        };
6457        assert_eq!(
6458            expr,
6459            &Expr::Binary {
6460                lhs: Box::new(Expr::Binary {
6461                    lhs: Box::new(lit_int(1)),
6462                    op: BinOp::Add,
6463                    rhs: Box::new(lit_int(2)),
6464                }),
6465                op: BinOp::Mul,
6466                rhs: Box::new(lit_int(3)),
6467            }
6468        );
6469    }
6470
6471    #[test]
6472    fn not_binds_below_comparison() {
6473        // `NOT a = 1` should parse as `NOT (a = 1)`.
6474        let s = parse("SELECT NOT a = 1 FROM t");
6475        let Statement::Select(s) = s else {
6476            panic!("expected SELECT")
6477        };
6478        let SelectItem::Expr { expr, .. } = &s.items[0] else {
6479            panic!()
6480        };
6481        assert_eq!(
6482            expr,
6483            &Expr::Unary {
6484                op: UnOp::Not,
6485                expr: Box::new(Expr::Binary {
6486                    lhs: Box::new(col("a")),
6487                    op: BinOp::Eq,
6488                    rhs: Box::new(lit_int(1)),
6489                }),
6490            }
6491        );
6492    }
6493
6494    #[test]
6495    fn unary_minus_binds_above_multiplication() {
6496        // `-a * 2` should be `(-a) * 2`.
6497        let s = parse("SELECT -a * 2 FROM t");
6498        let Statement::Select(s) = s else {
6499            panic!("expected SELECT")
6500        };
6501        let SelectItem::Expr { expr, .. } = &s.items[0] else {
6502            panic!()
6503        };
6504        assert_eq!(
6505            expr,
6506            &Expr::Binary {
6507                lhs: Box::new(Expr::Unary {
6508                    op: UnOp::Neg,
6509                    expr: Box::new(col("a")),
6510                }),
6511                op: BinOp::Mul,
6512                rhs: Box::new(lit_int(2)),
6513            }
6514        );
6515    }
6516
6517    #[test]
6518    fn qualified_column() {
6519        let s = parse("SELECT t.col FROM t");
6520        let Statement::Select(s) = s else {
6521            panic!("expected SELECT")
6522        };
6523        let SelectItem::Expr { expr, .. } = &s.items[0] else {
6524            panic!()
6525        };
6526        assert_eq!(
6527            expr,
6528            &Expr::Column(ColumnName {
6529                qualifier: Some("t".into()),
6530                name: "col".into()
6531            })
6532        );
6533    }
6534
6535    #[test]
6536    fn select_item_alias_with_as() {
6537        let s = parse("SELECT a AS y FROM t");
6538        let Statement::Select(s) = s else {
6539            panic!("expected SELECT")
6540        };
6541        let SelectItem::Expr { alias, .. } = &s.items[0] else {
6542            panic!()
6543        };
6544        assert_eq!(alias.as_deref(), Some("y"));
6545    }
6546
6547    #[test]
6548    fn trailing_semicolon_accepted() {
6549        let s = parse("SELECT 1;");
6550        let Statement::Select(s) = s else {
6551            panic!("expected SELECT")
6552        };
6553        assert_eq!(s.items.len(), 1);
6554    }
6555
6556    #[test]
6557    fn boolean_chain_with_and_or_not() {
6558        // (NOT a) OR (b AND (NOT c))
6559        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
6560        let Statement::Select(s) = s else {
6561            panic!("expected SELECT")
6562        };
6563        let SelectItem::Expr { expr, .. } = &s.items[0] else {
6564            panic!()
6565        };
6566        let expected = Expr::Binary {
6567            lhs: Box::new(Expr::Unary {
6568                op: UnOp::Not,
6569                expr: Box::new(col("a")),
6570            }),
6571            op: BinOp::Or,
6572            rhs: Box::new(Expr::Binary {
6573                lhs: Box::new(col("b")),
6574                op: BinOp::And,
6575                rhs: Box::new(Expr::Unary {
6576                    op: UnOp::Not,
6577                    expr: Box::new(col("c")),
6578                }),
6579            }),
6580        };
6581        assert_eq!(expr, &expected);
6582    }
6583
6584    #[test]
6585    fn empty_input_errors() {
6586        // v7.14.0 — pg_dump preambles emit several comment-only
6587        // / blank-line statements that collapse to Statement::
6588        // Empty rather than a parse error. The old "SELECT in
6589        // message" assertion is stale; verify the new contract:
6590        // empty / whitespace / comment-only input parses to
6591        // Statement::Empty.
6592        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
6593        assert!(matches!(
6594            parse_statement("  \n\t ").unwrap(),
6595            Statement::Empty
6596        ));
6597        // Sanity: malformed-but-non-empty still errors.
6598        assert!(parse_statement("SELECT FROM WHERE").is_err());
6599    }
6600
6601    #[test]
6602    fn unmatched_paren_errors() {
6603        assert!(parse_statement("SELECT (1 + 2").is_err());
6604    }
6605
6606    #[test]
6607    fn display_round_trip_simple_select() {
6608        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
6609        let text = original.to_string();
6610        let again = parse_statement(&text).expect("re-parse");
6611        assert_eq!(original, again);
6612    }
6613
6614    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
6615
6616    #[test]
6617    fn create_table_single_column() {
6618        let s = parse("CREATE TABLE foo (a INT)");
6619        let Statement::CreateTable(c) = s else {
6620            panic!("expected CreateTable")
6621        };
6622        assert_eq!(c.name, "foo");
6623        assert_eq!(c.columns.len(), 1);
6624        assert_eq!(c.columns[0].name, "a");
6625        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
6626        assert!(c.columns[0].nullable);
6627    }
6628
6629    #[test]
6630    fn create_table_multi_column_with_not_null_mix() {
6631        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
6632        let Statement::CreateTable(c) = s else {
6633            panic!()
6634        };
6635        assert_eq!(c.columns.len(), 4);
6636        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
6637        assert!(!c.columns[0].nullable);
6638        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
6639        assert!(c.columns[1].nullable);
6640        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
6641        assert!(!c.columns[2].nullable);
6642        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
6643    }
6644
6645    #[test]
6646    fn create_table_bigint_supported() {
6647        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
6648        let Statement::CreateTable(c) = s else {
6649            panic!()
6650        };
6651        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
6652    }
6653
6654    #[test]
6655    fn create_table_vector_default_is_f32() {
6656        let s = parse("CREATE TABLE t (v VECTOR(128))");
6657        let Statement::CreateTable(c) = s else {
6658            panic!()
6659        };
6660        assert_eq!(
6661            c.columns[0].ty,
6662            ColumnTypeName::Vector {
6663                dim: 128,
6664                encoding: VecEncoding::F32,
6665            },
6666        );
6667    }
6668
6669    #[test]
6670    fn create_table_vector_using_sq8() {
6671        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
6672        // Case-insensitive on both `USING` and the encoding name.
6673        for sql in [
6674            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
6675            "CREATE TABLE t (v VECTOR(128) using sq8)",
6676        ] {
6677            let s = parse(sql);
6678            let Statement::CreateTable(c) = s else {
6679                panic!()
6680            };
6681            assert_eq!(
6682                c.columns[0].ty,
6683                ColumnTypeName::Vector {
6684                    dim: 128,
6685                    encoding: VecEncoding::Sq8,
6686                },
6687                "{sql}",
6688            );
6689        }
6690    }
6691
6692    #[test]
6693    fn create_table_vector_using_unknown_errors() {
6694        // v7.16.1 — the inline `USING <encoding>` shape on
6695        // CREATE TABLE column defs was withdrawn before
6696        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
6697        // (col vector_<metric>_ops)`; the parser now rejects
6698        // USING at column-list position with a clearer
6699        // "expected ',' or ')'" message. Test asserts the
6700        // current rejection, not the old "unknown vector
6701        // encoding" string.
6702        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
6703        assert!(
6704            err.message.contains("USING")
6705                || err.message.contains("using")
6706                || err.message.contains("')'")
6707                || err.message.contains("','"),
6708            "expected USING/column-list rejection, got: {}",
6709            err.message
6710        );
6711    }
6712
6713    #[test]
6714    fn vector_using_sq8_display_roundtrips() {
6715        // The Display impl must produce text that re-parses to the
6716        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
6717        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
6718        let Statement::CreateTable(c) = s else {
6719            panic!()
6720        };
6721        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
6722    }
6723
6724    #[test]
6725    fn parser_recognises_placeholders() {
6726        use crate::ast::{Expr, SelectItem, Statement};
6727        // $N in expression position parses as Expr::Placeholder(N).
6728        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
6729        let Statement::Select(sel) = s else { panic!() };
6730        assert!(matches!(
6731            sel.items[0],
6732            SelectItem::Expr {
6733                expr: Expr::Placeholder(1),
6734                alias: None
6735            }
6736        ));
6737        // $2 + 1
6738        let SelectItem::Expr {
6739            expr: Expr::Binary { lhs, rhs, .. },
6740            ..
6741        } = &sel.items[1]
6742        else {
6743            panic!()
6744        };
6745        assert!(matches!(**lhs, Expr::Placeholder(2)));
6746        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
6747        // WHERE x = $3
6748        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
6749            panic!()
6750        };
6751        assert!(matches!(**rhs, Expr::Placeholder(3)));
6752    }
6753
6754    #[test]
6755    fn parser_rejects_dollar_zero() {
6756        // $0 is not valid in PG; the lexer rejects it.
6757        assert!(parse_statement("SELECT $0").is_err());
6758    }
6759
6760    #[test]
6761    fn placeholder_display_roundtrips() {
6762        // The Display impl must produce text that re-lexes to the
6763        // same Placeholder token.
6764        let s = parse("SELECT $42 FROM t");
6765        let printed = s.to_string();
6766        assert!(printed.contains("$42"));
6767        let again = parse(&printed);
6768        assert_eq!(s, again);
6769    }
6770
6771    #[test]
6772    fn alter_index_rebuild_bare() {
6773        use crate::ast::{AlterIndexTarget, Statement};
6774        let s = parse("ALTER INDEX my_idx REBUILD");
6775        let Statement::AlterIndex(a) = s else {
6776            panic!("expected AlterIndex, got {s:?}")
6777        };
6778        assert_eq!(a.name, "my_idx");
6779        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
6780    }
6781
6782    #[test]
6783    fn alter_index_rebuild_with_encoding() {
6784        use crate::ast::{AlterIndexTarget, Statement};
6785        for (sql, want) in [
6786            (
6787                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
6788                VecEncoding::F32,
6789            ),
6790            (
6791                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
6792                VecEncoding::Sq8,
6793            ),
6794            (
6795                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
6796                VecEncoding::F16,
6797            ),
6798        ] {
6799            let s = parse(sql);
6800            let Statement::AlterIndex(a) = s else {
6801                panic!("{sql}: expected AlterIndex")
6802            };
6803            assert_eq!(a.name, "my_idx");
6804            assert_eq!(
6805                a.target,
6806                AlterIndexTarget::Rebuild {
6807                    encoding: Some(want)
6808                },
6809                "{sql}"
6810            );
6811        }
6812    }
6813
6814    #[test]
6815    fn alter_index_rebuild_unknown_encoding_errors() {
6816        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
6817        assert!(
6818            err.message.contains("unknown vector encoding"),
6819            "got: {}",
6820            err.message
6821        );
6822    }
6823
6824    #[test]
6825    fn alter_index_rebuild_display_roundtrips() {
6826        for (input, want) in [
6827            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
6828            (
6829                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
6830                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
6831            ),
6832            (
6833                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
6834                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
6835            ),
6836        ] {
6837            let s = parse(input);
6838            assert_eq!(s.to_string(), want);
6839        }
6840    }
6841
6842    #[test]
6843    fn create_table_unknown_type_errors() {
6844        // v4.9: JSON is now real; pick an actually unsupported keyword
6845        // (XML never landed and isn't planned).
6846        let err = parse_statement("CREATE TABLE x (a xml)").unwrap_err();
6847        assert!(err.message.contains("unsupported column type"));
6848    }
6849
6850    #[test]
6851    fn create_table_missing_table_keyword_errors() {
6852        assert!(parse_statement("CREATE x (a INT)").is_err());
6853    }
6854
6855    #[test]
6856    fn insert_single_value() {
6857        let s = parse("INSERT INTO foo VALUES (42)");
6858        let Statement::Insert(i) = s else {
6859            panic!("expected Insert")
6860        };
6861        assert_eq!(i.table, "foo");
6862        assert_eq!(i.rows.len(), 1);
6863        assert_eq!(i.rows[0].len(), 1);
6864        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
6865    }
6866
6867    #[test]
6868    fn insert_multi_value_with_mixed_literals() {
6869        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
6870        let Statement::Insert(i) = s else { panic!() };
6871        assert_eq!(i.rows.len(), 1);
6872        assert_eq!(i.rows[0].len(), 5);
6873    }
6874
6875    #[test]
6876    fn insert_missing_into_errors() {
6877        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
6878    }
6879
6880    #[test]
6881    fn create_table_round_trip() {
6882        let original =
6883            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
6884        let text = original.to_string();
6885        let again = parse_statement(&text).expect("re-parse");
6886        assert_eq!(original, again);
6887    }
6888
6889    #[test]
6890    fn insert_round_trip_with_negation_and_string() {
6891        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
6892        let text = original.to_string();
6893        let again = parse_statement(&text).expect("re-parse");
6894        assert_eq!(original, again);
6895    }
6896
6897    #[test]
6898    fn unknown_keyword_at_statement_start_errors() {
6899        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
6900        // the top-level dispatch still has no branch to take.
6901        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
6902        assert!(err.message.contains("expected SELECT"));
6903    }
6904
6905    // --- v0.8 CREATE INDEX --------------------------------------------------
6906
6907    #[test]
6908    fn create_index_basic() {
6909        let s = parse("CREATE INDEX idx_id ON users (id)");
6910        let Statement::CreateIndex(c) = s else {
6911            panic!("expected CreateIndex")
6912        };
6913        assert_eq!(c.name, "idx_id");
6914        assert_eq!(c.table, "users");
6915        assert_eq!(c.column, "id");
6916    }
6917
6918    #[test]
6919    fn create_index_missing_on_errors() {
6920        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
6921    }
6922
6923    #[test]
6924    fn create_index_missing_paren_errors() {
6925        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
6926    }
6927
6928    #[test]
6929    fn create_index_round_trip() {
6930        let original = parse("CREATE INDEX by_name ON users (name)");
6931        let again = parse_statement(&original.to_string()).unwrap();
6932        assert_eq!(original, again);
6933    }
6934
6935    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
6936
6937    #[test]
6938    fn create_unique_index_basic() {
6939        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
6940        let Statement::CreateIndex(c) = s else {
6941            panic!("expected CreateIndex");
6942        };
6943        assert!(c.is_unique);
6944        assert_eq!(c.column, "a");
6945        assert!(c.partial_predicate.is_none());
6946    }
6947
6948    #[test]
6949    fn create_unique_index_partial() {
6950        // mailrs's email_templates "one default per user" shape.
6951        let s = parse(
6952            "CREATE UNIQUE INDEX idx_email_templates_user_default \
6953             ON email_templates (user_address) WHERE is_default = true",
6954        );
6955        let Statement::CreateIndex(c) = s else {
6956            panic!("expected CreateIndex");
6957        };
6958        assert!(c.is_unique);
6959        assert_eq!(c.table, "email_templates");
6960        assert_eq!(c.column, "user_address");
6961        assert!(c.partial_predicate.is_some());
6962    }
6963
6964    #[test]
6965    fn create_unique_index_composite_with_predicate() {
6966        // mailrs's calendar_events instance: composite columns.
6967        let s = parse(
6968            "CREATE UNIQUE INDEX uq_calendar_events_instance \
6969             ON calendar_events (calendar_id, uid, recurrence_id) \
6970             WHERE recurrence_id IS NOT NULL",
6971        );
6972        let Statement::CreateIndex(c) = s else {
6973            panic!("expected CreateIndex");
6974        };
6975        assert!(c.is_unique);
6976        assert_eq!(c.column, "calendar_id");
6977        assert_eq!(
6978            c.extra_columns,
6979            vec!["uid".to_string(), "recurrence_id".to_string()]
6980        );
6981        assert!(c.partial_predicate.is_some());
6982    }
6983
6984    #[test]
6985    fn create_unique_index_using_btree_ok() {
6986        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
6987        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
6988    }
6989
6990    #[test]
6991    fn create_unique_index_using_hnsw_rejected() {
6992        let err =
6993            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
6994        assert!(err.message.contains("UNIQUE"), "{}", err.message);
6995    }
6996
6997    #[test]
6998    fn create_unique_index_round_trip() {
6999        let original = parse(
7000            "CREATE UNIQUE INDEX uq_calendar_events_master \
7001             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
7002        );
7003        let again = parse_statement(&original.to_string()).unwrap();
7004        assert_eq!(original, again);
7005    }
7006
7007    #[test]
7008    fn create_unique_without_index_errors() {
7009        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
7010        assert!(err.message.contains("INDEX"), "{}", err.message);
7011    }
7012
7013    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
7014
7015    #[test]
7016    fn create_table_bytea_column() {
7017        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
7018        let Statement::CreateTable(c) = s else {
7019            panic!("expected CreateTable");
7020        };
7021        assert_eq!(c.columns.len(), 2);
7022        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
7023        assert!(!c.columns[1].nullable);
7024    }
7025
7026    #[test]
7027    fn create_table_bytes_alias_column() {
7028        let s = parse("CREATE TABLE t (blob BYTES)");
7029        let Statement::CreateTable(c) = s else {
7030            panic!("expected CreateTable");
7031        };
7032        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
7033    }
7034
7035    #[test]
7036    fn bytea_round_trip_display() {
7037        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
7038        let again = parse_statement(&original.to_string()).unwrap();
7039        assert_eq!(original, again);
7040    }
7041
7042    // --- v0.9 transactions -------------------------------------------------
7043
7044    #[test]
7045    fn begin_commit_rollback_parse_as_unit_variants() {
7046        assert_eq!(parse("BEGIN"), Statement::Begin);
7047        assert_eq!(parse("COMMIT"), Statement::Commit);
7048        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
7049        // Trailing semicolons accepted too.
7050        assert_eq!(parse("BEGIN;"), Statement::Begin);
7051    }
7052
7053    // --- v1.2: pgvector distance ops + ::vector cast --------------------
7054
7055    #[test]
7056    fn inner_product_binop_parses() {
7057        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
7058        let Statement::Select(s) = s else { panic!() };
7059        let SelectItem::Expr { expr, .. } = &s.items[0] else {
7060            panic!()
7061        };
7062        assert!(matches!(
7063            expr,
7064            Expr::Binary {
7065                op: BinOp::InnerProduct,
7066                ..
7067            }
7068        ));
7069    }
7070
7071    #[test]
7072    fn cosine_distance_binop_parses() {
7073        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
7074        let Statement::Select(s) = s else { panic!() };
7075        let SelectItem::Expr { expr, .. } = &s.items[0] else {
7076            panic!()
7077        };
7078        assert!(matches!(
7079            expr,
7080            Expr::Binary {
7081                op: BinOp::CosineDistance,
7082                ..
7083            }
7084        ));
7085    }
7086
7087    #[test]
7088    fn vector_cast_postfix_wraps_string_literal() {
7089        let s = parse("SELECT '[1,2,3]'::vector FROM t");
7090        let Statement::Select(s) = s else { panic!() };
7091        let SelectItem::Expr { expr, .. } = &s.items[0] else {
7092            panic!()
7093        };
7094        assert!(matches!(
7095            expr,
7096            Expr::Cast {
7097                target: CastTarget::Vector,
7098                ..
7099            }
7100        ));
7101    }
7102
7103    #[test]
7104    fn unsupported_cast_target_errors() {
7105        // `::numeric` isn't in the v1.3 cast target set.
7106        let err = parse_statement("SELECT 1::numeric FROM t").unwrap_err();
7107        assert!(err.message.contains("unsupported cast target"));
7108    }
7109
7110    #[test]
7111    fn tx_statements_round_trip() {
7112        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
7113            let original = parse(q);
7114            let again = parse_statement(&original.to_string()).unwrap();
7115            assert_eq!(original, again);
7116        }
7117    }
7118
7119    #[test]
7120    fn interval_text_parsing_units() {
7121        // Single unit.
7122        assert_eq!(parse_interval_text("1 day"), Some((0, 86_400_000_000)));
7123        assert_eq!(parse_interval_text("1 second"), Some((0, 1_000_000)));
7124        assert_eq!(parse_interval_text("1 month"), Some((1, 0)));
7125        assert_eq!(parse_interval_text("2 years"), Some((24, 0)));
7126        // Compound spans accumulate.
7127        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0)));
7128        assert_eq!(
7129            parse_interval_text("1 day 2 hours"),
7130            Some((0, 86_400_000_000 + 7_200_000_000))
7131        );
7132        // Negative numbers carry through.
7133        assert_eq!(parse_interval_text("-1 day"), Some((0, -86_400_000_000)));
7134        // Bad shapes return None.
7135        assert_eq!(parse_interval_text(""), None);
7136        assert_eq!(parse_interval_text("garbage"), None);
7137        assert_eq!(parse_interval_text("1 fortnight"), None);
7138        assert_eq!(parse_interval_text("1"), None);
7139    }
7140
7141    #[test]
7142    fn interval_literal_roundtrips_via_display() {
7143        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
7144        let s = parsed.to_string();
7145        // Display preserves the original text verbatim.
7146        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
7147        // And re-parsing yields a structurally equal statement.
7148        let again = parse_statement(&s).unwrap();
7149        assert_eq!(parsed, again);
7150    }
7151
7152    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
7153
7154    #[test]
7155    fn parser_recognises_create_publication_bare() {
7156        let s = parse("CREATE PUBLICATION pub_a");
7157        let Statement::CreatePublication(p) = s else {
7158            panic!("expected CreatePublication, got {s:?}")
7159        };
7160        assert_eq!(p.name, "pub_a");
7161        assert_eq!(p.scope, PublicationScope::AllTables);
7162    }
7163
7164    #[test]
7165    fn parser_recognises_create_publication_for_all_tables() {
7166        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
7167        let Statement::CreatePublication(p) = s else {
7168            panic!("expected CreatePublication, got {s:?}")
7169        };
7170        assert_eq!(p.name, "pub_a");
7171        assert_eq!(p.scope, PublicationScope::AllTables);
7172    }
7173
7174    #[test]
7175    fn parser_recognises_drop_publication() {
7176        let s = parse("DROP PUBLICATION pub_a");
7177        let Statement::DropPublication(name) = s else {
7178            panic!("expected DropPublication, got {s:?}")
7179        };
7180        assert_eq!(name, "pub_a");
7181    }
7182
7183    #[test]
7184    fn parser_recognises_for_table_list() {
7185        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
7186        let Statement::CreatePublication(p) = s else {
7187            panic!("expected CreatePublication, got {s:?}")
7188        };
7189        assert_eq!(p.name, "pub_a");
7190        let PublicationScope::ForTables(ts) = p.scope else {
7191            panic!("expected ForTables scope")
7192        };
7193        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
7194    }
7195
7196    #[test]
7197    fn parser_recognises_for_tables_plural() {
7198        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
7199        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
7200        let Statement::CreatePublication(p) = s else {
7201            panic!("expected CreatePublication, got {s:?}")
7202        };
7203        let PublicationScope::ForTables(ts) = p.scope else {
7204            panic!("expected ForTables")
7205        };
7206        assert_eq!(ts, alloc::vec!["t1", "t2"]);
7207    }
7208
7209    #[test]
7210    fn parser_recognises_for_all_tables_except_list() {
7211        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
7212        let Statement::CreatePublication(p) = s else {
7213            panic!()
7214        };
7215        let PublicationScope::AllTablesExcept(ts) = p.scope else {
7216            panic!("expected AllTablesExcept")
7217        };
7218        assert_eq!(ts, alloc::vec!["t1", "t2"]);
7219    }
7220
7221    #[test]
7222    fn parser_rejects_for_table_with_empty_list() {
7223        // `FOR TABLE` with nothing after is a parse error.
7224        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
7225            .expect_err("must error on empty list");
7226        // No specific message asserted — the call falls through to
7227        // expect_ident_like which yields "expected identifier, got …".
7228        assert!(!err.message.is_empty());
7229    }
7230
7231    #[test]
7232    fn parser_recognises_show_publications() {
7233        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
7234        // bare ident in this position, NOT a reserved keyword.
7235        let s = parse("SHOW PUBLICATIONS");
7236        assert!(matches!(s, Statement::ShowPublications));
7237    }
7238
7239    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
7240
7241    #[test]
7242    fn parser_recognises_create_subscription_single_publication() {
7243        let s = parse(
7244            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
7245        );
7246        let Statement::CreateSubscription(c) = s else {
7247            panic!("expected CreateSubscription, got {s:?}")
7248        };
7249        assert_eq!(c.name, "sub_a");
7250        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
7251        assert_eq!(c.publications, alloc::vec!["pub_a"]);
7252    }
7253
7254    #[test]
7255    fn parser_recognises_create_subscription_multi_publication() {
7256        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
7257        let Statement::CreateSubscription(c) = s else {
7258            panic!()
7259        };
7260        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
7261    }
7262
7263    #[test]
7264    fn parser_rejects_create_subscription_missing_connection() {
7265        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
7266            .expect_err("must error on missing CONNECTION");
7267        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
7268    }
7269
7270    #[test]
7271    fn parser_rejects_create_subscription_missing_publication() {
7272        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
7273            .expect_err("must error on missing PUBLICATION");
7274        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
7275    }
7276
7277    #[test]
7278    fn parser_recognises_drop_subscription() {
7279        let s = parse("DROP SUBSCRIPTION sub_a");
7280        let Statement::DropSubscription(name) = s else {
7281            panic!("expected DropSubscription, got {s:?}")
7282        };
7283        assert_eq!(name, "sub_a");
7284    }
7285
7286    #[test]
7287    fn parser_recognises_show_subscriptions() {
7288        let s = parse("SHOW SUBSCRIPTIONS");
7289        assert!(matches!(s, Statement::ShowSubscriptions));
7290    }
7291
7292    #[test]
7293    fn parser_recognises_wait_for_wal_position_no_timeout() {
7294        let s = parse("WAIT FOR WAL POSITION 12345");
7295        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
7296            panic!("expected WaitForWalPosition, got {s:?}")
7297        };
7298        assert_eq!(pos, 12345);
7299        assert!(timeout_ms.is_none());
7300    }
7301
7302    #[test]
7303    fn parser_recognises_wait_for_wal_position_with_timeout() {
7304        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
7305        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
7306            panic!()
7307        };
7308        assert_eq!(pos, 67890);
7309        assert_eq!(timeout_ms, Some(5000));
7310    }
7311
7312    #[test]
7313    fn parser_rejects_wait_with_negative_position() {
7314        // The lexer treats `-` as a token; `expect_u64_literal`
7315        // only sees the Integer that follows, so the negative
7316        // arrives as a unary-minus expression at higher levels.
7317        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
7318        // parse error one way or another.
7319        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
7320        assert!(!err.message.is_empty());
7321    }
7322
7323    #[test]
7324    fn parser_recognises_bare_analyze() {
7325        let s = parse("ANALYZE");
7326        assert!(matches!(s, Statement::Analyze(None)));
7327    }
7328
7329    #[test]
7330    fn parser_recognises_analyze_with_table() {
7331        let s = parse("ANALYZE users");
7332        let Statement::Analyze(Some(name)) = s else {
7333            panic!("expected Analyze, got {s:?}")
7334        };
7335        assert_eq!(name, "users");
7336    }
7337
7338    #[test]
7339    fn parser_recognises_analyze_with_quoted_table() {
7340        let s = parse("ANALYZE \"Mixed Case\"");
7341        let Statement::Analyze(Some(name)) = s else {
7342            panic!()
7343        };
7344        assert_eq!(name, "Mixed Case");
7345    }
7346
7347    #[test]
7348    fn parser_rejects_analyze_with_garbage_token() {
7349        let err = parse_statement("ANALYZE 42").expect_err("must error");
7350        assert!(!err.message.is_empty());
7351    }
7352
7353    #[test]
7354    fn analyze_display_roundtrips() {
7355        for sql in ["ANALYZE", "ANALYZE users"] {
7356            let s = parse(sql);
7357            let printed = s.to_string();
7358            let again = parse_statement(&printed)
7359                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
7360            assert_eq!(s, again);
7361        }
7362    }
7363
7364    #[test]
7365    fn wait_for_display_roundtrips() {
7366        for sql in [
7367            "WAIT FOR WAL POSITION 12345",
7368            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
7369        ] {
7370            let s = parse(sql);
7371            let printed = s.to_string();
7372            let again = parse_statement(&printed)
7373                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
7374            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
7375        }
7376    }
7377
7378    #[test]
7379    fn subscription_ddl_display_roundtrips() {
7380        for sql in [
7381            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
7382            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
7383            "DROP SUBSCRIPTION sub_a",
7384            "SHOW SUBSCRIPTIONS",
7385        ] {
7386            let s = parse(sql);
7387            let printed = s.to_string();
7388            let again = parse_statement(&printed)
7389                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
7390            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
7391        }
7392    }
7393
7394    #[test]
7395    fn parser_drop_dispatches_user_vs_publication() {
7396        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
7397        // tokenises DROP. Both targets must still parse.
7398        let s = parse("DROP USER 'alice'");
7399        let Statement::DropUser(name) = s else {
7400            panic!("expected DropUser, got {s:?}")
7401        };
7402        assert_eq!(name, "alice");
7403        // And DROP PUBLICATION lands the new variant.
7404        let s = parse("DROP PUBLICATION p1");
7405        assert!(matches!(s, Statement::DropPublication(_)));
7406    }
7407
7408    #[test]
7409    fn publication_ddl_display_roundtrips() {
7410        // Every CREATE PUBLICATION variant must Display → parse →
7411        // same AST. v6.1.3 covers all three scope shapes.
7412        for sql in [
7413            "CREATE PUBLICATION pub_a",
7414            "CREATE PUBLICATION pub_a FOR ALL TABLES",
7415            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
7416            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
7417            "DROP PUBLICATION pub_a",
7418            "SHOW PUBLICATIONS",
7419        ] {
7420            let s = parse(sql);
7421            let printed = s.to_string();
7422            let again = parse_statement(&printed)
7423                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
7424            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
7425        }
7426    }
7427
7428    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
7429
7430    #[test]
7431    fn create_function_returns_trigger_plpgsql_minimal() {
7432        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
7433        let s = parse(sql);
7434        let Statement::CreateFunction(f) = s else {
7435            panic!("expected CreateFunction");
7436        };
7437        assert_eq!(f.name, "noop");
7438        assert!(!f.or_replace);
7439        assert!(f.args.is_empty());
7440        assert!(matches!(f.returns, FunctionReturn::Trigger));
7441        assert_eq!(f.language, "plpgsql");
7442        let FunctionBody::PlPgSql(block) = f.body else {
7443            panic!("expected PlPgSql body");
7444        };
7445        assert_eq!(block.statements.len(), 1);
7446        assert!(matches!(
7447            block.statements[0],
7448            PlPgSqlStmt::Return(ReturnTarget::New)
7449        ));
7450    }
7451
7452    #[test]
7453    fn create_function_or_replace_with_assignment() {
7454        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
7455        // RETURN NEW.
7456        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
7457BEGIN
7458  NEW.search_vector := to_tsvector('english', NEW.subject);
7459  RETURN NEW;
7460END;
7461$$";
7462        let s = parse(sql);
7463        let Statement::CreateFunction(f) = s else {
7464            panic!("expected CreateFunction");
7465        };
7466        assert!(f.or_replace);
7467        let FunctionBody::PlPgSql(block) = &f.body else {
7468            panic!("expected PlPgSql body");
7469        };
7470        assert_eq!(block.statements.len(), 2);
7471        // First statement: NEW.search_vector := to_tsvector(...)
7472        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
7473            panic!("expected Assign as first stmt");
7474        };
7475        match target {
7476            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
7477            other => panic!("expected NEW.col, got {other:?}"),
7478        }
7479        // Second statement: RETURN NEW
7480        assert!(matches!(
7481            block.statements[1],
7482            PlPgSqlStmt::Return(ReturnTarget::New)
7483        ));
7484    }
7485
7486    #[test]
7487    fn create_trigger_after_insert_or_update() {
7488        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
7489        let s = parse(sql);
7490        let Statement::CreateTrigger(t) = s else {
7491            panic!("expected CreateTrigger");
7492        };
7493        assert_eq!(t.name, "tg");
7494        assert_eq!(t.table, "messages");
7495        assert_eq!(t.timing, TriggerTiming::After);
7496        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
7497        assert_eq!(t.for_each, TriggerForEach::Row);
7498        assert_eq!(t.function, "update_sv");
7499    }
7500
7501    #[test]
7502    fn create_trigger_before_delete_execute_procedure_alias() {
7503        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
7504        let sql =
7505            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
7506        let s = parse(sql);
7507        let Statement::CreateTrigger(t) = s else {
7508            panic!("expected CreateTrigger");
7509        };
7510        assert_eq!(t.timing, TriggerTiming::Before);
7511        assert_eq!(t.events, vec![TriggerEvent::Delete]);
7512    }
7513
7514    #[test]
7515    fn drop_trigger_if_exists_round_trips() {
7516        // No parser support for DROP TRIGGER yet — added in v7.12.5
7517        // alongside the broader DROP …{IF EXISTS} cleanup. The
7518        // AST + Display impls are in place so we round-trip via
7519        // construction:
7520        let s = Statement::DropTrigger {
7521            name: "tg".into(),
7522            table: "messages".into(),
7523            if_exists: true,
7524        };
7525        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
7526    }
7527
7528    #[test]
7529    fn trigger_ddl_display_roundtrips_through_parser() {
7530        // CREATE TRIGGER + its referenced CREATE FUNCTION must
7531        // Display → parse → same AST (modulo PL/pgSQL body
7532        // formatting which is parser-canonicalised).
7533        for sql in [
7534            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
7535            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
7536        ] {
7537            let s = parse(sql);
7538            let printed = s.to_string();
7539            let again = parse_statement(&printed)
7540                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
7541            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
7542        }
7543    }
7544}