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