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, Collation, 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, RangeKindAst, ReturnTarget, SelectItem, SelectStatement,
28    Statement, TableRef, TriggerEvent, TriggerForEach, TriggerTiming, UnOp, UnionKind, VecEncoding,
29    WindowFrame,
30};
31use crate::lexer::{self, LexError, Token};
32
33/// v7.14.0 — true when the leading keyword of a top-level
34/// statement is one of the dump-emitted DDL forms SPG accepts
35/// as a no-op (no behavioural effect on the single-schema /
36/// single-database model). These statements are consumed up to
37/// the next `;` / EOF and returned as `Statement::Empty`.
38fn is_dump_noise_statement(lc: &str) -> bool {
39    matches!(
40        lc,
41        // Object comments / privileges / ownership — none of
42        // these change schema semantics on SPG.
43        "comment"
44            | "grant"
45            | "revoke"
46            // MySQL bulk-load brackets.
47            | "lock"
48            | "unlock"
49            // MySQL OPTIMIZE / ANALYZE TABLE / CHECK TABLE
50            // diagnostics that pg_dump-style tools also emit
51            // post-restore.
52            | "optimize"
53            | "check"
54            | "use"
55            // PG psql backslash meta-commands that newer
56            // pg_dump versions emit unescaped (\restrict /
57            // \unrestrict). Real psql intercepts these; SPG's
58            // PG-wire sees them as raw text.
59            | "\\restrict"
60            | "\\unrestrict"
61            // v7.17.0 Phase 4.1 — MySQL `DELIMITER //` and
62            // `DELIMITER ;` directives. Technically client-side
63            // (the `mysql` CLI uses them to set the statement
64            // terminator), not SQL — but mysqldump and stored-
65            // procedure scripts emit them inline. SPG's parser
66            // sees one statement at a time and doesn't care
67            // about the terminator, so consume DELIMITER lines
68            // as Empty.
69            | "delimiter"
70    )
71}
72
73/// v7.9.22 — recognise pgvector / SPG vector-index opclass names
74/// in CREATE INDEX. SPG's HNSW already routes by query operator;
75/// the opclass is accepted for `pg_dump` compatibility (mailrs
76/// migration follow-up G5).
77/// v7.13.0 — extended to recognise PG built-in / pg_trgm opclasses
78/// (mailrs round-5 G5). These are tokens-only acceptance — SPG
79/// doesn't change index behaviour based on them.
80fn is_vector_opclass_name(name: &str) -> bool {
81    let lc = name.to_ascii_lowercase();
82    matches!(
83        lc.as_str(),
84        "vector_cosine_ops"
85            | "vector_l2_ops"
86            | "vector_ip_ops"
87            | "halfvec_cosine_ops"
88            | "halfvec_l2_ops"
89            | "halfvec_ip_ops"
90            | "sq8_cosine_ops"
91            | "sq8_l2_ops"
92            | "sq8_ip_ops"
93            // pg_trgm — trigram operator class. SPG's GIN index
94            // already uses tsvector tokens; trigram-style LIKE
95            // pattern matching still routes through a sequential
96            // scan, but the opclass name is accepted so PG schemas
97            // load.
98            | "gin_trgm_ops"
99            | "gist_trgm_ops"
100            // PG built-in btree opclasses occasionally appear in
101            // pg_dump output for column types with multiple
102            // sort orders (text_pattern_ops, varchar_pattern_ops,
103            // bpchar_pattern_ops).
104            | "text_pattern_ops"
105            | "varchar_pattern_ops"
106            | "bpchar_pattern_ops"
107            | "int4_ops"
108            | "int8_ops"
109            | "text_ops"
110    )
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ParseError {
115    pub message: String,
116    /// Index into the token stream where parsing tripped. Not a byte offset.
117    pub token_pos: usize,
118}
119
120impl fmt::Display for ParseError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(
123            f,
124            "parse error at token #{}: {}",
125            self.token_pos, self.message
126        )
127    }
128}
129
130impl From<LexError> for ParseError {
131    fn from(e: LexError) -> Self {
132        Self {
133            message: format!("lex: {e}"),
134            token_pos: 0,
135        }
136    }
137}
138
139/// v7.9.30 — parse a single expression (no trailing junk). Used by
140/// the engine to re-hydrate stored partial-index / unique-index
141/// predicates from their canonical Display form. The same Pratt
142/// parser the statement path uses; this entry point just skips the
143/// statement dispatch.
144pub fn parse_expression(input: &str) -> Result<Expr, ParseError> {
145    let tokens = lexer::tokenize(input)?;
146    let mut p = Parser::new(tokens);
147    let expr = p.parse_expr(0)?;
148    p.expect_eof()?;
149    Ok(expr)
150}
151
152/// Parse exactly one statement, swallow an optional trailing `;`, and require
153/// the token stream to end there. PG string semantics.
154pub fn parse_statement(input: &str) -> Result<Statement, ParseError> {
155    parse_statement_with(input, false)
156}
157
158/// v7.22 (round-13 T3) — dialect-aware entry: `backslash_escapes`
159/// selects MySQL-style string lexing (see `lexer::tokenize_with`).
160/// The engine threads its session flag through here.
161pub fn parse_statement_with(input: &str, backslash_escapes: bool) -> Result<Statement, ParseError> {
162    let tokens = lexer::tokenize_with(input, backslash_escapes)?;
163    let mut p = Parser::new(tokens);
164    let stmt = p.parse_one_statement()?;
165    if matches!(p.peek(), Token::Semicolon) {
166        p.advance();
167    }
168    p.expect_eof()?;
169    Ok(stmt)
170}
171
172struct Parser {
173    tokens: Vec<Token>,
174    pos: usize,
175    /// v7.30.2 (mailrs round-25 ask 2) — live nesting depth of the
176    /// mutually recursive expr/select parsers. Bounded so a deeply
177    /// nested input returns a parse error instead of overflowing
178    /// the stack (embed hosts die on overflow — it is an abort,
179    /// not a catchable error).
180    nest_depth: usize,
181}
182
183/// Max expr/select parser nesting (parens, subqueries, CASE, …).
184/// Real SQL nests a few dozen levels at the extreme. Each nesting
185/// level costs a parse_expr→parse_unary→parse_atom frame chain —
186/// over 10 KiB in debug builds (parse_atom is a giant match) — so
187/// 64 is the highest budget that stays comfortably inside a 2 MiB
188/// worker stack in BOTH debug and release builds.
189const MAX_NEST_DEPTH: usize = 64;
190
191/// Max consecutive binary operators at ONE precedence level
192/// (`a OR b OR c …`, `1+1+1…`). The chain builds iteratively at
193/// parse time but evaluates and drops recursively — depth beyond
194/// this overflows 2 MiB worker stacks (debug eval frames run
195/// multiple KiB). `IN (…)` lists are flat and unaffected.
196const MAX_BINARY_CHAIN: usize = 256;
197
198/// v7.22 (round-13 gap 5) — the kind keyword after `CONSTRAINT
199/// <name>` in a CREATE TABLE column list. FOREIGN KEY is not here:
200/// it keeps its dedicated path (`parse_table_level_fk`).
201enum NamedTableConstraintKind {
202    Check,
203    Unique,
204    PrimaryKey,
205}
206
207impl Parser {
208    fn new(tokens: Vec<Token>) -> Self {
209        Self {
210            tokens,
211            pos: 0,
212            nest_depth: 0,
213        }
214    }
215
216    /// v7.30.2 (mailrs round-25 ask 2) — bump the expr/select
217    /// nesting depth, erroring out cleanly past the budget.
218    fn enter_nested(&mut self) -> Result<(), ParseError> {
219        self.nest_depth += 1;
220        if self.nest_depth > MAX_NEST_DEPTH {
221            self.nest_depth -= 1;
222            return Err(self.err(alloc::format!(
223                "statement nests deeper than {MAX_NEST_DEPTH} levels"
224            )));
225        }
226        Ok(())
227    }
228
229    fn peek(&self) -> &Token {
230        // tokens always ends with Eof; pos is clamped in advance().
231        &self.tokens[self.pos]
232    }
233
234    fn advance(&mut self) -> Token {
235        let t = mem::replace(&mut self.tokens[self.pos], Token::Eof);
236        if self.pos + 1 < self.tokens.len() {
237            self.pos += 1;
238        }
239        t
240    }
241
242    fn err(&self, message: String) -> ParseError {
243        ParseError {
244            message,
245            token_pos: self.pos,
246        }
247    }
248
249    fn expect_eof(&self) -> Result<(), ParseError> {
250        if matches!(self.peek(), Token::Eof) {
251            Ok(())
252        } else {
253            Err(self.err(format!("expected end of input, got {:?}", self.peek())))
254        }
255    }
256
257    /// v7.14.0 — swallow every token up to (but not including) the
258    /// next semicolon / EOF. Used by the dump-noise dispatcher
259    /// to consume `COMMENT ON …`, `GRANT …`, `LOCK TABLES …`,
260    /// etc. without modeling each grammar.
261    fn consume_until_statement_boundary(&mut self) {
262        loop {
263            match self.peek() {
264                Token::Semicolon | Token::Eof => return,
265                _ => self.advance(),
266            };
267        }
268    }
269
270    /// v7.22 (round-13 T2) — consume to the statement boundary like
271    /// `consume_until_statement_boundary`, but pick out the sequence
272    /// name on the way: either `SEQUENCE NAME <ident>` (identity
273    /// columns) or the first string literal (`nextval('<seq>')`).
274    /// Schema qualifiers and `::regclass` casts are stripped.
275    fn scan_sequence_name_until_boundary(&mut self) -> Option<String> {
276        let mut seq: Option<String> = None;
277        let mut after_sequence_kw = false;
278        let mut after_name_kw = false;
279        loop {
280            match self.peek().clone() {
281                Token::Semicolon | Token::Eof => break,
282                Token::Ident(s) | Token::QuotedIdent(s) => {
283                    if after_name_kw && seq.is_none() {
284                        self.advance();
285                        let mut name = s;
286                        // `SEQUENCE NAME public.groups_id_seq` — keep
287                        // the bare name, drop qualifiers.
288                        while matches!(self.peek(), Token::Dot) {
289                            self.advance();
290                            if let Token::Ident(n) | Token::QuotedIdent(n) = self.advance() {
291                                name = n;
292                            }
293                        }
294                        seq = Some(name);
295                        after_name_kw = false;
296                        continue;
297                    }
298                    if after_sequence_kw && s.eq_ignore_ascii_case("name") {
299                        after_name_kw = true;
300                        after_sequence_kw = false;
301                    } else {
302                        after_sequence_kw = s.eq_ignore_ascii_case("sequence");
303                    }
304                    self.advance();
305                }
306                Token::String(s) => {
307                    if seq.is_none() {
308                        // `nextval('public.groups_id_seq'::regclass)`
309                        let bare = s
310                            .rsplit_once('.')
311                            .map_or_else(|| s.clone(), |(_, b)| b.to_string());
312                        seq = Some(bare);
313                    }
314                    self.advance();
315                }
316                _ => {
317                    after_sequence_kw = false;
318                    after_name_kw = false;
319                    self.advance();
320                }
321            }
322        }
323        seq
324    }
325
326    fn expect_ident_like(&mut self) -> Result<String, ParseError> {
327        let first = match self.advance() {
328            Token::Ident(s) | Token::QuotedIdent(s) => s,
329            other => {
330                return Err(ParseError {
331                    message: format!("expected identifier, got {other:?}"),
332                    token_pos: self.pos.saturating_sub(1),
333                });
334            }
335        };
336        // v7.14.0 — strip optional `<schema>.` prefix. PG dumps
337        // qualify every name with `public.` (and pg_catalog.* for
338        // functions); SPG is single-schema so we discard the
339        // prefix and return only the trailing ident. Same shape
340        // also handles MySQL `db.tbl` cross-database refs (SPG
341        // ignores the db part).
342        if matches!(self.peek(), Token::Dot) {
343            self.advance();
344            match self.advance() {
345                Token::Ident(s) | Token::QuotedIdent(s) => return Ok(s),
346                other => {
347                    return Err(ParseError {
348                        message: format!("expected identifier after '{first}.', got {other:?}"),
349                        token_pos: self.pos.saturating_sub(1),
350                    });
351                }
352            }
353        }
354        Ok(first)
355    }
356
357    #[allow(clippy::too_many_lines)]
358    fn parse_one_statement(&mut self) -> Result<Statement, ParseError> {
359        // v7.14.0 — empty / comment-only / semicolon-only input
360        // (after the lexer strips line + block + MySQL
361        // conditional comments) lands as Statement::Empty.
362        // pg_dump and mysqldump emit several wrappers that
363        // collapse to nothing after stripping (`/*!40101 SET …
364        // */;`, blank lines between statements); the engine
365        // returns CommandOk no-op so the dump loads cleanly.
366        if matches!(self.peek(), Token::Eof | Token::Semicolon) {
367            return Ok(Statement::Empty);
368        }
369        // v7.14.0 — pg_dump / mysqldump "noise" statements:
370        // catalog / metadata DDL that has no behavioural effect
371        // on SPG's single-schema, single-database, single-user
372        // model. Consume the whole statement up to the next
373        // semicolon / EOF and return Empty. This is broader than
374        // the per-keyword DROP / SET / COMMENT arms but lets the
375        // long tail of `LOCK TABLES`, `UNLOCK TABLES`, `GRANT`,
376        // `REVOKE`, `ALTER OWNER TO`, `\restrict`, `\unrestrict`,
377        // `BEGIN; COMMIT;` wrappers, etc. all pass through.
378        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
379            let lc = s.to_ascii_lowercase();
380            if is_dump_noise_statement(&lc) {
381                self.consume_until_statement_boundary();
382                return Ok(Statement::Empty);
383            }
384        }
385        match self.peek() {
386            Token::Select => self.parse_select_stmt(),
387            // v7.9.27 — `DO $$ … $$ [LANGUAGE plpgsql]`. The
388            // body is a dollar-quoted plpgsql block (lexer already
389            // collapsed `$$…$$` into a single Token::String).
390            // v7.16.2 — mailrs round-10 A.2: parse the body as a
391            // real PlPgSqlBlock so the engine can EXECUTE it at
392            // top level instead of silently swallowing. Pre-
393            // v7.16.2 the parser threw the body away and the
394            // engine returned CommandOk for the entire DO; that
395            // turned `DO BEGIN … IF EXISTS ... THEN ALTER …; END
396            // $$` into a SEV-1 silent no-op (the IF + the rename
397            // were both invisible — mailrs's migrate-042 didn't
398            // actually run). Now the body parses + executes;
399            // EmbeddedSql inside the block runs immediately
400            // against the engine (not deferred — we're at top
401            // level, not inside a trigger row-write loop).
402            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
403                self.advance();
404                let body_text = match self.advance() {
405                    Token::String(s) => s,
406                    other => {
407                        return Err(self.err(alloc::format!(
408                            "expected dollar-quoted body after DO, got {other:?}"
409                        )));
410                    }
411                };
412                // Optional `LANGUAGE <name>` trailer (idents only).
413                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("language")) {
414                    self.advance();
415                    let _ = self.expect_ident_like()?;
416                }
417                // Parse the body — same shape CREATE FUNCTION
418                // uses for trigger function bodies. If the body
419                // doesn't parse cleanly we surface the error
420                // (better than silent no-op).
421                let block = parse_plpgsql_body(&body_text)?;
422                Ok(Statement::DoBlock(block))
423            }
424            // v4.11: `WITH name AS (SELECT ...) [, ...] SELECT ...`.
425            // WITH isn't a reserved token in our lexer — comes through
426            // as `Token::Ident("with")` (case-insensitive).
427            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with") => {
428                self.advance();
429                self.parse_with_cte_then_select()
430            }
431            // v4.26: `EXPLAIN [ANALYZE] <select>`. Comes through as
432            // an identifier — not a reserved keyword.
433            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("explain") => {
434                self.advance();
435                let mut analyze = false;
436                let mut suggest = false;
437                // v6.8.3 — `EXPLAIN (SUGGEST)` opt-in.
438                if matches!(self.peek(), Token::LParen) {
439                    self.advance();
440                    let opt = match self.peek().clone() {
441                        Token::Ident(s) | Token::QuotedIdent(s) => s,
442                        other => {
443                            return Err(self.err(format!(
444                                "expected option keyword inside EXPLAIN (…), got {other:?}"
445                            )));
446                        }
447                    };
448                    if !opt.eq_ignore_ascii_case("suggest") {
449                        return Err(self.err(format!(
450                            "unknown EXPLAIN option {opt:?}; v6.8.3 supports SUGGEST"
451                        )));
452                    }
453                    self.advance();
454                    if !matches!(self.peek(), Token::RParen) {
455                        return Err(self.err(format!(
456                            "expected ')' after EXPLAIN option, got {:?}",
457                            self.peek()
458                        )));
459                    }
460                    self.advance();
461                    suggest = true;
462                } else if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
463                    && (s.eq_ignore_ascii_case("analyze") || s.eq_ignore_ascii_case("analyse"))
464                {
465                    self.advance();
466                    analyze = true;
467                }
468                let inner = self.parse_select_stmt()?;
469                let Statement::Select(s) = inner else {
470                    return Err(self.err(format!("EXPLAIN body must be a SELECT, got {inner:?}")));
471                };
472                Ok(Statement::Explain(crate::ast::ExplainStatement {
473                    analyze,
474                    inner: Box::new(s),
475                    suggest,
476                }))
477            }
478            Token::Create => self.parse_create_stmt(),
479            Token::Insert => self.parse_insert_stmt(),
480            Token::Begin => {
481                self.advance();
482                Ok(Statement::Begin)
483            }
484            Token::Commit => {
485                self.advance();
486                Ok(Statement::Commit)
487            }
488            Token::Rollback => {
489                self.advance();
490                // `ROLLBACK TO [SAVEPOINT] <name>` returns to that
491                // savepoint without ending the transaction. Bare
492                // `ROLLBACK` drops the whole TX.
493                if matches!(self.peek(), Token::To) {
494                    self.advance();
495                    if matches!(self.peek(), Token::Savepoint) {
496                        self.advance();
497                    }
498                    let name = self.expect_ident_like()?;
499                    Ok(Statement::RollbackToSavepoint(name))
500                } else {
501                    Ok(Statement::Rollback)
502                }
503            }
504            Token::Savepoint => {
505                self.advance();
506                let name = self.expect_ident_like()?;
507                Ok(Statement::Savepoint(name))
508            }
509            Token::Release => {
510                self.advance();
511                // `RELEASE [SAVEPOINT] <name>` — the `SAVEPOINT` keyword
512                // is optional in standard SQL.
513                if matches!(self.peek(), Token::Savepoint) {
514                    self.advance();
515                }
516                let name = self.expect_ident_like()?;
517                Ok(Statement::ReleaseSavepoint(name))
518            }
519            Token::Show => {
520                self.advance();
521                // `SHOW TABLES` / `SHOW USERS` / `SHOW COLUMNS FROM <table>`.
522                // v6.1.2 promoted TABLES to a reserved keyword (for
523                // `CREATE PUBLICATION … FOR ALL TABLES`), so it now
524                // arrives as `Token::Tables` rather than a bare ident.
525                // USERS / COLUMNS remain bare idents.
526                let target = match self.advance() {
527                    Token::Tables => "tables".to_string(),
528                    // v7.17.0 Phase 3.P0-59 — CREATE is a reserved
529                    // keyword token; recognise it as the SHOW CREATE
530                    // dispatch keyword too.
531                    Token::Create => "create".to_string(),
532                    // v7.17.0 Phase 3.P0-60 — INDEX is a reserved
533                    // keyword too; let SHOW INDEX FROM parse.
534                    Token::Index => "index".to_string(),
535                    Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
536                    other => {
537                        return Err(self.err(format!(
538                            "expected SHOW target, got {other:?}"
539                        )));
540                    }
541                };
542                match target.as_str() {
543                    "tables" => Ok(Statement::ShowTables),
544                    "users" => Ok(Statement::ShowUsers),
545                    // v7.17.0 Phase 3.P0-59 — MySQL `SHOW CREATE
546                    // TABLE <t>` returns a 2-column row: (Table,
547                    // Create Table). mysqldump emits this for every
548                    // table at scrape time; without it the dump
549                    // round-trip stalls.
550                    // v7.17.0 Phase 3.P0-60 — MySQL `SHOW INDEXES
551                    // FROM <t>` (also spelled `SHOW INDEX` and
552                    // `SHOW KEYS`). admin / mysqldump probes use
553                    // it to list per-table indexes.
554                    "indexes" | "index" | "keys" => {
555                        if !matches!(self.peek(), Token::From) {
556                            return Err(self.err(format!(
557                                "expected FROM after SHOW INDEXES, got {:?}",
558                                self.peek()
559                            )));
560                        }
561                        self.advance();
562                        let table = self.expect_ident_like()?;
563                        Ok(Statement::ShowIndexes(table))
564                    }
565                    // v7.17.0 Phase 3.P0-61 — MySQL `SHOW STATUS` /
566                    // `SHOW VARIABLES`. Both return a 2-column row
567                    // set listing server-side state; clients probe
568                    // them at connect time.
569                    "status" => Ok(Statement::ShowStatus),
570                    "variables" => Ok(Statement::ShowVariables),
571                    // v7.17.0 Phase 3.P0-62 — MySQL `SHOW PROCESSLIST`.
572                    "processlist" => Ok(Statement::ShowProcesslist),
573                    "create" => {
574                        // SHOW CREATE TABLE / VIEW / DATABASE — only
575                        // TABLE is supported in v7.17.
576                        let kind = match self.advance() {
577                            Token::Ident(s) | Token::QuotedIdent(s) => s,
578                            Token::Table => "table".to_string(),
579                            other => {
580                                return Err(self.err(format!(
581                                    "expected TABLE after SHOW CREATE, got {other:?}"
582                                )));
583                            }
584                        };
585                        if !kind.eq_ignore_ascii_case("table") {
586                            return Err(self.err(format!(
587                                "unsupported SHOW CREATE {kind:?}; v7.17 supports TABLE only"
588                            )));
589                        }
590                        let name = self.expect_ident_like()?;
591                        Ok(Statement::ShowCreateTable(name))
592                    }
593                    // v7.17.0 Phase 3.P0-58 — MySQL `SHOW DATABASES`
594                    // (and `SHOW SCHEMAS` alias). The mysql client uses
595                    // it to populate the database selector at connect
596                    // time; without it `mysql -p` errors before the
597                    // first user query.
598                    "databases" | "schemas" => Ok(Statement::ShowDatabases),
599                    // v6.1.3 — PUBLICATIONS plural is NOT a reserved
600                    // keyword on its own; it lands here as a bare
601                    // ident. Returning all publications + their
602                    // scope summary.
603                    "publications" => Ok(Statement::ShowPublications),
604                    // v6.1.4 — same shape for SUBSCRIPTIONS plural.
605                    "subscriptions" => Ok(Statement::ShowSubscriptions),
606                    "columns" => {
607                        if !matches!(self.peek(), Token::From) {
608                            return Err(self.err(format!(
609                                "expected FROM after SHOW COLUMNS, got {:?}",
610                                self.peek()
611                            )));
612                        }
613                        self.advance();
614                        let table = self.expect_ident_like()?;
615                        Ok(Statement::ShowColumns(table))
616                    }
617                    other => Err(self.err(format!(
618                        "unknown SHOW target {other:?}; supported: TABLES, COLUMNS, USERS, PUBLICATIONS"
619                    ))),
620                }
621            }
622            // v6.1.2: `DROP` is now a reserved keyword (it dispatches
623            // to DROP USER and DROP PUBLICATION today; DROP TABLE /
624            // DROP INDEX are still SHOW-shaped admin ops). Pre-6.1.2
625            // arrived as a bare ident; tokenising it dedicatedly
626            // keeps the dispatch tree small.
627            Token::Drop => {
628                self.advance();
629                match self.peek() {
630                    Token::Publication => {
631                        self.advance();
632                        let name = self.expect_ident_or_string()?;
633                        Ok(Statement::DropPublication(name))
634                    }
635                    Token::Subscription => {
636                        self.advance();
637                        let name = self.expect_ident_or_string()?;
638                        Ok(Statement::DropSubscription(name))
639                    }
640                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
641                        self.advance();
642                        let name = self.expect_ident_or_string()?;
643                        Ok(Statement::DropUser(name))
644                    }
645                    // v7.12.4 — DROP TRIGGER [IF EXISTS] name ON table.
646                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
647                        self.advance();
648                        let if_exists = self.consume_if_exists();
649                        let name = self.expect_ident_like()?;
650                        // ON <table>
651                        if !matches!(self.peek(), Token::On) {
652                            return Err(self.err(alloc::format!(
653                                "expected ON <table> after DROP TRIGGER {name:?}, got {:?}",
654                                self.peek()
655                            )));
656                        }
657                        self.advance();
658                        let table = self.expect_ident_like()?;
659                        Ok(Statement::DropTrigger {
660                            name,
661                            table,
662                            if_exists,
663                        })
664                    }
665                    // v7.12.4 — DROP FUNCTION [IF EXISTS] name [(args)].
666                    // v7.12.4 ignores any optional arg-list (signature-
667                    // based overload disambiguation lands in v7.12.5+).
668                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
669                        self.advance();
670                        let if_exists = self.consume_if_exists();
671                        let name = self.expect_ident_like()?;
672                        // Optional `()` — consume + discard.
673                        if matches!(self.peek(), Token::LParen) {
674                            self.advance();
675                            // Skip until matching RParen, accepting any tokens (typed args we don't model yet).
676                            let mut depth = 1usize;
677                            while depth > 0 {
678                                match self.peek() {
679                                    Token::LParen => depth += 1,
680                                    Token::RParen => depth -= 1,
681                                    Token::Eof => {
682                                        return Err(self.err(alloc::format!(
683                                            "unterminated arg list in DROP FUNCTION {name:?}"
684                                        )));
685                                    }
686                                    _ => {}
687                                }
688                                self.advance();
689                            }
690                        }
691                        Ok(Statement::DropFunction { name, if_exists })
692                    }
693                    // v7.14.0 — DROP TABLE [IF EXISTS] name [, name…]
694                    // [CASCADE|RESTRICT]. pg_dump and mysqldump both
695                    // emit DROP TABLE IF EXISTS at the head of every
696                    // CREATE TABLE block so re-importing a dump
697                    // overwrites prior state. SPG accepts and removes
698                    // matching tables; CASCADE/RESTRICT trailers
699                    // accepted silently.
700                    Token::Table => {
701                        self.advance();
702                        let if_exists = self.consume_if_exists();
703                        let mut names: Vec<String> = Vec::new();
704                        loop {
705                            names.push(self.expect_ident_like()?);
706                            if matches!(self.peek(), Token::Comma) {
707                                self.advance();
708                                continue;
709                            }
710                            break;
711                        }
712                        if matches!(
713                            self.peek(),
714                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
715                                || s.eq_ignore_ascii_case("restrict")
716                        ) {
717                            self.advance();
718                        }
719                        Ok(Statement::DropTable { names, if_exists })
720                    }
721                    // v7.14.0 — DROP INDEX [IF EXISTS] name
722                    // [CASCADE|RESTRICT]. PG / mysqldump emit this
723                    // for partial-index renames and pgvector
724                    // migrations. SPG removes the matching index;
725                    // IF EXISTS makes the drop idempotent.
726                    Token::Index => {
727                        self.advance();
728                        let if_exists = self.consume_if_exists();
729                        let name = self.expect_ident_like()?;
730                        if matches!(
731                            self.peek(),
732                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
733                                || s.eq_ignore_ascii_case("restrict")
734                        ) {
735                            self.advance();
736                        }
737                        Ok(Statement::DropIndex { name, if_exists })
738                    }
739                    // v7.14.0 — DROP SCHEMA [IF EXISTS] name
740                    // [CASCADE|RESTRICT]. SPG is single-database;
741                    // v7.17.0 Phase 1.6 — DROP SCHEMA [IF EXISTS]
742                    // name [, name…] [CASCADE | RESTRICT]. Real
743                    // unregister (was silent no-op pre-v7.17).
744                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
745                        self.advance();
746                        let if_exists = self.consume_if_exists();
747                        let mut names = vec![self.expect_ident_like()?];
748                        while matches!(self.peek(), Token::Comma) {
749                            self.advance();
750                            names.push(self.expect_ident_like()?);
751                        }
752                        if matches!(
753                            self.peek(),
754                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
755                                || s.eq_ignore_ascii_case("restrict")
756                        ) {
757                            self.advance();
758                        }
759                        Ok(Statement::DropSchema { names, if_exists })
760                    }
761                    // v7.17.0 Phase 1.4 — DROP TYPE [IF EXISTS]
762                    // name [, name…] [CASCADE|RESTRICT].
763                    Token::Ident(s) | Token::QuotedIdent(s)
764                        if s.eq_ignore_ascii_case("type") =>
765                    {
766                        self.advance();
767                        let if_exists = self.consume_if_exists();
768                        let mut names = vec![self.expect_ident_like()?];
769                        while matches!(self.peek(), Token::Comma) {
770                            self.advance();
771                            names.push(self.expect_ident_like()?);
772                        }
773                        if matches!(
774                            self.peek(),
775                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
776                                || s.eq_ignore_ascii_case("restrict")
777                        ) {
778                            self.advance();
779                        }
780                        Ok(Statement::DropType { names, if_exists })
781                    }
782                    // v7.17.0 Phase 1.5 — DROP DOMAIN [IF EXISTS]
783                    // name [, name…] [CASCADE|RESTRICT].
784                    Token::Ident(s) | Token::QuotedIdent(s)
785                        if s.eq_ignore_ascii_case("domain") =>
786                    {
787                        self.advance();
788                        let if_exists = self.consume_if_exists();
789                        let mut names = vec![self.expect_ident_like()?];
790                        while matches!(self.peek(), Token::Comma) {
791                            self.advance();
792                            names.push(self.expect_ident_like()?);
793                        }
794                        if matches!(
795                            self.peek(),
796                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
797                                || s.eq_ignore_ascii_case("restrict")
798                        ) {
799                            self.advance();
800                        }
801                        Ok(Statement::DropDomain { names, if_exists })
802                    }
803                    // v7.17.0 Phase 1.3 — DROP MATERIALIZED VIEW
804                    // [IF EXISTS] name [, name…] [CASCADE|RESTRICT].
805                    Token::Ident(s) | Token::QuotedIdent(s)
806                        if s.eq_ignore_ascii_case("materialized") =>
807                    {
808                        self.advance();
809                        let nxt = self.peek().clone();
810                        if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
811                        {
812                            return Err(self.err(alloc::format!(
813                                "expected VIEW after DROP MATERIALIZED, got {nxt:?}"
814                            )));
815                        }
816                        self.advance();
817                        let if_exists = self.consume_if_exists();
818                        let mut names = vec![self.expect_ident_like()?];
819                        while matches!(self.peek(), Token::Comma) {
820                            self.advance();
821                            names.push(self.expect_ident_like()?);
822                        }
823                        if matches!(
824                            self.peek(),
825                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
826                                || s.eq_ignore_ascii_case("restrict")
827                        ) {
828                            self.advance();
829                        }
830                        Ok(Statement::DropMaterializedView { names, if_exists })
831                    }
832                    // v7.17.0 Phase 1.2 — DROP VIEW [IF EXISTS]
833                    // name [, name…] [CASCADE|RESTRICT].
834                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
835                        self.advance();
836                        let if_exists = self.consume_if_exists();
837                        let mut names = vec![self.expect_ident_like()?];
838                        while matches!(self.peek(), Token::Comma) {
839                            self.advance();
840                            names.push(self.expect_ident_like()?);
841                        }
842                        if matches!(
843                            self.peek(),
844                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
845                                || s.eq_ignore_ascii_case("restrict")
846                        ) {
847                            self.advance();
848                        }
849                        Ok(Statement::DropView { names, if_exists })
850                    }
851                    // v7.17.0 — DROP SEQUENCE [IF EXISTS] name [,name…]
852                    // [CASCADE|RESTRICT]. Real removal from catalog
853                    // (was a silent no-op pre-v7.17).
854                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
855                        self.advance();
856                        let if_exists = self.consume_if_exists();
857                        let mut names = vec![self.expect_ident_like()?];
858                        while matches!(self.peek(), Token::Comma) {
859                            self.advance();
860                            names.push(self.expect_ident_like()?);
861                        }
862                        if matches!(
863                            self.peek(),
864                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
865                                || s.eq_ignore_ascii_case("restrict")
866                        ) {
867                            self.advance();
868                        }
869                        Ok(Statement::DropSequence { names, if_exists })
870                    }
871                    other => Err(self.err(format!(
872                        "expected TABLE / INDEX / SCHEMA / SEQUENCE / USER / PUBLICATION / \
873                         SUBSCRIPTION / TRIGGER / FUNCTION after DROP, got {other:?}"
874                    ))),
875                }
876            }
877            // v7.17.0 Phase 1.3 — REFRESH MATERIALIZED VIEW name [WITH [NO] DATA].
878            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("refresh") => {
879                self.advance();
880                let nxt = self.peek().clone();
881                if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("materialized"))
882                {
883                    return Err(self.err(alloc::format!(
884                        "expected MATERIALIZED after REFRESH, got {nxt:?}"
885                    )));
886                }
887                self.advance();
888                let nxt2 = self.peek().clone();
889                if !matches!(&nxt2, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
890                {
891                    return Err(self.err(alloc::format!(
892                        "expected VIEW after REFRESH MATERIALIZED, got {nxt2:?}"
893                    )));
894                }
895                self.advance();
896                let name = self.expect_ident_like()?;
897                let with_data = self.parse_optional_with_data(true)?;
898                Ok(Statement::RefreshMaterializedView { name, with_data })
899            }
900            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
901                self.advance();
902                self.parse_update_after_keyword()
903            }
904            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
905                self.advance();
906                self.parse_delete_after_keyword()
907            }
908            // v6.0.4: ALTER INDEX <name> REBUILD [WITH (encoding = ...)].
909            // ALTER is not a reserved keyword in the lexer — handled
910            // as a bare ident here.
911            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("alter") => {
912                self.advance();
913                self.parse_alter_after_keyword()
914            }
915            // v6.1.7: WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>].
916            // WAIT / POSITION / TIMEOUT are bare idents — no lexer
917            // additions needed.
918            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("wait") => {
919                self.advance();
920                self.parse_wait_after_keyword()
921            }
922            // v6.2.0: ANALYZE [<table>]. ANALYZE is a bare ident.
923            // Bare ANALYZE → analyse every user table; ANALYZE
924            // <name> → re-stats one. The argument is an optional
925            // ident (or quoted ident); anything else is a parse
926            // error.
927            // v6.7.3 — `COMPACT COLD SEGMENTS`. No arguments, no
928            // `WHERE` filter (carved out per V6_7_DESIGN.md
929            // STABILITY). Lex order: identifier "compact" → "cold"
930            // → "segments". Anything else after `COMPACT` is a
931            // parse error.
932            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("compact") => {
933                self.advance();
934                let next = self.peek().clone();
935                let cold = match next {
936                    Token::Ident(s) | Token::QuotedIdent(s) => s,
937                    _ => {
938                        return Err(
939                            self.err(format!("expected COLD after COMPACT, got {:?}", self.peek()))
940                        );
941                    }
942                };
943                if !cold.eq_ignore_ascii_case("cold") {
944                    return Err(self.err(format!("expected COLD after COMPACT, got {cold:?}")));
945                }
946                self.advance();
947                let next = self.peek().clone();
948                let segments = match next {
949                    Token::Ident(s) | Token::QuotedIdent(s) => s,
950                    _ => {
951                        return Err(self.err(format!(
952                            "expected SEGMENTS after COMPACT COLD, got {:?}",
953                            self.peek()
954                        )));
955                    }
956                };
957                if !segments.eq_ignore_ascii_case("segments") {
958                    return Err(self.err(format!(
959                        "expected SEGMENTS after COMPACT COLD, got {segments:?}"
960                    )));
961                }
962                self.advance();
963                Ok(Statement::CompactColdSegments)
964            }
965            // v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ MERGE.
966            // Parsed as a case-insensitive identifier since MERGE
967            // isn't a reserved lexer keyword (collides with the
968            // mysqldump `ALGORITHM = MERGE` view clause if it
969            // were); the inner parser drives the rest of the
970            // surface (USING / ON / WHEN [NOT] MATCHED / THEN).
971            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("merge") => {
972                self.advance();
973                self.parse_merge_after_keyword()
974            }
975            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("analyze") => {
976                self.advance();
977                let target = match self.peek() {
978                    Token::Eof | Token::Semicolon => None,
979                    Token::Ident(_) | Token::QuotedIdent(_) => {
980                        Some(self.expect_ident_like()?)
981                    }
982                    other => {
983                        return Err(self.err(format!(
984                            "expected table name or end of statement after ANALYZE, got {other:?}"
985                        )));
986                    }
987                };
988                Ok(Statement::Analyze(target))
989            }
990            // v7.12.1 — `SET <name> [TO|=] <value>`. The
991            // `default_text_search_config` parameter is consumed
992            // by the FTS function dispatcher; other parameter
993            // names are recorded but treated as a no-op so PG
994            // dump output loads.
995            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {
996                self.advance();
997                // PG allows `SET LOCAL` / `SET SESSION` qualifiers
998                // — accept and ignore. MySQL adds `SET GLOBAL` too
999                // (and the alias `SET @@global.name = …` which the
1000                // SessionVar path handles).
1001                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"))
1002                {
1003                    self.advance();
1004                }
1005                // v7.14.0 — MySQL `SET NAMES <charset> [COLLATE
1006                // <collation>]` — change the connection client
1007                // charset. SPG stores UTF-8 always and orders
1008                // bytewise; accept as a no-op.
1009                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("names"))
1010                {
1011                    self.advance();
1012                    // Charset ident-or-string.
1013                    if matches!(
1014                        self.peek(),
1015                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1016                    ) {
1017                        self.advance();
1018                    }
1019                    // Optional `COLLATE <name>`.
1020                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate"))
1021                    {
1022                        self.advance();
1023                        if matches!(
1024                            self.peek(),
1025                            Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1026                        ) {
1027                            self.advance();
1028                        }
1029                    }
1030                    return Ok(Statement::Empty);
1031                }
1032                // v7.16.2 — PG `SET [SESSION] AUTHORIZATION
1033                // { DEFAULT | '<role>' | <ident> }` (mailrs
1034                // round-10 A.1). pg_dump preamble emits the
1035                // `DEFAULT` form to reset session authorization;
1036                // SPG has no role system so this is a strict
1037                // no-op. PG also accepts `RESET SESSION
1038                // AUTHORIZATION` (handled by the RESET parser
1039                // elsewhere). Reference:
1040                // <https://www.postgresql.org/docs/current/sql-set-session-authorization.html>
1041                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("authorization"))
1042                {
1043                    self.advance(); // AUTHORIZATION
1044                    match self.peek().clone() {
1045                        Token::Default => {
1046                            self.advance();
1047                        }
1048                        Token::String(_)
1049                        | Token::Ident(_)
1050                        | Token::QuotedIdent(_) => {
1051                            self.advance();
1052                        }
1053                        other => {
1054                            return Err(self.err(alloc::format!(
1055                                "expected DEFAULT / '<role>' / <ident> after SET SESSION AUTHORIZATION, got {other:?}"
1056                            )));
1057                        }
1058                    }
1059                    return Ok(Statement::Empty);
1060                }
1061                // v7.14.0 — MySQL `SET CHARACTER SET <charset>`
1062                // alias — same accept-as-no-op as SET NAMES.
1063                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
1064                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
1065                {
1066                    self.advance(); // CHARACTER
1067                    self.advance(); // SET
1068                    if matches!(
1069                        self.peek(),
1070                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1071                    ) {
1072                        self.advance();
1073                    }
1074                    return Ok(Statement::Empty);
1075                }
1076                // v7.14.0 — multi-assignment form
1077                // `SET a = 1, b = 2, …`. Single-assignment is the
1078                // 1-element case. Each LHS may be a regular ident
1079                // or a SessionVar (`@VAR` / `@@VAR`).
1080                let mut pairs: Vec<(String, crate::ast::SetValue)> = Vec::new();
1081                loop {
1082                    let lhs = match self.peek().clone() {
1083                        Token::SessionVar(s) => {
1084                            self.advance();
1085                            s
1086                        }
1087                        Token::Ident(_) | Token::QuotedIdent(_) => self.parse_set_param_name()?,
1088                        other => {
1089                            return Err(self.err(format!(
1090                                "expected parameter name after SET, got {other:?}"
1091                            )));
1092                        }
1093                    };
1094                    // Accept either `=` or the bare `TO` keyword.
1095                    match self.peek() {
1096                        Token::Eq => {
1097                            self.advance();
1098                        }
1099                        Token::To => {
1100                            self.advance();
1101                        }
1102                        other => {
1103                            return Err(self.err(format!(
1104                                "expected `=` or TO after SET {lhs}, got {other:?}"
1105                            )));
1106                        }
1107                    }
1108                    let value = self.parse_set_value()?;
1109                    pairs.push((lhs, value));
1110                    if matches!(self.peek(), Token::Comma) {
1111                        self.advance();
1112                        continue;
1113                    }
1114                    break;
1115                }
1116                if pairs.len() == 1 {
1117                    let (name, value) = pairs.into_iter().next().unwrap();
1118                    Ok(Statement::SetParameter { name, value })
1119                } else {
1120                    Ok(Statement::SetParameterList(pairs))
1121                }
1122            }
1123            // v7.12.1 — `RESET <name>` / `RESET ALL`.
1124            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("reset") => {
1125                self.advance();
1126                match self.peek().clone() {
1127                    Token::All => {
1128                        self.advance();
1129                        Ok(Statement::ResetParameter(None))
1130                    }
1131                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("all") => {
1132                        self.advance();
1133                        Ok(Statement::ResetParameter(None))
1134                    }
1135                    _ => {
1136                        let name = self.parse_set_param_name()?;
1137                        Ok(Statement::ResetParameter(Some(name)))
1138                    }
1139                }
1140            }
1141            other => Err(self.err(format!(
1142                "expected SELECT / CREATE / DROP / INSERT / UPDATE / DELETE / ALTER / BEGIN / COMMIT / \
1143                 ROLLBACK / SAVEPOINT / RELEASE / SHOW at start of statement, got {other:?}"
1144            ))),
1145        }
1146    }
1147
1148    fn parse_create_stmt(&mut self) -> Result<Statement, ParseError> {
1149        debug_assert!(matches!(self.peek(), Token::Create));
1150        self.advance();
1151        match self.peek() {
1152            Token::Table => self.parse_create_table_stmt_after_create(),
1153            Token::Index => self.parse_create_index_stmt_after_create(false),
1154            // v7.9.29 — `CREATE UNIQUE INDEX … [WHERE pred]`.
1155            // The `UNIQUE` modifier turns a partial index into a
1156            // partial-uniqueness invariant (only rows matching the
1157            // WHERE predicate are checked for duplicates). mailrs
1158            // K1 (3 hits: email_templates default, calendar_events
1159            // master, calendar_events instance).
1160            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unique") => {
1161                self.advance();
1162                if !matches!(self.peek(), Token::Index) {
1163                    return Err(self.err(alloc::format!(
1164                        "expected INDEX after CREATE UNIQUE, got {:?}",
1165                        self.peek()
1166                    )));
1167                }
1168                self.parse_create_index_stmt_after_create(true)
1169            }
1170            Token::Publication => {
1171                self.advance();
1172                self.parse_create_publication_after_keyword()
1173            }
1174            Token::Subscription => {
1175                self.advance();
1176                self.parse_create_subscription_after_keyword()
1177            }
1178            // v4.1: CREATE USER 'name' WITH PASSWORD 'pw' [ROLE 'role'].
1179            // USER isn't a reserved keyword — we look for the bare
1180            // identifier so the lexer doesn't have to grow a token.
1181            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
1182                self.advance();
1183                self.parse_create_user_after_keyword()
1184            }
1185            // v7.9.15 — `CREATE EXTENSION [IF NOT EXISTS] <name>
1186            // [WITH SCHEMA …] [VERSION '…'] [CASCADE]` as a
1187            // no-op. mailrs follow-up F3.
1188            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("extension") => {
1189                self.advance();
1190                self.parse_create_extension_after_keyword()
1191            }
1192            // v7.12.4 — `CREATE [OR REPLACE] FUNCTION …` and
1193            // `CREATE [OR REPLACE] TRIGGER …`. `OR REPLACE` is
1194            // optional; absorb it here and forward to the
1195            // per-kind parsers with the flag. OR is a reserved
1196            // keyword token.
1197            Token::Or => {
1198                self.advance();
1199                let next = self.peek();
1200                let (Token::Ident(s2) | Token::QuotedIdent(s2)) = next else {
1201                    return Err(self.err(alloc::format!(
1202                        "expected REPLACE after CREATE OR, got {next:?}"
1203                    )));
1204                };
1205                if !s2.eq_ignore_ascii_case("replace") {
1206                    return Err(self.err(alloc::format!(
1207                        "expected REPLACE after CREATE OR, got {s2:?}"
1208                    )));
1209                }
1210                self.advance();
1211                self.parse_create_function_or_trigger_after_or_replace(true)
1212            }
1213            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
1214                self.advance();
1215                self.parse_create_function_after_keyword(false)
1216            }
1217            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
1218                self.advance();
1219                self.parse_create_trigger_after_keyword(false)
1220            }
1221            // v7.17.0 — CREATE [TEMPORARY] SEQUENCE …
1222            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
1223                self.advance();
1224                self.parse_create_sequence_after_keyword(false)
1225            }
1226            // v7.17.0 Phase 1.2 — CREATE [TEMPORARY] VIEW …
1227            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
1228                self.advance();
1229                self.parse_create_view_after_keyword(false, false, false)
1230            }
1231            // v7.17.0 Phase 2.6 — MySQL view prefix clauses
1232            // `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}` /
1233            // `DEFINER = <user>` / `SQL SECURITY {DEFINER|INVOKER}`
1234            // appear (in any order) between `CREATE` and `VIEW` in
1235            // every mysqldump-emitted view. Pre-2.6 the parser
1236            // rejected the prefix and the customer's whole view
1237            // backup failed on the first view. The hints are pure
1238            // planner / permission metadata; SPG's view-rewrite
1239            // path is semantically equivalent for all three
1240            // algorithms in v7.17 (TEMPTABLE differs only in
1241            // perf for huge views — out of v7.17 scope), and
1242            // DEFINER / SQL SECURITY are pure single-user
1243            // permissioning that SPG ignores by design.
1244            Token::Ident(s) | Token::QuotedIdent(s)
1245                if s.eq_ignore_ascii_case("algorithm")
1246                    || s.eq_ignore_ascii_case("definer")
1247                    || s.eq_ignore_ascii_case("sql") =>
1248            {
1249                self.consume_mysql_view_prefix()?;
1250                // After absorbing ALGORITHM / DEFINER / SQL SECURITY
1251                // (in any order, in any combination), the next
1252                // keyword must be VIEW. mysqldump never emits these
1253                // prefixes on non-view statements.
1254                let next = self.peek().clone();
1255                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2)
1256                    if s2.eq_ignore_ascii_case("view"))
1257                {
1258                    self.advance();
1259                    self.parse_create_view_after_keyword(false, false, false)
1260                } else {
1261                    Err(self.err(alloc::format!(
1262                        "expected VIEW after MySQL view prefix (ALGORITHM/DEFINER/SQL SECURITY), got {next:?}"
1263                    )))
1264                }
1265            }
1266            // v7.17.0 Phase 1.4 — CREATE TYPE name AS ENUM (…).
1267            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("type") => {
1268                self.advance();
1269                self.parse_create_type_after_keyword()
1270            }
1271            // v7.17.0 Phase 1.5 — CREATE DOMAIN name AS base
1272            // [DEFAULT expr] [NOT NULL] [CHECK (expr)]*.
1273            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("domain") => {
1274                self.advance();
1275                self.parse_create_domain_after_keyword()
1276            }
1277            // v7.17.0 Phase 1.6 — CREATE SCHEMA [IF NOT EXISTS]
1278            // name [AUTHORIZATION user]. Real catalog registry
1279            // (was silent-no-op'd pre-v7.17).
1280            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
1281                self.advance();
1282                let if_not_exists = self.parse_if_not_exists();
1283                let name = self.expect_ident_like()?;
1284                // Optional `AUTHORIZATION <user>` trailer — accepted,
1285                // ignored (single-user catalog).
1286                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1287                    if s.eq_ignore_ascii_case("authorization"))
1288                {
1289                    self.advance();
1290                    let _ = self.expect_ident_like()?;
1291                }
1292                Ok(Statement::CreateSchema { name, if_not_exists })
1293            }
1294            // v7.17.0 Phase 1.3 — CREATE MATERIALIZED VIEW …
1295            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("materialized") => {
1296                self.advance();
1297                let next = self.peek().clone();
1298                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1299                {
1300                    self.advance();
1301                    self.parse_create_materialized_view_after_keyword()
1302                } else {
1303                    Err(self.err(alloc::format!(
1304                        "expected VIEW after CREATE MATERIALIZED, got {next:?}"
1305                    )))
1306                }
1307            }
1308            Token::Ident(s) | Token::QuotedIdent(s)
1309                if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") =>
1310            {
1311                self.advance();
1312                // TEMPORARY/TEMP followed by SEQUENCE / VIEW.
1313                let next = self.peek().clone();
1314                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("sequence"))
1315                {
1316                    self.advance();
1317                    self.parse_create_sequence_after_keyword(true)
1318                } else if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1319                {
1320                    self.advance();
1321                    self.parse_create_view_after_keyword(false, false, true)
1322                } else {
1323                    // TEMP TABLE etc — consume to boundary as noop for now.
1324                    self.consume_until_statement_boundary();
1325                    Ok(Statement::Empty)
1326                }
1327            }
1328            // v7.17.0 Phase 4.2 — MySQL `CREATE PROCEDURE name (…)
1329            // BEGIN <body> END`. The body may reference `@var`
1330            // session variables, SET statements, internal `;`
1331            // terminators, etc. SPG has no procedure runtime, so
1332            // consume the whole `CREATE PROCEDURE … END` block as
1333            // a no-op so mysqldump scripts that include stored
1334            // routines load through. The matching-END consumer
1335            // tracks BEGIN/END nesting depth to handle nested
1336            // BEGIN blocks correctly.
1337            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("procedure") => {
1338                self.consume_mysql_routine_body();
1339                Ok(Statement::Empty)
1340            }
1341            // v7.14.0 — pg_dump / mysqldump emit
1342            // `CREATE SCHEMA / VIEW / MATERIALIZED VIEW /
1343            // TYPE / DOMAIN / DATABASE / ROLE / POLICY / OPERATOR`.
1344            // SPG is single-schema / single-database; these have
1345            // no behavioural effect, so consume + return Empty.
1346            // v7.17.0 NOTE: SEQUENCE / VIEW / MATERIALIZED VIEW /
1347            // TYPE / DOMAIN / SCHEMA were here pre-v7.17; all
1348            // moved up to real parser branches. DATABASE / ROLE /
1349            // POLICY / OPERATOR stay no-op forever
1350            // (single-database, hardcoded roles).
1351            Token::Ident(s) | Token::QuotedIdent(s)
1352                if matches!(
1353                    s.to_ascii_lowercase().as_str(),
1354                    "database"
1355                        | "role"
1356                        | "policy"
1357                        | "operator"
1358                        | "cast"
1359                        | "rule"
1360                        | "aggregate"
1361                        | "language"
1362                        | "collation"
1363                        | "conversion"
1364                        // v7.17.0 Phase 8 (audit N6) — rarely-
1365                        // emitted pg_dump shapes that should
1366                        // load through without a parser error.
1367                        // SPG has no planner statistics catalog,
1368                        // no event-trigger hooks, no foreign-
1369                        // data-wrapper infrastructure; consume
1370                        // + return Empty.
1371                        | "statistics"
1372                        | "event"
1373                        | "foreign"
1374                ) =>
1375            {
1376                self.consume_until_statement_boundary();
1377                Ok(Statement::Empty)
1378            }
1379            other => Err(self.err(format!(
1380                "expected TABLE / INDEX / USER / EXTENSION / PUBLICATION / SUBSCRIPTION / FUNCTION / TRIGGER / SEQUENCE / SCHEMA / VIEW / TYPE / DOMAIN [OR REPLACE …] after CREATE, got {other:?}"
1381            ))),
1382        }
1383    }
1384
1385    /// v7.12.4 — `CREATE OR REPLACE` already consumed; the next
1386    /// keyword decides whether we parse a function or trigger
1387    /// body. PG accepts other `OR REPLACE`-able objects (VIEW,
1388    /// PROCEDURE) — those land in later releases.
1389    fn parse_create_function_or_trigger_after_or_replace(
1390        &mut self,
1391        or_replace: bool,
1392    ) -> Result<Statement, ParseError> {
1393        let tok = self.peek();
1394        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1395            return Err(self.err(alloc::format!(
1396                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {tok:?}"
1397            )));
1398        };
1399        if s.eq_ignore_ascii_case("function") {
1400            self.advance();
1401            self.parse_create_function_after_keyword(or_replace)
1402        } else if s.eq_ignore_ascii_case("trigger") {
1403            self.advance();
1404            self.parse_create_trigger_after_keyword(or_replace)
1405        } else if s.eq_ignore_ascii_case("view") {
1406            // v7.17.0 Phase 1.2 — CREATE OR REPLACE VIEW name AS SELECT …
1407            self.advance();
1408            self.parse_create_view_after_keyword(or_replace, false, false)
1409        } else if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") {
1410            // CREATE OR REPLACE TEMPORARY VIEW … (rare but legal).
1411            self.advance();
1412            let nxt = self.peek().clone();
1413            if matches!(&nxt, Token::Ident(n) | Token::QuotedIdent(n) if n.eq_ignore_ascii_case("view"))
1414            {
1415                self.advance();
1416                self.parse_create_view_after_keyword(or_replace, false, true)
1417            } else {
1418                Err(self.err(alloc::format!(
1419                    "expected VIEW after CREATE OR REPLACE TEMPORARY, got {nxt:?}"
1420                )))
1421            }
1422        } else {
1423            Err(self.err(alloc::format!(
1424                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {s:?}"
1425            )))
1426        }
1427    }
1428
1429    /// v7.9.15 — accept and discard `CREATE EXTENSION` DDL.
1430    /// SPG doesn't have a registry; pgvector / similar are
1431    /// either builtin (VECTOR(N) ↔ pgvector) or n/a. Parsing
1432    /// the syntax lets dual-target schemas keep the line.
1433    fn parse_create_extension_after_keyword(&mut self) -> Result<Statement, ParseError> {
1434        // Optional `IF NOT EXISTS`.
1435        self.consume_if_not_exists();
1436        let name = self.expect_ident_like()?;
1437        // Drain optional WITH SCHEMA <ident> / VERSION '<v>' /
1438        // CASCADE / FROM '<v>' clauses; we don't model them.
1439        loop {
1440            match self.peek() {
1441                Token::Ident(s) if s.eq_ignore_ascii_case("with") => {
1442                    self.advance();
1443                    continue;
1444                }
1445                Token::Ident(s) if s.eq_ignore_ascii_case("schema") => {
1446                    self.advance();
1447                    let _ = self.expect_ident_like()?;
1448                    continue;
1449                }
1450                Token::Ident(s) if s.eq_ignore_ascii_case("version") => {
1451                    self.advance();
1452                    // String or ident literal.
1453                    let _ = self.advance();
1454                    continue;
1455                }
1456                Token::Ident(s) if s.eq_ignore_ascii_case("from") => {
1457                    self.advance();
1458                    let _ = self.advance();
1459                    continue;
1460                }
1461                Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => {
1462                    self.advance();
1463                    continue;
1464                }
1465                _ => break,
1466            }
1467        }
1468        Ok(Statement::CreateExtension(name))
1469    }
1470
1471    /// v7.12.4 — body of `CREATE [OR REPLACE] FUNCTION`. The
1472    /// `[OR REPLACE]` flag (and the `FUNCTION` keyword) have
1473    /// already been consumed by the caller. Grammar accepted:
1474    ///
1475    ///   name `(` arg-list `)`
1476    ///   `RETURNS` return-type
1477    ///   [ `LANGUAGE` ident ]
1478    ///   `AS` $$ body $$
1479    ///   [ `LANGUAGE` ident ]
1480    ///
1481    /// Either `LANGUAGE` position is allowed; PG accepts both.
1482    fn parse_create_function_after_keyword(
1483        &mut self,
1484        or_replace: bool,
1485    ) -> Result<Statement, ParseError> {
1486        let name = self.expect_ident_like()?;
1487        // Argument list. v7.12.4 commonly sees the empty `()`
1488        // (trigger functions); typed args parse and round-trip
1489        // but the executor only invokes nullary functions.
1490        if !matches!(self.peek(), Token::LParen) {
1491            return Err(self.err(alloc::format!(
1492                "expected '(' after function name {name:?}, got {:?}",
1493                self.peek()
1494            )));
1495        }
1496        self.advance();
1497        let args = self.parse_function_arg_list()?;
1498        // RETURNS clause.
1499        let tok = self.peek();
1500        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1501            return Err(self.err(alloc::format!(
1502                "expected RETURNS after function arg list, got {tok:?}"
1503            )));
1504        };
1505        if !s.eq_ignore_ascii_case("returns") {
1506            return Err(self.err(alloc::format!(
1507                "expected RETURNS after function arg list, got {s:?}"
1508            )));
1509        }
1510        self.advance();
1511        let returns = self.parse_function_return()?;
1512        // Optional LANGUAGE clause (PG also accepts after AS — we'll
1513        // re-check after the body too).
1514        let mut language: Option<String> = self.parse_optional_language()?;
1515        // `AS` followed by a $$-quoted body (lexer already
1516        // collapses both `$$…$$` and `$tag$…$tag$` to a single
1517        // Token::String). AS is a reserved keyword (Token::As).
1518        if !matches!(self.peek(), Token::As) {
1519            return Err(self.err(alloc::format!(
1520                "expected AS before function body, got {:?}",
1521                self.peek()
1522            )));
1523        }
1524        self.advance();
1525        let body_text = match self.peek() {
1526            Token::String(s) => {
1527                let body = s.clone();
1528                self.advance();
1529                body
1530            }
1531            other => {
1532                return Err(self.err(alloc::format!(
1533                    "expected $$-quoted function body after AS, got {other:?}"
1534                )));
1535            }
1536        };
1537        // Trailing optional LANGUAGE clause (the other PG position).
1538        if language.is_none() {
1539            language = self.parse_optional_language()?;
1540        }
1541        let language = language.unwrap_or_else(|| String::from("sql"));
1542        // PL/pgSQL bodies get structure-parsed. Other languages
1543        // (or PL/pgSQL bodies the v7.12.4 parser doesn't yet
1544        // recognise) round-trip as Raw text — the executor errors
1545        // when invoked with a clear unsupported message.
1546        let body = if language.eq_ignore_ascii_case("plpgsql") {
1547            match parse_plpgsql_body(&body_text) {
1548                Ok(block) => FunctionBody::PlPgSql(block),
1549                // Best-effort: if the body parser doesn't yet
1550                // support a construct used inside, fall back to
1551                // raw — keeps `CREATE FUNCTION` itself working
1552                // (catalogue accepts), executor errors on
1553                // invocation only.
1554                Err(_) => FunctionBody::Raw(body_text),
1555            }
1556        } else {
1557            FunctionBody::Raw(body_text)
1558        };
1559        Ok(Statement::CreateFunction(CreateFunctionStatement {
1560            name,
1561            or_replace,
1562            args,
1563            returns,
1564            language,
1565            body,
1566        }))
1567    }
1568
1569    /// Closing `)`-terminated argument list. v7.12.4 commonly
1570    /// sees the empty `()`; typed args round-trip but the
1571    /// executor (yet) doesn't invoke them.
1572    fn parse_function_arg_list(&mut self) -> Result<Vec<FunctionArg>, ParseError> {
1573        let mut args: Vec<FunctionArg> = Vec::new();
1574        if matches!(self.peek(), Token::RParen) {
1575            self.advance();
1576            return Ok(args);
1577        }
1578        loop {
1579            // Optional `IN` / `OUT` / `INOUT` mode keyword. IN is
1580            // a reserved token; OUT / INOUT are bare idents.
1581            let mode = if matches!(self.peek(), Token::In) {
1582                self.advance();
1583                FunctionArgMode::In
1584            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("out"))
1585            {
1586                self.advance();
1587                FunctionArgMode::Out
1588            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("inout"))
1589            {
1590                self.advance();
1591                FunctionArgMode::InOut
1592            } else {
1593                FunctionArgMode::In
1594            };
1595            // Optional name. The next token is either a name
1596            // (followed by a type ident) or the type itself.
1597            // Disambiguate by peeking ahead: if the token after
1598            // the next ident is also an ident, we treat the
1599            // first as the name.
1600            let (name, ty_token) = {
1601                let first = self.expect_ident_like()?;
1602                // Peek next: if it's an ident (i.e. a type
1603                // name) the `first` was the arg name.
1604                match self.peek() {
1605                    Token::Ident(_) | Token::QuotedIdent(_) => {
1606                        let ty = self.expect_ident_like()?;
1607                        (Some(first), ty)
1608                    }
1609                    _ => (None, first),
1610                }
1611            };
1612            // Type — try to map to ColumnTypeName, else Raw.
1613            let ty = match map_type_ident_to_column_type_name(&ty_token) {
1614                Some(t) => FunctionArgType::Typed(t),
1615                None => FunctionArgType::Raw(ty_token),
1616            };
1617            args.push(FunctionArg { mode, name, ty });
1618            match self.peek() {
1619                Token::Comma => {
1620                    self.advance();
1621                    continue;
1622                }
1623                Token::RParen => {
1624                    self.advance();
1625                    return Ok(args);
1626                }
1627                other => {
1628                    return Err(self.err(alloc::format!(
1629                        "expected , or ) in function arg list, got {other:?}"
1630                    )));
1631                }
1632            }
1633        }
1634    }
1635
1636    fn parse_function_return(&mut self) -> Result<FunctionReturn, ParseError> {
1637        let ident = self.expect_ident_like()?;
1638        if ident.eq_ignore_ascii_case("trigger") {
1639            return Ok(FunctionReturn::Trigger);
1640        }
1641        if ident.eq_ignore_ascii_case("void") {
1642            return Ok(FunctionReturn::Void);
1643        }
1644        match map_type_ident_to_column_type_name(&ident) {
1645            Some(t) => Ok(FunctionReturn::Type(t)),
1646            None => Ok(FunctionReturn::Other(ident)),
1647        }
1648    }
1649
1650    fn parse_optional_language(&mut self) -> Result<Option<String>, ParseError> {
1651        match self.peek() {
1652            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("language") => {
1653                self.advance();
1654                let lang = self.expect_ident_like()?;
1655                Ok(Some(lang.to_ascii_lowercase()))
1656            }
1657            _ => Ok(None),
1658        }
1659    }
1660
1661    /// v7.17.0 Phase 1.5 — body of `CREATE DOMAIN name AS
1662    /// base_type [DEFAULT expr] [NOT NULL | NULL] [CHECK
1663    /// (expr)]*`. The `DOMAIN` keyword has already been
1664    /// consumed. PG allows the trailing constraints in any
1665    /// order; we approximate with a small loop.
1666    fn parse_create_domain_after_keyword(&mut self) -> Result<Statement, ParseError> {
1667        let name = self.expect_ident_like()?;
1668        // Optional `AS`.
1669        if matches!(self.peek(), Token::As) {
1670            self.advance();
1671        }
1672        let base_type = self.parse_column_type_name()?;
1673        let mut default: Option<Expr> = None;
1674        let mut not_null = false;
1675        let mut checks: Vec<Expr> = Vec::new();
1676        loop {
1677            match self.peek() {
1678                Token::Default => {
1679                    if default.is_some() {
1680                        return Err(self.err("DOMAIN DEFAULT specified twice".into()));
1681                    }
1682                    self.advance();
1683                    default = Some(self.parse_expr(0)?);
1684                }
1685                Token::Not => {
1686                    self.advance();
1687                    if !matches!(self.peek(), Token::Null) {
1688                        return Err(self.err(alloc::format!(
1689                            "expected NULL after NOT in DOMAIN, got {:?}",
1690                            self.peek()
1691                        )));
1692                    }
1693                    self.advance();
1694                    not_null = true;
1695                }
1696                Token::Null => {
1697                    self.advance();
1698                    // NULL after a NOT NULL is contradictory, but
1699                    // PG accepts bare NULL as the default-nullable
1700                    // marker. No-op.
1701                }
1702                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("check") => {
1703                    self.advance();
1704                    if !matches!(self.peek(), Token::LParen) {
1705                        return Err(self.err(alloc::format!(
1706                            "expected '(' after CHECK in DOMAIN, got {:?}",
1707                            self.peek()
1708                        )));
1709                    }
1710                    self.advance();
1711                    let expr = self.parse_expr(0)?;
1712                    if !matches!(self.peek(), Token::RParen) {
1713                        return Err(self.err(alloc::format!(
1714                            "expected ')' after CHECK expr, got {:?}",
1715                            self.peek()
1716                        )));
1717                    }
1718                    self.advance();
1719                    checks.push(expr);
1720                }
1721                // CONSTRAINT <name> CHECK (…) — PG accepts a name
1722                // prefix on the constraint; we drop the name and
1723                // recurse into the constraint parsing.
1724                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("constraint") => {
1725                    self.advance();
1726                    let _ = self.expect_ident_like()?;
1727                }
1728                _ => break,
1729            }
1730        }
1731        Ok(Statement::CreateDomain(crate::ast::CreateDomainStatement {
1732            name,
1733            base_type,
1734            default,
1735            not_null,
1736            checks,
1737        }))
1738    }
1739
1740    /// v7.17.0 Phase 1.4 — body of `CREATE TYPE name AS ENUM
1741    /// ('a', 'b', …)`. The `TYPE` keyword has already been
1742    /// consumed.
1743    fn parse_create_type_after_keyword(&mut self) -> Result<Statement, ParseError> {
1744        let name = self.expect_ident_like()?;
1745        // Required `AS`.
1746        if !matches!(self.peek(), Token::As) {
1747            return Err(self.err(alloc::format!(
1748                "expected AS after CREATE TYPE {name:?}, got {:?}",
1749                self.peek()
1750            )));
1751        }
1752        self.advance();
1753        // Required `ENUM` ident.
1754        let kind_ident = match self.peek().clone() {
1755            Token::Ident(s) | Token::QuotedIdent(s) => s,
1756            other => {
1757                return Err(self.err(alloc::format!(
1758                    "expected ENUM after CREATE TYPE {name:?} AS, got {other:?}"
1759                )));
1760            }
1761        };
1762        if !kind_ident.eq_ignore_ascii_case("enum") {
1763            return Err(self.err(alloc::format!(
1764                "Phase 1.4 only supports ENUM; got {kind_ident:?}"
1765            )));
1766        }
1767        self.advance();
1768        if !matches!(self.peek(), Token::LParen) {
1769            return Err(self.err(alloc::format!(
1770                "expected '(' after ENUM, got {:?}",
1771                self.peek()
1772            )));
1773        }
1774        self.advance();
1775        let mut labels: Vec<String> = Vec::new();
1776        loop {
1777            match self.peek().clone() {
1778                Token::String(s) => {
1779                    self.advance();
1780                    labels.push(s);
1781                }
1782                other => {
1783                    return Err(
1784                        self.err(alloc::format!("expected enum label string, got {other:?}"))
1785                    );
1786                }
1787            }
1788            if matches!(self.peek(), Token::Comma) {
1789                self.advance();
1790                continue;
1791            }
1792            if matches!(self.peek(), Token::RParen) {
1793                self.advance();
1794                break;
1795            }
1796            return Err(self.err(alloc::format!(
1797                "expected , or ) in ENUM label list, got {:?}",
1798                self.peek()
1799            )));
1800        }
1801        if labels.is_empty() {
1802            return Err(self.err("CREATE TYPE … AS ENUM must declare at least one label".into()));
1803        }
1804        Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
1805            name,
1806            kind: crate::ast::TypeKind::Enum { labels },
1807        }))
1808    }
1809
1810    /// v7.17.0 Phase 1.3 — body of `CREATE MATERIALIZED VIEW
1811    /// [IF NOT EXISTS] name [(col, …)] AS <SELECT …> [WITH [NO] DATA]`.
1812    /// The `CREATE MATERIALIZED VIEW` keywords have already been
1813    /// consumed.
1814    fn parse_create_materialized_view_after_keyword(&mut self) -> Result<Statement, ParseError> {
1815        let if_not_exists = self.parse_if_not_exists();
1816        let name = self.expect_ident_like()?;
1817        let mut columns: Vec<String> = Vec::new();
1818        if matches!(self.peek(), Token::LParen) {
1819            self.advance();
1820            loop {
1821                let c = self.expect_ident_like()?;
1822                columns.push(c);
1823                if matches!(self.peek(), Token::Comma) {
1824                    self.advance();
1825                    continue;
1826                }
1827                if matches!(self.peek(), Token::RParen) {
1828                    self.advance();
1829                    break;
1830                }
1831                return Err(self.err(alloc::format!(
1832                    "expected , or ) in MATERIALIZED VIEW column list, got {:?}",
1833                    self.peek()
1834                )));
1835            }
1836        }
1837        if !matches!(self.peek(), Token::As) {
1838            return Err(self.err(alloc::format!(
1839                "expected AS <SELECT …> after CREATE MATERIALIZED VIEW {name:?}, got {:?}",
1840                self.peek()
1841            )));
1842        }
1843        self.advance();
1844        let body_stmt = self.parse_select_stmt()?;
1845        let Statement::Select(body) = body_stmt else {
1846            return Err(self.err(alloc::format!(
1847                "CREATE MATERIALIZED VIEW body must be a SELECT, got {body_stmt:?}"
1848            )));
1849        };
1850        // Optional trailing `WITH [NO] DATA`.
1851        let with_data = self.parse_optional_with_data(true)?;
1852        Ok(Statement::CreateMaterializedView(
1853            crate::ast::CreateMaterializedViewStatement {
1854                name,
1855                if_not_exists,
1856                columns,
1857                body,
1858                with_data,
1859            },
1860        ))
1861    }
1862
1863    /// v7.17.0 Phase 1.3 — `WITH [NO] DATA` trailer.
1864    /// `default_when_absent` is what to return if the tail is
1865    /// missing (CREATE defaults to WITH DATA, REFRESH defaults to
1866    /// WITH DATA).
1867    fn parse_optional_with_data(&mut self, default_when_absent: bool) -> Result<bool, ParseError> {
1868        let save = self.pos;
1869        // `WITH` is an Ident (not reserved in the lexer).
1870        let is_with = match self.peek() {
1871            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("with"),
1872            _ => false,
1873        };
1874        if !is_with {
1875            return Ok(default_when_absent);
1876        }
1877        self.advance();
1878        // Optional `NO`.
1879        let mut with_data = true;
1880        let is_no = match self.peek() {
1881            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("no"),
1882            _ => false,
1883        };
1884        if is_no {
1885            self.advance();
1886            with_data = false;
1887        }
1888        // Required `DATA` ident.
1889        let is_data = match self.peek() {
1890            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("data"),
1891            _ => false,
1892        };
1893        if is_data {
1894            self.advance();
1895            Ok(with_data)
1896        } else {
1897            // Caller's WITH wasn't WITH-DATA — rewind so the outer
1898            // parser can interpret it.
1899            self.pos = save;
1900            Ok(default_when_absent)
1901        }
1902    }
1903
1904    /// v7.17.0 Phase 1.2 — body of `CREATE [OR REPLACE]
1905    /// [TEMPORARY] VIEW [IF NOT EXISTS] name [(col, …)] AS <SELECT>`.
1906    /// All keyword prefixes have already been consumed; the flags
1907    /// say which were present.
1908    fn parse_create_view_after_keyword(
1909        &mut self,
1910        or_replace: bool,
1911        _materialized_unused: bool,
1912        temporary: bool,
1913    ) -> Result<Statement, ParseError> {
1914        let if_not_exists = self.parse_if_not_exists();
1915        let name = self.expect_ident_like()?;
1916        // Optional `(col, col, …)` rename list.
1917        let mut columns: Vec<String> = Vec::new();
1918        if matches!(self.peek(), Token::LParen) {
1919            self.advance();
1920            loop {
1921                let c = self.expect_ident_like()?;
1922                columns.push(c);
1923                if matches!(self.peek(), Token::Comma) {
1924                    self.advance();
1925                    continue;
1926                }
1927                if matches!(self.peek(), Token::RParen) {
1928                    self.advance();
1929                    break;
1930                }
1931                return Err(self.err(alloc::format!(
1932                    "expected , or ) in VIEW column list, got {:?}",
1933                    self.peek()
1934                )));
1935            }
1936        }
1937        // Required `AS`.
1938        if !matches!(self.peek(), Token::As) {
1939            return Err(self.err(alloc::format!(
1940                "expected AS <SELECT …> after CREATE VIEW {name:?}, got {:?}",
1941                self.peek()
1942            )));
1943        }
1944        self.advance();
1945        // Body: a regular SELECT statement.
1946        let body_stmt = self.parse_select_stmt()?;
1947        let Statement::Select(body) = body_stmt else {
1948            return Err(self.err(alloc::format!(
1949                "CREATE VIEW body must be a SELECT statement, got {body_stmt:?}"
1950            )));
1951        };
1952        Ok(Statement::CreateView(crate::ast::CreateViewStatement {
1953            name,
1954            or_replace,
1955            if_not_exists,
1956            temporary,
1957            columns,
1958            body,
1959        }))
1960    }
1961
1962    /// v7.17.0 — body of `CREATE [TEMPORARY] SEQUENCE`. The
1963    /// `[TEMPORARY]` and `SEQUENCE` tokens have already been
1964    /// consumed; `temporary` carries whether TEMPORARY was seen.
1965    fn parse_create_sequence_after_keyword(
1966        &mut self,
1967        temporary: bool,
1968    ) -> Result<Statement, ParseError> {
1969        let if_not_exists = self.parse_if_not_exists();
1970        let name = self.expect_ident_like()?;
1971        // Optional `AS data_type`.
1972        let data_type = if matches!(self.peek(), Token::As) {
1973            self.advance();
1974            Some(self.parse_sequence_data_type()?)
1975        } else {
1976            None
1977        };
1978        let options = self.parse_sequence_options(/* allow_restart = */ false)?;
1979        Ok(Statement::CreateSequence(
1980            crate::ast::CreateSequenceStatement {
1981                name,
1982                if_not_exists,
1983                temporary,
1984                data_type,
1985                options,
1986            },
1987        ))
1988    }
1989
1990    /// v7.17.0 — body of `ALTER SEQUENCE`. The `ALTER` keyword has
1991    /// already been consumed; this is reached after `SEQUENCE`.
1992    fn parse_alter_sequence_after_keyword(&mut self) -> Result<Statement, ParseError> {
1993        let if_exists = self.parse_if_exists();
1994        let name = self.expect_ident_like()?;
1995        let options = self.parse_sequence_options(/* allow_restart = */ true)?;
1996        Ok(Statement::AlterSequence(
1997            crate::ast::AlterSequenceStatement {
1998                name,
1999                if_exists,
2000                options,
2001            },
2002        ))
2003    }
2004
2005    fn parse_sequence_data_type(&mut self) -> Result<crate::ast::SequenceDataType, ParseError> {
2006        let kw = self.expect_ident_like()?;
2007        match kw.to_ascii_lowercase().as_str() {
2008            "smallint" | "int2" => Ok(crate::ast::SequenceDataType::SmallInt),
2009            "integer" | "int" | "int4" => Ok(crate::ast::SequenceDataType::Int),
2010            "bigint" | "int8" => Ok(crate::ast::SequenceDataType::BigInt),
2011            other => Err(self.err(alloc::format!(
2012                "expected SMALLINT / INTEGER / BIGINT after SEQUENCE AS, got {other:?}"
2013            ))),
2014        }
2015    }
2016
2017    fn parse_sequence_options(
2018        &mut self,
2019        allow_restart: bool,
2020    ) -> Result<crate::ast::SequenceOptions, ParseError> {
2021        use crate::ast::{SeqBound, SequenceOptions, SequenceOwnedBy};
2022        let mut opts = SequenceOptions::default();
2023        #[allow(clippy::while_let_loop)]
2024        loop {
2025            // Match an ident; stop at any non-ident token (sentinel,
2026            // semicolon, end of statement).
2027            let kw_lc = match self.peek() {
2028                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
2029                _ => break,
2030            };
2031            match kw_lc.as_str() {
2032                "increment" => {
2033                    self.advance();
2034                    // Optional BY.
2035                    if matches!(self.peek(), Token::By) {
2036                        self.advance();
2037                    }
2038                    opts.increment = Some(self.expect_signed_int()?);
2039                }
2040                "minvalue" => {
2041                    self.advance();
2042                    opts.min_value = Some(SeqBound::Value(self.expect_signed_int()?));
2043                }
2044                "maxvalue" => {
2045                    self.advance();
2046                    opts.max_value = Some(SeqBound::Value(self.expect_signed_int()?));
2047                }
2048                "no" => {
2049                    self.advance();
2050                    let what = self.expect_ident_like()?;
2051                    match what.to_ascii_lowercase().as_str() {
2052                        "minvalue" => opts.min_value = Some(SeqBound::NoBound),
2053                        "maxvalue" => opts.max_value = Some(SeqBound::NoBound),
2054                        "cycle" => opts.cycle = Some(false),
2055                        other => {
2056                            return Err(self.err(alloc::format!(
2057                                "expected MINVALUE / MAXVALUE / CYCLE after NO, got {other:?}"
2058                            )));
2059                        }
2060                    }
2061                }
2062                "start" => {
2063                    self.advance();
2064                    // Optional WITH.
2065                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2066                        if s.eq_ignore_ascii_case("with"))
2067                    {
2068                        self.advance();
2069                    }
2070                    opts.start = Some(self.expect_signed_int()?);
2071                }
2072                "restart" if allow_restart => {
2073                    self.advance();
2074                    // Optional WITH n; bare RESTART means restart at START.
2075                    let mut with_val: Option<i64> = None;
2076                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2077                        if s.eq_ignore_ascii_case("with"))
2078                    {
2079                        self.advance();
2080                        with_val = Some(self.expect_signed_int()?);
2081                    } else if matches!(self.peek(), Token::Integer(_) | Token::Minus) {
2082                        with_val = Some(self.expect_signed_int()?);
2083                    }
2084                    opts.restart = Some(with_val);
2085                }
2086                "cache" => {
2087                    self.advance();
2088                    opts.cache = Some(self.expect_signed_int()?);
2089                }
2090                "cycle" => {
2091                    self.advance();
2092                    opts.cycle = Some(true);
2093                }
2094                "owned" => {
2095                    self.advance();
2096                    // BY is a reserved Token::By; accept either form.
2097                    match self.peek() {
2098                        Token::By => {
2099                            self.advance();
2100                        }
2101                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("by") => {
2102                            self.advance();
2103                        }
2104                        other => {
2105                            return Err(
2106                                self.err(alloc::format!("expected BY after OWNED, got {other:?}"))
2107                            );
2108                        }
2109                    }
2110                    // OWNED BY {NONE | tab.col}. Read just one ident
2111                    // (NOT expect_ident_like which would auto-strip
2112                    // a schema prefix and consume the `.col` we need).
2113                    let first = match self.advance() {
2114                        Token::Ident(s) | Token::QuotedIdent(s) => s,
2115                        other => {
2116                            return Err(self.err(alloc::format!(
2117                                "expected identifier or NONE after OWNED BY, got {other:?}"
2118                            )));
2119                        }
2120                    };
2121                    if first.eq_ignore_ascii_case("none") {
2122                        opts.owned_by = Some(SequenceOwnedBy::None);
2123                    } else if matches!(self.peek(), Token::Dot) {
2124                        self.advance();
2125                        let second = match self.advance() {
2126                            Token::Ident(s) | Token::QuotedIdent(s) => s,
2127                            other => {
2128                                return Err(self.err(alloc::format!(
2129                                    "expected column name after OWNED BY {first}., got {other:?}"
2130                                )));
2131                            }
2132                        };
2133                        // v7.17 dump-compat fix — pg_dump emits
2134                        // OWNED BY clauses as
2135                        // `schema.table.column` (three segments).
2136                        // If a third `.<ident>` follows, treat the
2137                        // first ident as schema (drop it; SPG is
2138                        // single-schema) and the middle / last
2139                        // pair as table.column. Otherwise it's
2140                        // the two-segment form table.column.
2141                        if matches!(self.peek(), Token::Dot) {
2142                            self.advance();
2143                            let third = match self.advance() {
2144                                Token::Ident(s) | Token::QuotedIdent(s) => s,
2145                                other => {
2146                                    return Err(self.err(alloc::format!(
2147                                        "expected column name after OWNED BY {first}.{second}., got {other:?}"
2148                                    )));
2149                                }
2150                            };
2151                            let _ = first; // schema prefix discarded
2152                            opts.owned_by = Some(SequenceOwnedBy::Column {
2153                                table: second,
2154                                column: third,
2155                            });
2156                        } else {
2157                            opts.owned_by = Some(SequenceOwnedBy::Column {
2158                                table: first,
2159                                column: second,
2160                            });
2161                        }
2162                    } else {
2163                        return Err(self.err(alloc::format!(
2164                            "expected table.column or NONE after OWNED BY, got {first:?}"
2165                        )));
2166                    }
2167                }
2168                _ => break,
2169            }
2170        }
2171        Ok(opts)
2172    }
2173
2174    fn expect_signed_int(&mut self) -> Result<i64, ParseError> {
2175        let neg = if matches!(self.peek(), Token::Minus) {
2176            self.advance();
2177            true
2178        } else {
2179            false
2180        };
2181        match self.peek() {
2182            Token::Integer(n) => {
2183                let v = *n;
2184                self.advance();
2185                Ok(if neg { -v } else { v })
2186            }
2187            other => Err(self.err(alloc::format!("expected signed integer, got {other:?}"))),
2188        }
2189    }
2190
2191    /// v7.17.0 Phase 3.1 — absorb `[NOT] DEFERRABLE [INITIALLY
2192    /// {DEFERRED | IMMEDIATE}]` constraint-timing clauses. Each
2193    /// clause is fully accepted and discarded — SPG always runs
2194    /// constraint checks immediately (single-writer model). The
2195    /// loop allows DEFERRABLE and the INITIALLY suffix to appear
2196    /// in either order (per the SQL spec they're independent),
2197    /// though pg_dump always emits them in the canonical
2198    /// `[NOT] DEFERRABLE INITIALLY {DEFERRED|IMMEDIATE}` shape.
2199    /// Stops at the first token that isn't part of the clause.
2200    fn consume_optional_deferrable_clauses(&mut self) -> Result<(), ParseError> {
2201        loop {
2202            // Bare `DEFERRABLE` (Phase 3.1 — was hard-error pre-3.1).
2203            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable")) {
2204                self.advance();
2205                self.consume_optional_initially_clause()?;
2206                continue;
2207            }
2208            // `NOT DEFERRABLE` — already worked pre-3.1.
2209            if matches!(self.peek(), Token::Not) {
2210                let look = self.tokens.get(self.pos + 1);
2211                if matches!(look, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("deferrable")) {
2212                    self.advance(); // NOT
2213                    self.advance(); // DEFERRABLE
2214                    self.consume_optional_initially_clause()?;
2215                    continue;
2216                }
2217                break;
2218            }
2219            // Standalone `INITIALLY {DEFERRED|IMMEDIATE}` — PG
2220            // accepts this without a leading [NOT] DEFERRABLE
2221            // (the timing keyword alone). pg_dump occasionally
2222            // emits it on FK constraints that inherit timing.
2223            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2224                self.consume_optional_initially_clause()?;
2225                continue;
2226            }
2227            break;
2228        }
2229        Ok(())
2230    }
2231
2232    /// Helper for [`consume_optional_deferrable_clauses`]. When the
2233    /// next token is `INITIALLY`, consume it plus the required
2234    /// `DEFERRED` | `IMMEDIATE` trailer. No-op otherwise.
2235    fn consume_optional_initially_clause(&mut self) -> Result<(), ParseError> {
2236        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2237            return Ok(());
2238        }
2239        self.advance(); // INITIALLY
2240        match self.advance() {
2241            Token::Ident(s)
2242                if s.eq_ignore_ascii_case("deferred") || s.eq_ignore_ascii_case("immediate") =>
2243            {
2244                Ok(())
2245            }
2246            other => Err(self.err(alloc::format!(
2247                "expected DEFERRED or IMMEDIATE after INITIALLY, got {other:?}"
2248            ))),
2249        }
2250    }
2251
2252    /// v7.17.0 Phase 4.2 — consume a MySQL `CREATE PROCEDURE` body
2253    /// in its entirety so the parser returns Empty without
2254    /// touching the runtime. The CREATE+PROCEDURE keywords are
2255    /// already consumed; this swallows everything from the
2256    /// procedure name through the matching `END`, including
2257    /// nested `BEGIN`/`END` blocks, internal `;` terminators
2258    /// (DELIMITER `//` makes the script splitter forward the
2259    /// whole block as one statement), `@var` session-variable
2260    /// references, and the trailing terminator.
2261    ///
2262    /// Tracks nesting depth so:
2263    ///   BEGIN
2264    ///     IF cond THEN
2265    ///       BEGIN ... END;
2266    ///     END IF;
2267    ///   END
2268    /// terminates at the outer END.
2269    fn consume_mysql_routine_body(&mut self) {
2270        // Outer skeleton: name, (...), optional clauses, BEGIN
2271        // <body> END [;]. Scan for the first BEGIN — anything
2272        // before it is signature decoration we don't care about.
2273        // Once inside BEGIN, count up on BEGIN, down on END.
2274        let mut depth: i32 = 0;
2275        let mut started = false;
2276        loop {
2277            match self.peek().clone() {
2278                Token::Begin => {
2279                    self.advance();
2280                    depth += 1;
2281                    started = true;
2282                }
2283                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2284                    self.advance();
2285                    if started {
2286                        depth -= 1;
2287                        if depth <= 0 {
2288                            // Optional trailing ident (`END IF`,
2289                            // `END LOOP`, `END WHILE`, `END CASE`,
2290                            // `END label_name`) — eat the next
2291                            // ident if present so we don't
2292                            // mistake `END IF;` for the outer
2293                            // close.
2294                            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2295                                // If the next token is one of the
2296                                // PL/SQL block-closer keywords,
2297                                // the END belongs to an inner
2298                                // block; bump depth back up.
2299                                let is_inner_close = matches!(
2300                                    self.peek(),
2301                                    Token::Ident(s) | Token::QuotedIdent(s)
2302                                        if matches!(
2303                                            s.to_ascii_lowercase().as_str(),
2304                                            "if" | "loop" | "while" | "case" | "repeat"
2305                                        )
2306                                );
2307                                if is_inner_close {
2308                                    self.advance();
2309                                    depth += 1;
2310                                    continue;
2311                                }
2312                            }
2313                            // Eat optional trailing `;`.
2314                            if matches!(self.peek(), Token::Semicolon) {
2315                                self.advance();
2316                            }
2317                            return;
2318                        }
2319                    }
2320                }
2321                Token::Eof => return,
2322                _ => {
2323                    self.advance();
2324                }
2325            }
2326        }
2327    }
2328
2329    /// v7.17.0 Phase 2.6 — absorb the MySQL view-prefix clauses
2330    /// that appear between `CREATE` and `VIEW` in mysqldump output:
2331    ///
2332    /// * `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}`
2333    /// * `DEFINER = <user>`  (user may be a quoted string, a bare
2334    ///   ident, or `ident @ ident-or-quoted-string` host form)
2335    /// * `SQL SECURITY {DEFINER|INVOKER}`
2336    ///
2337    /// Each clause may appear at most once but in any order.
2338    /// The hints are pure planner / permission metadata that
2339    /// SPG's view-rewrite engine handles uniformly; we accept
2340    /// and discard. Returns `Ok(())` once a non-clause token is
2341    /// peeked (the caller then checks for the `VIEW` keyword).
2342    fn consume_mysql_view_prefix(&mut self) -> Result<(), ParseError> {
2343        loop {
2344            match self.peek().clone() {
2345                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("algorithm") => {
2346                    self.advance(); // ALGORITHM
2347                    // Optional `=`. MySQL spec requires it but be
2348                    // generous.
2349                    if matches!(self.peek(), Token::Eq) {
2350                        self.advance();
2351                    }
2352                    // UNDEFINED / MERGE / TEMPTABLE — accept any
2353                    // bare ident; unknown values still parse so
2354                    // future MySQL versions don't break.
2355                    if matches!(
2356                        self.peek(),
2357                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2358                    ) {
2359                        self.advance();
2360                    }
2361                }
2362                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("definer") => {
2363                    self.advance(); // DEFINER
2364                    if matches!(self.peek(), Token::Eq) {
2365                        self.advance();
2366                    }
2367                    // User: quoted string, ident, OR ident @ host
2368                    // (host may itself be quoted or bare).
2369                    match self.peek().clone() {
2370                        Token::String(_) | Token::Ident(_) | Token::QuotedIdent(_) => {
2371                            self.advance();
2372                            // Optional `@host`.
2373                            if matches!(self.peek(), Token::At) {
2374                                self.advance();
2375                                if matches!(
2376                                    self.peek(),
2377                                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2378                                ) {
2379                                    self.advance();
2380                                }
2381                            }
2382                        }
2383                        _ => {}
2384                    }
2385                }
2386                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sql") => {
2387                    // `SQL SECURITY {DEFINER|INVOKER}`. Only honoured
2388                    // when followed by SECURITY — the dispatcher must
2389                    // not consume a bare `SQL` token (it's not a
2390                    // legal CREATE prefix on its own).
2391                    let save = self.pos;
2392                    self.advance(); // SQL
2393                    if matches!(self.peek(), Token::Ident(s2) | Token::QuotedIdent(s2)
2394                        if s2.eq_ignore_ascii_case("security"))
2395                    {
2396                        self.advance(); // SECURITY
2397                        // DEFINER / INVOKER trailing ident.
2398                        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2399                            self.advance();
2400                        }
2401                    } else {
2402                        // Not a SQL SECURITY clause — roll back and
2403                        // bail; the caller will error out cleanly.
2404                        self.pos = save;
2405                        return Ok(());
2406                    }
2407                }
2408                _ => return Ok(()),
2409            }
2410        }
2411    }
2412
2413    fn parse_if_not_exists(&mut self) -> bool {
2414        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2415        {
2416            let save = self.pos;
2417            self.advance();
2418            if matches!(self.peek(), Token::Not) {
2419                self.advance();
2420                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2421                {
2422                    self.advance();
2423                    return true;
2424                }
2425            }
2426            self.pos = save;
2427        }
2428        false
2429    }
2430
2431    fn parse_if_exists(&mut self) -> bool {
2432        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2433        {
2434            let save = self.pos;
2435            self.advance();
2436            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2437            {
2438                self.advance();
2439                return true;
2440            }
2441            self.pos = save;
2442        }
2443        false
2444    }
2445
2446    /// v7.12.4 — body of `CREATE [OR REPLACE] TRIGGER`. The
2447    /// `[OR REPLACE]` flag and the `TRIGGER` keyword have already
2448    /// been consumed.
2449    fn parse_create_trigger_after_keyword(
2450        &mut self,
2451        or_replace: bool,
2452    ) -> Result<Statement, ParseError> {
2453        let name = self.expect_ident_like()?;
2454        let timing = {
2455            let ident = self.expect_ident_like()?;
2456            if ident.eq_ignore_ascii_case("before") {
2457                TriggerTiming::Before
2458            } else if ident.eq_ignore_ascii_case("after") {
2459                TriggerTiming::After
2460            } else if ident.eq_ignore_ascii_case("instead") {
2461                let next = self.expect_ident_like()?;
2462                if !next.eq_ignore_ascii_case("of") {
2463                    return Err(self.err(alloc::format!(
2464                        "expected OF after INSTEAD in trigger timing, got {next:?}"
2465                    )));
2466                }
2467                TriggerTiming::InsteadOf
2468            } else {
2469                return Err(self.err(alloc::format!(
2470                    "expected BEFORE / AFTER / INSTEAD OF in trigger timing, got {ident:?}"
2471                )));
2472            }
2473        };
2474        // Events: INSERT [ OR UPDATE [ OR DELETE [ OR TRUNCATE ] ] ].
2475        // OR is a reserved keyword token (Token::Or), not an Ident.
2476        // v7.13.0 — after an UPDATE event we may optionally see
2477        // `OF col, col, …` (mailrs round-5 G7). Columns are
2478        // captured into `update_columns` once across the whole
2479        // events list; multiple `UPDATE OF` clauses are rejected.
2480        let mut events: Vec<TriggerEvent> = Vec::new();
2481        let mut update_columns: Vec<String> = Vec::new();
2482        let (first_ev, first_cols) = self.parse_trigger_event_with_optional_of()?;
2483        events.push(first_ev);
2484        if !first_cols.is_empty() {
2485            update_columns = first_cols;
2486        }
2487        while matches!(self.peek(), Token::Or) {
2488            self.advance();
2489            let (ev, cols) = self.parse_trigger_event_with_optional_of()?;
2490            events.push(ev);
2491            if !cols.is_empty() {
2492                if !update_columns.is_empty() {
2493                    return Err(
2494                        self.err("CREATE TRIGGER: `UPDATE OF cols` may appear at most once".into())
2495                    );
2496                }
2497                update_columns = cols;
2498            }
2499        }
2500        // ON <table>
2501        let tok = self.peek();
2502        let Token::On = tok else {
2503            return Err(self.err(alloc::format!(
2504                "expected ON after trigger events, got {tok:?}"
2505            )));
2506        };
2507        self.advance();
2508        let table = self.expect_ident_like()?;
2509        // FOR EACH ROW / FOR EACH STATEMENT. FOR is a reserved
2510        // keyword (Token::For); EACH / ROW / STATEMENT are bare
2511        // idents.
2512        if !matches!(self.peek(), Token::For) {
2513            return Err(self.err(alloc::format!(
2514                "expected FOR EACH ROW / STATEMENT, got {:?}",
2515                self.peek()
2516            )));
2517        }
2518        self.advance();
2519        let for_each = {
2520            let e = self.expect_ident_like()?;
2521            if !e.eq_ignore_ascii_case("each") {
2522                return Err(self.err(alloc::format!("expected EACH after FOR, got {e:?}")));
2523            }
2524            let unit = self.expect_ident_like()?;
2525            if unit.eq_ignore_ascii_case("row") {
2526                TriggerForEach::Row
2527            } else if unit.eq_ignore_ascii_case("statement") {
2528                TriggerForEach::Statement
2529            } else {
2530                return Err(self.err(alloc::format!(
2531                    "expected ROW / STATEMENT after FOR EACH, got {unit:?}"
2532                )));
2533            }
2534        };
2535        // EXECUTE FUNCTION/PROCEDURE name(...)
2536        let exec = self.expect_ident_like()?;
2537        if !exec.eq_ignore_ascii_case("execute") {
2538            return Err(self.err(alloc::format!(
2539                "expected EXECUTE FUNCTION/PROCEDURE in CREATE TRIGGER, got {exec:?}"
2540            )));
2541        }
2542        let fn_or_proc = self.expect_ident_like()?;
2543        if !(fn_or_proc.eq_ignore_ascii_case("function")
2544            || fn_or_proc.eq_ignore_ascii_case("procedure"))
2545        {
2546            return Err(self.err(alloc::format!(
2547                "expected FUNCTION / PROCEDURE after EXECUTE, got {fn_or_proc:?}"
2548            )));
2549        }
2550        let function = self.expect_ident_like()?;
2551        // Optional empty arg list `()`.
2552        if matches!(self.peek(), Token::LParen) {
2553            self.advance();
2554            if !matches!(self.peek(), Token::RParen) {
2555                return Err(self.err(alloc::format!(
2556                    "v7.12.4 trigger function calls take no args; got {:?}",
2557                    self.peek()
2558                )));
2559            }
2560            self.advance();
2561        }
2562        Ok(Statement::CreateTrigger(CreateTriggerStatement {
2563            name,
2564            or_replace,
2565            timing,
2566            events,
2567            table,
2568            for_each,
2569            function,
2570            update_columns,
2571        }))
2572    }
2573
2574    /// v7.13.0 — parse one trigger event, then optionally consume
2575    /// `OF col, col, …` after `UPDATE` (mailrs round-5 G7). Other
2576    /// events (INSERT/DELETE/TRUNCATE) don't accept the OF tail.
2577    fn parse_trigger_event_with_optional_of(
2578        &mut self,
2579    ) -> Result<(TriggerEvent, Vec<String>), ParseError> {
2580        let ev = self.parse_trigger_event()?;
2581        if !matches!(ev, TriggerEvent::Update) {
2582            return Ok((ev, Vec::new()));
2583        }
2584        // `OF` is a bare ident.
2585        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("of")) {
2586            return Ok((ev, Vec::new()));
2587        }
2588        self.advance(); // OF
2589        let mut cols: Vec<String> = Vec::new();
2590        loop {
2591            cols.push(self.expect_ident_like()?);
2592            if matches!(self.peek(), Token::Comma) {
2593                self.advance();
2594                continue;
2595            }
2596            break;
2597        }
2598        if cols.is_empty() {
2599            return Err(
2600                self.err("CREATE TRIGGER: `UPDATE OF` requires at least one column name".into())
2601            );
2602        }
2603        Ok((ev, cols))
2604    }
2605
2606    /// v7.12.4 — `BEGIN stmt; stmt; … END[;]` PL/pgSQL block.
2607    /// v7.12.6 — optional `DECLARE var TYPE [:= init];` prelude
2608    /// before `BEGIN`, and IF / RAISE / embedded SQL statements
2609    /// inside the body.
2610    /// Called by [`parse_plpgsql_body`] after the body's tokens
2611    /// have been lexed into this temporary parser.
2612    pub(crate) fn parse_plpgsql_block(&mut self) -> Result<PlPgSqlBlock, ParseError> {
2613        // v7.12.6 — optional DECLARE prelude.
2614        let declarations = if matches!(
2615            self.peek(),
2616            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("declare")
2617        ) {
2618            self.advance();
2619            self.parse_plpgsql_declare_block()?
2620        } else {
2621            Vec::new()
2622        };
2623        // BEGIN keyword (PL/pgSQL — distinct from the SQL
2624        // `BEGIN` transaction-start, but we can reuse the
2625        // reserved Token::Begin since the body is a separate
2626        // lex/parse context).
2627        if !matches!(self.peek(), Token::Begin) {
2628            return Err(self.err(alloc::format!(
2629                "expected BEGIN at start of plpgsql block, got {:?}",
2630                self.peek()
2631            )));
2632        }
2633        self.advance();
2634        let statements = self.parse_plpgsql_stmt_list_until_end()?;
2635        Ok(PlPgSqlBlock {
2636            declarations,
2637            statements,
2638        })
2639    }
2640
2641    /// v7.12.6 — parse the `DECLARE ... [var TYPE [:= init];]+`
2642    /// prelude. Caller has already consumed `DECLARE`. We stop
2643    /// reading entries when we hit `BEGIN`.
2644    fn parse_plpgsql_declare_block(&mut self) -> Result<Vec<PlPgSqlDeclare>, ParseError> {
2645        let mut out: Vec<PlPgSqlDeclare> = Vec::new();
2646        loop {
2647            if matches!(self.peek(), Token::Begin) {
2648                return Ok(out);
2649            }
2650            let name = self.expect_ident_like()?;
2651            let ty_token = self.expect_ident_like()?;
2652            let ty = match map_type_ident_to_column_type_name(&ty_token) {
2653                Some(t) => FunctionArgType::Typed(t),
2654                None => FunctionArgType::Raw(ty_token),
2655            };
2656            let default = match self.peek() {
2657                Token::ColonEq => {
2658                    self.advance();
2659                    Some(self.parse_expr(0)?)
2660                }
2661                Token::Eq => {
2662                    // PL/pgSQL also accepts `=` for the
2663                    // DECLARE default (PG treats them the same
2664                    // in this position).
2665                    self.advance();
2666                    Some(self.parse_expr(0)?)
2667                }
2668                _ => None,
2669            };
2670            // Mandatory `;` between declarations.
2671            if !matches!(self.peek(), Token::Semicolon) {
2672                return Err(self.err(alloc::format!(
2673                    "expected ; after DECLARE entry for {name:?}, got {:?}",
2674                    self.peek()
2675                )));
2676            }
2677            self.advance();
2678            out.push(PlPgSqlDeclare { name, ty, default });
2679        }
2680    }
2681
2682    /// v7.12.6 — parse PL/pgSQL statements up to (and consuming)
2683    /// the terminating `END;` (or `END IF;` etc — handled by the
2684    /// per-construct sub-parsers). Used by both the outer block
2685    /// and the IF/ELSE branch bodies.
2686    fn parse_plpgsql_stmt_list_until_end(&mut self) -> Result<Vec<PlPgSqlStmt>, ParseError> {
2687        let mut statements: Vec<PlPgSqlStmt> = Vec::new();
2688        loop {
2689            // Allow trailing semicolons + END.
2690            while matches!(self.peek(), Token::Semicolon) {
2691                self.advance();
2692            }
2693            // END / ELSE / ELSIF — handled by the caller.
2694            if matches!(
2695                self.peek(),
2696                Token::Ident(s) | Token::QuotedIdent(s)
2697                    if s.eq_ignore_ascii_case("end")
2698                        || s.eq_ignore_ascii_case("else")
2699                        || s.eq_ignore_ascii_case("elsif")
2700                        || s.eq_ignore_ascii_case("elseif")
2701            ) {
2702                return Ok(statements);
2703            }
2704            // Otherwise: one statement, then expect `;` or
2705            // a block-terminator keyword.
2706            let stmt = self.parse_plpgsql_stmt()?;
2707            statements.push(stmt);
2708            match self.peek() {
2709                Token::Semicolon => {
2710                    self.advance();
2711                }
2712                Token::Ident(s) | Token::QuotedIdent(s)
2713                    if s.eq_ignore_ascii_case("end")
2714                        || s.eq_ignore_ascii_case("else")
2715                        || s.eq_ignore_ascii_case("elsif")
2716                        || s.eq_ignore_ascii_case("elseif") =>
2717                {
2718                    // Final statement of the block without `;`.
2719                }
2720                other => {
2721                    return Err(self.err(alloc::format!(
2722                        "expected ; or END/ELSE/ELSIF after plpgsql statement, got {other:?}"
2723                    )));
2724                }
2725            }
2726        }
2727    }
2728
2729    fn parse_plpgsql_stmt(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2730        // RETURN keyword?
2731        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("return"))
2732        {
2733            self.advance();
2734            return self.parse_plpgsql_return();
2735        }
2736        // v7.12.6 — IF block.
2737        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2738        {
2739            self.advance();
2740            return self.parse_plpgsql_if();
2741        }
2742        // v7.12.6 — RAISE.
2743        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("raise"))
2744        {
2745            self.advance();
2746            return self.parse_plpgsql_raise();
2747        }
2748        // v7.16.2 — `SELECT <projection> INTO <var> [FROM …]`
2749        // plpgsql-specific shape (mailrs round-10 migrate-042).
2750        // PG's SELECT INTO at top-level SQL would CREATE a new
2751        // table; inside plpgsql it ASSIGNS the query result to
2752        // a local variable. We detect the INTO at paren-depth
2753        // 0 between SELECT and the statement boundary; if
2754        // found, split the token stream into "pre-INTO
2755        // projection" + "var" + "post-INTO FROM/WHERE…" and
2756        // rebuild as a SelectInto with a regular SELECT body
2757        // (no INTO clause).
2758        if matches!(self.peek(), Token::Select)
2759            && let Some((select_body, var_name)) = self.try_parse_plpgsql_select_into()?
2760        {
2761            return Ok(PlPgSqlStmt::SelectInto {
2762                var: var_name,
2763                body: Box::new(select_body),
2764            });
2765        }
2766        // v7.12.6 — embedded SQL statements. INSERT/UPDATE/DELETE/
2767        // SELECT can appear directly inside a trigger body; we
2768        // recurse into the regular Statement parser, which will
2769        // stop at the trailing `;` (which our caller then
2770        // consumes).
2771        // v7.16.2 — top-level DO blocks (mailrs round-10 A.2)
2772        // also embed ALTER / CREATE / DROP statements; route
2773        // those through the same parser so the DO body parses
2774        // cleanly.
2775        if matches!(self.peek(), Token::Insert)
2776            || matches!(self.peek(), Token::Select)
2777            || matches!(self.peek(), Token::Create)
2778            || matches!(self.peek(), Token::Drop)
2779            || matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2780                if s.eq_ignore_ascii_case("update")
2781                    || s.eq_ignore_ascii_case("delete")
2782                    || s.eq_ignore_ascii_case("alter"))
2783        {
2784            let stmt = self.parse_one_statement()?;
2785            return Ok(PlPgSqlStmt::EmbeddedSql(Box::new(stmt)));
2786        }
2787        // Otherwise: assignment. `NEW.col` / `OLD.col` / `var`
2788        // followed by `:=` and an expression.
2789        let target = self.parse_plpgsql_assign_target()?;
2790        // PL/pgSQL assignment uses `:=`. The lexer represents
2791        // this as a colon followed by `=`; check both shapes.
2792        match self.peek() {
2793            Token::ColonEq => {
2794                self.advance();
2795            }
2796            Token::Colon => {
2797                self.advance();
2798                if !matches!(self.peek(), Token::Eq) {
2799                    return Err(self.err(alloc::format!(
2800                        "expected := after plpgsql assign target, got `:` then {:?}",
2801                        self.peek()
2802                    )));
2803                }
2804                self.advance();
2805            }
2806            other => {
2807                return Err(self.err(alloc::format!(
2808                    "expected := after plpgsql assign target, got {other:?}"
2809                )));
2810            }
2811        }
2812        let value = self.parse_expr(0)?;
2813        Ok(PlPgSqlStmt::Assign { target, value })
2814    }
2815
2816    /// v7.12.6 — `IF cond THEN body [ELSIF cond THEN body]*
2817    /// [ELSE body] END IF`. `IF` keyword already consumed.
2818    fn parse_plpgsql_if(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2819        let mut branches: Vec<(Expr, Vec<PlPgSqlStmt>)> = Vec::new();
2820        let mut else_branch: Vec<PlPgSqlStmt> = Vec::new();
2821        loop {
2822            // <expr> THEN
2823            let cond = self.parse_expr(0)?;
2824            let then_kw = self.expect_ident_like()?;
2825            if !then_kw.eq_ignore_ascii_case("then") {
2826                return Err(self.err(alloc::format!(
2827                    "expected THEN after IF/ELSIF condition, got {then_kw:?}"
2828                )));
2829            }
2830            let body = self.parse_plpgsql_stmt_list_until_end()?;
2831            branches.push((cond, body));
2832            // Look at terminator: ELSIF/ELSEIF, ELSE, or END IF.
2833            match self.peek() {
2834                Token::Ident(s) | Token::QuotedIdent(s)
2835                    if s.eq_ignore_ascii_case("elsif") || s.eq_ignore_ascii_case("elseif") =>
2836                {
2837                    self.advance();
2838                    continue;
2839                }
2840                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("else") => {
2841                    self.advance();
2842                    else_branch = self.parse_plpgsql_stmt_list_until_end()?;
2843                    break;
2844                }
2845                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2846                    break;
2847                }
2848                other => {
2849                    return Err(self.err(alloc::format!(
2850                        "expected ELSIF / ELSE / END after IF branch body, got {other:?}"
2851                    )));
2852                }
2853            }
2854        }
2855        // Expect `END IF` (the END keyword is the one we're
2856        // looking at right now).
2857        let end_kw = self.expect_ident_like()?;
2858        if !end_kw.eq_ignore_ascii_case("end") {
2859            return Err(self.err(alloc::format!("expected END IF, got {end_kw:?}")));
2860        }
2861        let if_kw = self.expect_ident_like()?;
2862        if !if_kw.eq_ignore_ascii_case("if") {
2863            return Err(self.err(alloc::format!("expected END IF, got END {if_kw:?}")));
2864        }
2865        Ok(PlPgSqlStmt::If {
2866            branches,
2867            else_branch,
2868        })
2869    }
2870
2871    /// v7.12.6 — `RAISE { NOTICE | WARNING | INFO | LOG | DEBUG
2872    /// | EXCEPTION } '<message>' [, args]*`. The `RAISE` keyword
2873    /// is already consumed.
2874    fn parse_plpgsql_raise(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2875        let lvl_ident = self.expect_ident_like()?;
2876        let level = match lvl_ident.to_ascii_lowercase().as_str() {
2877            "notice" => RaiseLevel::Notice,
2878            "warning" => RaiseLevel::Warning,
2879            "info" => RaiseLevel::Info,
2880            "log" => RaiseLevel::Log,
2881            "debug" => RaiseLevel::Debug,
2882            "exception" => RaiseLevel::Exception,
2883            other => {
2884                return Err(self.err(alloc::format!(
2885                    "expected RAISE level (NOTICE/WARNING/INFO/LOG/DEBUG/EXCEPTION), got {other:?}"
2886                )));
2887            }
2888        };
2889        // Message: required for v7.12.6. PG accepts a bare
2890        // RAISE-rethrow form (no message), reserved for future
2891        // RAISE-no-args support.
2892        let Token::String(msg) = self.peek() else {
2893            return Err(self.err(alloc::format!(
2894                "expected RAISE message string, got {:?}",
2895                self.peek()
2896            )));
2897        };
2898        let message = msg.clone();
2899        self.advance();
2900        // Optional comma-separated args (PG `%` format substitution).
2901        let mut args: Vec<Expr> = Vec::new();
2902        while matches!(self.peek(), Token::Comma) {
2903            self.advance();
2904            args.push(self.parse_expr(0)?);
2905        }
2906        Ok(PlPgSqlStmt::Raise {
2907            level,
2908            message,
2909            args,
2910        })
2911    }
2912
2913    /// v7.16.2 — scan ahead for a plpgsql-flavoured `SELECT
2914    /// <projection> INTO <var> [FROM …]` (mailrs round-10
2915    /// migrate-042). Returns `(rebuilt_select_without_into,
2916    /// var_name)` when the pattern matches; `None` for
2917    /// regular SELECTs (those go through the embedded-SQL
2918    /// path). Token-stream surgery so the rebuilt SELECT
2919    /// parses through the regular `parse_select_stmt`.
2920    #[allow(clippy::too_many_lines)]
2921    fn try_parse_plpgsql_select_into(
2922        &mut self,
2923    ) -> Result<Option<(SelectStatement, String)>, ParseError> {
2924        // Scan forward from `self.pos + 1` (past Token::Select)
2925        // for Token::Into at paren-depth 0, stopping at the
2926        // first `;`, `END`, `ELSE`, `ELSIF` keyword that would
2927        // end the plpgsql statement.
2928        let start = self.pos;
2929        let mut into_pos: Option<usize> = None;
2930        let mut depth: i32 = 0;
2931        let mut i = start + 1;
2932        while i < self.tokens.len() {
2933            match &self.tokens[i] {
2934                Token::LParen => depth += 1,
2935                Token::RParen => depth -= 1,
2936                Token::Semicolon if depth == 0 => break,
2937                Token::Ident(s)
2938                    if depth == 0
2939                        && (s.eq_ignore_ascii_case("end")
2940                            || s.eq_ignore_ascii_case("else")
2941                            || s.eq_ignore_ascii_case("elsif")) =>
2942                {
2943                    break;
2944                }
2945                Token::Into if depth == 0 => {
2946                    into_pos = Some(i);
2947                    break;
2948                }
2949                _ => {}
2950            }
2951            i += 1;
2952        }
2953        let Some(into_at) = into_pos else {
2954            return Ok(None);
2955        };
2956        // The token immediately after INTO must be the target
2957        // var ident; anything else (e.g. INSERT INTO table)
2958        // ruled out by the depth-0 check above. Capture it.
2959        let var = match self.tokens.get(into_at + 1) {
2960            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
2961            other => {
2962                return Err(self.err(alloc::format!(
2963                    "expected variable name after SELECT … INTO, got {other:?}"
2964                )));
2965            }
2966        };
2967        // Find the end of the plpgsql SELECT INTO statement —
2968        // same boundary rules as the depth-0 scan above.
2969        let mut end = into_at + 2;
2970        let mut depth2: i32 = 0;
2971        while end < self.tokens.len() {
2972            match &self.tokens[end] {
2973                Token::LParen => depth2 += 1,
2974                Token::RParen => depth2 -= 1,
2975                Token::Semicolon if depth2 == 0 => break,
2976                Token::Ident(s)
2977                    if depth2 == 0
2978                        && (s.eq_ignore_ascii_case("end")
2979                            || s.eq_ignore_ascii_case("else")
2980                            || s.eq_ignore_ascii_case("elsif")) =>
2981                {
2982                    break;
2983                }
2984                _ => {}
2985            }
2986            end += 1;
2987        }
2988        // Rebuild a token stream that represents the SELECT
2989        // WITHOUT the INTO clause: [SELECT .. up-to-INTO] + [
2990        // post-var tokens up to statement end]. Run the
2991        // regular `parse_select_stmt` against it.
2992        let mut rebuilt: Vec<Token> = Vec::with_capacity(end - start);
2993        for j in start..into_at {
2994            rebuilt.push(self.tokens[j].clone());
2995        }
2996        for j in (into_at + 2)..end {
2997            rebuilt.push(self.tokens[j].clone());
2998        }
2999        rebuilt.push(Token::Eof);
3000        let saved_pos = self.pos;
3001        let saved_tokens = core::mem::replace(&mut self.tokens, rebuilt);
3002        self.pos = 0;
3003        // parse_select_stmt → parse_bare_select consumes Token::Select itself.
3004        if !matches!(self.peek(), Token::Select) {
3005            self.tokens = saved_tokens;
3006            self.pos = saved_pos;
3007            return Err(self.err("plpgsql SELECT … INTO: rebuilt stream missing SELECT".into()));
3008        }
3009        let sel = self.parse_select_stmt();
3010        self.tokens = saved_tokens;
3011        self.pos = end;
3012        let sel = sel?;
3013        let Statement::Select(body) = sel else {
3014            return Err(self.err(alloc::format!(
3015                "plpgsql SELECT … INTO: rebuilt SELECT did not produce a Select node, got {sel:?}"
3016            )));
3017        };
3018        Ok(Some((body, var)))
3019    }
3020
3021    fn parse_plpgsql_assign_target(&mut self) -> Result<AssignTarget, ParseError> {
3022        // v7.16.1 — read the head token DIRECTLY rather than
3023        // via `expect_ident_like`. The v7.14.0 schema-qualifier
3024        // strip (`public.t` → `t`) inside `expect_ident_like`
3025        // greedily consumes any `ident . ident` pair, which
3026        // silently turned every `NEW.col := …` /
3027        // `OLD.col := …` plpgsql assignment into a Local("col")
3028        // assignment — the head "new"/"old" was eaten as if it
3029        // were a schema name and the Dot was consumed too, so
3030        // this function's own `peek() == Token::Dot` check
3031        // below never fired. Every BEFORE trigger that rewrote
3032        // a NEW cell was a silent no-op for two major releases
3033        // (v7.14.0 + v7.15.0) until the e2e_trigger workspace-
3034        // gate failures were investigated as v7.16.1 backlog.
3035        let head = match self.advance() {
3036            Token::Ident(s) | Token::QuotedIdent(s) => s,
3037            other => {
3038                return Err(self.err(alloc::format!(
3039                    "expected NEW / OLD / <local_var> as plpgsql assign target, got {other:?}"
3040                )));
3041            }
3042        };
3043        if matches!(self.peek(), Token::Dot) {
3044            self.advance();
3045            let col = self.expect_ident_like()?;
3046            if head.eq_ignore_ascii_case("new") {
3047                return Ok(AssignTarget::NewColumn(col));
3048            }
3049            if head.eq_ignore_ascii_case("old") {
3050                return Ok(AssignTarget::OldColumn(col));
3051            }
3052            return Err(self.err(alloc::format!(
3053                "plpgsql assign target must be NEW.<col> / OLD.<col> / <local_var>; \
3054                 got {head:?}.<col>"
3055            )));
3056        }
3057        Ok(AssignTarget::Local(head))
3058    }
3059
3060    fn parse_plpgsql_return(&mut self) -> Result<PlPgSqlStmt, ParseError> {
3061        // RETURN NEW / OLD / NULL — bare-ident forms.
3062        match self.peek() {
3063            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("new") => {
3064                self.advance();
3065                return Ok(PlPgSqlStmt::Return(ReturnTarget::New));
3066            }
3067            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("old") => {
3068                self.advance();
3069                return Ok(PlPgSqlStmt::Return(ReturnTarget::Old));
3070            }
3071            Token::Null => {
3072                self.advance();
3073                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3074            }
3075            // Bare `RETURN;` (no value) — treated as `RETURN NULL`
3076            // per PL/pgSQL convention.
3077            Token::Semicolon => {
3078                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3079            }
3080            _ => {}
3081        }
3082        // Fall through: parse a full expression.
3083        let e = self.parse_expr(0)?;
3084        Ok(PlPgSqlStmt::Return(ReturnTarget::Expr(e)))
3085    }
3086
3087    fn parse_trigger_event(&mut self) -> Result<TriggerEvent, ParseError> {
3088        // INSERT is a reserved Token; UPDATE / DELETE / TRUNCATE
3089        // are ident-shaped (the parser keys off case-insensitive
3090        // match — same shape used by the top-level Update / Delete
3091        // dispatchers at parse_one_statement).
3092        if matches!(self.peek(), Token::Insert) {
3093            self.advance();
3094            return Ok(TriggerEvent::Insert);
3095        }
3096        match self.peek() {
3097            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3098                self.advance();
3099                Ok(TriggerEvent::Update)
3100            }
3101            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3102                self.advance();
3103                Ok(TriggerEvent::Delete)
3104            }
3105            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("truncate") => {
3106                self.advance();
3107                Ok(TriggerEvent::Truncate)
3108            }
3109            other => Err(self.err(alloc::format!(
3110                "expected INSERT / UPDATE / DELETE / TRUNCATE in trigger event list, got {other:?}"
3111            ))),
3112        }
3113    }
3114
3115    /// v6.1.2 → v6.1.3 — `CREATE PUBLICATION <name>` body. Accepts:
3116    ///   - (no clause) → implicit `FOR ALL TABLES`
3117    ///   - `FOR ALL TABLES`
3118    ///   - `FOR ALL TABLES EXCEPT t1, t2, …` (v6.1.3)
3119    ///   - `FOR TABLE t1, t2, …` (v6.1.3) — `FOR TABLES …` also
3120    ///     accepted (PG accepts both forms in PG 19).
3121    fn parse_create_publication_after_keyword(&mut self) -> Result<Statement, ParseError> {
3122        let name = self.expect_ident_or_string()?;
3123        // Bare DDL maps to FOR ALL TABLES — matches the v6.1.2
3124        // shape so existing publications keep parsing identically.
3125        let scope = if matches!(self.peek(), Token::For) {
3126            self.advance();
3127            if matches!(self.peek(), Token::All) {
3128                self.advance();
3129                if !matches!(self.peek(), Token::Tables) {
3130                    return Err(self.err(format!(
3131                        "expected TABLES after FOR ALL, got {:?}",
3132                        self.peek()
3133                    )));
3134                }
3135                self.advance();
3136                if matches!(self.peek(), Token::Except) {
3137                    self.advance();
3138                    let tables = self.parse_publication_table_list()?;
3139                    PublicationScope::AllTablesExcept(tables)
3140                } else {
3141                    PublicationScope::AllTables
3142                }
3143            } else if matches!(self.peek(), Token::Table | Token::Tables) {
3144                // PG 19 accepts both `FOR TABLE …` (singular) and
3145                // `FOR TABLES …` (plural); SPG matches.
3146                self.advance();
3147                let tables = self.parse_publication_table_list()?;
3148                PublicationScope::ForTables(tables)
3149            } else {
3150                return Err(self.err(format!(
3151                    "expected ALL TABLES or TABLE <list> after FOR, got {:?}",
3152                    self.peek()
3153                )));
3154            }
3155        } else {
3156            PublicationScope::AllTables
3157        };
3158        Ok(Statement::CreatePublication(CreatePublicationStatement {
3159            name,
3160            scope,
3161        }))
3162    }
3163
3164    /// v6.1.3 — Comma-separated identifier list for the publication
3165    /// FOR-clause. Requires at least one entry; empty list is a
3166    /// parse error (PG behaviour). Quoted idents are accepted; the
3167    /// names round-trip through `Display` as `quote_ident(name)`.
3168    fn parse_publication_table_list(&mut self) -> Result<Vec<String>, ParseError> {
3169        let first = self.expect_ident_like()?;
3170        let mut out = alloc::vec![first];
3171        while matches!(self.peek(), Token::Comma) {
3172            self.advance();
3173            out.push(self.expect_ident_like()?);
3174        }
3175        Ok(out)
3176    }
3177
3178    /// v6.1.4 — `CREATE SUBSCRIPTION <name>
3179    ///                 CONNECTION '<conn>'
3180    ///                 PUBLICATION <pub> [, <pub> ...]`.
3181    ///
3182    /// The clause order is fixed (CONNECTION first, then
3183    /// PUBLICATION) to match PG. No WITH-options accepted in
3184    /// v6.1.4 — `enabled` defaults to true, no other knobs ship.
3185    fn parse_create_subscription_after_keyword(&mut self) -> Result<Statement, ParseError> {
3186        let name = self.expect_ident_or_string()?;
3187        if !matches!(self.peek(), Token::Connection) {
3188            return Err(self.err(format!(
3189                "expected CONNECTION after CREATE SUBSCRIPTION <name>, got {:?}",
3190                self.peek()
3191            )));
3192        }
3193        self.advance();
3194        let conn_str = self.expect_string_literal()?;
3195        if !matches!(self.peek(), Token::Publication) {
3196            return Err(self.err(format!(
3197                "expected PUBLICATION after CONNECTION '<conn>', got {:?}",
3198                self.peek()
3199            )));
3200        }
3201        self.advance();
3202        // Reuse the publication FOR-list parser shape: at least one
3203        // identifier, comma-separated.
3204        let first = self.expect_ident_like()?;
3205        let mut publications = alloc::vec![first];
3206        while matches!(self.peek(), Token::Comma) {
3207            self.advance();
3208            publications.push(self.expect_ident_like()?);
3209        }
3210        Ok(Statement::CreateSubscription(CreateSubscriptionStatement {
3211            name,
3212            conn_str,
3213            publications,
3214        }))
3215    }
3216
3217    /// v6.1.7 — `WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>]`.
3218    /// All keywords after `WAIT` are bare idents in v6.1.x; no
3219    /// lexer churn. Both `<pos>` and `<ms>` are positive integers
3220    /// that fit `u64`.
3221    /// v7.12.1 — parameter name in `SET <name>` may be dotted
3222    /// (`pg_catalog.default_text_search_config` etc).
3223    fn parse_set_param_name(&mut self) -> Result<String, ParseError> {
3224        let mut name = self.expect_ident_like()?;
3225        while matches!(self.peek(), Token::Dot) {
3226            self.advance();
3227            let next = self.expect_ident_like()?;
3228            name.push('.');
3229            name.push_str(&next);
3230        }
3231        Ok(name.to_ascii_lowercase())
3232    }
3233
3234    fn parse_set_value(&mut self) -> Result<crate::ast::SetValue, ParseError> {
3235        match self.advance() {
3236            Token::String(s) => Ok(crate::ast::SetValue::String(s)),
3237            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("default") => {
3238                Ok(crate::ast::SetValue::Default)
3239            }
3240            Token::Ident(s) | Token::QuotedIdent(s) => {
3241                let mut accum = s;
3242                while matches!(self.peek(), Token::Dot) {
3243                    self.advance();
3244                    let next = self.expect_ident_like()?;
3245                    accum.push('.');
3246                    accum.push_str(&next);
3247                }
3248                Ok(crate::ast::SetValue::Ident(accum))
3249            }
3250            Token::Integer(n) => Ok(crate::ast::SetValue::Number(n.to_string())),
3251            Token::Float(f) => Ok(crate::ast::SetValue::Number(f.to_string())),
3252            // v7.22 (mailrs round-13 gap 2) — PG boolean parameter
3253            // spellings that lex as keyword tokens, not idents:
3254            // `SET standard_conforming_strings = on` is in every
3255            // pg_dump preamble (`off` already lexes as an ident).
3256            Token::On => Ok(crate::ast::SetValue::Ident("on".to_string())),
3257            Token::True => Ok(crate::ast::SetValue::Ident("true".to_string())),
3258            Token::False => Ok(crate::ast::SetValue::Ident("false".to_string())),
3259            // v7.14.0 — MySQL session/user variable RHS
3260            // (e.g. `SET OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS`).
3261            // Wrap as Ident so the SET handler can record it; the
3262            // engine treats `@VAR` / `@@VAR` values as opaque
3263            // strings.
3264            Token::SessionVar(s) => Ok(crate::ast::SetValue::Ident(s)),
3265            // v7.14.0 — `SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO,STRICT_TRANS_TABLES'`
3266            // is the common MySQL preamble shape. Allow a `+` or
3267            // `-` prefix on negative numerics for parity with PG
3268            // (some param defaults are negative).
3269            Token::Minus => match self.advance() {
3270                Token::Integer(n) => Ok(crate::ast::SetValue::Number(alloc::format!("-{n}"))),
3271                Token::Float(f) => Ok(crate::ast::SetValue::Number(alloc::format!("-{f}"))),
3272                other => Err(self.err(format!(
3273                    "expected numeric after `-` in SET value, got {other:?}"
3274                ))),
3275            },
3276            other => Err(self.err(format!(
3277                "expected literal, identifier, or DEFAULT after `=` in SET, got {other:?}"
3278            ))),
3279        }
3280    }
3281
3282    fn parse_wait_after_keyword(&mut self) -> Result<Statement, ParseError> {
3283        // FOR is a v6.1.2-reserved keyword (Token::For). The
3284        // other two are bare idents — they've never needed lexer
3285        // support and we keep it that way.
3286        if !matches!(self.peek(), Token::For) {
3287            return Err(self.err(format!("expected FOR after WAIT, got {:?}", self.peek())));
3288        }
3289        self.advance();
3290        self.expect_keyword_ident("wal")?;
3291        self.expect_keyword_ident("position")?;
3292        let pos = self.expect_u64_literal()?;
3293        let timeout_ms = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with"))
3294        {
3295            self.advance();
3296            self.expect_keyword_ident("timeout")?;
3297            Some(self.expect_u64_literal()?)
3298        } else {
3299            None
3300        };
3301        Ok(Statement::WaitForWalPosition { pos, timeout_ms })
3302    }
3303
3304    /// v6.1.7 helper — consume a `Token::Integer` and check it
3305    /// fits `u64`. WAL positions and millisecond timeouts are
3306    /// non-negative.
3307    fn expect_u64_literal(&mut self) -> Result<u64, ParseError> {
3308        match self.advance() {
3309            Token::Integer(n) if n >= 0 => Ok(n as u64),
3310            Token::Integer(n) => Err(ParseError {
3311                message: format!("expected non-negative integer, got {n}"),
3312                token_pos: self.pos.saturating_sub(1),
3313            }),
3314            other => Err(ParseError {
3315                message: format!("expected integer literal, got {other:?}"),
3316                token_pos: self.pos.saturating_sub(1),
3317            }),
3318        }
3319    }
3320
3321    /// `CREATE USER` body — name + WITH PASSWORD '<pw>' + optional
3322    /// ROLE '<role>' (defaults to readonly). All string slots accept
3323    /// either a quoted ident or a quoted string literal.
3324    fn parse_create_user_after_keyword(&mut self) -> Result<Statement, ParseError> {
3325        let name = self.expect_ident_or_string()?;
3326        self.expect_keyword_ident("with")?;
3327        self.expect_keyword_ident("password")?;
3328        let password = self.expect_string_literal()?;
3329        let role = if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
3330            && s.eq_ignore_ascii_case("role")
3331        {
3332            self.advance();
3333            self.expect_string_literal()?
3334        } else {
3335            "readonly".to_string()
3336        };
3337        Ok(Statement::CreateUser(crate::ast::CreateUserStatement {
3338            name,
3339            password,
3340            role,
3341        }))
3342    }
3343
3344    /// v4.4 `UPDATE <table> SET col = expr [, col = expr]* [WHERE cond]`.
3345    /// Caller already consumed the leading `UPDATE` ident.
3346    fn parse_update_after_keyword(&mut self) -> Result<Statement, ParseError> {
3347        let table = self.expect_ident_like()?;
3348        self.expect_keyword_ident("set")?;
3349        let mut assignments = Vec::new();
3350        loop {
3351            let col = self.expect_ident_like()?;
3352            if !matches!(self.peek(), Token::Eq) {
3353                return Err(self.err(format!(
3354                    "expected `=` after column name in UPDATE SET, got {:?}",
3355                    self.peek()
3356                )));
3357            }
3358            self.advance();
3359            let value = self.parse_expr(0)?;
3360            assignments.push((col, value));
3361            if matches!(self.peek(), Token::Comma) {
3362                self.advance();
3363                continue;
3364            }
3365            break;
3366        }
3367        let where_ = if matches!(self.peek(), Token::Where) {
3368            self.advance();
3369            Some(self.parse_expr(0)?)
3370        } else {
3371            None
3372        };
3373        let returning = self.parse_optional_returning()?;
3374        Ok(Statement::Update(crate::ast::UpdateStatement {
3375            table,
3376            assignments,
3377            where_,
3378            returning,
3379        }))
3380    }
3381
3382    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Caller already consumed
3383    /// the leading `DELETE` ident.
3384    fn parse_delete_after_keyword(&mut self) -> Result<Statement, ParseError> {
3385        if !matches!(self.peek(), Token::From) {
3386            return Err(self.err(format!("expected FROM after DELETE, got {:?}", self.peek())));
3387        }
3388        self.advance();
3389        let table = self.expect_ident_like()?;
3390        let where_ = if matches!(self.peek(), Token::Where) {
3391            self.advance();
3392            Some(self.parse_expr(0)?)
3393        } else {
3394            None
3395        };
3396        let returning = self.parse_optional_returning()?;
3397        Ok(Statement::Delete(crate::ast::DeleteStatement {
3398            table,
3399            where_,
3400            returning,
3401        }))
3402    }
3403
3404    /// v7.17.0 Phase 3.P0-42 — parse `MERGE INTO <target> [alias]
3405    /// USING <source> [alias] ON <expr> WHEN [NOT] MATCHED [AND
3406    /// <expr>] THEN <action> [WHEN …]` after the leading `MERGE`
3407    /// keyword. v7.17 surface:
3408    ///   * source: table reference (subquery source is a follow-up)
3409    ///   * actions: UPDATE SET / DELETE / DO NOTHING (matched);
3410    ///     INSERT (cols) VALUES (vals) / DO NOTHING (not matched)
3411    ///   * AND-conditioned WHEN clauses; clauses tried in declaration
3412    ///     order
3413    fn parse_merge_after_keyword(&mut self) -> Result<Statement, ParseError> {
3414        // INTO
3415        let is_into_kw = matches!(self.peek(), Token::Into)
3416            || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("into"));
3417        if !is_into_kw {
3418            return Err(self.err(format!("expected INTO after MERGE, got {:?}", self.peek())));
3419        }
3420        self.advance();
3421        let target = self.expect_ident_like()?;
3422        // Optional alias — bare ident before USING.
3423        let target_alias = match self.peek() {
3424            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("using") => {
3425                Some(self.expect_ident_like()?)
3426            }
3427            _ => None,
3428        };
3429        // USING
3430        let is_using_kw = matches!(
3431            self.peek(),
3432            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("using")
3433        );
3434        if !is_using_kw {
3435            return Err(self.err(format!(
3436                "expected USING after MERGE INTO target, got {:?}",
3437                self.peek()
3438            )));
3439        }
3440        self.advance();
3441        let source = self.expect_ident_like()?;
3442        let source_alias = match self.peek() {
3443            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("on") => {
3444                Some(self.expect_ident_like()?)
3445            }
3446            _ => None,
3447        };
3448        // ON
3449        if !matches!(self.peek(), Token::On) {
3450            return Err(self.err(format!(
3451                "expected ON after MERGE … USING source, got {:?}",
3452                self.peek()
3453            )));
3454        }
3455        self.advance();
3456        let on = self.parse_expr(0)?;
3457        // One or more WHEN clauses.
3458        let mut clauses: Vec<crate::ast::MergeWhenClause> = Vec::new();
3459        loop {
3460            let is_when_kw = matches!(
3461                self.peek(),
3462                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("when")
3463            );
3464            if !is_when_kw {
3465                break;
3466            }
3467            self.advance(); // WHEN
3468            // [NOT] MATCHED
3469            let matched = if matches!(self.peek(), Token::Not) {
3470                self.advance();
3471                crate::ast::MergeMatched::NotMatched
3472            } else {
3473                crate::ast::MergeMatched::Matched
3474            };
3475            let is_matched_kw = matches!(
3476                self.peek(),
3477                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("matched")
3478            );
3479            if !is_matched_kw {
3480                return Err(self.err(format!(
3481                    "expected MATCHED in WHEN clause, got {:?}",
3482                    self.peek()
3483                )));
3484            }
3485            self.advance();
3486            // Optional AND <expr>
3487            let condition = if matches!(self.peek(), Token::And) {
3488                self.advance();
3489                Some(self.parse_expr(0)?)
3490            } else {
3491                None
3492            };
3493            // THEN
3494            let is_then_kw = matches!(
3495                self.peek(),
3496                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("then")
3497            );
3498            if !is_then_kw {
3499                return Err(self.err(format!(
3500                    "expected THEN in WHEN clause, got {:?}",
3501                    self.peek()
3502                )));
3503            }
3504            self.advance();
3505            // Action: INSERT / UPDATE / DELETE / DO NOTHING
3506            let action = match self.peek().clone() {
3507                Token::Insert => {
3508                    self.advance();
3509                    // (cols)
3510                    if !matches!(self.peek(), Token::LParen) {
3511                        return Err(self.err(format!(
3512                            "expected '(' after INSERT in MERGE, got {:?}",
3513                            self.peek()
3514                        )));
3515                    }
3516                    self.advance();
3517                    let mut columns: Vec<String> = Vec::new();
3518                    loop {
3519                        columns.push(self.expect_ident_like()?);
3520                        if matches!(self.peek(), Token::Comma) {
3521                            self.advance();
3522                            continue;
3523                        }
3524                        break;
3525                    }
3526                    if !matches!(self.peek(), Token::RParen) {
3527                        return Err(self.err(format!(
3528                            "expected ')' after INSERT column list, got {:?}",
3529                            self.peek()
3530                        )));
3531                    }
3532                    self.advance();
3533                    // VALUES (...)
3534                    if !matches!(self.peek(), Token::Values) {
3535                        return Err(self.err(format!(
3536                            "expected VALUES in MERGE INSERT, got {:?}",
3537                            self.peek()
3538                        )));
3539                    }
3540                    self.advance();
3541                    if !matches!(self.peek(), Token::LParen) {
3542                        return Err(self.err(format!(
3543                            "expected '(' after VALUES in MERGE INSERT, got {:?}",
3544                            self.peek()
3545                        )));
3546                    }
3547                    self.advance();
3548                    let mut values: Vec<crate::ast::Expr> = Vec::new();
3549                    loop {
3550                        values.push(self.parse_expr(0)?);
3551                        if matches!(self.peek(), Token::Comma) {
3552                            self.advance();
3553                            continue;
3554                        }
3555                        break;
3556                    }
3557                    if !matches!(self.peek(), Token::RParen) {
3558                        return Err(self.err(format!(
3559                            "expected ')' after MERGE INSERT values, got {:?}",
3560                            self.peek()
3561                        )));
3562                    }
3563                    self.advance();
3564                    if columns.len() != values.len() {
3565                        return Err(self.err(format!(
3566                            "MERGE INSERT column count ({}) ≠ value count ({})",
3567                            columns.len(),
3568                            values.len()
3569                        )));
3570                    }
3571                    crate::ast::MergeAction::Insert { columns, values }
3572                }
3573                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3574                    self.advance();
3575                    // SET
3576                    let is_set_kw = matches!(
3577                        self.peek(),
3578                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set")
3579                    );
3580                    if !is_set_kw {
3581                        return Err(self.err(format!(
3582                            "expected SET after UPDATE in MERGE, got {:?}",
3583                            self.peek()
3584                        )));
3585                    }
3586                    self.advance();
3587                    let mut assignments: Vec<(String, crate::ast::Expr)> = Vec::new();
3588                    loop {
3589                        let col = self.expect_ident_like()?;
3590                        if !matches!(self.peek(), Token::Eq) {
3591                            return Err(self.err(format!(
3592                                "expected '=' in MERGE UPDATE assignment, got {:?}",
3593                                self.peek()
3594                            )));
3595                        }
3596                        self.advance();
3597                        let expr = self.parse_expr(0)?;
3598                        assignments.push((col, expr));
3599                        if matches!(self.peek(), Token::Comma) {
3600                            self.advance();
3601                            continue;
3602                        }
3603                        break;
3604                    }
3605                    crate::ast::MergeAction::Update { assignments }
3606                }
3607                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3608                    self.advance();
3609                    crate::ast::MergeAction::Delete
3610                }
3611                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
3612                    self.advance();
3613                    let is_nothing_kw = matches!(
3614                        self.peek(),
3615                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing")
3616                    );
3617                    if !is_nothing_kw {
3618                        return Err(self.err(format!(
3619                            "expected NOTHING after DO in MERGE clause, got {:?}",
3620                            self.peek()
3621                        )));
3622                    }
3623                    self.advance();
3624                    crate::ast::MergeAction::DoNothing
3625                }
3626                other => {
3627                    return Err(self.err(format!(
3628                        "expected INSERT / UPDATE / DELETE / DO NOTHING in MERGE clause, got {other:?}"
3629                    )));
3630                }
3631            };
3632            clauses.push(crate::ast::MergeWhenClause {
3633                matched,
3634                condition,
3635                action,
3636            });
3637        }
3638        if clauses.is_empty() {
3639            return Err(self.err(String::from("MERGE requires at least one WHEN clause")));
3640        }
3641        Ok(Statement::Merge(crate::ast::MergeStatement {
3642            target,
3643            target_alias,
3644            source,
3645            source_alias,
3646            on,
3647            clauses,
3648        }))
3649    }
3650
3651    /// v7.9.4 — parse the optional trailing `RETURNING <projection>`
3652    /// clause on INSERT / UPDATE / DELETE. Same projection grammar
3653    /// as SELECT, so `RETURNING *`, `RETURNING col`,
3654    /// `RETURNING expr AS alias`, and `RETURNING a, b, c` all work.
3655    fn parse_optional_returning(
3656        &mut self,
3657    ) -> Result<Option<Vec<crate::ast::SelectItem>>, ParseError> {
3658        let is_returning_kw = matches!(
3659            self.peek(),
3660            Token::Ident(s) if s.eq_ignore_ascii_case("returning")
3661        );
3662        if !is_returning_kw {
3663            return Ok(None);
3664        }
3665        self.advance();
3666        let mut items = Vec::new();
3667        loop {
3668            items.push(self.parse_select_item()?);
3669            if matches!(self.peek(), Token::Comma) {
3670                self.advance();
3671                continue;
3672            }
3673            break;
3674        }
3675        Ok(Some(items))
3676    }
3677
3678    /// v6.0.4 — parse the tail of an ALTER statement after the
3679    /// leading `ALTER` keyword has been consumed. Only one form is
3680    /// supported in v6.0.4:
3681    ///
3682    /// ```text
3683    /// ALTER INDEX <name> REBUILD [WITH (encoding = <enc>)]
3684    /// ```
3685    fn parse_alter_after_keyword(&mut self) -> Result<Statement, ParseError> {
3686        // ALTER INDEX <name> ... | ALTER TABLE <name> SET hot_tier_bytes = <n>
3687        // v7.14.0 — `ALTER TABLE ONLY` modifier (PG partition-
3688        // exclusion) is accepted by stripping the `ONLY` keyword
3689        // before the table parse.
3690        // v7.14.0 — `ALTER SEQUENCE / ALTER VIEW / ALTER OWNER`
3691        // and the long PG-dump tail are accepted as no-ops.
3692        match self.advance() {
3693            Token::Index => {}
3694            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("index") => {}
3695            // v6.7.2 — ALTER TABLE t SET hot_tier_bytes = X
3696            // v7.14.0 — ALTER TABLE ONLY t … strip the `ONLY`.
3697            Token::Table => {
3698                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3699                    self.advance();
3700                }
3701                return self.parse_alter_table_after_keyword();
3702            }
3703            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("table") => {
3704                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3705                    self.advance();
3706                }
3707                return self.parse_alter_table_after_keyword();
3708            }
3709            // v7.17.0 — ALTER SEQUENCE name <options>. Moved out
3710            // of the silent-noop tail.
3711            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
3712                return self.parse_alter_sequence_after_keyword();
3713            }
3714            // v7.14.0 — ALTER VIEW / ALTER FUNCTION / ALTER TYPE /
3715            // ALTER DOMAIN / ALTER DATABASE / ALTER USER / ALTER
3716            // ROLE / ALTER SCHEMA / ALTER OWNER / ALTER DEFAULT
3717            // PRIVILEGES — accept as no-op so pg_dump's tail loads.
3718            // v7.17.0 NOTE: ALTER SEQUENCE moved out (above).
3719            Token::Ident(s) | Token::QuotedIdent(s)
3720                if matches!(
3721                    s.to_ascii_lowercase().as_str(),
3722                    "view"
3723                        | "function"
3724                        | "type"
3725                        | "domain"
3726                        | "database"
3727                        | "role"
3728                        | "schema"
3729                        | "owner"
3730                        | "default"
3731                        | "extension"
3732                        | "materialized"
3733                        | "policy"
3734                        | "publication"
3735                        | "subscription"
3736                ) =>
3737            {
3738                self.consume_until_statement_boundary();
3739                return Ok(Statement::Empty);
3740            }
3741            other => {
3742                return Err(self.err(format!(
3743                    "expected INDEX / TABLE / SEQUENCE / VIEW / FUNCTION / TYPE / OWNER / etc \
3744                     after ALTER, got {other:?}"
3745                )));
3746            }
3747        }
3748        // v7.16.2 — optional `IF EXISTS` after ALTER INDEX
3749        // (mailrs migrate-042 ships these). The presence of an
3750        // IF EXISTS makes the subsequent name lookup tolerate
3751        // a missing index — engine returns CommandOk no-op.
3752        let if_exists = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3753            let next = self.tokens.get(self.pos + 1);
3754            if matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
3755                self.advance();
3756                self.advance();
3757                true
3758            } else {
3759                false
3760            }
3761        } else {
3762            false
3763        };
3764        let name = self.expect_ident_like()?;
3765        // v7.16.2 — RENAME TO new_name shape (mailrs migrate-042).
3766        // Detect BEFORE the REBUILD path so the existing REBUILD
3767        // arm stays untouched.
3768        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("rename")) {
3769            self.advance();
3770            if matches!(self.peek(), Token::To) {
3771                self.advance();
3772            } else {
3773                self.expect_keyword_ident("to")?;
3774            }
3775            let new = self.expect_ident_like()?;
3776            return Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3777                name,
3778                target: crate::ast::AlterIndexTarget::Rename { new, if_exists },
3779            }));
3780        }
3781        // REBUILD
3782        self.expect_keyword_ident("rebuild")?;
3783        // Optional: WITH (encoding = <enc>)
3784        let encoding = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
3785            self.advance();
3786            if !matches!(self.peek(), Token::LParen) {
3787                return Err(self.err(format!(
3788                    "expected '(' after WITH in ALTER INDEX REBUILD, got {:?}",
3789                    self.peek()
3790                )));
3791            }
3792            self.advance();
3793            self.expect_keyword_ident("encoding")?;
3794            if !matches!(self.peek(), Token::Eq) {
3795                return Err(self.err(format!(
3796                    "expected '=' after encoding in ALTER INDEX REBUILD, got {:?}",
3797                    self.peek()
3798                )));
3799            }
3800            self.advance();
3801            let enc_ident = match self.advance() {
3802                Token::Ident(s) | Token::QuotedIdent(s) => s,
3803                other => {
3804                    return Err(self.err(format!("expected encoding name after =, got {other:?}")));
3805                }
3806            };
3807            let enc = match enc_ident.to_ascii_lowercase().as_str() {
3808                "f32" => VecEncoding::F32,
3809                "sq8" => VecEncoding::Sq8,
3810                "half" => VecEncoding::F16,
3811                other => {
3812                    return Err(self.err(format!(
3813                        "unknown vector encoding {other:?} in ALTER INDEX REBUILD; supported: F32, SQ8, HALF"
3814                    )));
3815                }
3816            };
3817            if !matches!(self.peek(), Token::RParen) {
3818                return Err(self.err(format!(
3819                    "expected ')' after encoding value, got {:?}",
3820                    self.peek()
3821                )));
3822            }
3823            self.advance();
3824            Some(enc)
3825        } else {
3826            None
3827        };
3828        Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3829            name,
3830            target: crate::ast::AlterIndexTarget::Rebuild { encoding },
3831        }))
3832    }
3833
3834    /// v6.7.2 — `ALTER TABLE <name> SET hot_tier_bytes = <n>`. The
3835    /// only `SET` form currently supported; future v6.7.x can add
3836    /// more SET subjects without changing the dispatch shape.
3837    /// v7.13.2 — mailrs round-6 S1: accepts comma-separated
3838    /// subactions. Single-subaction shape stays a 1-element vec.
3839    fn parse_alter_table_after_keyword(&mut self) -> Result<Statement, ParseError> {
3840        let table_name = self.expect_ident_like()?;
3841        let mut targets: Vec<crate::ast::AlterTableTarget> = Vec::new();
3842        loop {
3843            let subaction = self.parse_alter_table_subaction()?;
3844            // ADD COLUMN with inline REFERENCES emits both an
3845            // AddColumn and an AddForeignKey subaction; the
3846            // helper returns 1 or 2 items.
3847            targets.extend(subaction);
3848            if matches!(self.peek(), Token::Comma) {
3849                self.advance();
3850                continue;
3851            }
3852            break;
3853        }
3854        Ok(Statement::AlterTable(crate::ast::AlterTableStatement {
3855            name: table_name,
3856            targets,
3857        }))
3858    }
3859
3860    /// Parse one ALTER TABLE subaction. Returns a Vec because
3861    /// inline `REFERENCES` on `ADD COLUMN` produces both an
3862    /// AddColumn and an AddForeignKey entry (mailrs round-6 S3).
3863    fn parse_alter_table_subaction(
3864        &mut self,
3865    ) -> Result<Vec<crate::ast::AlterTableTarget>, ParseError> {
3866        match self.peek() {
3867            Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
3868                self.advance();
3869                let setting = self.expect_ident_like()?;
3870                if !setting.eq_ignore_ascii_case("hot_tier_bytes") {
3871                    return Err(self.err(alloc::format!(
3872                        "ALTER TABLE SET: unknown setting {setting:?}; supported: hot_tier_bytes"
3873                    )));
3874                }
3875                if !matches!(self.peek(), Token::Eq) {
3876                    return Err(self.err(alloc::format!(
3877                        "expected '=' after hot_tier_bytes, got {:?}",
3878                        self.peek()
3879                    )));
3880                }
3881                self.advance();
3882                let n = self.expect_u64_literal()?;
3883                Ok(alloc::vec![crate::ast::AlterTableTarget::SetHotTierBytes(n)])
3884            }
3885            Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
3886                self.advance();
3887                // v7.14.0 — ADD CONSTRAINT <name> { FOREIGN KEY |
3888                // PRIMARY KEY | UNIQUE | CHECK }. pg_dump emits
3889                // PRIMARY KEY this way; mysqldump emits both.
3890                // Peek-only dispatch (no advance) — `advance()`
3891                // destructively replaces consumed tokens with Eof,
3892                // so saved-pos restore would land on Eofs.
3893                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint"))
3894                {
3895                    // The next-but-one ident is the constraint
3896                    // name; the one after THAT is the kind.
3897                    let kind_pos = self.pos + 2;
3898                    let kind = self.tokens.get(kind_pos).cloned();
3899                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("foreign"))
3900                    {
3901                        let fk = self.parse_table_level_fk()?;
3902                        return Ok(alloc::vec![
3903                            crate::ast::AlterTableTarget::AddForeignKey(fk)
3904                        ]);
3905                    }
3906                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary"))
3907                    {
3908                        self.advance(); // CONSTRAINT
3909                        let _name = self.expect_ident_like()?;
3910                        self.advance(); // PRIMARY
3911                        self.expect_keyword_ident("key")?;
3912                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
3913                        return Ok(alloc::vec![
3914                            crate::ast::AlterTableTarget::AddTableConstraint(
3915                                crate::ast::TableConstraint::PrimaryKey {
3916                                    name: None,
3917                                    columns: cols,
3918                                }
3919                            )
3920                        ]);
3921                    }
3922                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique"))
3923                    {
3924                        self.advance(); // CONSTRAINT
3925                        let _name = self.expect_ident_like()?;
3926                        // v7.22 (mailrs round-13 gap 6) — delegate so
3927                        // the optional `NULLS [NOT] DISTINCT` modifier
3928                        // parses here too (pg_dump emits the ALTER
3929                        // form; semantics enforced by the engine
3930                        // since v7.13).
3931                        let uc = self.parse_table_level_unique()?;
3932                        return Ok(alloc::vec![
3933                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
3934                        ]);
3935                    }
3936                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check"))
3937                    {
3938                        self.advance(); // CONSTRAINT
3939                        let _name = self.expect_ident_like()?;
3940                        self.advance(); // CHECK
3941                        if !matches!(self.peek(), Token::LParen) {
3942                            return Err(self.err(alloc::format!(
3943                                "expected '(' after CHECK, got {:?}", self.peek()
3944                            )));
3945                        }
3946                        self.advance();
3947                        let expr = self.parse_expr(0)?;
3948                        if matches!(self.peek(), Token::RParen) {
3949                            self.advance();
3950                        }
3951                        return Ok(alloc::vec![
3952                            crate::ast::AlterTableTarget::AddTableConstraint(
3953                                crate::ast::TableConstraint::Check { name: None, expr }
3954                            )
3955                        ]);
3956                    }
3957                    // Unknown kind — fall through to FK path which
3958                    // produces a descriptive parse error.
3959                }
3960                let is_fk = matches!(
3961                    self.peek(),
3962                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
3963                        || s.eq_ignore_ascii_case("foreign")
3964                );
3965                if is_fk {
3966                    let fk = self.parse_table_level_fk()?;
3967                    return Ok(alloc::vec![crate::ast::AlterTableTarget::AddForeignKey(fk)]);
3968                }
3969                // v7.14.0 — bare ADD PRIMARY KEY / UNIQUE / CHECK
3970                // (no CONSTRAINT prefix) — same dispatch.
3971                match self.peek().clone() {
3972                    Token::Ident(s) if s.eq_ignore_ascii_case("primary") => {
3973                        self.advance();
3974                        self.expect_keyword_ident("key")?;
3975                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
3976                        return Ok(alloc::vec![
3977                            crate::ast::AlterTableTarget::AddTableConstraint(
3978                                crate::ast::TableConstraint::PrimaryKey {
3979                                    name: None,
3980                                    columns: cols,
3981                                }
3982                            )
3983                        ]);
3984                    }
3985                    Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
3986                        // v7.22 — delegate (NULLS [NOT] DISTINCT).
3987                        let uc = self.parse_table_level_unique()?;
3988                        return Ok(alloc::vec![
3989                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
3990                        ]);
3991                    }
3992                    _ => {}
3993                }
3994                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
3995                    self.advance();
3996                }
3997                let mut if_not_exists = false;
3998                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3999                    self.advance();
4000                    if !matches!(self.peek(), Token::Not) {
4001                        return Err(self.err(alloc::format!(
4002                            "expected NOT after IF in ALTER TABLE ADD COLUMN, got {:?}",
4003                            self.peek()
4004                        )));
4005                    }
4006                    self.advance();
4007                    if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("exists")) {
4008                        return Err(self.err(alloc::format!(
4009                            "expected EXISTS after IF NOT in ALTER TABLE ADD COLUMN, got {:?}",
4010                            self.peek()
4011                        )));
4012                    }
4013                    self.advance();
4014                    if_not_exists = true;
4015                }
4016                // v7.13.2 — mailrs round-6 S3: `ADD COLUMN col TYPE
4017                // REFERENCES other(col) [ON DELETE …]`. parse_column_def
4018                // returns ColumnDef + an optional inline FK.
4019                let (column, col_level_fk) = self.parse_column_def_with_fk()?;
4020                let col_name = column.name.clone();
4021                let mut out = alloc::vec![crate::ast::AlterTableTarget::AddColumn {
4022                    column,
4023                    if_not_exists,
4024                }];
4025                if let Some(mut fk) = col_level_fk {
4026                    if fk.columns.is_empty() {
4027                        fk.columns.push(col_name);
4028                    }
4029                    out.push(crate::ast::AlterTableTarget::AddForeignKey(fk));
4030                }
4031                Ok(out)
4032            }
4033            Token::Drop => {
4034                self.advance();
4035                // v7.13.3 — dispatch on the next token. mailrs round-7
4036                // S8 closed DROP COLUMN; round-6 S7 closed
4037                // DROP CONSTRAINT. Both share IF EXISTS / CASCADE /
4038                // RESTRICT modifiers.
4039                //   DROP CONSTRAINT [IF EXISTS] <name> [CASCADE|RESTRICT]
4040                //   DROP [COLUMN] [IF EXISTS] <col> [CASCADE|RESTRICT]
4041                let subject = match self.peek() {
4042                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint") => {
4043                        self.advance();
4044                        "constraint"
4045                    }
4046                    Token::Ident(s) if s.eq_ignore_ascii_case("column") => {
4047                        self.advance();
4048                        "column"
4049                    }
4050                    // PG-canonical bare `DROP <col>` without COLUMN
4051                    // keyword is also valid; treat any other ident
4052                    // as the column name.
4053                    Token::Ident(_) | Token::QuotedIdent(_) => "column",
4054                    other => {
4055                        return Err(self.err(alloc::format!(
4056                            "expected COLUMN / CONSTRAINT after DROP in ALTER TABLE, got {other:?}"
4057                        )));
4058                    }
4059                };
4060                let mut if_exists = false;
4061                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4062                    let n1 = self.tokens.get(self.pos + 1);
4063                    if matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
4064                        self.advance();
4065                        self.advance();
4066                        if_exists = true;
4067                    }
4068                }
4069                let name = self.expect_ident_like()?;
4070                let mut cascade = false;
4071                if matches!(
4072                    self.peek(),
4073                    Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
4074                        || s.eq_ignore_ascii_case("restrict")
4075                ) {
4076                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("cascade"))
4077                    {
4078                        cascade = true;
4079                    }
4080                    self.advance();
4081                }
4082                if subject == "constraint" {
4083                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropForeignKey {
4084                        name,
4085                        if_exists,
4086                    }])
4087                } else {
4088                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropColumn {
4089                        column: name,
4090                        if_exists,
4091                        cascade,
4092                    }])
4093                }
4094            }
4095            Token::Ident(s) if s.eq_ignore_ascii_case("alter") => {
4096                self.advance();
4097                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4098                    self.advance();
4099                }
4100                let col_name = self.expect_ident_like()?;
4101                match self.peek() {
4102                    Token::Ident(s) if s.eq_ignore_ascii_case("type") => {
4103                        self.advance();
4104                    }
4105                    // v7.14.0 — pg_dump emits BIGSERIAL via
4106                    // `ALTER TABLE … ALTER COLUMN id SET DEFAULT
4107                    // nextval('seq')` (the sequence is created
4108                    // separately). SPG's BIGSERIAL already uses
4109                    // AUTO_INCREMENT; accept SET DEFAULT / DROP
4110                    // DEFAULT / SET NOT NULL / DROP NOT NULL as
4111                    // engine no-ops by consuming the tail.
4112                    Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
4113                        // v7.22 (round-13 T2) — `SET DEFAULT
4114                        // nextval('…')` is how pg_dump spells a
4115                        // SERIAL column (plain integer in CREATE
4116                        // TABLE + this ALTER). It used to be
4117                        // swallowed as a no-op, which silently
4118                        // STRIPPED auto-increment from imported
4119                        // schemas — the first post-import INSERT
4120                        // without an explicit id then violated NOT
4121                        // NULL. Lower it to the auto-increment
4122                        // marker instead.
4123                        let is_default_nextval =
4124                            matches!(self.tokens.get(self.pos + 1), Some(Token::Default))
4125                                && matches!(
4126                                    self.tokens.get(self.pos + 2),
4127                                    Some(Token::Ident(f)) if f.eq_ignore_ascii_case("nextval")
4128                                );
4129                        // Capture the nextval target so the engine
4130                        // can guarantee the sequence exists.
4131                        let seq_name = if is_default_nextval {
4132                            self.scan_sequence_name_until_boundary()
4133                        } else {
4134                            self.consume_until_statement_boundary();
4135                            None
4136                        };
4137                        if is_default_nextval {
4138                            return Ok(alloc::vec![
4139                                crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4140                                    column: col_name,
4141                                    seq_name,
4142                                }
4143                            ]);
4144                        }
4145                        // Other SET DEFAULT … / SET NOT NULL forms
4146                        // stay engine no-ops (real defaults arrive
4147                        // inline in CREATE TABLE in every dump;
4148                        // nullability change would need a row scan
4149                        // — deferred).
4150                        return Ok(Vec::new());
4151                    }
4152                    Token::Ident(s) if s.eq_ignore_ascii_case("drop") => {
4153                        // ALTER COLUMN col DROP DEFAULT / DROP NOT NULL.
4154                        self.consume_until_statement_boundary();
4155                        return Ok(Vec::new());
4156                    }
4157                    Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
4158                        // v7.22 (round-13 T2) — `ALTER COLUMN c ADD
4159                        // GENERATED { ALWAYS | BY DEFAULT } AS
4160                        // IDENTITY ( … )`: pg_dump's spelling for
4161                        // identity columns. Same auto-increment
4162                        // lowering as the nextval default; the
4163                        // sequence options inside the parens are
4164                        // no-ops under SPG's max+1 semantics.
4165                        let is_generated = matches!(
4166                            self.tokens.get(self.pos + 1),
4167                            Some(Token::Ident(g)) if g.eq_ignore_ascii_case("generated")
4168                        );
4169                        if !is_generated {
4170                            return Err(self.err(alloc::format!(
4171                                "expected GENERATED after ALTER COLUMN {col_name} ADD, got {:?}",
4172                                self.tokens.get(self.pos + 1)
4173                            )));
4174                        }
4175                        let seq_name = self.scan_sequence_name_until_boundary();
4176                        return Ok(alloc::vec![
4177                            crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4178                                column: col_name,
4179                                seq_name,
4180                            }
4181                        ]);
4182                    }
4183                    other => {
4184                        return Err(self.err(alloc::format!(
4185                            "expected TYPE / SET / DROP / ADD after ALTER COLUMN <name>, got {other:?}"
4186                        )));
4187                    }
4188                }
4189                let new_type = self.parse_column_type_name()?;
4190                let using = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using"))
4191                {
4192                    self.advance();
4193                    Some(self.parse_expr(0)?)
4194                } else {
4195                    None
4196                };
4197                Ok(alloc::vec![crate::ast::AlterTableTarget::AlterColumnType {
4198                    column: col_name,
4199                    new_type,
4200                    using,
4201                }])
4202            }
4203            // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO new`.
4204            // PG also supports `RENAME TO new_table` for table-name
4205            // rename; that surface is deferred (pg_dump never emits
4206            // it). If the first post-RENAME ident is `TO`, the user
4207            // is asking for table rename — error with a clear
4208            // message rather than misparsing `TO` as a column name.
4209            Token::Ident(s) if s.eq_ignore_ascii_case("rename") => {
4210                self.advance();
4211                // v7.16.2 — `ALTER TABLE t RENAME TO new_table`
4212                // table-name rename (mailrs round-10 A.5 — used
4213                // by migrate-042's `RENAME TO email_contacts`).
4214                // `TO` lexes as Token::To.
4215                if matches!(self.peek(), Token::To)
4216                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("to"))
4217                {
4218                    self.advance();
4219                    let new = self.expect_ident_like()?;
4220                    return Ok(alloc::vec![crate::ast::AlterTableTarget::RenameTable {
4221                        new,
4222                    }]);
4223                }
4224                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4225                    self.advance();
4226                }
4227                let old = self.expect_ident_like()?;
4228                // `TO` is a reserved keyword token; accept both
4229                // Token::To and Token::Ident("to") for consistency.
4230                if matches!(self.peek(), Token::To) {
4231                    self.advance();
4232                } else {
4233                    self.expect_keyword_ident("to")?;
4234                }
4235                let new = self.expect_ident_like()?;
4236                Ok(alloc::vec![crate::ast::AlterTableTarget::RenameColumn {
4237                    old,
4238                    new,
4239                }])
4240            }
4241            // v7.16.1 — `ALTER TABLE t { ENABLE | DISABLE } TRIGGER
4242            // { ALL | <name> }`. pg_dump --disable-triggers wraps
4243            // every data block with these. Real disable semantics —
4244            // not no-op — because reload correctness assumes the
4245            // triggers don't fire (rows already carry their
4246            // computed values from prod).
4247            Token::Ident(s)
4248                if s.eq_ignore_ascii_case("enable") || s.eq_ignore_ascii_case("disable") =>
4249            {
4250                let enabled = s.eq_ignore_ascii_case("enable");
4251                self.advance();
4252                // PG also accepts ENABLE/DISABLE { REPLICA | ALWAYS }
4253                // TRIGGER … and ENABLE/DISABLE RULE / ROW LEVEL
4254                // SECURITY. v7.16.1 only matches TRIGGER (mailrs's
4255                // pg_dump output) — anything else falls through to
4256                // the catch-all error below.
4257                // v7.22 (round-13 T3) — mysqldump wraps every data
4258                // section in `/*!40000 ALTER TABLE t DISABLE KEYS */`
4259                // + ENABLE KEYS (a MyISAM index-rebuild hint). SPG
4260                // maintains indexes incrementally — engine no-op.
4261                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("keys")) {
4262                    self.advance();
4263                    return Ok(Vec::new());
4264                }
4265                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("trigger")) {
4266                    return Err(self.err(alloc::format!(
4267                        "expected TRIGGER after {}, got {:?}",
4268                        if enabled { "ENABLE" } else { "DISABLE" },
4269                        self.peek()
4270                    )));
4271                }
4272                self.advance();
4273                // `ALL` lexes as Token::All (reserved); also
4274                // accept Token::Ident("all") for symmetry.
4275                let which = if matches!(self.peek(), Token::All)
4276                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("all"))
4277                {
4278                    self.advance();
4279                    crate::ast::TriggerSelector::All
4280                } else {
4281                    let name = self.expect_ident_like()?;
4282                    crate::ast::TriggerSelector::Named(name)
4283                };
4284                Ok(alloc::vec![crate::ast::AlterTableTarget::SetTriggerEnabled {
4285                    which,
4286                    enabled,
4287                }])
4288            }
4289            other => Err(self.err(alloc::format!(
4290                "expected SET / ADD / DROP / ALTER / RENAME / ENABLE / DISABLE in ALTER TABLE, got {other:?}"
4291            ))),
4292        }
4293    }
4294
4295    /// v7.16.2 — peek for `information_schema.<tbl>` /
4296    /// `pg_catalog.<tbl>` triples and, if matched, consume all
4297    /// three tokens + return a synthetic table name the engine's
4298    /// SELECT path recognises as a virtual view. Returns `None`
4299    /// when the head doesn't look like a meta-qualified name.
4300    /// Used by `parse_table_ref` to bypass the
4301    /// `expect_ident_like` schema-strip for these specific PG
4302    /// meta schemas (mailrs round-10 A.3).
4303    fn try_peek_meta_qualified(&mut self) -> Option<String> {
4304        // Extract the schema name. Must be a plain ident token.
4305        let schema = match self.tokens.get(self.pos) {
4306            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
4307            _ => return None,
4308        };
4309        // Dot.
4310        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4311            return None;
4312        }
4313        // The table-side ident may lex as a reserved keyword
4314        // (e.g. `Token::Tables`). Tolerate the common ones via a
4315        // helper that reads the trailing token's underlying name.
4316        let tbl = match self.tokens.get(self.pos + 2)? {
4317            Token::Ident(t) | Token::QuotedIdent(t) => t.clone(),
4318            Token::Tables => "tables".to_string(),
4319            // Other PG meta table names that may collide with
4320            // reserved keywords land here as needed.
4321            _ => return None,
4322        };
4323        // Strip the `pg_` prefix from `pg_catalog.pg_class`-style
4324        // names so the synthetic name doesn't double-prefix
4325        // (`__spg_pg_class`, not `__spg_pg_pg_class`).
4326        let (prefix, normalised) = if schema.eq_ignore_ascii_case("information_schema") {
4327            ("__spg_info_", tbl.to_ascii_lowercase())
4328        } else if schema.eq_ignore_ascii_case("pg_catalog") {
4329            let bare = tbl
4330                .to_ascii_lowercase()
4331                .strip_prefix("pg_")
4332                .map(alloc::string::String::from)
4333                .unwrap_or_else(|| tbl.to_ascii_lowercase());
4334            ("__spg_pg_", bare)
4335        } else if schema.eq_ignore_ascii_case("mysql") {
4336            // v7.17.0 Phase 3.P0-65 — MySQL system schema
4337            // (`mysql.user`, `mysql.db`). Same synthetic-name
4338            // shape as pg_catalog.
4339            ("__spg_mysql_", tbl.to_ascii_lowercase())
4340        } else {
4341            return None;
4342        };
4343        self.advance(); // schema
4344        self.advance(); // dot
4345        self.advance(); // tbl
4346        Some(alloc::format!("{prefix}{normalised}"))
4347    }
4348
4349    /// Unqualified PG meta-table names (`FROM pg_extension`, `FROM
4350    /// pg_class`) resolve the same way: PG puts `pg_catalog` at the
4351    /// implicit front of every search_path, so a bare reference to a
4352    /// known catalog table always means the catalog table. Only the
4353    /// names the engine actually synthesises are recognised — any
4354    /// other `pg_*` ident stays a user table (mailrs embed round-12).
4355    fn try_peek_meta_bare(&mut self) -> Option<String> {
4356        const PG_META_TABLES: &[&str] = &[
4357            "pg_attribute",
4358            "pg_class",
4359            "pg_constraint",
4360            "pg_database",
4361            "pg_extension",
4362            "pg_index",
4363            "pg_indexes",
4364            "pg_matviews",
4365            "pg_namespace",
4366            "pg_proc",
4367            "pg_roles",
4368            "pg_settings",
4369            "pg_trigger",
4370            "pg_type",
4371            "pg_user",
4372            "pg_views",
4373        ];
4374        let name = match self.tokens.get(self.pos) {
4375            Some(Token::Ident(s)) => s.to_ascii_lowercase(),
4376            _ => return None,
4377        };
4378        // A following dot means this ident is a schema qualifier,
4379        // not a table name — let the qualified path handle it.
4380        if matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4381            return None;
4382        }
4383        if !PG_META_TABLES.contains(&name.as_str()) {
4384            return None;
4385        }
4386        self.advance();
4387        let bare = name.strip_prefix("pg_").unwrap_or(&name);
4388        Some(alloc::format!("__spg_pg_{bare}"))
4389    }
4390
4391    /// Consume a bare ident if its lowercase matches `kw`, else err.
4392    fn expect_keyword_ident(&mut self, kw: &str) -> Result<(), ParseError> {
4393        match self.advance() {
4394            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case(kw) => Ok(()),
4395            other => Err(ParseError {
4396                message: format!("expected {kw:?}, got {other:?}"),
4397                token_pos: self.pos.saturating_sub(1),
4398            }),
4399        }
4400    }
4401
4402    /// Accept either a quoted identifier (`"foo"`) or a quoted string
4403    /// literal (`'foo'`) — same shape used by CREATE USER for the
4404    /// username slot.
4405    fn expect_ident_or_string(&mut self) -> Result<String, ParseError> {
4406        match self.advance() {
4407            Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => Ok(s),
4408            other => Err(ParseError {
4409                message: format!("expected identifier or string, got {other:?}"),
4410                token_pos: self.pos.saturating_sub(1),
4411            }),
4412        }
4413    }
4414
4415    fn expect_string_literal(&mut self) -> Result<String, ParseError> {
4416        match self.advance() {
4417            Token::String(s) => Ok(s),
4418            other => Err(ParseError {
4419                message: format!("expected quoted string, got {other:?}"),
4420                token_pos: self.pos.saturating_sub(1),
4421            }),
4422        }
4423    }
4424
4425    fn parse_select_stmt(&mut self) -> Result<Statement, ParseError> {
4426        // v7.30.2 (mailrs round-25 ask 2) — derived tables /
4427        // subqueries recurse through here without passing
4428        // parse_expr; share the same nesting budget.
4429        self.enter_nested()?;
4430        let r = self.parse_select_stmt_inner();
4431        self.nest_depth -= 1;
4432        r
4433    }
4434
4435    fn parse_select_stmt_inner(&mut self) -> Result<Statement, ParseError> {
4436        // Caller dispatches on Token::Select; the inner helper handles
4437        // the rest. ORDER BY / LIMIT bind at this top level; UNION peers
4438        // get a fresh bare-select parse and may not have their own ORDER
4439        // BY / LIMIT.
4440        let mut head = self.parse_bare_select()?;
4441        while matches!(self.peek(), Token::Union) {
4442            self.advance();
4443            let kind = if matches!(self.peek(), Token::All) {
4444                self.advance();
4445                UnionKind::All
4446            } else {
4447                UnionKind::Distinct
4448            };
4449            let peer = self.parse_bare_select()?;
4450            head.unions.push((kind, peer));
4451        }
4452        head.order_by = if matches!(self.peek(), Token::Order) {
4453            self.advance();
4454            if !matches!(self.peek(), Token::By) {
4455                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
4456            }
4457            self.advance();
4458            // v6.4.0 — multi-key ORDER BY. Loop over comma-separated
4459            // `<expr> [ASC|DESC]` items.
4460            let mut keys = Vec::new();
4461            loop {
4462                let expr = self.parse_expr(0)?;
4463                let desc = if matches!(self.peek(), Token::Desc) {
4464                    self.advance();
4465                    true
4466                } else if matches!(self.peek(), Token::Asc) {
4467                    self.advance();
4468                    false
4469                } else {
4470                    false
4471                };
4472                // v7.24 (round-16 A) — explicit NULLS FIRST/LAST.
4473                let nulls_first = self.parse_optional_nulls_placement()?;
4474                keys.push(OrderBy {
4475                    expr,
4476                    desc,
4477                    nulls_first,
4478                });
4479                if matches!(self.peek(), Token::Comma) {
4480                    self.advance();
4481                } else {
4482                    break;
4483                }
4484            }
4485            keys
4486        } else {
4487            Vec::new()
4488        };
4489        head.limit = if matches!(self.peek(), Token::Limit) {
4490            self.advance();
4491            // v7.17.0 Phase 5.1 — `LIMIT NULL` / `LIMIT ALL` are
4492            // PG synonyms for "no limit". Treat both as None
4493            // (no head.limit set) so the engine's existing
4494            // unlimited-result path takes over. Reject was the
4495            // pre-5.1 behaviour and broke pg_dump-flavoured
4496            // tooling that occasionally emits LIMIT NULL.
4497            if self.consume_limit_unbounded_sentinel() {
4498                None
4499            } else {
4500                Some(self.parse_limit_expr("LIMIT")?)
4501            }
4502        } else {
4503            None
4504        };
4505        head.offset = if matches!(self.peek(), Token::Offset) {
4506            self.advance();
4507            // PG also accepts an optional `ROW` / `ROWS` trailer
4508            // after the offset value (`OFFSET 10 ROWS`). The
4509            // FETCH-FIRST branch below relies on the same.
4510            let off = self.parse_limit_expr("OFFSET")?;
4511            self.consume_optional_rows_keyword();
4512            Some(off)
4513        } else {
4514            None
4515        };
4516        // v7.17.0 Phase 5.1 — `FETCH FIRST <int|$N> ROWS ONLY` is
4517        // the SQL-standard alias for LIMIT. PG accepts both
4518        // spellings interchangeably; pg_dump emits FETCH FIRST in
4519        // newer versions. We map it onto `head.limit` so the
4520        // engine path is unified.
4521        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("fetch"))
4522        {
4523            self.advance(); // FETCH
4524            // `FIRST` or `NEXT` (both legal per SQL standard).
4525            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4526                if s.eq_ignore_ascii_case("first") || s.eq_ignore_ascii_case("next"))
4527            {
4528                self.advance();
4529            }
4530            // Count (optional in the bare `FETCH FIRST ROW ONLY` —
4531            // implicit 1 — but we always consume one if present).
4532            let count = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4533                if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4534            {
4535                // Bare `FETCH FIRST ROW ONLY` = LIMIT 1.
4536                crate::ast::LimitExpr::Literal(1)
4537            } else {
4538                self.parse_limit_expr("FETCH FIRST")?
4539            };
4540            // Eat `ROW` / `ROWS` if not already consumed above.
4541            self.consume_optional_rows_keyword();
4542            // Optional `ONLY` (the spec form) — or the SQL:2008
4543            // `WITH TIES` form. v7.17.0 Phase 3.P0-49: the executor
4544            // now honours WITH TIES by extending past the LIMIT
4545            // truncation point through every row that shares the
4546            // last-kept row's ORDER BY key.
4547            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4548                if s.eq_ignore_ascii_case("only"))
4549            {
4550                self.advance();
4551            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4552                if s.eq_ignore_ascii_case("with"))
4553            {
4554                self.advance(); // WITH
4555                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4556                    if s.eq_ignore_ascii_case("ties"))
4557                {
4558                    self.advance();
4559                    head.limit_with_ties = true;
4560                }
4561            }
4562            head.limit = Some(count);
4563        }
4564        // v7.17.0 Phase 3.4 — trailing row-lock clauses:
4565        //   FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
4566        //       [ OF table_name [, …] ]
4567        //       [ NOWAIT | SKIP LOCKED ]
4568        // Multiple FOR clauses may stack (PG: `FOR UPDATE OF t1
4569        // FOR SHARE OF t2`). SPG is a single-writer engine — every
4570        // SELECT already returns a consistent snapshot — so these
4571        // are accept-and-discard: the parser absorbs them so
4572        // mailrs / Rails / Django code paths that emit `SELECT
4573        // … FOR UPDATE` for advisory pessimistic locking load
4574        // without a parser error. The on-disk locking model is
4575        // unchanged; callers that rely on FOR UPDATE for read-
4576        // through-write ordering still get the right answer
4577        // because SPG serialises writes anyway.
4578        self.consume_optional_for_lock_clauses();
4579        Ok(Statement::Select(head))
4580    }
4581
4582    /// v7.17.0 Phase 3.4 — eat zero or more `FOR { UPDATE | NO KEY
4583    /// UPDATE | SHARE | KEY SHARE } [ OF tbl[, …] ] [ NOWAIT | SKIP
4584    /// LOCKED ]` trailers. Each clause is fully accepted and
4585    /// discarded — SPG's single-writer model already satisfies the
4586    /// callers' implicit ordering requirement. Stops at the first
4587    /// token that isn't `FOR`.
4588    fn consume_optional_for_lock_clauses(&mut self) {
4589        while matches!(self.peek(), Token::For) {
4590            self.advance(); // FOR
4591            // `NO KEY` prefix (PG) — `NO` is reserved-keyword-shaped
4592            // (`Token::Not` isn't it; PG `NO` lexes as Token::Ident).
4593            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4594                if s.eq_ignore_ascii_case("no"))
4595            {
4596                self.advance(); // NO
4597                // The next ident should be KEY but be generous;
4598                // anything followed by UPDATE/SHARE is accepted.
4599                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4600                    if s.eq_ignore_ascii_case("key"))
4601                {
4602                    self.advance(); // KEY
4603                }
4604            }
4605            // `KEY` prefix (PG `FOR KEY SHARE`).
4606            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4607                if s.eq_ignore_ascii_case("key"))
4608            {
4609                self.advance(); // KEY
4610            }
4611            // Lock-strength keyword: UPDATE / SHARE. Required, but
4612            // we're lenient — an unexpected token here just bails
4613            // (we already consumed FOR; caller's downstream
4614            // dispatch will error if anything actually depends on
4615            // the trailing tokens).
4616            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4617                if s.eq_ignore_ascii_case("update") || s.eq_ignore_ascii_case("share"))
4618            {
4619                self.advance();
4620            } else {
4621                // FOR by itself (or `FOR KEY` with nothing after) —
4622                // give up on the lock-clause path. We've already
4623                // advanced past FOR; further attempts to parse
4624                // here would clobber state.
4625                return;
4626            }
4627            // Optional `OF tbl[, tbl …]`. mailrs emits this when
4628            // joining and locking only a subset of tables.
4629            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4630                if s.eq_ignore_ascii_case("of"))
4631            {
4632                self.advance(); // OF
4633                #[allow(clippy::while_let_loop)]
4634                loop {
4635                    match self.peek() {
4636                        Token::Ident(_) | Token::QuotedIdent(_) => {
4637                            self.advance();
4638                            // Optional schema-qualified `schema.table`.
4639                            if matches!(self.peek(), Token::Dot) {
4640                                self.advance();
4641                                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4642                                    self.advance();
4643                                }
4644                            }
4645                        }
4646                        _ => break,
4647                    }
4648                    if matches!(self.peek(), Token::Comma) {
4649                        self.advance();
4650                    } else {
4651                        break;
4652                    }
4653                }
4654            }
4655            // Optional `NOWAIT` | `SKIP LOCKED`.
4656            match self.peek().clone() {
4657                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nowait") => {
4658                    self.advance();
4659                }
4660                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("skip") => {
4661                    self.advance(); // SKIP
4662                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4663                        if s.eq_ignore_ascii_case("locked"))
4664                    {
4665                        self.advance(); // LOCKED
4666                    }
4667                }
4668                _ => {}
4669            }
4670            // Loop: PG allows multiple FOR clauses chained.
4671        }
4672    }
4673
4674    /// v7.9.24 — accept `LIMIT <int>` or `LIMIT $N`. mailrs H2.
4675    /// Bind value gets resolved during prepared-statement Execute;
4676    /// the Pratt expression parser would over-accept here (e.g.
4677    /// `LIMIT 5 + 5`), so we narrowly accept only the two PG forms.
4678    /// v7.17.0 Phase 5.1 — consume the `LIMIT NULL` / `LIMIT ALL`
4679    /// sentinel tokens (PG synonyms for "no limit"). Returns true
4680    /// when one was consumed; caller skips the regular
4681    /// limit-value parse and leaves `head.limit` at None.
4682    fn consume_limit_unbounded_sentinel(&mut self) -> bool {
4683        if matches!(self.peek(), Token::Null) {
4684            self.advance();
4685            return true;
4686        }
4687        if matches!(self.peek(), Token::All) {
4688            self.advance();
4689            return true;
4690        }
4691        false
4692    }
4693
4694    /// v7.17.0 Phase 5.1 — eat an optional trailing `ROW` / `ROWS`
4695    /// keyword after a LIMIT / OFFSET / FETCH FIRST value, the
4696    /// SQL-standard shape. No-op when missing.
4697    fn consume_optional_rows_keyword(&mut self) {
4698        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4699            if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4700        {
4701            self.advance();
4702        }
4703    }
4704
4705    fn parse_limit_expr(&mut self, label: &str) -> Result<crate::ast::LimitExpr, ParseError> {
4706        match self.advance() {
4707            Token::Integer(n) if n >= 0 => u32::try_from(n)
4708                .map(crate::ast::LimitExpr::Literal)
4709                .map_err(|_| ParseError {
4710                    message: alloc::format!("{label} value too large: {n}"),
4711                    token_pos: self.pos.saturating_sub(1),
4712                }),
4713            Token::Placeholder(n) => Ok(crate::ast::LimitExpr::Placeholder(n)),
4714            other => Err(ParseError {
4715                message: alloc::format!(
4716                    "expected non-negative integer or $N placeholder after {label}, got {other:?}"
4717                ),
4718                token_pos: self.pos.saturating_sub(1),
4719            }),
4720        }
4721    }
4722
4723    /// Parse one SELECT block without ORDER BY / LIMIT / UNION chaining —
4724    /// just `[DISTINCT] items [FROM] [WHERE] [GROUP BY]`. Returned with
4725    /// `unions` empty and `order_by` / `limit` `None`; the top-level
4726    /// `parse_select_stmt` is responsible for filling those in.
4727    fn parse_bare_select(&mut self) -> Result<SelectStatement, ParseError> {
4728        if !matches!(self.peek(), Token::Select) {
4729            return Err(self.err(format!(
4730                "expected SELECT to start a query block, got {:?}",
4731                self.peek()
4732            )));
4733        }
4734        self.advance();
4735        let distinct = if matches!(self.peek(), Token::Distinct) {
4736            self.advance();
4737            true
4738        } else {
4739            false
4740        };
4741        let items = self.parse_select_list()?;
4742        let from = if matches!(self.peek(), Token::From) {
4743            self.advance();
4744            Some(self.parse_from_clause()?)
4745        } else {
4746            None
4747        };
4748        let where_ = if matches!(self.peek(), Token::Where) {
4749            self.advance();
4750            Some(self.parse_expr(0)?)
4751        } else {
4752            None
4753        };
4754        let mut group_by_all = false;
4755        let group_by = if matches!(self.peek(), Token::Group) {
4756            self.advance();
4757            if !matches!(self.peek(), Token::By) {
4758                return Err(self.err(format!("expected BY after GROUP, got {:?}", self.peek())));
4759            }
4760            self.advance();
4761            // v6.4.1 — `GROUP BY ALL` shortcut. Planner expands to
4762            // every non-aggregate SELECT-list item later.
4763            if matches!(self.peek(), Token::All) {
4764                self.advance();
4765                group_by_all = true;
4766                None
4767            } else {
4768                let mut groups = Vec::new();
4769                loop {
4770                    groups.push(self.parse_expr(0)?);
4771                    if matches!(self.peek(), Token::Comma) {
4772                        self.advance();
4773                    } else {
4774                        break;
4775                    }
4776                }
4777                Some(groups)
4778            }
4779        } else {
4780            None
4781        };
4782        let having = if matches!(self.peek(), Token::Having) {
4783            self.advance();
4784            Some(self.parse_expr(0)?)
4785        } else {
4786            None
4787        };
4788        Ok(SelectStatement {
4789            ctes: Vec::new(),
4790            distinct,
4791            items,
4792            from,
4793            where_,
4794            group_by,
4795            group_by_all,
4796            having,
4797            unions: Vec::new(),
4798            order_by: Vec::new(),
4799            limit: None,
4800            offset: None,
4801            limit_with_ties: false,
4802        })
4803    }
4804
4805    fn parse_create_table_stmt_after_create(&mut self) -> Result<Statement, ParseError> {
4806        // Caller already consumed CREATE; we're sitting on TABLE.
4807        debug_assert!(matches!(self.peek(), Token::Table));
4808        self.advance();
4809        let if_not_exists = self.consume_if_not_exists();
4810        let name = self.expect_ident_like()?;
4811        if !matches!(self.peek(), Token::LParen) {
4812            return Err(self.err(format!(
4813                "expected '(' after table name, got {:?}",
4814                self.peek()
4815            )));
4816        }
4817        self.advance();
4818        let mut columns = Vec::new();
4819        let mut foreign_keys: Vec<ForeignKeyConstraint> = Vec::new();
4820        let mut table_constraints: Vec<crate::ast::TableConstraint> = Vec::new();
4821        loop {
4822            // v7.6.0 / v7.9.18 — distinguish table-level constraint
4823            // clauses from column definitions. Constraints start
4824            // with `CONSTRAINT <name> …`, `FOREIGN KEY (…)`,
4825            // `PRIMARY KEY (…)`, or `UNIQUE (…)`. Anything else is
4826            // a column.
4827            if self.peek_table_level_pk_start() {
4828                table_constraints.push(self.parse_table_level_primary_key()?);
4829            } else if self.peek_table_level_unique_start() {
4830                table_constraints.push(self.parse_table_level_unique()?);
4831            } else if self.peek_table_level_check_start() {
4832                // v7.13.0 — table-level CHECK (mailrs round-5 G3).
4833                table_constraints.push(self.parse_table_level_check()?);
4834            } else if self.peek_mysql_inline_key_start() {
4835                // v7.14.0 — mysqldump emits inline `KEY name (cols)`,
4836                // `INDEX name (cols)`, `UNIQUE KEY name (cols)`,
4837                // `FULLTEXT KEY name (cols)`, `SPATIAL KEY name (cols)`
4838                // inside the column list. Skip name + paren list;
4839                // for UNIQUE KEY, register as a UC.
4840                if let Some(uc) = self.parse_mysql_inline_key()? {
4841                    table_constraints.push(uc);
4842                }
4843            } else if let Some(kind) = self.peek_named_table_constraint_kind() {
4844                // v7.22 (mailrs round-13 gap 5) — `CONSTRAINT <name>
4845                // { CHECK | UNIQUE | PRIMARY KEY }`: every pg_dump'd
4846                // CHECK is named, and the named-CONSTRAINT arm used
4847                // to accept FOREIGN KEY only. The name is accepted
4848                // and discarded — same handling as every other SPG
4849                // constraint name.
4850                self.advance(); // CONSTRAINT
4851                let _name = self.expect_ident_like()?;
4852                table_constraints.push(match kind {
4853                    NamedTableConstraintKind::Check => self.parse_table_level_check()?,
4854                    NamedTableConstraintKind::Unique => self.parse_table_level_unique()?,
4855                    NamedTableConstraintKind::PrimaryKey => self.parse_table_level_primary_key()?,
4856                });
4857            } else if self.peek_constraint_or_fk_start() {
4858                foreign_keys.push(self.parse_table_level_fk()?);
4859            } else {
4860                let (col, col_level_fk) = self.parse_column_def_with_fk()?;
4861                // v7.13.0 — fold inline UNIQUE / CHECK column
4862                // constraints into table-level entries so the
4863                // engine path stays uniform.
4864                if col.is_unique {
4865                    table_constraints.push(crate::ast::TableConstraint::Unique {
4866                        name: None,
4867                        columns: alloc::vec![col.name.clone()],
4868                        nulls_not_distinct: false,
4869                    });
4870                }
4871                if let Some(check_expr) = col.check.clone() {
4872                    table_constraints.push(crate::ast::TableConstraint::Check {
4873                        name: None,
4874                        expr: check_expr,
4875                    });
4876                }
4877                columns.push(col);
4878                if let Some(fk) = col_level_fk {
4879                    foreign_keys.push(fk);
4880                }
4881            }
4882            match self.peek() {
4883                Token::Comma => {
4884                    self.advance();
4885                }
4886                Token::RParen => {
4887                    self.advance();
4888                    break;
4889                }
4890                other => {
4891                    return Err(
4892                        self.err(format!("expected ',' or ')' in column list, got {other:?}"))
4893                    );
4894                }
4895            }
4896        }
4897        if columns.is_empty() {
4898            return Err(self.err("CREATE TABLE requires at least one column".into()));
4899        }
4900        // v7.14.0 — consume MySQL/MariaDB table options after the
4901        // closing `)`. mysqldump emits things like
4902        // `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
4903        // AUTO_INCREMENT=42 ROW_FORMAT=DYNAMIC COMMENT='blog posts'`.
4904        // SPG accepts all forms as no-ops (each option is
4905        // `<ident> [=] <ident-or-string>` separated by whitespace).
4906        self.consume_mysql_table_options();
4907        Ok(Statement::CreateTable(CreateTableStatement {
4908            name,
4909            columns,
4910            if_not_exists,
4911            foreign_keys,
4912            table_constraints,
4913        }))
4914    }
4915
4916    /// v7.14.0 — true when the next tokens look like an inline
4917    /// MySQL index declaration: KEY / INDEX / UNIQUE KEY /
4918    /// UNIQUE INDEX / FULLTEXT [KEY|INDEX] / SPATIAL [KEY|INDEX]
4919    /// — each followed by an optional name + `(...)`. Critical:
4920    /// a column NAMED `key` / `index` (PG accepts as ident) must
4921    /// NOT be mistaken for the KEY constraint shape. We disambig
4922    /// by requiring the keyword to be followed by either `(` or
4923    /// `<ident> (`.
4924    fn peek_mysql_inline_key_start(&self) -> bool {
4925        let cur = self.peek();
4926        // Shapes:
4927        //   KEY (cols)
4928        //   KEY name (cols)
4929        //   INDEX (cols)
4930        //   INDEX name (cols)
4931        //   UNIQUE KEY [name] (cols)
4932        //   UNIQUE INDEX [name] (cols)
4933        //   FULLTEXT [KEY|INDEX] [name] (cols)
4934        //   SPATIAL [KEY|INDEX] [name] (cols)
4935        let after_keyword_followed_by_paren_or_ident_paren = |skip: usize| -> bool {
4936            // tokens at skip = the position AFTER the index-form
4937            // keywords (KEY/INDEX) have been consumed.
4938            match self.tokens.get(skip) {
4939                Some(Token::LParen) => true,
4940                Some(Token::Ident(_) | Token::QuotedIdent(_)) => {
4941                    matches!(self.tokens.get(skip + 1), Some(Token::LParen))
4942                }
4943                _ => false,
4944            }
4945        };
4946        // `INDEX` lexes as Token::Index (reserved), not as
4947        // Token::Ident("index"). Both shapes count as a KEY/INDEX
4948        // start; the peek helper below handles either.
4949        let is_key_or_index_tok = |t: &Token| -> bool {
4950            matches!(t, Token::Index)
4951                || matches!(t, Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index"))
4952        };
4953        match cur {
4954            Token::Index => after_keyword_followed_by_paren_or_ident_paren(self.pos + 1),
4955            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
4956                after_keyword_followed_by_paren_or_ident_paren(self.pos + 1)
4957            }
4958            Token::Ident(s)
4959                if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial") =>
4960            {
4961                let nxt = self.tokens.get(self.pos + 1);
4962                let after_after = if nxt.is_some_and(is_key_or_index_tok) {
4963                    self.pos + 2
4964                } else {
4965                    self.pos + 1
4966                };
4967                after_keyword_followed_by_paren_or_ident_paren(after_after)
4968            }
4969            Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
4970                let nxt = self.tokens.get(self.pos + 1);
4971                if !nxt.is_some_and(is_key_or_index_tok) {
4972                    return false;
4973                }
4974                after_keyword_followed_by_paren_or_ident_paren(self.pos + 2)
4975            }
4976            _ => false,
4977        }
4978    }
4979
4980    /// v7.14.0 — parse the MySQL inline KEY/INDEX form. Returns
4981    /// Some(TableConstraint::Unique) for UNIQUE KEY (so SPG
4982    /// enforces uniqueness on INSERT). v7.15.0: plain KEY/INDEX
4983    /// returns Some(TableConstraint::Index) so the engine builds
4984    /// a real BTree index on the leading column (mysqldump
4985    /// `KEY idx_posts_author (author_id)` shape).
4986    /// FULLTEXT / SPATIAL still return None — accepted-as-no-op
4987    /// (the storage layer has no matching AM).
4988    fn parse_mysql_inline_key(
4989        &mut self,
4990    ) -> Result<Option<crate::ast::TableConstraint>, ParseError> {
4991        // Detect UNIQUE prefix.
4992        let is_unique = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unique"))
4993        {
4994            self.advance();
4995            true
4996        } else {
4997            false
4998        };
4999        // Consume FULLTEXT / SPATIAL prefix and record which one
5000        // it was. v7.17.0 Phase 2.2 — FULLTEXT routes through a
5001        // dedicated TableConstraint variant so the engine can
5002        // build a tsvector-GIN; SPATIAL still has no matching
5003        // AM, so it falls back to accept-as-no-op.
5004        let mut is_fulltext = false;
5005        let mut is_spatial = false;
5006        if let Token::Ident(s) = self.peek().clone() {
5007            if s.eq_ignore_ascii_case("fulltext") {
5008                self.advance();
5009                is_fulltext = true;
5010            } else if s.eq_ignore_ascii_case("spatial") {
5011                self.advance();
5012                is_spatial = true;
5013            }
5014        }
5015        // KEY / INDEX keyword. `INDEX` lexes as Token::Index
5016        // (reserved); accept either token shape.
5017        match self.peek() {
5018            Token::Index => {
5019                self.advance();
5020            }
5021            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
5022                self.advance();
5023            }
5024            other => {
5025                return Err(self.err(alloc::format!(
5026                    "expected KEY/INDEX in inline index declaration, got {other:?}"
5027                )));
5028            }
5029        }
5030        // Optional index name (an ident before the `(`).
5031        // v7.15.0 — capture the name when present so the engine
5032        // builds the secondary index under the user's chosen
5033        // name (matches mysqldump's `KEY idx_x (col)` shape).
5034        let mut idx_name: Option<String> = None;
5035        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_))
5036            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
5037        {
5038            if let Token::Ident(s) | Token::QuotedIdent(s) = self.advance() {
5039                idx_name = Some(s);
5040            }
5041        }
5042        // Optional `USING BTREE` / `USING HASH` (MySQL).
5043        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5044            self.advance();
5045            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5046                self.advance();
5047            }
5048        }
5049        // Required column list `(col [, col]*)`.
5050        if !matches!(self.peek(), Token::LParen) {
5051            return Err(self.err(alloc::format!(
5052                "expected '(' in inline KEY/INDEX, got {:?}",
5053                self.peek()
5054            )));
5055        }
5056        self.advance();
5057        let mut cols: Vec<String> = Vec::new();
5058        while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
5059            self.advance();
5060            cols.push(s);
5061            // Skip optional `(length)` per-column prefix.
5062            if matches!(self.peek(), Token::LParen) {
5063                let mut depth = 1usize;
5064                self.advance();
5065                while depth > 0 {
5066                    match self.peek() {
5067                        Token::LParen => depth += 1,
5068                        Token::RParen => depth -= 1,
5069                        Token::Eof => break,
5070                        _ => {}
5071                    }
5072                    self.advance();
5073                }
5074            }
5075            // Skip optional ASC / DESC.
5076            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("asc") || s.eq_ignore_ascii_case("desc"))
5077                || matches!(self.peek(), Token::Asc | Token::Desc)
5078            {
5079                self.advance();
5080            }
5081            if matches!(self.peek(), Token::Comma) {
5082                self.advance();
5083                continue;
5084            }
5085            break;
5086        }
5087        if matches!(self.peek(), Token::RParen) {
5088            self.advance();
5089        }
5090        // Trailing options on the inline index — comment / etc.
5091        // Skip until comma or `)`.
5092        while !matches!(self.peek(), Token::Comma | Token::RParen | Token::Eof) {
5093            self.advance();
5094        }
5095        if cols.is_empty() {
5096            return Ok(None);
5097        }
5098        if is_unique {
5099            // Carry the captured idx_name on UNIQUE too so future
5100            // engine work can name the underlying BTree
5101            // accordingly; today the unique-constraint installer
5102            // synthesises the name itself, but Display round-trip
5103            // benefits from preserving it.
5104            Ok(Some(crate::ast::TableConstraint::Unique {
5105                name: idx_name,
5106                columns: cols,
5107                nulls_not_distinct: false,
5108            }))
5109        } else if is_fulltext {
5110            // v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` now
5111            // routes through `TableConstraint::FulltextIndex`;
5112            // the engine builds a tsvector-GIN over each named
5113            // column so MATCH AGAINST gets a real inverted
5114            // index instead of a silently-dropped declaration.
5115            Ok(Some(crate::ast::TableConstraint::FulltextIndex {
5116                name: idx_name,
5117                columns: cols,
5118            }))
5119        } else if is_spatial {
5120            // SPG has no native SPATIAL AM. Accept-as-no-op
5121            // (declaration is parsed, but no index is built).
5122            Ok(None)
5123        } else {
5124            // v7.15.0 — plain KEY / INDEX builds a real BTree
5125            // secondary index.
5126            Ok(Some(crate::ast::TableConstraint::Index {
5127                name: idx_name,
5128                columns: cols,
5129            }))
5130        }
5131    }
5132
5133    /// v7.14.0 — consume MySQL/MariaDB table-options tail after
5134    /// the closing `)`: ENGINE=..., DEFAULT CHARSET=...,
5135    /// COLLATE=..., AUTO_INCREMENT=N, ROW_FORMAT=..., COMMENT='...'
5136    /// (in any order, separated by whitespace).
5137    fn consume_mysql_table_options(&mut self) {
5138        loop {
5139            // Heuristic: a table option is an ident (or `DEFAULT`
5140            // reserved keyword) followed by `=` and an
5141            // ident / string / integer.
5142            let name_lc = match self.peek().clone() {
5143                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
5144                Token::Default => alloc::string::String::from("default"),
5145                _ => break,
5146            };
5147            let known = matches!(
5148                name_lc.as_str(),
5149                "engine"
5150                    | "default"
5151                    | "charset"
5152                    | "collate"
5153                    | "auto_increment"
5154                    | "row_format"
5155                    | "comment"
5156                    | "pack_keys"
5157                    | "stats_persistent"
5158                    | "stats_auto_recalc"
5159                    | "stats_sample_pages"
5160                    | "key_block_size"
5161                    | "tablespace"
5162                    | "min_rows"
5163                    | "max_rows"
5164                    | "checksum"
5165                    | "delay_key_write"
5166                    | "insert_method"
5167                    | "data"
5168                    | "index"
5169                    | "encryption"
5170                    | "compression"
5171            );
5172            if !known {
5173                break;
5174            }
5175            self.advance(); // option name
5176            // `DEFAULT` optional prefix is followed by `CHARSET` /
5177            // `COLLATE`; consume the next ident too.
5178            if name_lc == "default" {
5179                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5180                    self.advance();
5181                }
5182            }
5183            if matches!(self.peek(), Token::Eq) {
5184                self.advance();
5185            }
5186            match self.peek() {
5187                Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_) | Token::Integer(_) => {
5188                    self.advance();
5189                }
5190                _ => {}
5191            }
5192        }
5193    }
5194
5195    /// v7.9.18 — true when the next tokens are `PRIMARY KEY (…)`.
5196    /// PRIMARY and KEY are bare idents; we look-ahead 2 to be
5197    /// sure (otherwise a column literally named `primary` would
5198    /// be mistaken).
5199    fn peek_table_level_pk_start(&self) -> bool {
5200        let cur = self.peek();
5201        let nxt = self.tokens.get(self.pos + 1);
5202        let nxt2 = self.tokens.get(self.pos + 2);
5203        let is_primary = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("primary"));
5204        let is_key = matches!(nxt, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("key"));
5205        let is_lparen = matches!(nxt2, Some(Token::LParen));
5206        is_primary && is_key && is_lparen
5207    }
5208
5209    /// v7.9.18 — true when the next tokens are `UNIQUE (…)`.
5210    /// v7.13.0 — also matches `UNIQUE NULLS [NOT] DISTINCT (…)`
5211    /// (mailrs round-5 G10).
5212    fn peek_table_level_unique_start(&self) -> bool {
5213        let cur = self.peek();
5214        let is_unique = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("unique"));
5215        if !is_unique {
5216            return false;
5217        }
5218        let n1 = self.tokens.get(self.pos + 1);
5219        // Plain `UNIQUE (…)`.
5220        if matches!(n1, Some(Token::LParen)) {
5221            return true;
5222        }
5223        // `UNIQUE NULLS [NOT] DISTINCT (…)`.
5224        let is_nulls = matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("nulls"));
5225        if !is_nulls {
5226            return false;
5227        }
5228        let n2 = self.tokens.get(self.pos + 2);
5229        let n3 = self.tokens.get(self.pos + 3);
5230        let n4 = self.tokens.get(self.pos + 4);
5231        // `UNIQUE NULLS DISTINCT (…)` — 4 tokens before `(`.
5232        if matches!(n2, Some(Token::Distinct)) && matches!(n3, Some(Token::LParen)) {
5233            return true;
5234        }
5235        // `UNIQUE NULLS NOT DISTINCT (…)` — 5 tokens before `(`.
5236        if matches!(n2, Some(Token::Not))
5237            && matches!(n3, Some(Token::Distinct))
5238            && matches!(n4, Some(Token::LParen))
5239        {
5240            return true;
5241        }
5242        false
5243    }
5244
5245    fn parse_table_level_primary_key(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5246        self.advance(); // PRIMARY
5247        self.advance(); // KEY
5248        let columns = self.parse_paren_ident_list("PRIMARY KEY")?;
5249        Ok(crate::ast::TableConstraint::PrimaryKey {
5250            name: None,
5251            columns,
5252        })
5253    }
5254
5255    fn parse_table_level_unique(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5256        self.advance(); // UNIQUE
5257        // v7.13.0 — optional `NULLS NOT DISTINCT` modifier
5258        // (mailrs round-5 G10, PG 15+ surface). Default behaviour
5259        // is `NULLS DISTINCT` per the SQL standard.
5260        let mut nulls_not_distinct = false;
5261        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5262            let n1 = self.tokens.get(self.pos + 1);
5263            let n2 = self.tokens.get(self.pos + 2);
5264            let is_not = matches!(n1, Some(Token::Not));
5265            let is_distinct = matches!(n2, Some(Token::Distinct));
5266            if is_not && is_distinct {
5267                self.advance(); // NULLS
5268                self.advance(); // NOT
5269                self.advance(); // DISTINCT
5270                nulls_not_distinct = true;
5271            } else if matches!(n1, Some(Token::Distinct)) {
5272                self.advance(); // NULLS
5273                self.advance(); // DISTINCT
5274            }
5275        }
5276        let columns = self.parse_paren_ident_list("UNIQUE")?;
5277        Ok(crate::ast::TableConstraint::Unique {
5278            name: None,
5279            columns,
5280            nulls_not_distinct,
5281        })
5282    }
5283
5284    /// v7.13.0 — table-level `CHECK (<expr>)` constraint
5285    /// (mailrs round-5 G3). Consumes `CHECK` then a parenthesised
5286    /// expression.
5287    fn parse_table_level_check(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5288        self.advance(); // CHECK
5289        if !matches!(self.peek(), Token::LParen) {
5290            return Err(self.err(alloc::format!(
5291                "expected '(' after CHECK, got {:?}",
5292                self.peek()
5293            )));
5294        }
5295        self.advance();
5296        let expr = self.parse_expr(0)?;
5297        if !matches!(self.peek(), Token::RParen) {
5298            return Err(self.err(alloc::format!(
5299                "expected ')' to close CHECK predicate, got {:?}",
5300                self.peek()
5301            )));
5302        }
5303        self.advance();
5304        Ok(crate::ast::TableConstraint::Check { name: None, expr })
5305    }
5306
5307    /// v7.13.0 — `true` when the next token is `CHECK` (a bare ident).
5308    fn peek_table_level_check_start(&self) -> bool {
5309        matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("check"))
5310    }
5311
5312    /// v7.22 (round-13 gap 5) — `Some(kind)` when the next tokens are
5313    /// `CONSTRAINT <name> { CHECK | UNIQUE | PRIMARY }`. FOREIGN stays
5314    /// on the dedicated FK path (`parse_table_level_fk` consumes its
5315    /// own CONSTRAINT prefix).
5316    fn peek_named_table_constraint_kind(&self) -> Option<NamedTableConstraintKind> {
5317        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5318            return None;
5319        }
5320        // tokens[pos+1] is the constraint name (any ident-like);
5321        // tokens[pos+2] is the kind keyword.
5322        match self.tokens.get(self.pos + 2) {
5323            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check") => {
5324                Some(NamedTableConstraintKind::Check)
5325            }
5326            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique") => {
5327                Some(NamedTableConstraintKind::Unique)
5328            }
5329            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary") => {
5330                Some(NamedTableConstraintKind::PrimaryKey)
5331            }
5332            _ => None,
5333        }
5334    }
5335
5336    fn parse_paren_ident_list(&mut self, ctx: &str) -> Result<Vec<String>, ParseError> {
5337        if !matches!(self.peek(), Token::LParen) {
5338            return Err(self.err(alloc::format!(
5339                "expected '(' after {ctx}, got {:?}",
5340                self.peek()
5341            )));
5342        }
5343        self.advance();
5344        let mut out = Vec::new();
5345        loop {
5346            out.push(self.expect_ident_like()?);
5347            match self.peek() {
5348                Token::Comma => {
5349                    self.advance();
5350                }
5351                Token::RParen => {
5352                    self.advance();
5353                    break;
5354                }
5355                other => {
5356                    return Err(self.err(alloc::format!(
5357                        "expected ',' or ')' in {ctx} list, got {other:?}"
5358                    )));
5359                }
5360            }
5361        }
5362        if out.is_empty() {
5363            return Err(self.err(alloc::format!("{ctx} requires at least one column")));
5364        }
5365        Ok(out)
5366    }
5367
5368    /// v7.6.0 — true when the next tokens are `CONSTRAINT <name>
5369    /// FOREIGN KEY` or bare `FOREIGN KEY`. Both introduce a
5370    /// table-level FK; a column def never starts with either keyword
5371    /// (column names are not in this reserved set).
5372    fn peek_constraint_or_fk_start(&self) -> bool {
5373        let is_constraint_kw = matches!(
5374            self.peek(),
5375            Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
5376        );
5377        let is_foreign_kw = matches!(
5378            self.peek(),
5379            Token::Ident(s) if s.eq_ignore_ascii_case("foreign")
5380        );
5381        is_constraint_kw || is_foreign_kw
5382    }
5383
5384    /// v7.6.0 — parse a table-level FK clause:
5385    /// `[CONSTRAINT <name>] FOREIGN KEY (<col>[,<col>]*) REFERENCES
5386    /// <tbl> [(<pcol>[,<pcol>]*)] [ON DELETE <action>] [ON UPDATE <action>]`.
5387    fn parse_table_level_fk(&mut self) -> Result<ForeignKeyConstraint, ParseError> {
5388        let mut name: Option<String> = None;
5389        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5390            self.advance();
5391            name = Some(self.expect_ident_like()?);
5392        }
5393        // `FOREIGN`
5394        match self.advance() {
5395            Token::Ident(s) if s.eq_ignore_ascii_case("foreign") => {}
5396            other => return Err(self.err(format!("expected FOREIGN, got {other:?}"))),
5397        }
5398        // `KEY`
5399        match self.advance() {
5400            Token::Ident(s) if s.eq_ignore_ascii_case("key") => {}
5401            other => return Err(self.err(format!("expected KEY after FOREIGN, got {other:?}"))),
5402        }
5403        // `(col, col, ...)`
5404        if !matches!(self.peek(), Token::LParen) {
5405            return Err(self.err(format!(
5406                "expected '(' after FOREIGN KEY, got {:?}",
5407                self.peek()
5408            )));
5409        }
5410        self.advance();
5411        let mut columns = Vec::new();
5412        loop {
5413            columns.push(self.expect_ident_like()?);
5414            match self.peek() {
5415                Token::Comma => {
5416                    self.advance();
5417                }
5418                Token::RParen => {
5419                    self.advance();
5420                    break;
5421                }
5422                other => {
5423                    return Err(self.err(format!(
5424                        "expected ',' or ')' in FK column list, got {other:?}"
5425                    )));
5426                }
5427            }
5428        }
5429        if columns.is_empty() {
5430            return Err(self.err("FOREIGN KEY requires at least one column".into()));
5431        }
5432        let (parent_table, parent_columns, on_delete, on_update) =
5433            self.parse_references_tail(columns.len())?;
5434        Ok(ForeignKeyConstraint {
5435            name,
5436            columns,
5437            parent_table,
5438            parent_columns,
5439            on_delete,
5440            on_update,
5441        })
5442    }
5443
5444    /// v7.6.0 — parse the tail `REFERENCES <tbl> [(<pcol>...)] [ON
5445    /// DELETE <action>] [ON UPDATE <action>]`. `expected_arity` is
5446    /// the local column count, used to default the parent column
5447    /// list when omitted (SQL spec: parent's PK is implied).
5448    fn parse_references_tail(
5449        &mut self,
5450        expected_arity: usize,
5451    ) -> Result<(String, Vec<String>, FkAction, FkAction), ParseError> {
5452        match self.advance() {
5453            Token::Ident(s) if s.eq_ignore_ascii_case("references") => {}
5454            other => return Err(self.err(format!("expected REFERENCES, got {other:?}"))),
5455        }
5456        let parent_table = self.expect_ident_like()?;
5457        let mut parent_columns: Vec<String> = Vec::new();
5458        if matches!(self.peek(), Token::LParen) {
5459            self.advance();
5460            loop {
5461                parent_columns.push(self.expect_ident_like()?);
5462                match self.peek() {
5463                    Token::Comma => {
5464                        self.advance();
5465                    }
5466                    Token::RParen => {
5467                        self.advance();
5468                        break;
5469                    }
5470                    other => {
5471                        return Err(self.err(format!(
5472                            "expected ',' or ')' in REFERENCES column list, got {other:?}"
5473                        )));
5474                    }
5475                }
5476            }
5477        }
5478        if !parent_columns.is_empty() && parent_columns.len() != expected_arity {
5479            return Err(self.err(format!(
5480                "FK arity mismatch: {} local column(s) vs {} parent column(s)",
5481                expected_arity,
5482                parent_columns.len()
5483            )));
5484        }
5485        // v7.6.7 / v7.17.0 Phase 3.1 — interleave `[NOT] DEFERRABLE
5486        // [INITIALLY {DEFERRED | IMMEDIATE}]` and `ON DELETE
5487        // <action>` / `ON UPDATE <action>` in either order. PG /
5488        // pg_dump emits the timing clause AFTER the ON clauses
5489        // (`ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED`),
5490        // but the SQL spec allows either order. We loop over
5491        // every possible trailer and dispatch on the next token,
5492        // stopping when nothing matches. Phase 3.1 changes the
5493        // bare DEFERRABLE form from hard-error to accept-as-
5494        // immediate; SPG is single-writer with no deferred-
5495        // constraint window so the runtime semantics are always
5496        // immediate even when INITIALLY DEFERRED is requested.
5497        let mut on_delete = FkAction::Restrict;
5498        let mut on_update = FkAction::Restrict;
5499        let mut seen_on_delete = false;
5500        let mut seen_on_update = false;
5501        loop {
5502            // DEFERRABLE / NOT DEFERRABLE / INITIALLY shapes.
5503            let before = self.pos;
5504            self.consume_optional_deferrable_clauses()?;
5505            if self.pos != before {
5506                continue;
5507            }
5508            // ON DELETE / ON UPDATE.
5509            if !matches!(self.peek(), Token::On) {
5510                break;
5511            }
5512            self.advance();
5513            let which = self.advance();
5514            let action = self.parse_fk_action()?;
5515            match which {
5516                Token::Ident(ref s) if s.eq_ignore_ascii_case("delete") => {
5517                    if seen_on_delete {
5518                        return Err(self.err("ON DELETE specified twice".into()));
5519                    }
5520                    seen_on_delete = true;
5521                    on_delete = action;
5522                }
5523                Token::Ident(ref s) if s.eq_ignore_ascii_case("update") => {
5524                    if seen_on_update {
5525                        return Err(self.err("ON UPDATE specified twice".into()));
5526                    }
5527                    seen_on_update = true;
5528                    on_update = action;
5529                }
5530                other => {
5531                    return Err(
5532                        self.err(format!("expected DELETE or UPDATE after ON, got {other:?}"))
5533                    );
5534                }
5535            }
5536        }
5537        Ok((parent_table, parent_columns, on_delete, on_update))
5538    }
5539
5540    /// v7.6.0 — parse `CASCADE | RESTRICT | SET NULL | SET DEFAULT |
5541    /// NO ACTION`.
5542    fn parse_fk_action(&mut self) -> Result<FkAction, ParseError> {
5543        match self.advance() {
5544            Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => Ok(FkAction::Cascade),
5545            Token::Ident(s) if s.eq_ignore_ascii_case("restrict") => Ok(FkAction::Restrict),
5546            Token::Ident(s) if s.eq_ignore_ascii_case("set") => match self.advance() {
5547                Token::Null => Ok(FkAction::SetNull),
5548                Token::Default => Ok(FkAction::SetDefault),
5549                other => Err(self.err(format!(
5550                    "expected NULL or DEFAULT after SET in FK action, got {other:?}"
5551                ))),
5552            },
5553            Token::Ident(s) if s.eq_ignore_ascii_case("no") => match self.advance() {
5554                Token::Ident(s) if s.eq_ignore_ascii_case("action") => Ok(FkAction::NoAction),
5555                other => Err(self.err(format!(
5556                    "expected ACTION after NO in FK action, got {other:?}"
5557                ))),
5558            },
5559            other => Err(self.err(format!(
5560                "expected CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION, got {other:?}"
5561            ))),
5562        }
5563    }
5564
5565    /// Recognise the optional `IF NOT EXISTS` prefix shared by `CREATE
5566    /// TABLE` and `CREATE INDEX`. Returns `true` if consumed.
5567    fn consume_if_not_exists(&mut self) -> bool {
5568        // `IF` arrives as a bare Ident (we don't reserve it because it
5569        // also appears mid-expression in PG, though we don't support
5570        // those forms yet).
5571        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5572        if !looks_like_if {
5573            return false;
5574        }
5575        // Peek one ahead before committing: only consume IF when it's
5576        // actually `IF NOT EXISTS`.
5577        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Not)) {
5578            return false;
5579        }
5580        if !matches!(
5581            self.tokens.get(self.pos + 2),
5582            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5583        ) {
5584            return false;
5585        }
5586        self.advance(); // IF
5587        self.advance(); // NOT
5588        self.advance(); // EXISTS
5589        true
5590    }
5591
5592    /// v7.12.4 — `IF EXISTS` modifier for DROP statements.
5593    /// Consumes IF EXISTS as a pair; returns false otherwise
5594    /// without consuming any tokens.
5595    fn consume_if_exists(&mut self) -> bool {
5596        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5597        if !looks_like_if {
5598            return false;
5599        }
5600        if !matches!(
5601            self.tokens.get(self.pos + 1),
5602            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5603        ) {
5604            return false;
5605        }
5606        self.advance(); // IF
5607        self.advance(); // EXISTS
5608        true
5609    }
5610
5611    /// v7.9.14 — consume `ASC | DESC | NULLS FIRST | NULLS LAST`
5612    /// qualifiers after an index column ref. ASC / DESC are
5613    /// reserved tokens; NULLS / FIRST / LAST are bare idents.
5614    /// We accept and discard them since single-column BTree
5615    /// stores rows in natural key order today.
5616    /// v7.24 (round-16 A) — `NULLS FIRST` / `NULLS LAST` after an
5617    /// ORDER BY key. Returns None when absent.
5618    fn parse_optional_nulls_placement(&mut self) -> Result<Option<bool>, ParseError> {
5619        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5620            return Ok(None);
5621        }
5622        self.advance();
5623        match self.advance() {
5624            Token::Ident(s) if s.eq_ignore_ascii_case("first") => Ok(Some(true)),
5625            Token::Ident(s) if s.eq_ignore_ascii_case("last") => Ok(Some(false)),
5626            other => Err(self.err(alloc::format!(
5627                "expected FIRST or LAST after NULLS, got {other:?}"
5628            ))),
5629        }
5630    }
5631
5632    fn consume_optional_index_column_qualifiers(&mut self) {
5633        loop {
5634            match self.peek() {
5635                Token::Asc | Token::Desc => {
5636                    self.advance();
5637                }
5638                Token::Ident(s) if s.eq_ignore_ascii_case("nulls") => {
5639                    let look = self.tokens.get(self.pos + 1);
5640                    if matches!(
5641                        look,
5642                        Some(Token::Ident(k)) if k.eq_ignore_ascii_case("first")
5643                            || k.eq_ignore_ascii_case("last")
5644                    ) {
5645                        self.advance();
5646                        self.advance();
5647                    } else {
5648                        break;
5649                    }
5650                }
5651                _ => break,
5652            }
5653        }
5654    }
5655
5656    fn parse_create_index_stmt_after_create(
5657        &mut self,
5658        is_unique: bool,
5659    ) -> Result<Statement, ParseError> {
5660        // Caller consumed CREATE (and the optional UNIQUE); we're on INDEX.
5661        debug_assert!(matches!(self.peek(), Token::Index));
5662        self.advance();
5663        let if_not_exists = self.consume_if_not_exists();
5664        let name = self.expect_ident_like()?;
5665        if !matches!(self.peek(), Token::On) {
5666            return Err(self.err(format!(
5667                "expected ON after CREATE INDEX <name>, got {:?}",
5668                self.peek()
5669            )));
5670        }
5671        self.advance();
5672        let table = self.expect_ident_like()?;
5673        // Optional `USING <method>` — only recognised method in v2.0 is
5674        // `hnsw` (a single-layer NSW graph for kNN). `USING` is the bare
5675        // ident `using` (we don't promote it to a reserved keyword
5676        // because it isn't reserved anywhere else in our SQL surface).
5677        let method = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5678            self.advance();
5679            let m = self.expect_ident_like()?;
5680            match m.to_ascii_lowercase().as_str() {
5681                "hnsw" => IndexMethod::Hnsw,
5682                "btree" => IndexMethod::BTree,
5683                "brin" => IndexMethod::Brin,
5684                // v7.12.3 — real GIN inverted index over `tsvector`.
5685                // v7.9.26b's `USING gin` → BTree silent fallback is
5686                // gone; the engine validates that the indexed column
5687                // is `tsvector` at CREATE INDEX time.
5688                "gin" => IndexMethod::Gin,
5689                // v7.9.26b — PG `pg_dump` emits `USING gist` /
5690                // `USING spgist` / `USING hash` for their built-in
5691                // AMs that SPG doesn't have a matching
5692                // implementation for; degrade to BTree on the
5693                // leading column so the schema loads + the index
5694                // catalogue stays consistent. Operator pays the
5695                // planner cost only for the queries that would have
5696                // used the specialised AM.
5697                "gist" | "spgist" | "hash" => IndexMethod::BTree,
5698                // v7.11.3 — pgvector ships both `ivfflat` and
5699                // `hnsw`. Customers shouldn't have to choose
5700                // their on-disk index method based on what SPG
5701                // implements; accept `ivfflat` as a synonym for
5702                // `hnsw` so PG schemas using either method drop
5703                // in. The vector distance op (`<->` / `<#>` /
5704                // `<=>`) at query time still picks the metric.
5705                "ivfflat" => IndexMethod::Hnsw,
5706                other => {
5707                    return Err(self.err(alloc::format!(
5708                        "unknown index method {other:?}; supported: hnsw, btree, brin, gin (gist/spgist/hash accepted as BTree fallback)"
5709                    )));
5710                }
5711            }
5712        } else {
5713            IndexMethod::BTree
5714        };
5715        if !matches!(self.peek(), Token::LParen) {
5716            return Err(self.err(format!(
5717                "expected '(' before indexed column, got {:?}",
5718                self.peek()
5719            )));
5720        }
5721        self.advance();
5722        // v6.8.2 — accept either a bare column ident (legacy) or
5723        // an expression `fn(col, …)` for expression indexes.
5724        // Distinguish by peeking the token *after* the current
5725        // ident: `ident )` is the legacy column-only path;
5726        // anything else triggers the Pratt expression parser.
5727        // (`advance()` uses `mem::replace` to nil out the current
5728        // slot, so we can't save+rewind cleanly — peek-ahead via
5729        // direct index avoids the mutation.)
5730        let mut opclass: Option<String> = None;
5731        let (column, expression): (String, Option<Expr>) = match self.peek().clone() {
5732            // Single column with `)` immediately after — fast path.
5733            // v7.9.29 — also: bare column followed by `,` (the
5734            // multi-column form `(a, b, c)`). Without this branch
5735            // the leading ident gets pulled into `parse_expr`
5736            // which then sets `expression = Some(Column(a))` and
5737            // breaks Display round-trip on the multi-column shape.
5738            Token::Ident(s) | Token::QuotedIdent(s)
5739                if matches!(
5740                    self.tokens.get(self.pos + 1),
5741                    Some(Token::RParen | Token::Comma)
5742                ) =>
5743            {
5744                self.advance();
5745                (s, None)
5746            }
5747            // v7.9.22 — single column followed by a pgvector
5748            // opclass ident: `(col vector_cosine_ops)`. mailrs G5.
5749            // v7.15.0 — capture the opclass instead of discarding
5750            // it so the engine can dispatch (e.g. `gin_trgm_ops`
5751            // → real trigram-shingle GIN over a TEXT column).
5752            // Vector/HNSW opclasses still take their distance
5753            // metric from the query operator (`<->` / `<#>` /
5754            // `<=>`), so for those callers the opclass stays
5755            // informational.
5756            // v7.22 (mailrs round-13 gap 7) — pg_dump qualifies the
5757            // opclass: `(embedding public.vector_cosine_ops)`. Strip
5758            // the schema and dispatch on the bare opclass, the same
5759            // treatment table/type names get.
5760            Token::Ident(s) | Token::QuotedIdent(s)
5761                if matches!(
5762                    self.tokens.get(self.pos + 1),
5763                    Some(Token::Ident(_) | Token::QuotedIdent(_))
5764                ) && matches!(self.tokens.get(self.pos + 2), Some(Token::Dot))
5765                    && matches!(
5766                        self.tokens.get(self.pos + 3),
5767                        Some(Token::Ident(op) | Token::QuotedIdent(op))
5768                            if is_vector_opclass_name(op)
5769                    ) =>
5770            {
5771                self.advance(); // column name
5772                self.advance(); // schema qualifier
5773                self.advance(); // dot
5774                let op_tok = self.advance();
5775                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
5776                    opclass = Some(op.to_ascii_lowercase());
5777                }
5778                (s, None)
5779            }
5780            Token::Ident(s) | Token::QuotedIdent(s)
5781                if matches!(
5782                    self.tokens.get(self.pos + 1),
5783                    Some(Token::Ident(op) | Token::QuotedIdent(op))
5784                        if is_vector_opclass_name(op)
5785                ) =>
5786            {
5787                self.advance(); // column name
5788                // Capture the opclass token, lower-cased for
5789                // case-insensitive engine dispatch.
5790                let op_tok = self.advance();
5791                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
5792                    opclass = Some(op.to_ascii_lowercase());
5793                }
5794                (s, None)
5795            }
5796            Token::Ident(_) | Token::QuotedIdent(_) => {
5797                let key_expr = self.parse_expr(0)?;
5798                let primary = extract_first_column(&key_expr).ok_or_else(|| {
5799                    self.err("expression index key must reference at least one column".into())
5800                })?;
5801                (primary, Some(key_expr))
5802            }
5803            other => {
5804                return Err(self.err(format!(
5805                    "expected column ident or expression, got {other:?}"
5806                )));
5807            }
5808        };
5809        // v7.9.14 — accept extra comma-separated columns inside
5810        // the index key parens (`CREATE INDEX … (a, b, c)`).
5811        // mailrs F2. Each extra column may carry an optional
5812        // `ASC` / `DESC` / `NULLS FIRST` / `NULLS LAST` clause
5813        // — parsed and discarded; SPG doesn't honour direction
5814        // on a BTree index today (column ordering is intrinsic
5815        // to the storage). v7.10 will widen to genuine composite
5816        // index keys.
5817        let mut extra_columns: Vec<String> = Vec::new();
5818        // The leading column may also have ASC/DESC after it.
5819        self.consume_optional_index_column_qualifiers();
5820        while matches!(self.peek(), Token::Comma) {
5821            self.advance();
5822            let extra = self.expect_ident_like()?;
5823            self.consume_optional_index_column_qualifiers();
5824            extra_columns.push(extra);
5825        }
5826        if !matches!(self.peek(), Token::RParen) {
5827            return Err(self.err(format!(
5828                "expected ')' after indexed column / expression, got {:?}",
5829                self.peek()
5830            )));
5831        }
5832        self.advance();
5833        // v6.8.0 — optional `INCLUDE (col1, col2, …)` clause for
5834        // index-only-scan annotation. Bare ident (not a reserved
5835        // keyword) so we test by case-insensitive string match.
5836        let included_columns = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("include"))
5837        {
5838            self.advance();
5839            if !matches!(self.peek(), Token::LParen) {
5840                return Err(self.err(format!("expected '(' after INCLUDE, got {:?}", self.peek())));
5841            }
5842            self.advance();
5843            let mut cols = Vec::new();
5844            loop {
5845                cols.push(self.expect_ident_like()?);
5846                match self.peek() {
5847                    Token::Comma => {
5848                        self.advance();
5849                    }
5850                    Token::RParen => {
5851                        self.advance();
5852                        break;
5853                    }
5854                    other => {
5855                        return Err(self.err(format!(
5856                            "expected ',' or ')' in INCLUDE list, got {other:?}"
5857                        )));
5858                    }
5859                }
5860            }
5861            cols
5862        } else {
5863            Vec::new()
5864        };
5865        // v7.11.3 — accept and discard PG `WITH (k = v, ...)` index
5866        // storage parameters. pgvector emits `WITH (lists = N)` for
5867        // ivfflat and `WITH (m = N, ef_construction = M)` for hnsw;
5868        // SPG's HNSW picks its own parameters today (tunable via
5869        // env vars), so the WITH clause is informational and dropped.
5870        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
5871            self.advance();
5872            if !matches!(self.peek(), Token::LParen) {
5873                return Err(self.err(format!(
5874                    "expected '(' after WITH in CREATE INDEX, got {:?}",
5875                    self.peek()
5876                )));
5877            }
5878            self.advance();
5879            loop {
5880                if matches!(self.peek(), Token::RParen) {
5881                    self.advance();
5882                    break;
5883                }
5884                // Drain `key = value` or bare `key` tokens.
5885                let _ = self.advance(); // key
5886                if matches!(self.peek(), Token::Eq) {
5887                    self.advance();
5888                    let _ = self.advance(); // value (int / string / ident)
5889                }
5890                match self.peek() {
5891                    Token::Comma => {
5892                        self.advance();
5893                    }
5894                    Token::RParen => {
5895                        self.advance();
5896                        break;
5897                    }
5898                    other => {
5899                        return Err(self.err(format!(
5900                            "expected ',' or ')' in WITH (…) clause, got {other:?}"
5901                        )));
5902                    }
5903                }
5904            }
5905        }
5906        // v6.8.1 — optional `WHERE <expr>` partial-index predicate.
5907        let partial_predicate = if matches!(self.peek(), Token::Where) {
5908            self.advance();
5909            Some(self.parse_expr(0)?)
5910        } else {
5911            None
5912        };
5913        // v7.9.29 — UNIQUE on a vector index (HNSW) makes no
5914        // sense: uniqueness over an ANN structure has no clean
5915        // semantics. Reject early. (BRIN UNIQUE is similarly
5916        // meaningless — block both.)
5917        if is_unique && !matches!(method, IndexMethod::BTree) {
5918            return Err(self.err(alloc::format!(
5919                "UNIQUE is only supported on BTree indexes, got USING {:?}",
5920                method
5921            )));
5922        }
5923        Ok(Statement::CreateIndex(CreateIndexStatement {
5924            name,
5925            table,
5926            column,
5927            method,
5928            if_not_exists,
5929            included_columns,
5930            partial_predicate,
5931            extra_columns: extra_columns.clone(),
5932            expression,
5933            is_unique,
5934            opclass,
5935        }))
5936    }
5937
5938    /// v7.6.0 — wraps `parse_column_def` and consumes an optional
5939    /// column-level `REFERENCES ...` clause. The trailing FK is
5940    /// normalised into table-level shape (single-element columns +
5941    /// parent_columns) so the engine sees one uniform constraint list.
5942    fn parse_column_def_with_fk(
5943        &mut self,
5944    ) -> Result<(ColumnDef, Option<ForeignKeyConstraint>), ParseError> {
5945        let col = self.parse_column_def()?;
5946        // Inline form: `col INT REFERENCES tbl(pcol) [ON DELETE ...] [ON UPDATE ...]`.
5947        let inline_references = matches!(
5948            self.peek(),
5949            Token::Ident(s) if s.eq_ignore_ascii_case("references")
5950        );
5951        if !inline_references {
5952            return Ok((col, None));
5953        }
5954        let (parent_table, parent_columns, on_delete, on_update) = self.parse_references_tail(1)?;
5955        let fk = ForeignKeyConstraint {
5956            name: None,
5957            columns: vec![col.name.clone()],
5958            parent_table,
5959            parent_columns,
5960            on_delete,
5961            on_update,
5962        };
5963        Ok((col, Some(fk)))
5964    }
5965
5966    /// v7.13.0 — parse a column type (consuming the type ident and
5967    /// any trailing parameters / `[]`), without surrounding column
5968    /// constraints. Used by ALTER COLUMN TYPE (mailrs round-5 G8).
5969    /// Returns the resolved `ColumnTypeName` plus implied
5970    /// `(auto_increment, not_null)` flags from PG SERIAL family
5971    /// shorthands — callers that don't expect those (ALTER COLUMN
5972    /// TYPE) can discard them.
5973    fn parse_column_type_name(&mut self) -> Result<ColumnTypeName, ParseError> {
5974        let (ty, _, _, _, _, _, _, _) = self.parse_type_with_implied_flags()?;
5975        Ok(ty)
5976    }
5977
5978    #[allow(clippy::type_complexity)]
5979    fn parse_type_with_implied_flags(
5980        &mut self,
5981    ) -> Result<
5982        (
5983            ColumnTypeName,
5984            bool,
5985            bool,
5986            Option<String>,
5987            Collation,
5988            bool,
5989            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant
5990            // list captured at type-parse time. None for all
5991            // non-ENUM types.
5992            Option<Vec<String>>,
5993            // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant
5994            // list. Distinct from ENUM (subset semantics).
5995            Option<Vec<String>>,
5996        ),
5997        ParseError,
5998    > {
5999        let mut ty_ident = match self.advance() {
6000            Token::Ident(s) => s,
6001            other => {
6002                return Err(ParseError {
6003                    message: format!("expected column type, got {other:?}"),
6004                    token_pos: self.pos.saturating_sub(1),
6005                });
6006            }
6007        };
6008        // v7.22 (mailrs round-13 gap 4) — schema-qualified type names:
6009        // pg_dump qualifies extension types (`public.vector(1024)`).
6010        // SPG is single-namespace; drop the schema and resolve the
6011        // bare type — same treatment table names already get.
6012        while matches!(self.peek(), Token::Dot) {
6013            self.advance();
6014            ty_ident = self.expect_ident_like()?;
6015        }
6016        let mut implied_auto_increment = false;
6017        let mut implied_not_null = false;
6018        let mut user_type_ref: Option<String> = None;
6019        // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM('a','b','c')
6020        // value list, captured here and bubbled up through the
6021        // ColumnDef so the engine can attach it to the column
6022        // schema (and validate INSERT cells against it).
6023        let mut inline_enum_variants: Option<Vec<String>> = None;
6024        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
6025        let mut inline_set_variants: Option<Vec<String>> = None;
6026        let mut ty = match ty_ident.as_str() {
6027            // PG SERIAL family. Implies NOT NULL + AUTO_INCREMENT.
6028            "smallserial" | "serial2" => {
6029                implied_auto_increment = true;
6030                implied_not_null = true;
6031                ColumnTypeName::SmallInt
6032            }
6033            "serial" | "serial4" => {
6034                implied_auto_increment = true;
6035                implied_not_null = true;
6036                ColumnTypeName::Int
6037            }
6038            "bigserial" | "serial8" => {
6039                implied_auto_increment = true;
6040                implied_not_null = true;
6041                ColumnTypeName::BigInt
6042            }
6043            // MySQL flavours we accept by aliasing to the closest SPG
6044            // type. TINYINT covers MySQL's i8 — held inside SMALLINT
6045            // since SPG doesn't have a dedicated i8. MEDIUMINT (MySQL
6046            // 24-bit) → INT. UNSIGNED modifiers are consumed below
6047            // without semantic effect.
6048            "smallint" => {
6049                // v7.14.0 — MySQL display-width on integers
6050                // (`SMALLINT(5)`, `INT(11)`, `BIGINT(20)`). The
6051                // parenthesised number is purely cosmetic — it
6052                // doesn't change storage. Accept + discard.
6053                self.consume_optional_paren_size();
6054                ColumnTypeName::SmallInt
6055            }
6056            // v7.17.0 Phase 4.3 — MySQL `TINYINT(1)` is the
6057            // canonical encoding for BOOLEAN. Every MySQL driver
6058            // (JDBC `tinyInt1isBit=true`, PHP `mysql_field_type`,
6059            // .NET `MySqlConnection`, sqlx) maps it to bit. Pre-
6060            // 4.3 SPG classified TINYINT(1) as SmallInt, which
6061            // gave the customer i16-shaped values where the app
6062            // expected bool — a Tier-A silent type drift on
6063            // mysqldump restores. Now: `TINYINT(1)` → Bool;
6064            // `TINYINT` (no width) and `TINYINT(N)` for N ≠ 1
6065            // stay SmallInt (the legacy width-agnostic path).
6066            "tinyint" => {
6067                let width = self.peek_optional_paren_size_value();
6068                self.consume_optional_paren_size();
6069                if width == Some(1) {
6070                    ColumnTypeName::Bool
6071                } else {
6072                    ColumnTypeName::SmallInt
6073                }
6074            }
6075            "int" | "integer" | "mediumint" => {
6076                self.consume_optional_paren_size();
6077                ColumnTypeName::Int
6078            }
6079            "bigint" => {
6080                self.consume_optional_paren_size();
6081                ColumnTypeName::BigInt
6082            }
6083            // DOUBLE / REAL are 64-bit IEEE — same as our FLOAT.
6084            // v7.13.0 — `DOUBLE PRECISION` (PG canonical spelling)
6085            // (mailrs round-5 G6). Consume the optional `PRECISION`
6086            // tail when the type keyword was `double` / `DOUBLE`.
6087            "float" | "double" | "real" => {
6088                if ty_ident.eq_ignore_ascii_case("double")
6089                    && matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("precision"))
6090                {
6091                    self.advance();
6092                }
6093                ColumnTypeName::Float
6094            }
6095            // v7.13.0 — `FLOAT8` (PG short form) maps the same as FLOAT.
6096            "float4" | "float8" => ColumnTypeName::Float,
6097            "text" => ColumnTypeName::Text,
6098            "bool" | "boolean" => ColumnTypeName::Bool,
6099            "varchar" => ColumnTypeName::Varchar(self.parse_paren_size("VARCHAR")?),
6100            "char" => ColumnTypeName::Char(self.parse_paren_size("CHAR")?),
6101            "vector" => {
6102                let dim = self.parse_paren_size("VECTOR")?;
6103                let encoding = self.parse_optional_vector_encoding()?;
6104                ColumnTypeName::Vector { dim, encoding }
6105            }
6106            "numeric" => {
6107                let (precision, scale) = self.parse_optional_numeric_params()?;
6108                ColumnTypeName::Numeric(precision, scale)
6109            }
6110            "date" => ColumnTypeName::Date,
6111            // MySQL's `DATETIME` is the same domain as standard
6112            // `TIMESTAMP` — accept both spellings.
6113            "timestamp" | "datetime" => {
6114                // v7.14.0 — PG canonical `TIMESTAMP WITH TIME ZONE`
6115                // / `TIMESTAMP WITHOUT TIME ZONE`. pg_dump emits
6116                // the full form. SPG canonicalises:
6117                //   - WITH TIME ZONE    → Timestamptz
6118                //   - WITHOUT TIME ZONE → Timestamp
6119                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with"))
6120                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6121                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6122                {
6123                    self.advance(); // WITH
6124                    self.advance(); // TIME
6125                    self.advance(); // ZONE
6126                    ColumnTypeName::Timestamptz
6127                } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("without"))
6128                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6129                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6130                {
6131                    self.advance(); // WITHOUT
6132                    self.advance(); // TIME
6133                    self.advance(); // ZONE
6134                    ColumnTypeName::Timestamp
6135                } else {
6136                    // Optional `(precision)` parenthesised modifier
6137                    // (PG fractional seconds precision). SPG stores
6138                    // µs always; accept + discard.
6139                    self.consume_optional_paren_size();
6140                    ColumnTypeName::Timestamp
6141                }
6142            }
6143            // v7.9.2 — `TIMESTAMPTZ` and full PG spelling
6144            // `TIMESTAMP WITH TIME ZONE`. Same storage as TIMESTAMP;
6145            // only PG-wire OID differs.
6146            "timestamptz" => ColumnTypeName::Timestamptz,
6147            // v4.9: JSON / JSONB. Stored as raw text — no parse-time
6148            // validation. We accept the JSONB spelling too because
6149            // most PG clients default to it; SPG doesn't distinguish
6150            // the two (no path-operator perf advantage to model).
6151            "json" => ColumnTypeName::Json,
6152            "jsonb" => ColumnTypeName::Jsonb,
6153            // v7.10.4 — PG `BYTEA` and the SPG `BYTES` alias both
6154            // surface here. Same storage shape; mapping happens at
6155            // the engine side via the ColumnTypeName → DataType
6156            // resolver. Literal forms are handled at coerce_value
6157            // time so the lexer stays untouched.
6158            "bytea" | "bytes" => ColumnTypeName::Bytes,
6159            // v7.17.0 Phase 7 — PG network address types
6160            // (`inet`, `cidr`, `macaddr`). pg_dump / Django ORM
6161            // emit these for IP-address columns; pre-7 SPG
6162            // errored with "unknown type", breaking schema
6163            // restores. Accept as Text-backed (no new storage
6164            // shape): the customer's data round-trips, and the
6165            // host() / network() helpers added in the same
6166            // phase work textually. Containment operators
6167            // `<<=` / `>>=` are out of v7.17 scope.
6168            "inet" | "cidr" | "macaddr" => ColumnTypeName::Text,
6169            // v7.12.0 — PG full-text search types. mailrs G-CRIT-3.
6170            // The actual `to_tsvector` / `@@` / `ts_rank` surface
6171            // arrives in v7.12.1+; the type itself loads here so
6172            // mailrs's `scripts/init-schema.sql` runs unmodified.
6173            "tsvector" => ColumnTypeName::TsVector,
6174            "tsquery" => ColumnTypeName::TsQuery,
6175            // v7.17.0 — PG `UUID`. Wire OID 2950. The drop-in PG
6176            // surface for Django / Rails / Hibernate's default
6177            // PK pattern.
6178            "uuid" => ColumnTypeName::Uuid,
6179            // v7.17.0 Phase 3.P0-32 — PG `TIME` (without time zone).
6180            // i64 microseconds since 00:00:00. Wire OID 1083.
6181            "time" => ColumnTypeName::Time,
6182            // v7.17.0 Phase 3.P0-33 — MySQL `YEAR`. u16 in
6183            // 1901..=2155 + zero-year sentinel 0. Wire = INT4.
6184            "year" => ColumnTypeName::Year,
6185            // v7.17.0 Phase 3.P0-34 — PG `TIMETZ` / `TIME WITH
6186            // TIME ZONE`. i64 us + i32 offset_secs. Wire OID 1266.
6187            "timetz" => ColumnTypeName::TimeTz,
6188            // v7.17.0 Phase 3.P0-35 — PG `MONEY` — i64 cents.
6189            // Wire OID 790.
6190            "money" => ColumnTypeName::Money,
6191            // v7.17.0 Phase 3.P0-38 — PG range types.
6192            "int4range" => ColumnTypeName::Range(RangeKindAst::Int4),
6193            "int8range" => ColumnTypeName::Range(RangeKindAst::Int8),
6194            "numrange" => ColumnTypeName::Range(RangeKindAst::Num),
6195            "tsrange" => ColumnTypeName::Range(RangeKindAst::Ts),
6196            "tstzrange" => ColumnTypeName::Range(RangeKindAst::TsTz),
6197            "daterange" => ColumnTypeName::Range(RangeKindAst::Date),
6198            // v7.17.0 Phase 3.P0-39 — PG hstore extension type.
6199            "hstore" => ColumnTypeName::Hstore,
6200            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6201            // `ENUM('a','b','c')`. Storage is TEXT; the value
6202            // list lands on `inline_enum_variants` for the
6203            // engine to validate INSERT cells against. Empty
6204            // value list is a parse error (matches MySQL).
6205            "enum" => {
6206                // Expect the opening `(`.
6207                if !matches!(self.peek(), Token::LParen) {
6208                    return Err(self.err(alloc::format!(
6209                        "expected '(' after ENUM, got {:?}",
6210                        self.peek()
6211                    )));
6212                }
6213                self.advance();
6214                let mut variants: Vec<String> = Vec::new();
6215                loop {
6216                    match self.advance() {
6217                        Token::String(s) => variants.push(s),
6218                        other => {
6219                            return Err(self.err(alloc::format!(
6220                                "ENUM(...) expects string literal variants, got {other:?}"
6221                            )));
6222                        }
6223                    }
6224                    match self.peek() {
6225                        Token::Comma => {
6226                            self.advance();
6227                            continue;
6228                        }
6229                        Token::RParen => {
6230                            self.advance();
6231                            break;
6232                        }
6233                        other => {
6234                            return Err(self.err(alloc::format!(
6235                                "expected ',' or ')' in ENUM(...), got {other:?}"
6236                            )));
6237                        }
6238                    }
6239                }
6240                if variants.is_empty() {
6241                    return Err(self.err("ENUM(...) must declare at least one variant".into()));
6242                }
6243                inline_enum_variants = Some(variants);
6244                // Storage is plain TEXT; the variant list lives on
6245                // the ColumnSchema side.
6246                ColumnTypeName::Text
6247            }
6248            // v7.17.0 Phase 3.P0-37 — MySQL inline SET
6249            // `SET('a','b','c')`. Same parse shape as ENUM;
6250            // semantics differ (subset rather than pick-one).
6251            "set" => {
6252                if !matches!(self.peek(), Token::LParen) {
6253                    return Err(self.err(alloc::format!(
6254                        "expected '(' after SET, got {:?}",
6255                        self.peek()
6256                    )));
6257                }
6258                self.advance();
6259                let mut variants: Vec<String> = Vec::new();
6260                loop {
6261                    match self.advance() {
6262                        Token::String(s) => variants.push(s),
6263                        other => {
6264                            return Err(self.err(alloc::format!(
6265                                "SET(...) expects string literal variants, got {other:?}"
6266                            )));
6267                        }
6268                    }
6269                    match self.peek() {
6270                        Token::Comma => {
6271                            self.advance();
6272                            continue;
6273                        }
6274                        Token::RParen => {
6275                            self.advance();
6276                            break;
6277                        }
6278                        other => {
6279                            return Err(self.err(alloc::format!(
6280                                "expected ',' or ')' in SET(...), got {other:?}"
6281                            )));
6282                        }
6283                    }
6284                }
6285                if variants.is_empty() {
6286                    return Err(self.err("SET(...) must declare at least one variant".into()));
6287                }
6288                inline_set_variants = Some(variants);
6289                ColumnTypeName::Text
6290            }
6291            _other => {
6292                // v7.17.0 Phase 1.4 — unknown ident → defer
6293                // resolution to the engine. Stored as Text in
6294                // ColumnTypeName + the original name carried as
6295                // `user_type_ref` so CREATE TABLE can look up
6296                // user-defined enum / domain types.
6297                user_type_ref = Some(ty_ident.clone());
6298                ColumnTypeName::Text
6299            }
6300        };
6301        // v7.17.0 Phase 4.4 — MySQL's `UNSIGNED` modifier sits
6302        // right after the type keyword. Pre-4.4 SPG consumed +
6303        // discarded the keyword, leaving a customer column
6304        // declared `id INT UNSIGNED NOT NULL` silently accepting
6305        // negative values — a Tier-A correctness drift where
6306        // application invariants (auto-increment-IDs never
6307        // negative) silently broke on cutover. Now: capture as
6308        // a column flag, persist on the schema, enforce at
6309        // INSERT / UPDATE time.
6310        let is_unsigned = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unsigned"))
6311        {
6312            self.advance();
6313            true
6314        } else {
6315            false
6316        };
6317        // v7.14.0 — mysqldump emits `<type> CHARACTER SET <name>` and
6318        // `<type> COLLATE <name>` post-fixes on text columns. SPG
6319        // stores text as UTF-8 always so CHARACTER SET is still a
6320        // no-op. v7.17.0 Phase 2.5 — COLLATE no longer drops the
6321        // name: it gets classified into a `Collation` variant the
6322        // engine consults at WHERE-eval time. PG `default` /
6323        // `pg_catalog.default` / `C` / `POSIX` collations all
6324        // resolve to `Binary` (the prior behaviour); `_ci` /
6325        // `case_insensitive` / `nocase` shift to CaseInsensitive.
6326        // The schema-qualifier form (`pg_catalog.default`) lexes
6327        // as `Ident '.' Ident` — peek for the `.` and consume both
6328        // halves so it's treated as one collation name. PG's
6329        // `IDENT.IDENT` collation form (which can appear here) is
6330        // resolved by Collation::from_collation_name on the bare
6331        // identifier after the dot.
6332        let mut collation = Collation::Binary;
6333        loop {
6334            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
6335                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
6336            {
6337                self.advance(); // CHARACTER
6338                self.advance(); // SET
6339                if matches!(
6340                    self.peek(),
6341                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
6342                ) {
6343                    self.advance();
6344                }
6345                continue;
6346            }
6347            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate")) {
6348                self.advance(); // COLLATE
6349                // Accept Ident / QuotedIdent / String AND the
6350                // keyword-tokenised `Default` (PG `pg_catalog.default`
6351                // and bare `DEFAULT` collation names — `default` is a
6352                // reserved word so the lexer hands back Token::Default
6353                // not Token::Ident).
6354                let read_collation_atom = |this: &mut Self| -> Option<alloc::string::String> {
6355                    match this.peek().clone() {
6356                        Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => {
6357                            this.advance();
6358                            Some(s)
6359                        }
6360                        Token::Default => {
6361                            this.advance();
6362                            Some(alloc::string::String::from("default"))
6363                        }
6364                        _ => None,
6365                    }
6366                };
6367                let raw = if let Some(head) = read_collation_atom(self) {
6368                    // Schema-qualified PG form: `pg_catalog.default`.
6369                    if matches!(self.peek(), Token::Dot) {
6370                        self.advance();
6371                        let tail = read_collation_atom(self).unwrap_or_default();
6372                        alloc::format!("{head}.{tail}")
6373                    } else {
6374                        head
6375                    }
6376                } else {
6377                    alloc::string::String::new()
6378                };
6379                if !raw.is_empty() {
6380                    let parsed = Collation::from_collation_name(&raw);
6381                    // Last COLLATE clause wins, but `Binary` from a
6382                    // bare keyword like `default` should not
6383                    // silently downgrade a stronger one set earlier
6384                    // on the same column. v7.17 only ships one
6385                    // non-Binary variant so a simple OR is enough.
6386                    if parsed != Collation::Binary {
6387                        collation = parsed;
6388                    }
6389                }
6390                continue;
6391            }
6392            break;
6393        }
6394        // v7.10.10 — postfix `[]` widens TEXT → TEXT[]. PG accepts
6395        // `TYPE[]` after any base type; v7.10 only models TEXT[]
6396        // so we reject other base types here. mailrs uses TEXT[]
6397        // for labels / addresses / message-on-thread.
6398        if matches!(self.peek(), Token::LBracket) {
6399            self.advance();
6400            if !matches!(self.peek(), Token::RBracket) {
6401                return Err(self.err(alloc::format!(
6402                    "TEXT[] takes no dimension; got {:?}",
6403                    self.peek()
6404                )));
6405            }
6406            self.advance();
6407            // v7.11.13 — widened to INT[] and BIGINT[] in addition
6408            // to TEXT[]. Other base types (BOOL[], NUMERIC[], etc.)
6409            // still error here.
6410            ty = match ty {
6411                ColumnTypeName::Text => ColumnTypeName::TextArray,
6412                ColumnTypeName::Int => ColumnTypeName::IntArray,
6413                ColumnTypeName::BigInt => ColumnTypeName::BigIntArray,
6414                other => {
6415                    return Err(self.err(alloc::format!(
6416                        "v7.11 supports TEXT[] / INT[] / BIGINT[] only; got {other:?}[]"
6417                    )));
6418                }
6419            };
6420            // v7.17.0 Phase 3.P0-40 — second `[]` widens 1D → 2D
6421            // for INT/TEXT/BIGINT. Anything else is an error.
6422            if matches!(self.peek(), Token::LBracket) {
6423                self.advance();
6424                if !matches!(self.peek(), Token::RBracket) {
6425                    return Err(self.err(alloc::format!(
6426                        "TYPE[][] second dimension takes no size; got {:?}",
6427                        self.peek()
6428                    )));
6429                }
6430                self.advance();
6431                ty = match ty {
6432                    ColumnTypeName::IntArray => ColumnTypeName::IntArray2D,
6433                    ColumnTypeName::BigIntArray => ColumnTypeName::BigIntArray2D,
6434                    ColumnTypeName::TextArray => ColumnTypeName::TextArray2D,
6435                    other => {
6436                        return Err(self.err(alloc::format!(
6437                            "v7.17 2D arrays support INT[][] / BIGINT[][] / \
6438                             TEXT[][] only; got {other:?}"
6439                        )));
6440                    }
6441                };
6442            }
6443        }
6444        Ok((
6445            ty,
6446            implied_auto_increment,
6447            implied_not_null,
6448            user_type_ref,
6449            collation,
6450            is_unsigned,
6451            inline_enum_variants,
6452            inline_set_variants,
6453        ))
6454    }
6455
6456    fn parse_column_def(&mut self) -> Result<ColumnDef, ParseError> {
6457        // v7.20 — PG reserves the table-constraint keywords, so a
6458        // BARE `UNIQUE` / `PRIMARY` / … in column position is a
6459        // malformed constraint clause (e.g. `UNIQUE a` missing its
6460        // parens), not a column named "unique". Since v7.17's
6461        // unknown-type leniency (`user_type_ref`) such a clause
6462        // would otherwise parse as a column with a user-defined
6463        // type — silently accepting invalid DDL. Quoted
6464        // identifiers ("unique" / `unique`) remain valid names.
6465        if let Token::Ident(s) = self.peek()
6466            && [
6467                "unique",
6468                "primary",
6469                "foreign",
6470                "constraint",
6471                "check",
6472                "references",
6473                "exclude",
6474            ]
6475            .iter()
6476            .any(|kw| s.eq_ignore_ascii_case(kw))
6477        {
6478            return Err(self.err(alloc::format!(
6479                "unexpected reserved keyword '{s}' at start of column definition \
6480                 (malformed table constraint?)"
6481            )));
6482        }
6483        let name = self.expect_ident_like()?;
6484        let (
6485            ty,
6486            implied_auto_increment,
6487            implied_not_null,
6488            user_type_ref,
6489            collation,
6490            is_unsigned,
6491            inline_enum_variants,
6492            inline_set_variants,
6493        ) = self.parse_type_with_implied_flags()?;
6494        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
6495        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
6496        // each at most once.
6497        let mut default: Option<Expr> = None;
6498        let mut nullable = !implied_not_null;
6499        let mut nullability_seen = implied_not_null;
6500        let mut auto_increment = implied_auto_increment;
6501        let mut is_primary_key = false;
6502        let mut is_unique = false;
6503        let mut check: Option<Expr> = None;
6504        let mut on_update_runtime: Option<Expr> = None;
6505        loop {
6506            // v7.22 (mailrs round-13 gap 3) — PG 18 catalogs
6507            // not-null constraints by name and pg_dump emits them
6508            // inline: `id bigint CONSTRAINT contacts_id_not_null1
6509            // NOT NULL`. Accept and discard the name; whatever
6510            // constraint follows is parsed by the arms below.
6511            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
6512                self.advance();
6513                let _name = self.expect_ident_like()?;
6514                continue;
6515            }
6516            // v7.22 (round-13 T2) — inline `GENERATED { ALWAYS |
6517            // BY DEFAULT } AS IDENTITY [(seq options)]` (PG 10+;
6518            // the modern replacement for SERIAL in hand-written
6519            // schemas). Both flavours map onto the auto-increment
6520            // machinery — SPG's serial semantics ≈ BY DEFAULT;
6521            // ALWAYS's reject-explicit-values nuance is documented
6522            // leniency. Generated EXPRESSION columns
6523            // (`AS (expr) STORED`) are not supported: error loudly
6524            // instead of silently storing NULLs.
6525            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("generated")) {
6526                self.advance();
6527                match self.peek().clone() {
6528                    Token::Ident(s) if s.eq_ignore_ascii_case("always") => {
6529                        self.advance();
6530                    }
6531                    // `BY` is a reserved keyword token (GROUP BY).
6532                    Token::By => {
6533                        self.advance();
6534                        if !matches!(self.peek(), Token::Default) {
6535                            return Err(self.err(alloc::format!(
6536                                "expected DEFAULT after GENERATED BY, got {:?}",
6537                                self.peek()
6538                            )));
6539                        }
6540                        self.advance();
6541                    }
6542                    other => {
6543                        return Err(self.err(alloc::format!(
6544                            "expected ALWAYS or BY DEFAULT after GENERATED, got {other:?}"
6545                        )));
6546                    }
6547                }
6548                if !matches!(self.peek(), Token::As) {
6549                    return Err(self.err(alloc::format!(
6550                        "expected AS after GENERATED ALWAYS/BY DEFAULT, got {:?}",
6551                        self.peek()
6552                    )));
6553                }
6554                self.advance();
6555                if matches!(self.peek(), Token::LParen) {
6556                    return Err(self.err(
6557                        "generated expression columns (GENERATED … AS (expr) STORED) \
6558                         are not supported"
6559                            .into(),
6560                    ));
6561                }
6562                self.expect_keyword_ident("identity")?;
6563                // Optional `(START WITH 1 INCREMENT BY 1 …)` —
6564                // consume the balanced parens and discard (SPG's
6565                // auto-increment is max+1-scan based).
6566                if matches!(self.peek(), Token::LParen) {
6567                    let mut depth = 0usize;
6568                    loop {
6569                        match self.advance() {
6570                            Token::LParen => depth += 1,
6571                            Token::RParen => {
6572                                depth -= 1;
6573                                if depth == 0 {
6574                                    break;
6575                                }
6576                            }
6577                            Token::Eof => {
6578                                return Err(self.err(
6579                                    "unterminated sequence-options parens after IDENTITY".into(),
6580                                ));
6581                            }
6582                            _ => {}
6583                        }
6584                    }
6585                }
6586                auto_increment = true;
6587                // PG identity columns are implicitly NOT NULL.
6588                nullable = false;
6589                continue;
6590            }
6591            // v7.17.0 Phase 2.1 — MySQL `ON UPDATE
6592            // CURRENT_TIMESTAMP[(N)]`. Only CURRENT_TIMESTAMP
6593            // is accepted today. The "ON" token is an Ident
6594            // (not reserved) — peek before consuming.
6595            if matches!(self.peek(), Token::On)
6596                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("update"))
6597            {
6598                self.advance(); // ON
6599                self.advance(); // update
6600                // Accept CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(N).
6601                let next = self.peek().clone();
6602                match next {
6603                    Token::Ident(s) | Token::QuotedIdent(s)
6604                        if s.eq_ignore_ascii_case("current_timestamp") =>
6605                    {
6606                        self.advance();
6607                        // Optional `(N)` precision.
6608                        if matches!(self.peek(), Token::LParen) {
6609                            self.advance();
6610                            if !matches!(self.peek(), Token::Integer(_)) {
6611                                return Err(self.err(alloc::format!(
6612                                    "expected integer precision inside CURRENT_TIMESTAMP(…), got {:?}",
6613                                    self.peek()
6614                                )));
6615                            }
6616                            self.advance();
6617                            if !matches!(self.peek(), Token::RParen) {
6618                                return Err(self.err(alloc::format!(
6619                                    "expected ')' after CURRENT_TIMESTAMP precision, got {:?}",
6620                                    self.peek()
6621                                )));
6622                            }
6623                            self.advance();
6624                        }
6625                        on_update_runtime = Some(Expr::FunctionCall {
6626                            name: "now".into(),
6627                            args: Vec::new(),
6628                        });
6629                        continue;
6630                    }
6631                    other => {
6632                        return Err(self.err(alloc::format!(
6633                            "v7.17 only supports ON UPDATE CURRENT_TIMESTAMP, got {other:?}"
6634                        )));
6635                    }
6636                }
6637            }
6638            if matches!(self.peek(), Token::Default) {
6639                if default.is_some() {
6640                    return Err(self.err("DEFAULT specified twice".into()));
6641                }
6642                self.advance();
6643                default = Some(self.parse_expr(0)?);
6644                continue;
6645            }
6646            if matches!(self.peek(), Token::Not) {
6647                if nullability_seen {
6648                    return Err(self.err("NOT NULL specified twice".into()));
6649                }
6650                self.advance();
6651                if !matches!(self.peek(), Token::Null) {
6652                    return Err(self.err(format!(
6653                        "expected NULL after NOT in column def, got {:?}",
6654                        self.peek()
6655                    )));
6656                }
6657                self.advance();
6658                nullable = false;
6659                nullability_seen = true;
6660                continue;
6661            }
6662            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
6663            // "this column is nullable" marker (the default in
6664            // standard SQL anyway). mysqldump emits it routinely
6665            // (`col TYPE NULL DEFAULT NULL` for nullable
6666            // timestamps etc). Accept + no-op.
6667            if matches!(self.peek(), Token::Null) {
6668                if nullability_seen && !nullable {
6669                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
6670                }
6671                self.advance();
6672                nullable = true;
6673                nullability_seen = true;
6674                continue;
6675            }
6676            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
6677            // arrives as a bare Ident. Match either, case-insensitive.
6678            if let Token::Ident(s) = self.peek()
6679                && (s.eq_ignore_ascii_case("auto_increment")
6680                    || s.eq_ignore_ascii_case("autoincrement"))
6681            {
6682                if auto_increment {
6683                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
6684                }
6685                self.advance();
6686                auto_increment = true;
6687                continue;
6688            }
6689            // v7.9.13 — inline `PRIMARY KEY` column constraint
6690            // (mailrs F1). Implies `NOT NULL`. The engine creates
6691            // a BTree index for the PK column at CREATE TABLE time
6692            // so FK parent-side index lookups resolve.
6693            if let Token::Ident(s) = self.peek()
6694                && s.eq_ignore_ascii_case("primary")
6695            {
6696                if is_primary_key {
6697                    return Err(self.err("PRIMARY KEY specified twice".into()));
6698                }
6699                // Peek-ahead for the required `KEY` token.
6700                let next = self.tokens.get(self.pos + 1);
6701                let next_is_key = matches!(
6702                    next,
6703                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
6704                );
6705                if !next_is_key {
6706                    return Err(self.err(format!(
6707                        "expected KEY after PRIMARY in column def, got {:?}",
6708                        next
6709                    )));
6710                }
6711                self.advance(); // PRIMARY
6712                self.advance(); // KEY
6713                is_primary_key = true;
6714                if nullability_seen && nullable {
6715                    return Err(self.err(
6716                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
6717                    ));
6718                }
6719                nullable = false;
6720                nullability_seen = true;
6721                continue;
6722            }
6723            // v7.13.0 — inline `UNIQUE` column constraint
6724            // (mailrs round-5 G2). Fold into a single-column
6725            // table-level UNIQUE at CREATE TABLE post-process time.
6726            if let Token::Ident(s) = self.peek()
6727                && s.eq_ignore_ascii_case("unique")
6728            {
6729                if is_unique {
6730                    return Err(self.err("UNIQUE specified twice".into()));
6731                }
6732                self.advance();
6733                is_unique = true;
6734                continue;
6735            }
6736            // v7.13.0 — inline `CHECK (<expr>)` column constraint
6737            // (mailrs round-5 G3). PG semantics: column-level
6738            // CHECK is equivalent to a table-level CHECK. Multiple
6739            // inline CHECKs on the same column AND together.
6740            if let Token::Ident(s) = self.peek()
6741                && s.eq_ignore_ascii_case("check")
6742            {
6743                self.advance();
6744                if !matches!(self.peek(), Token::LParen) {
6745                    return Err(self.err(alloc::format!(
6746                        "expected '(' after CHECK in column def, got {:?}",
6747                        self.peek()
6748                    )));
6749                }
6750                self.advance();
6751                let pred = self.parse_expr(0)?;
6752                if !matches!(self.peek(), Token::RParen) {
6753                    return Err(self.err(alloc::format!(
6754                        "expected ')' to close CHECK predicate, got {:?}",
6755                        self.peek()
6756                    )));
6757                }
6758                self.advance();
6759                check = Some(match check.take() {
6760                    Some(prev) => Expr::Binary {
6761                        op: BinOp::And,
6762                        lhs: Box::new(prev),
6763                        rhs: Box::new(pred),
6764                    },
6765                    None => pred,
6766                });
6767                continue;
6768            }
6769            break;
6770        }
6771        Ok(ColumnDef {
6772            name,
6773            ty,
6774            nullable,
6775            default,
6776            auto_increment,
6777            is_primary_key,
6778            is_unique,
6779            check,
6780            user_type_ref,
6781            on_update_runtime,
6782            collation,
6783            is_unsigned,
6784            inline_enum_variants,
6785            inline_set_variants,
6786        })
6787    }
6788
6789    /// `NUMERIC` may appear without parameters, with one (precision
6790    /// only, scale=0), or with both. Returns `(precision, scale)` with
6791    /// 0 = unspecified for the bare form.
6792    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
6793        if !matches!(self.peek(), Token::LParen) {
6794            // Bare `NUMERIC` — PG treats this as "unlimited precision";
6795            // we surface it as precision=0 to mean "unconstrained" so
6796            // the engine doesn't need a separate variant.
6797            return Ok((0, 0));
6798        }
6799        self.advance();
6800        let precision = match self.advance() {
6801            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
6802            other => {
6803                return Err(ParseError {
6804                    message: format!(
6805                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
6806                    ),
6807                    token_pos: self.pos.saturating_sub(1),
6808                });
6809            }
6810        };
6811        let scale = if matches!(self.peek(), Token::Comma) {
6812            self.advance();
6813            match self.advance() {
6814                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
6815                    u8::try_from(n).expect("range-checked")
6816                }
6817                other => {
6818                    return Err(ParseError {
6819                        message: format!(
6820                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
6821                        ),
6822                        token_pos: self.pos.saturating_sub(1),
6823                    });
6824                }
6825            }
6826        } else {
6827            0
6828        };
6829        if !matches!(self.peek(), Token::RParen) {
6830            return Err(self.err(format!(
6831                "expected ')' to close NUMERIC params, got {:?}",
6832                self.peek()
6833            )));
6834        }
6835        self.advance();
6836        Ok((precision, scale))
6837    }
6838
6839    /// Parse `(N)` where `N` is a positive integer literal — used by the
6840    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
6841    /// for the error message.
6842    /// v6.0.1: parse the optional `USING <encoding>` clause that
6843    /// follows `VECTOR(N)` in a column definition. Missing clause
6844    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
6845    /// ident → `ParseError` listing the encodings recognised today.
6846    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
6847        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
6848            return Ok(VecEncoding::F32);
6849        }
6850        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
6851        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
6852        // consume the token when the very next token is a known
6853        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
6854        // `USING` for the caller — it's the rewrite-expression form.
6855        let n1 = self.tokens.get(self.pos + 1);
6856        let next_is_encoding = matches!(
6857            n1,
6858            Some(Token::Ident(s))
6859                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
6860        );
6861        if !next_is_encoding {
6862            return Ok(VecEncoding::F32);
6863        }
6864        self.advance();
6865        let enc_ident = match self.advance() {
6866            Token::Ident(s) => s,
6867            other => {
6868                return Err(self.err(format!(
6869                    "expected vector encoding after USING, got {other:?}"
6870                )));
6871            }
6872        };
6873        match enc_ident.to_ascii_lowercase().as_str() {
6874            "sq8" => Ok(VecEncoding::Sq8),
6875            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
6876            // binary16 per-element storage.
6877            "half" => Ok(VecEncoding::F16),
6878            other => Err(self.err(format!(
6879                "unknown vector encoding {other:?}; supported: SQ8, HALF"
6880            ))),
6881        }
6882    }
6883
6884    /// v7.17.0 Phase 4.3 — peek at the MySQL display-width
6885    /// without consuming it. Returns `Some(N)` when the next
6886    /// tokens are `( <int> )`; None otherwise. Used by the
6887    /// TINYINT classifier to decide whether to map to Bool or
6888    /// SmallInt.
6889    fn peek_optional_paren_size_value(&self) -> Option<i64> {
6890        if !matches!(self.peek(), Token::LParen) {
6891            return None;
6892        }
6893        let next = self.tokens.get(self.pos + 1)?;
6894        let n = match next {
6895            Token::Integer(n) => *n,
6896            _ => return None,
6897        };
6898        if !matches!(self.tokens.get(self.pos + 2), Some(Token::RParen)) {
6899            return None;
6900        }
6901        Some(n)
6902    }
6903
6904    /// v7.14.0 — consume an optional MySQL display-width
6905    /// parenthesised number after an integer type, returning
6906    /// nothing. `TINYINT(1)` etc.
6907    fn consume_optional_paren_size(&mut self) {
6908        if !matches!(self.peek(), Token::LParen) {
6909            return;
6910        }
6911        self.advance();
6912        // Skip until matching RParen (allow nested or any tokens).
6913        let mut depth = 1usize;
6914        while depth > 0 {
6915            match self.peek() {
6916                Token::LParen => depth += 1,
6917                Token::RParen => depth -= 1,
6918                Token::Eof => return,
6919                _ => {}
6920            }
6921            self.advance();
6922        }
6923    }
6924
6925    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
6926        if !matches!(self.peek(), Token::LParen) {
6927            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
6928        }
6929        self.advance();
6930        let n = match self.advance() {
6931            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
6932                message: format!("{label} size too large: {n}"),
6933                token_pos: self.pos.saturating_sub(1),
6934            })?,
6935            other => {
6936                return Err(ParseError {
6937                    message: format!("expected positive integer {label} size, got {other:?}"),
6938                    token_pos: self.pos.saturating_sub(1),
6939                });
6940            }
6941        };
6942        if !matches!(self.peek(), Token::RParen) {
6943            return Err(self.err(format!(
6944                "expected ')' after {label} size, got {:?}",
6945                self.peek()
6946            )));
6947        }
6948        self.advance();
6949        Ok(n)
6950    }
6951
6952    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
6953        debug_assert!(matches!(self.peek(), Token::Insert));
6954        self.advance();
6955        if !matches!(self.peek(), Token::Into) {
6956            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
6957        }
6958        self.advance();
6959        let table = self.expect_ident_like()?;
6960        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
6961        let columns = if matches!(self.peek(), Token::LParen) {
6962            self.advance();
6963            let mut names = Vec::new();
6964            loop {
6965                names.push(self.expect_ident_like()?);
6966                match self.peek() {
6967                    Token::Comma => {
6968                        self.advance();
6969                    }
6970                    Token::RParen => {
6971                        self.advance();
6972                        break;
6973                    }
6974                    other => {
6975                        return Err(self.err(format!(
6976                            "expected ',' or ')' in INSERT column list, got {other:?}"
6977                        )));
6978                    }
6979                }
6980            }
6981            Some(names)
6982        } else {
6983            None
6984        };
6985        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6986        // round-5 G4). Dispatch on VALUES vs SELECT.
6987        if matches!(self.peek(), Token::Select) {
6988            let select_stmt = match self.parse_select_stmt()? {
6989                Statement::Select(s) => s,
6990                other => {
6991                    return Err(self.err(alloc::format!(
6992                        "expected SELECT after INSERT INTO ... target, got {other:?}"
6993                    )));
6994                }
6995            };
6996            let on_conflict = self.parse_optional_on_conflict()?;
6997            let returning = self.parse_optional_returning()?;
6998            return Ok(Statement::Insert(InsertStatement {
6999                table,
7000                columns,
7001                rows: Vec::new(),
7002                select_source: Some(Box::new(select_stmt)),
7003                on_conflict,
7004                returning,
7005            }));
7006        }
7007        if !matches!(self.peek(), Token::Values) {
7008            return Err(self.err(format!(
7009                "expected VALUES or SELECT after table name, got {:?}",
7010                self.peek()
7011            )));
7012        }
7013        self.advance();
7014        if !matches!(self.peek(), Token::LParen) {
7015            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
7016        }
7017        let mut rows = Vec::new();
7018        loop {
7019            // Each iteration consumes one `(expr, expr, …)` tuple.
7020            if !matches!(self.peek(), Token::LParen) {
7021                return Err(self.err(format!(
7022                    "expected '(' for next VALUES tuple, got {:?}",
7023                    self.peek()
7024                )));
7025            }
7026            self.advance();
7027            let mut tuple = Vec::new();
7028            loop {
7029                tuple.push(self.parse_expr(0)?);
7030                match self.peek() {
7031                    Token::Comma => {
7032                        self.advance();
7033                    }
7034                    Token::RParen => {
7035                        self.advance();
7036                        break;
7037                    }
7038                    other => {
7039                        return Err(self.err(format!(
7040                            "expected ',' or ')' in VALUES tuple, got {other:?}"
7041                        )));
7042                    }
7043                }
7044            }
7045            if tuple.is_empty() {
7046                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
7047            }
7048            rows.push(tuple);
7049            // Continue with comma-separated tuples.
7050            if matches!(self.peek(), Token::Comma) {
7051                self.advance();
7052            } else {
7053                break;
7054            }
7055        }
7056        let on_conflict = self.parse_optional_on_conflict()?;
7057        let returning = self.parse_optional_returning()?;
7058        Ok(Statement::Insert(InsertStatement {
7059            table,
7060            columns,
7061            rows,
7062            select_source: None,
7063            on_conflict,
7064            returning,
7065        }))
7066    }
7067
7068    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
7069    /// clause sitting between the INSERT body and the trailing
7070    /// RETURNING. All keywords come in as bare idents; `ON` is
7071    /// a reserved Token though.
7072    fn parse_optional_on_conflict(
7073        &mut self,
7074    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
7075        if !matches!(self.peek(), Token::On) {
7076            return Ok(None);
7077        }
7078        // Peek further: we want exactly "ON CONFLICT ...". If the
7079        // next ident isn't "conflict", let some other parser handle.
7080        let next_is_conflict = matches!(
7081            self.tokens.get(self.pos + 1),
7082            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
7083        );
7084        if !next_is_conflict {
7085            return Ok(None);
7086        }
7087        self.advance(); // ON
7088        self.advance(); // CONFLICT
7089        // Optional `(col [, col]*)` target list.
7090        let mut target_columns: Vec<String> = Vec::new();
7091        if matches!(self.peek(), Token::LParen) {
7092            self.advance();
7093            loop {
7094                target_columns.push(self.expect_ident_like()?);
7095                match self.peek() {
7096                    Token::Comma => {
7097                        self.advance();
7098                    }
7099                    Token::RParen => {
7100                        self.advance();
7101                        break;
7102                    }
7103                    other => {
7104                        return Err(self.err(alloc::format!(
7105                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
7106                        )));
7107                    }
7108                }
7109            }
7110        }
7111        // Required `DO`.
7112        match self.advance() {
7113            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
7114            other => {
7115                return Err(self.err(alloc::format!(
7116                    "expected DO after ON CONFLICT [(…)], got {other:?}"
7117                )));
7118            }
7119        }
7120        // Action: NOTHING | UPDATE SET …
7121        let action = match self.advance() {
7122            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
7123                crate::ast::OnConflictAction::Nothing
7124            }
7125            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
7126                self.parse_on_conflict_update_action()?
7127            }
7128            other => {
7129                return Err(self.err(alloc::format!(
7130                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
7131                )));
7132            }
7133        };
7134        Ok(Some(crate::ast::OnConflictClause {
7135            target_columns,
7136            action,
7137        }))
7138    }
7139
7140    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
7141    /// `SET col = expr [, …] [WHERE cond]`. Caller already
7142    /// consumed `UPDATE`.
7143    fn parse_on_conflict_update_action(
7144        &mut self,
7145    ) -> Result<crate::ast::OnConflictAction, ParseError> {
7146        // `SET`
7147        match self.advance() {
7148            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
7149            other => {
7150                return Err(self.err(alloc::format!(
7151                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
7152                )));
7153            }
7154        }
7155        let mut assignments: Vec<(String, Expr)> = Vec::new();
7156        loop {
7157            let col = self.expect_ident_like()?;
7158            if !matches!(self.peek(), Token::Eq) {
7159                return Err(self.err(alloc::format!(
7160                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
7161                    self.peek()
7162                )));
7163            }
7164            self.advance();
7165            let value = self.parse_expr(0)?;
7166            assignments.push((col, value));
7167            if matches!(self.peek(), Token::Comma) {
7168                self.advance();
7169                continue;
7170            }
7171            break;
7172        }
7173        let where_ = if matches!(self.peek(), Token::Where) {
7174            self.advance();
7175            Some(self.parse_expr(0)?)
7176        } else {
7177            None
7178        };
7179        Ok(crate::ast::OnConflictAction::Update {
7180            assignments,
7181            where_,
7182        })
7183    }
7184
7185    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
7186        let mut items = Vec::new();
7187        loop {
7188            items.push(self.parse_select_item()?);
7189            if matches!(self.peek(), Token::Comma) {
7190                self.advance();
7191            } else {
7192                break;
7193            }
7194        }
7195        Ok(items)
7196    }
7197
7198    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
7199        if matches!(self.peek(), Token::Star) {
7200            self.advance();
7201            return Ok(SelectItem::Wildcard);
7202        }
7203        let expr = self.parse_expr(0)?;
7204        let alias = self.parse_optional_alias();
7205        Ok(SelectItem::Expr { expr, alias })
7206    }
7207
7208    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
7209        // v7.17.0 Phase 3.P0-41 — `LATERAL ( SELECT … )` derived
7210        // table. Detect at the head so it claims precedence over
7211        // every other table-ref shape (unnest / generate_series /
7212        // bare ident); the lateral subquery itself follows the
7213        // regular SELECT grammar.
7214        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7215            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7216        {
7217            self.advance(); // LATERAL
7218            self.advance(); // (
7219            // Parse the inner SELECT.
7220            let inner = match self.parse_one_statement()? {
7221                Statement::Select(s) => s,
7222                other => {
7223                    return Err(self.err(alloc::format!(
7224                        "expected SELECT inside LATERAL ( … ), got {other:?}"
7225                    )));
7226                }
7227            };
7228            if !matches!(self.peek(), Token::RParen) {
7229                return Err(self.err(alloc::format!(
7230                    "expected ')' after LATERAL subquery, got {:?}",
7231                    self.peek()
7232                )));
7233            }
7234            self.advance();
7235            let alias_ident = self.parse_optional_alias();
7236            let name = alias_ident.clone().unwrap_or_else(|| "lateral".to_string());
7237            return Ok(TableRef {
7238                name,
7239                alias: alias_ident,
7240                as_of_segment: None,
7241                unnest_expr: None,
7242                unnest_column_aliases: Vec::new(),
7243                generate_series_args: None,
7244                lateral_subquery: Some(Box::new(inner)),
7245            });
7246        }
7247        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
7248        // source. Detect at the head before the bare-ident fallback;
7249        // unnest is not a reserved token.
7250        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
7251            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7252        {
7253            self.advance(); // unnest
7254            self.advance(); // (
7255            let expr = self.parse_expr(0)?;
7256            if !matches!(self.peek(), Token::RParen) {
7257                return Err(self.err(alloc::format!(
7258                    "expected ')' after unnest() argument, got {:?}",
7259                    self.peek()
7260                )));
7261            }
7262            self.advance();
7263            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
7264            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
7265            return Ok(TableRef {
7266                name,
7267                alias: alias_ident,
7268                as_of_segment: None,
7269                unnest_expr: Some(Box::new(expr)),
7270                unnest_column_aliases,
7271                generate_series_args: None,
7272                lateral_subquery: None,
7273            });
7274        }
7275        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7276        // [, step])` set-returning source. Same shape as unnest:
7277        // detect at the head, parse the comma-separated arg list,
7278        // dispatch downstream through the engine's set-returning
7279        // path. Supports integer triplets (mailrs's `WITH row_no AS
7280        // (SELECT * FROM generate_series(1, N))` pattern) and
7281        // TIMESTAMP + INTERVAL triplets (the Tier-A audit's
7282        // date-range iteration pattern, which pre-3.10 had no
7283        // direct equivalent in SPG).
7284        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("generate_series"))
7285            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7286        {
7287            self.advance(); // generate_series
7288            self.advance(); // (
7289            let mut args: Vec<Expr> = Vec::new();
7290            loop {
7291                args.push(self.parse_expr(0)?);
7292                if matches!(self.peek(), Token::Comma) {
7293                    self.advance();
7294                    continue;
7295                }
7296                break;
7297            }
7298            if !matches!(self.peek(), Token::RParen) {
7299                return Err(self.err(alloc::format!(
7300                    "expected ')' after generate_series() arguments, got {:?}",
7301                    self.peek()
7302                )));
7303            }
7304            self.advance();
7305            if args.len() < 2 || args.len() > 3 {
7306                return Err(self.err(alloc::format!(
7307                    "generate_series() expects 2 or 3 arguments (start, stop [, step]); got {}",
7308                    args.len()
7309                )));
7310            }
7311            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
7312            let name = alias_ident
7313                .clone()
7314                .unwrap_or_else(|| "generate_series".to_string());
7315            return Ok(TableRef {
7316                name,
7317                alias: alias_ident,
7318                as_of_segment: None,
7319                unnest_expr: None,
7320                unnest_column_aliases: Vec::new(),
7321                generate_series_args: Some(args),
7322                lateral_subquery: None,
7323            });
7324        }
7325        // v7.16.2 — preserve information_schema / pg_catalog
7326        // qualifiers (mailrs round-10 A.3). The generic
7327        // `expect_ident_like` strip silently drops the schema;
7328        // we want the engine to recognise these PG meta tables
7329        // and synthesise rows from the live catalog. Produce a
7330        // synthetic name (`__spg_info_columns` etc.) so the
7331        // engine's SELECT-side router can dispatch without
7332        // clashing with any user-defined `columns` table.
7333        let name = if let Some(synth) = self.try_peek_meta_qualified() {
7334            synth
7335        } else if let Some(synth) = self.try_peek_meta_bare() {
7336            synth
7337        } else {
7338            self.expect_ident_like()?
7339        };
7340        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
7341        // time-travel clause. Parse BEFORE the alias so the
7342        // alias can still ride at the tail (`tbl AS OF SEGMENT
7343        // '5' alias`). `AS` is a reserved keyword token, while
7344        // `OF` and `SEGMENT` are bare idents.
7345        let as_of_segment = if matches!(self.peek(), Token::As)
7346            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
7347        {
7348            self.advance(); // AS
7349            self.advance(); // OF
7350            let kw = match self.peek().clone() {
7351                Token::Ident(s) | Token::QuotedIdent(s) => s,
7352                other => {
7353                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
7354                }
7355            };
7356            if !kw.eq_ignore_ascii_case("segment") {
7357                return Err(self.err(format!(
7358                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
7359                )));
7360            }
7361            self.advance();
7362            // Segment id literal — accept either a string or
7363            // integer for operator ergonomics.
7364            let id = match self.advance() {
7365                Token::String(s) => s
7366                    .parse::<u32>()
7367                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7368                Token::Integer(n) => u32::try_from(n)
7369                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7370                other => {
7371                    return Err(self.err(format!(
7372                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
7373                    )));
7374                }
7375            };
7376            Some(id)
7377        } else {
7378            None
7379        };
7380        let alias = self.parse_optional_alias();
7381        Ok(TableRef {
7382            name,
7383            alias,
7384            as_of_segment,
7385            unnest_expr: None,
7386            unnest_column_aliases: Vec::new(),
7387            generate_series_args: None,
7388            lateral_subquery: None,
7389        })
7390    }
7391
7392    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
7393    /// but also accepts `AS alias(col [, col, …])` — the
7394    /// PG-standard table-function column-list form. The column
7395    /// list is only honoured when paired with `UNNEST(...)` in
7396    /// the parent; other call sites currently discard it.
7397    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
7398        let alias = self.parse_optional_alias();
7399        if alias.is_none() {
7400            return (None, Vec::new());
7401        }
7402        let mut cols: Vec<String> = Vec::new();
7403        if matches!(self.peek(), Token::LParen) {
7404            self.advance();
7405            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
7406                self.advance();
7407                cols.push(s);
7408                if matches!(self.peek(), Token::Comma) {
7409                    self.advance();
7410                    continue;
7411                }
7412                break;
7413            }
7414            if matches!(self.peek(), Token::RParen) {
7415                self.advance();
7416            }
7417        }
7418        (alias, cols)
7419    }
7420
7421    /// FROM-clause: a primary table reference plus zero-or-more joined
7422    /// peers expressed via either `, <table>` (cross-product, no ON) or
7423    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
7424    /// the join list flat (left-associative nested-loop semantics).
7425    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
7426        let primary = self.parse_table_ref()?;
7427        let mut joins = Vec::new();
7428        loop {
7429            // `, <table>` — cross-product with no ON.
7430            if matches!(self.peek(), Token::Comma) {
7431                self.advance();
7432                let table = self.parse_table_ref()?;
7433                joins.push(FromJoin {
7434                    kind: JoinKind::Cross,
7435                    table,
7436                    on: None,
7437                });
7438                continue;
7439            }
7440            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
7441            // CROSS JOIN, and bare JOIN (defaults to INNER).
7442            let kind =
7443                match self.peek() {
7444                    Token::Inner => {
7445                        self.advance();
7446                        if !matches!(self.peek(), Token::Join) {
7447                            return Err(self
7448                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
7449                        }
7450                        self.advance();
7451                        JoinKind::Inner
7452                    }
7453                    Token::Left => {
7454                        self.advance();
7455                        if matches!(self.peek(), Token::Outer) {
7456                            self.advance();
7457                        }
7458                        if !matches!(self.peek(), Token::Join) {
7459                            return Err(self.err(format!(
7460                                "expected JOIN after LEFT [OUTER], got {:?}",
7461                                self.peek()
7462                            )));
7463                        }
7464                        self.advance();
7465                        JoinKind::Left
7466                    }
7467                    Token::Cross => {
7468                        self.advance();
7469                        if !matches!(self.peek(), Token::Join) {
7470                            return Err(self
7471                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
7472                        }
7473                        self.advance();
7474                        JoinKind::Cross
7475                    }
7476                    Token::Join => {
7477                        self.advance();
7478                        JoinKind::Inner
7479                    }
7480                    _ => break,
7481                };
7482            let table = self.parse_table_ref()?;
7483            let on = if matches!(self.peek(), Token::On) {
7484                self.advance();
7485                Some(self.parse_expr(0)?)
7486            } else if kind == JoinKind::Cross {
7487                None
7488            } else {
7489                return Err(self.err(format!(
7490                    "expected ON after {:?} JOIN, got {:?}",
7491                    kind,
7492                    self.peek()
7493                )));
7494            };
7495            joins.push(FromJoin { kind, table, on });
7496        }
7497        Ok(FromClause { primary, joins })
7498    }
7499
7500    /// Optional alias after an expression or table:
7501    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
7502    /// accepted (PG-style implicit alias). Returns `None` if the next token
7503    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
7504    fn parse_optional_alias(&mut self) -> Option<String> {
7505        if matches!(self.peek(), Token::As) {
7506            self.advance();
7507            // After AS, the next token MUST be an identifier-like — if not,
7508            // we still return None and let the caller surface the error on the
7509            // next expectation. v0.2 keeps the alias path forgiving; the
7510            // corpus tests don't exercise the malformed case.
7511            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
7512                return self.expect_ident_like().ok();
7513            }
7514            return None;
7515        }
7516        // v7.17.0 Phase 1.3 — implicit alias (no `AS`). PG's
7517        // grammar reserves a long list of follow-keywords from the
7518        // alias slot. SPG's bareword approximation: skip a small
7519        // set of idents that would otherwise be swallowed as the
7520        // table alias and break trailing clauses like CREATE
7521        // MATERIALIZED VIEW … WITH [NO] DATA or future ON
7522        // CONFLICT WHERE shapes.
7523        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
7524            if is_alias_stopword(s) {
7525                return None;
7526            }
7527            return self.expect_ident_like().ok();
7528        }
7529        None
7530    }
7531
7532    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
7533    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
7534        // v7.30.2 (mailrs round-25 ask 2) — nesting budget: a parse
7535        // error beats a stack overflow (an overflow aborts the
7536        // embedding host process).
7537        self.enter_nested()?;
7538        let r = self.parse_expr_inner(min_prec);
7539        self.nest_depth -= 1;
7540        r
7541    }
7542
7543    fn parse_expr_inner(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
7544        let mut lhs = self.parse_unary()?;
7545        let mut chain_len = 0usize;
7546        while let Some((op, prec)) = binop_from(self.peek()) {
7547            if prec < min_prec {
7548                break;
7549            }
7550            // v7.30.2 (mailrs round-25 ask 2) — the chain builds
7551            // iteratively but evaluates and drops recursively;
7552            // depth beyond the budget overflows worker stacks.
7553            chain_len += 1;
7554            if chain_len > MAX_BINARY_CHAIN {
7555                return Err(self.err(alloc::format!(
7556                    "more than {MAX_BINARY_CHAIN} chained binary operators; rewrite long OR-equality chains as IN (…)"
7557                )));
7558            }
7559            self.advance();
7560            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
7561            // ANY is a bare ident; ALL is a reserved Token. Both
7562            // require an immediate `(` to disambiguate from
7563            // identifier columns named `any` / `all`.
7564            let any_kind = match self.peek() {
7565                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
7566                    Some(false)
7567                }
7568                Token::Ident(s) | Token::QuotedIdent(s)
7569                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
7570                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
7571                {
7572                    Some(s.eq_ignore_ascii_case("any"))
7573                }
7574                _ => None,
7575            };
7576            if let Some(is_any) = any_kind {
7577                self.advance(); // ident
7578                self.advance(); // (
7579                let arr = self.parse_expr(0)?;
7580                if !matches!(self.peek(), Token::RParen) {
7581                    return Err(self.err(alloc::format!(
7582                        "expected ')' after ANY/ALL argument, got {:?}",
7583                        self.peek()
7584                    )));
7585                }
7586                self.advance();
7587                lhs = Expr::AnyAll {
7588                    expr: Box::new(lhs),
7589                    op,
7590                    array: Box::new(arr),
7591                    is_any,
7592                };
7593                continue;
7594            }
7595            let rhs = self.parse_expr(prec + 1)?;
7596            lhs = Expr::Binary {
7597                lhs: Box::new(lhs),
7598                op,
7599                rhs: Box::new(rhs),
7600            };
7601        }
7602        Ok(lhs)
7603    }
7604
7605    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
7606        match self.peek() {
7607            Token::Not => {
7608                self.advance();
7609                // NOT sits between AND (2) and comparisons (4) — bind everything
7610                // ≥3, which leaves AND/OR outside.
7611                let e = self.parse_expr(3)?;
7612                Ok(Expr::Unary {
7613                    op: UnOp::Not,
7614                    expr: Box::new(e),
7615                })
7616            }
7617            Token::Minus => {
7618                self.advance();
7619                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
7620                // `<->` slotted into 5 and arithmetic shifted up).
7621                let e = self.parse_expr(8)?;
7622                Ok(Expr::Unary {
7623                    op: UnOp::Neg,
7624                    expr: Box::new(e),
7625                })
7626            }
7627            Token::Tilde => {
7628                self.advance();
7629                // Bitwise NOT binds like unary minus.
7630                let e = self.parse_expr(8)?;
7631                Ok(Expr::Unary {
7632                    op: UnOp::BitNot,
7633                    expr: Box::new(e),
7634                })
7635            }
7636            _ => self.parse_atom(),
7637        }
7638    }
7639
7640    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
7641        let tok_pos = self.pos;
7642        match self.advance() {
7643            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
7644            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
7645            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
7646            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
7647            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
7648            Token::Null => Ok(Expr::Literal(Literal::Null)),
7649            // v6.1.1 — `$N` placeholder. The actual Value lookup
7650            // happens in the engine eval path against the prepared-
7651            // statement bind buffer.
7652            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
7653            Token::LParen => {
7654                // v4.10: `(SELECT ...)` in expression position is a
7655                // scalar subquery; otherwise it's a parenthesised
7656                // expression. Peek for SELECT keyword to dispatch.
7657                if matches!(self.peek(), Token::Select) {
7658                    let inner = self.parse_select_stmt()?;
7659                    match self.advance() {
7660                        Token::RParen => {
7661                            let Statement::Select(s) = inner else {
7662                                unreachable!("parse_select_stmt returns Select")
7663                            };
7664                            Ok(Expr::ScalarSubquery(Box::new(s)))
7665                        }
7666                        other => Err(ParseError {
7667                            message: format!("expected ')' after scalar subquery, got {other:?}"),
7668                            token_pos: self.pos.saturating_sub(1),
7669                        }),
7670                    }
7671                } else {
7672                    let e = self.parse_expr(0)?;
7673                    match self.advance() {
7674                        Token::RParen => Ok(e),
7675                        other => Err(ParseError {
7676                            message: format!("expected ')', got {other:?}"),
7677                            token_pos: self.pos.saturating_sub(1),
7678                        }),
7679                    }
7680                }
7681            }
7682            Token::LBracket => self.parse_vector_literal_body(),
7683            Token::Extract => self.parse_extract_atom(),
7684            Token::Interval => self.parse_interval_atom(),
7685            // `LEFT` is a reserved-keyword token because the
7686            // grammar dedicates an arm for `LEFT [OUTER] JOIN`.
7687            // When `left` is followed by `(` we're in expression
7688            // position calling the PG `left(string, n)` function;
7689            // rebuild the AST as a regular function call so the
7690            // engine's apply_function dispatch picks it up.
7691            Token::Left if matches!(self.peek(), Token::LParen) => {
7692                self.advance(); // (
7693                let mut args = Vec::new();
7694                if !matches!(self.peek(), Token::RParen) {
7695                    loop {
7696                        args.push(self.parse_expr(0)?);
7697                        match self.peek() {
7698                            Token::Comma => {
7699                                self.advance();
7700                            }
7701                            Token::RParen => break,
7702                            other => {
7703                                return Err(self.err(alloc::format!(
7704                                    "expected ',' or ')' in left() args, got {other:?}"
7705                                )));
7706                            }
7707                        }
7708                    }
7709                }
7710                self.advance(); // )
7711                Ok(Expr::FunctionCall {
7712                    name: "left".into(),
7713                    args,
7714                })
7715            }
7716            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
7717            // token; we match on the bare ident. NOT is a token
7718            // (consumed in the comparison rung), but `EXISTS (...)`
7719            // at the top of an expression starts here.
7720            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
7721                self.parse_exists_atom(false)
7722            }
7723            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
7724            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
7725            // CASE is a bare ident; we dispatch on lowercase match.
7726            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
7727                self.parse_case_atom()
7728            }
7729            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
7730            // is not a reserved token; we match by case-insensitive
7731            // ident. The opening `[` must follow immediately.
7732            Token::Ident(s) | Token::QuotedIdent(s)
7733                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
7734            {
7735                self.advance(); // consume `[`
7736                let mut items: Vec<Expr> = Vec::new();
7737                if !matches!(self.peek(), Token::RBracket) {
7738                    loop {
7739                        items.push(self.parse_expr(0)?);
7740                        match self.peek() {
7741                            Token::Comma => {
7742                                self.advance();
7743                            }
7744                            Token::RBracket => break,
7745                            other => {
7746                                return Err(self.err(alloc::format!(
7747                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
7748                                )));
7749                            }
7750                        }
7751                    }
7752                }
7753                self.advance(); // consume `]`
7754                Ok(Expr::Array(items))
7755            }
7756            // v7.17.0 Phase 2.2 — MySQL `MATCH(col, ...) AGAINST
7757            // ('term' [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE])`.
7758            // We special-case before the generic ident dispatch so
7759            // the AGAINST clause never reaches the function-call
7760            // loop (which would mis-read `(cols) AGAINST` as a
7761            // call with no trailing modifier). The shape is
7762            // rewritten to a Boolean OR over per-column
7763            // `to_tsvector('simple', col) @@ plainto_tsquery('simple',
7764            // term)` so the existing FTS evaluator handles
7765            // semantics — the fulltext-GIN built at CREATE TABLE
7766            // time is currently a "real index that survives dump
7767            // round-trip"; the planner hook that actually uses
7768            // it for posting-list intersection lands in a later
7769            // sub-phase (Phase 2.2b) without touching this surface.
7770            Token::Ident(s) | Token::QuotedIdent(s)
7771                if s.eq_ignore_ascii_case("match") && matches!(self.peek(), Token::LParen) =>
7772            {
7773                self.parse_match_against_atom()
7774            }
7775            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
7776            other => Err(ParseError {
7777                message: format!("unexpected token {other:?} in expression"),
7778                token_pos: tok_pos,
7779            }),
7780        }
7781        // After parsing the atom, fold any postfix `::vector` casts.
7782        .and_then(|atom| self.finish_postfix_casts(atom))
7783    }
7784
7785    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
7786    /// Both bind tighter than any binary op.
7787    /// Shared cast-target parser for postfix `::TYPE` and the
7788    /// standard `CAST(expr AS TYPE)` form (v7.25, round-17).
7789    fn parse_cast_target(&mut self) -> Result<CastTarget, ParseError> {
7790        let target = match self.advance() {
7791            Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
7792                "int" | "integer" | "int4" => {
7793                    if matches!(self.peek(), Token::LBracket)
7794                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7795                    {
7796                        self.advance();
7797                        self.advance();
7798                        CastTarget::IntArray
7799                    } else {
7800                        CastTarget::Int
7801                    }
7802                }
7803                "bigint" | "int8" => {
7804                    if matches!(self.peek(), Token::LBracket)
7805                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7806                    {
7807                        self.advance();
7808                        self.advance();
7809                        CastTarget::BigIntArray
7810                    } else {
7811                        CastTarget::BigInt
7812                    }
7813                }
7814                "float" | "double" | "real" => CastTarget::Float,
7815                "text" => {
7816                    // v7.10.11 — `::TEXT[]` widens to TextArray.
7817                    if matches!(self.peek(), Token::LBracket)
7818                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7819                    {
7820                        self.advance();
7821                        self.advance();
7822                        CastTarget::TextArray
7823                    } else {
7824                        CastTarget::Text
7825                    }
7826                }
7827                "bool" | "boolean" => CastTarget::Bool,
7828                "vector" => CastTarget::Vector,
7829                "date" => CastTarget::Date,
7830                "timestamp" | "datetime" => CastTarget::Timestamp,
7831                "timestamptz" => CastTarget::Timestamptz,
7832                "interval" => CastTarget::Interval,
7833                "json" => CastTarget::Json,
7834                "jsonb" => CastTarget::Jsonb,
7835                "regtype" => CastTarget::RegType,
7836                "regclass" => CastTarget::RegClass,
7837                // v7.12.0 — `::tsvector` / `::tsquery`.
7838                // Engine decodes the LHS text via the PG
7839                // external form parser.
7840                "tsvector" => CastTarget::TsVector,
7841                "tsquery" => CastTarget::TsQuery,
7842                // v7.17.0 — `::uuid`. Engine decodes the LHS
7843                // text via `spg_storage::parse_uuid_str`.
7844                "uuid" => CastTarget::Uuid,
7845                // v7.18 — `::bytea`. Engine decodes the LHS
7846                // text via the PG hex form (`'\xdeadbeef'`)
7847                // or escape form (`'\\x05\\x00'`). Closes
7848                // mailrs D-pre #3 reverse-acceptance gap.
7849                "bytea" => CastTarget::Bytea,
7850                // v7.17.0 Phase 3.P0-47 — `::inet` / `::cidr` /
7851                // `::macaddr`. SPG stores these as Text (Phase 7);
7852                // the cast is a no-op passthrough so containment
7853                // and overlap operators can read the textual form.
7854                "inet" | "cidr" | "macaddr" => CastTarget::Text,
7855                other => {
7856                    return Err(ParseError {
7857                        message: format!("unsupported cast target `::{other}`"),
7858                        token_pos: self.pos.saturating_sub(1),
7859                    });
7860                }
7861            },
7862            Token::Interval => CastTarget::Interval,
7863            other => {
7864                return Err(ParseError {
7865                    message: format!("expected type ident after `::`, got {other:?}"),
7866                    token_pos: self.pos.saturating_sub(1),
7867                });
7868            }
7869        };
7870        Ok(target)
7871    }
7872
7873    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
7874        loop {
7875            if matches!(self.peek(), Token::DoubleColon) {
7876                self.advance();
7877                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
7878                // target set to include INTERVAL (reserved Token),
7879                // TIMESTAMPTZ, and PG catalog regtype / regclass.
7880                // mailrs follow-up H3a + H3b.
7881                let target = self.parse_cast_target()?;
7882                expr = Expr::Cast {
7883                    expr: Box::new(expr),
7884                    target,
7885                };
7886                continue;
7887            }
7888            if matches!(self.peek(), Token::Is) {
7889                self.advance();
7890                let negated = if matches!(self.peek(), Token::Not) {
7891                    self.advance();
7892                    true
7893                } else {
7894                    false
7895                };
7896                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
7897                // mailrs pg_dump.
7898                if matches!(self.peek(), Token::Distinct) {
7899                    self.advance();
7900                    if !matches!(self.peek(), Token::From) {
7901                        return Err(self.err(format!(
7902                            "expected FROM after IS{} DISTINCT, got {:?}",
7903                            if negated { " NOT" } else { "" },
7904                            self.peek()
7905                        )));
7906                    }
7907                    self.advance();
7908                    // Right-hand side: parse at the same precedence
7909                    // tier as comparison so `x IS DISTINCT FROM a + b`
7910                    // groups as `x IS DISTINCT FROM (a + b)`.
7911                    let rhs = self.parse_expr(20)?;
7912                    let op = if negated {
7913                        BinOp::IsNotDistinctFrom
7914                    } else {
7915                        BinOp::IsDistinctFrom
7916                    };
7917                    expr = Expr::Binary {
7918                        op,
7919                        lhs: Box::new(expr),
7920                        rhs: Box::new(rhs),
7921                    };
7922                    continue;
7923                }
7924                if !matches!(self.peek(), Token::Null) {
7925                    return Err(self.err(format!(
7926                        "expected NULL or DISTINCT after IS{}, got {:?}",
7927                        if negated { " NOT" } else { "" },
7928                        self.peek()
7929                    )));
7930                }
7931                self.advance();
7932                expr = Expr::IsNull {
7933                    expr: Box::new(expr),
7934                    negated,
7935                };
7936                continue;
7937            }
7938            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
7939            // Look one token ahead so a stray `NOT` not followed by any of
7940            // these flows through to the early return below untouched.
7941            let negated = if matches!(self.peek(), Token::Not) {
7942                let next = self.tokens.get(self.pos + 1);
7943                matches!(next, Some(Token::Between | Token::In | Token::Like))
7944                    || matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("ilike"))
7945            } else {
7946                false
7947            };
7948            if negated {
7949                self.advance();
7950            }
7951            if matches!(self.peek(), Token::Between) {
7952                expr = self.parse_between_tail(expr, negated)?;
7953                continue;
7954            }
7955            if matches!(self.peek(), Token::In) {
7956                expr = self.parse_in_tail(expr, negated)?;
7957                continue;
7958            }
7959            if matches!(self.peek(), Token::Like) {
7960                self.advance();
7961                // Pattern at the same precedence as other comparison RHSes —
7962                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
7963                let pattern = self.parse_expr(5)?;
7964                expr = Expr::Like {
7965                    expr: Box::new(expr),
7966                    pattern: Box::new(pattern),
7967                    negated,
7968                    case_insensitive: false,
7969                };
7970                continue;
7971            }
7972            // v7.25 (round-17) — ILIKE: case-insensitive LIKE. The
7973            // keyword reaches us as a plain identifier.
7974            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("ilike")) {
7975                self.advance();
7976                let pattern = self.parse_expr(5)?;
7977                expr = Expr::Like {
7978                    expr: Box::new(expr),
7979                    pattern: Box::new(pattern),
7980                    negated,
7981                    case_insensitive: true,
7982                };
7983                continue;
7984            }
7985            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
7986            // returns NULL for out-of-range. Multiple subscripts
7987            // chain: `a[i][j]` parses left-to-right.
7988            if matches!(self.peek(), Token::LBracket) {
7989                self.advance();
7990                let index = self.parse_expr(0)?;
7991                if !matches!(self.peek(), Token::RBracket) {
7992                    return Err(self.err(alloc::format!(
7993                        "expected ']' after array index, got {:?}",
7994                        self.peek()
7995                    )));
7996                }
7997                self.advance();
7998                expr = Expr::ArraySubscript {
7999                    target: Box::new(expr),
8000                    index: Box::new(index),
8001                };
8002                continue;
8003            }
8004            return Ok(expr);
8005        }
8006    }
8007
8008    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
8009    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
8010    /// `AND` is not swallowed.
8011    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
8012        self.advance(); // BETWEEN
8013        let low = self.parse_expr(5)?;
8014        if !matches!(self.peek(), Token::And) {
8015            return Err(self.err(format!(
8016                "expected AND after BETWEEN low bound, got {:?}",
8017                self.peek()
8018            )));
8019        }
8020        self.advance();
8021        let high = self.parse_expr(5)?;
8022        let target = Box::new(expr);
8023        let combined = Expr::Binary {
8024            lhs: Box::new(Expr::Binary {
8025                lhs: target.clone(),
8026                op: BinOp::GtEq,
8027                rhs: Box::new(low),
8028            }),
8029            op: BinOp::And,
8030            rhs: Box::new(Expr::Binary {
8031                lhs: target,
8032                op: BinOp::LtEq,
8033                rhs: Box::new(high),
8034            }),
8035        };
8036        Ok(maybe_not(combined, negated))
8037    }
8038
8039    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
8040    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
8041    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
8042    /// Caller already consumed the leading `WITH` ident.
8043    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
8044        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
8045        // Comes through as an identifier; consume it if present and
8046        // mark every CTE in the clause as recursive (PG semantics —
8047        // the flag is per-WITH, not per-CTE).
8048        let mut recursive = false;
8049        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8050            && s.eq_ignore_ascii_case("recursive")
8051        {
8052            self.advance();
8053            recursive = true;
8054        }
8055        let mut ctes = Vec::new();
8056        loop {
8057            let name = self.expect_ident_like()?;
8058            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
8059            // PG uses these to rename the body's output columns; we
8060            // do the same below by overriding `columns[i].name`.
8061            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
8062                self.advance();
8063                let mut names = Vec::new();
8064                loop {
8065                    names.push(self.expect_ident_like()?);
8066                    if matches!(self.peek(), Token::Comma) {
8067                        self.advance();
8068                        continue;
8069                    }
8070                    break;
8071                }
8072                if !matches!(self.peek(), Token::RParen) {
8073                    return Err(self.err(format!(
8074                        "expected ')' to close CTE column list, got {:?}",
8075                        self.peek()
8076                    )));
8077                }
8078                self.advance();
8079                names
8080            } else {
8081                Vec::new()
8082            };
8083            // AS is a reserved Token::As (used by SELECT-item / FROM
8084            // aliasing) — handle it specially rather than as a bare
8085            // ident.
8086            if !matches!(self.peek(), Token::As) {
8087                return Err(self.err(format!(
8088                    "expected AS after CTE name {name:?}, got {:?}",
8089                    self.peek()
8090                )));
8091            }
8092            self.advance();
8093            if !matches!(self.peek(), Token::LParen) {
8094                return Err(self.err(format!(
8095                    "expected '(' after AS in WITH clause, got {:?}",
8096                    self.peek()
8097                )));
8098            }
8099            self.advance();
8100            if !matches!(self.peek(), Token::Select) {
8101                return Err(self.err(format!("WITH body must be a SELECT, got {:?}", self.peek())));
8102            }
8103            let inner = self.parse_select_stmt()?;
8104            if !matches!(self.peek(), Token::RParen) {
8105                return Err(self.err(format!(
8106                    "expected ')' after CTE body, got {:?}",
8107                    self.peek()
8108                )));
8109            }
8110            self.advance();
8111            let Statement::Select(body) = inner else {
8112                unreachable!("parse_select_stmt returns Select")
8113            };
8114            ctes.push(crate::ast::Cte {
8115                name,
8116                body,
8117                recursive,
8118                column_overrides,
8119            });
8120            if matches!(self.peek(), Token::Comma) {
8121                self.advance();
8122                continue;
8123            }
8124            break;
8125        }
8126        // The body SELECT follows. Must start with SELECT.
8127        if !matches!(self.peek(), Token::Select) {
8128            return Err(self.err(format!(
8129                "expected SELECT after WITH clause, got {:?}",
8130                self.peek()
8131            )));
8132        }
8133        let body_stmt = self.parse_select_stmt()?;
8134        let Statement::Select(mut body) = body_stmt else {
8135            unreachable!()
8136        };
8137        body.ctes = ctes;
8138        Ok(Statement::Select(body))
8139    }
8140
8141    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
8142    /// already consumed the leading `EXISTS` ident via
8143    /// `self.advance()`.
8144    /// v7.13.0 — parse the rest of a `CASE … END` expression after
8145    /// the leading `CASE` ident has been consumed (mailrs round-5
8146    /// G9). Supports both the searched form
8147    /// (`CASE WHEN cond THEN val …`) and the simple form
8148    /// (`CASE operand WHEN val THEN val …`).
8149    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
8150        // Disambiguate searched vs simple form: if the next token
8151        // is `WHEN`, we're in the searched form. Otherwise the
8152        // intervening expression is the operand.
8153        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
8154            None
8155        } else {
8156            Some(Box::new(self.parse_expr(0)?))
8157        };
8158        let mut branches: Vec<(Expr, Expr)> = Vec::new();
8159        loop {
8160            match self.peek() {
8161                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
8162                    self.advance();
8163                    let cond = self.parse_expr(0)?;
8164                    match self.peek() {
8165                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
8166                            self.advance();
8167                        }
8168                        other => {
8169                            return Err(self.err(alloc::format!(
8170                                "expected THEN after CASE WHEN <expr>, got {other:?}"
8171                            )));
8172                        }
8173                    }
8174                    let value = self.parse_expr(0)?;
8175                    branches.push((cond, value));
8176                }
8177                _ => break,
8178            }
8179        }
8180        if branches.is_empty() {
8181            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
8182        }
8183        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
8184        {
8185            self.advance();
8186            Some(Box::new(self.parse_expr(0)?))
8187        } else {
8188            None
8189        };
8190        match self.peek() {
8191            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
8192                self.advance();
8193            }
8194            other => {
8195                return Err(self.err(alloc::format!(
8196                    "expected END to close CASE expression, got {other:?}"
8197                )));
8198            }
8199        }
8200        Ok(Expr::Case {
8201            operand,
8202            branches,
8203            else_branch,
8204        })
8205    }
8206
8207    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
8208        if !matches!(self.peek(), Token::LParen) {
8209            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
8210        }
8211        self.advance();
8212        let inner = self.parse_select_stmt()?;
8213        if !matches!(self.peek(), Token::RParen) {
8214            return Err(self.err(format!(
8215                "expected ')' after EXISTS-subquery, got {:?}",
8216                self.peek()
8217            )));
8218        }
8219        self.advance();
8220        let Statement::Select(s) = inner else {
8221            unreachable!("parse_select_stmt returns Select")
8222        };
8223        Ok(Expr::Exists {
8224            subquery: Box::new(s),
8225            negated,
8226        })
8227    }
8228
8229    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
8230        self.advance(); // IN
8231        if !matches!(self.peek(), Token::LParen) {
8232            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
8233        }
8234        self.advance();
8235        // v4.10: `IN (SELECT ...)` — subquery branch.
8236        if matches!(self.peek(), Token::Select) {
8237            let inner = self.parse_select_stmt()?;
8238            if !matches!(self.peek(), Token::RParen) {
8239                return Err(self.err(format!(
8240                    "expected ')' after IN-subquery, got {:?}",
8241                    self.peek()
8242                )));
8243            }
8244            self.advance();
8245            let Statement::Select(s) = inner else {
8246                unreachable!("parse_select_stmt always returns Statement::Select")
8247            };
8248            return Ok(Expr::InSubquery {
8249                expr: Box::new(expr),
8250                subquery: Box::new(s),
8251                negated,
8252            });
8253        }
8254        let mut elements = Vec::new();
8255        if !matches!(self.peek(), Token::RParen) {
8256            loop {
8257                elements.push(self.parse_expr(0)?);
8258                match self.peek() {
8259                    Token::Comma => {
8260                        self.advance();
8261                    }
8262                    Token::RParen => break,
8263                    other => {
8264                        return Err(
8265                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
8266                        );
8267                    }
8268                }
8269            }
8270        }
8271        self.advance(); // ')'
8272        // v7.30.2 (mailrs round-25) — flat InList node instead of a
8273        // left-deep OR-Eq chain: chain depth scaled with the element
8274        // count and overflowed the stack (eval + drop are recursive).
8275        if elements.is_empty() {
8276            return Ok(maybe_not(Expr::Literal(Literal::Bool(false)), negated));
8277        }
8278        Ok(Expr::InList {
8279            expr: Box::new(expr),
8280            list: elements,
8281            negated,
8282        })
8283    }
8284
8285    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
8286    /// already consumed by the caller. Elements must be numeric literals
8287    /// (with optional unary `-`); any compound expression is rejected at
8288    /// parse time so the runtime never needs to evaluate inside a vector.
8289    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
8290    /// has already consumed the `EXTRACT` token before calling us —
8291    /// we pick up at the opening `(`.
8292    /// v7.17.0 Phase 2.2 — MySQL `MATCH(col [, col ...]) AGAINST
8293    /// (expr [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE
8294    /// [WITH QUERY EXPANSION]])`. Rewritten in-place to a
8295    /// per-column OR-fold of
8296    /// `to_tsvector('simple', col) @@ plainto_tsquery('simple',
8297    /// term)` so the existing FTS evaluator handles semantics.
8298    ///
8299    /// The mode modifier is accepted-and-ignored at v7.17 — all
8300    /// modes map to the same `plainto_tsquery` rewrite. Boolean-
8301    /// mode operators (`+foo -bar`) would need their own parser
8302    /// (Phase 2.2c); customers who hit them today already get a
8303    /// correct lexeme-match against the bare term, only without
8304    /// the +/- precedence the customer asked for.
8305    fn parse_match_against_atom(&mut self) -> Result<Expr, ParseError> {
8306        // Already at `MATCH`-consumed position; the dispatcher
8307        // confirmed the next token is `(`.
8308        if !matches!(self.peek(), Token::LParen) {
8309            return Err(self.err(alloc::format!(
8310                "expected '(' after MATCH, got {:?}",
8311                self.peek()
8312            )));
8313        }
8314        self.advance();
8315        let mut cols: Vec<Expr> = Vec::new();
8316        loop {
8317            cols.push(self.parse_expr(0)?);
8318            match self.peek() {
8319                Token::Comma => {
8320                    self.advance();
8321                }
8322                Token::RParen => break,
8323                other => {
8324                    return Err(self.err(alloc::format!(
8325                        "expected ',' or ')' in MATCH column list, got {other:?}"
8326                    )));
8327                }
8328            }
8329        }
8330        self.advance(); // ')'
8331        // Expect AGAINST.
8332        match self.peek() {
8333            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("against") => {
8334                self.advance();
8335            }
8336            other => {
8337                return Err(self.err(alloc::format!(
8338                    "expected AGAINST after MATCH column list, got {other:?}"
8339                )));
8340            }
8341        }
8342        if !matches!(self.peek(), Token::LParen) {
8343            return Err(self.err(alloc::format!(
8344                "expected '(' after AGAINST, got {:?}",
8345                self.peek()
8346            )));
8347        }
8348        self.advance();
8349        // Read AGAINST's argument as a single primary token —
8350        // string literal, placeholder, or column-ref ident. We
8351        // can't call `parse_expr` / `parse_unary` here because
8352        // the postfix chain inside `parse_atom` would greedily
8353        // fold a trailing `IN BOOLEAN MODE` as `expr IN (...)`
8354        // and fail at "expected '(' after IN". Customers always
8355        // write a literal or bound parameter in AGAINST, so this
8356        // restriction is non-blocking; the error path explains
8357        // the limit if a more complex expression shows up.
8358        let term = match self.advance() {
8359            Token::String(s) => Expr::Literal(crate::ast::Literal::String(s)),
8360            Token::Placeholder(n) => Expr::Placeholder(n),
8361            Token::Ident(s) | Token::QuotedIdent(s) => Expr::Column(crate::ast::ColumnName {
8362                qualifier: None,
8363                name: s,
8364            }),
8365            other => {
8366                return Err(self.err(alloc::format!(
8367                    "MATCH ... AGAINST(<term>) expects a string literal, \
8368                     bound parameter, or column ref, got {other:?}"
8369                )));
8370            }
8371        };
8372        // Optional mode tail — accept-and-ignore at v7.17:
8373        //   IN NATURAL LANGUAGE MODE [WITH QUERY EXPANSION]
8374        //   IN BOOLEAN MODE
8375        //   WITH QUERY EXPANSION
8376        loop {
8377            match self.peek() {
8378                // IN lexes as a reserved Token::In, not an ident,
8379                // so it gets its own arm.
8380                Token::In => {
8381                    self.advance();
8382                }
8383                Token::Ident(s) | Token::QuotedIdent(s)
8384                    if s.eq_ignore_ascii_case("natural")
8385                        || s.eq_ignore_ascii_case("language")
8386                        || s.eq_ignore_ascii_case("boolean")
8387                        || s.eq_ignore_ascii_case("mode")
8388                        || s.eq_ignore_ascii_case("with")
8389                        || s.eq_ignore_ascii_case("query")
8390                        || s.eq_ignore_ascii_case("expansion") =>
8391                {
8392                    self.advance();
8393                }
8394                _ => break,
8395            }
8396        }
8397        if !matches!(self.peek(), Token::RParen) {
8398            return Err(self.err(alloc::format!(
8399                "expected ')' to close AGAINST, got {:?}",
8400                self.peek()
8401            )));
8402        }
8403        self.advance();
8404        // Build per-column `to_tsvector('simple', col) @@
8405        // plainto_tsquery('simple', term)` and OR-fold.
8406        let simple_lit = || Expr::Literal(crate::ast::Literal::String(String::from("simple")));
8407        let plainto = Expr::FunctionCall {
8408            name: String::from("plainto_tsquery"),
8409            args: alloc::vec![simple_lit(), term.clone()],
8410        };
8411        let mut folded: Option<Expr> = None;
8412        for col in cols {
8413            let to_tsv = Expr::FunctionCall {
8414                name: String::from("to_tsvector"),
8415                args: alloc::vec![simple_lit(), col],
8416            };
8417            let leaf = Expr::Binary {
8418                lhs: Box::new(to_tsv),
8419                op: crate::ast::BinOp::TsMatch,
8420                rhs: Box::new(plainto.clone()),
8421            };
8422            folded = Some(match folded {
8423                None => leaf,
8424                Some(prev) => Expr::Binary {
8425                    lhs: Box::new(prev),
8426                    op: crate::ast::BinOp::Or,
8427                    rhs: Box::new(leaf),
8428                },
8429            });
8430        }
8431        match folded {
8432            Some(e) => Ok(e),
8433            None => Err(self.err(String::from(
8434                "MATCH(...) AGAINST(...) requires at least one column",
8435            ))),
8436        }
8437    }
8438
8439    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
8440        if !matches!(self.peek(), Token::LParen) {
8441            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
8442        }
8443        self.advance();
8444        let field_name = self.expect_ident_like()?;
8445        let field = match field_name.to_ascii_lowercase().as_str() {
8446            "year" => ExtractField::Year,
8447            "month" => ExtractField::Month,
8448            "day" => ExtractField::Day,
8449            "hour" => ExtractField::Hour,
8450            "minute" => ExtractField::Minute,
8451            "second" => ExtractField::Second,
8452            "microsecond" | "microseconds" => ExtractField::Microsecond,
8453            "epoch" => ExtractField::Epoch,
8454            other => {
8455                return Err(self.err(format!(
8456                    "unknown EXTRACT field {other:?}; \
8457                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND, EPOCH"
8458                )));
8459            }
8460        };
8461        if !matches!(self.peek(), Token::From) {
8462            return Err(self.err(format!(
8463                "expected FROM after EXTRACT field, got {:?}",
8464                self.peek()
8465            )));
8466        }
8467        self.advance();
8468        let source = self.parse_expr(0)?;
8469        if !matches!(self.peek(), Token::RParen) {
8470            return Err(self.err(format!(
8471                "expected ')' to close EXTRACT, got {:?}",
8472                self.peek()
8473            )));
8474        }
8475        self.advance();
8476        Ok(Expr::Extract {
8477            field,
8478            source: Box::new(source),
8479        })
8480    }
8481
8482    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
8483    /// is already consumed; we expect a single string literal next and
8484    /// resolve it into `Literal::Interval` at parse time so the engine
8485    /// never has to re-tokenise inside the string.
8486    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
8487        let tok = self.advance();
8488        let Token::String(text) = tok else {
8489            return Err(self.err(format!(
8490                "expected string literal after INTERVAL, got {tok:?}"
8491            )));
8492        };
8493        let (months, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
8494            message: format!(
8495                "cannot parse INTERVAL {text:?}; \
8496                     expected `<n> <unit> [<n> <unit> ...]` with units \
8497                     microsecond[s], millisecond[s], second[s], minute[s], \
8498                     hour[s], day[s], week[s], month[s], year[s]"
8499            ),
8500            token_pos: self.pos.saturating_sub(1),
8501        })?;
8502        Ok(Expr::Literal(Literal::Interval {
8503            months,
8504            micros,
8505            text,
8506        }))
8507    }
8508
8509    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
8510        let mut elems = Vec::new();
8511        if matches!(self.peek(), Token::RBracket) {
8512            self.advance();
8513            return Ok(Expr::Literal(Literal::Vector(elems)));
8514        }
8515        loop {
8516            let e = self.parse_expr(0)?;
8517            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
8518                message: format!("vector element must be a numeric literal, got {e:?}"),
8519                token_pos: self.pos,
8520            })?;
8521            elems.push(x);
8522            match self.peek() {
8523                Token::Comma => {
8524                    self.advance();
8525                }
8526                Token::RBracket => {
8527                    self.advance();
8528                    break;
8529                }
8530                other => {
8531                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
8532                }
8533            }
8534        }
8535        Ok(Expr::Literal(Literal::Vector(elems)))
8536    }
8537
8538    /// Atom that started with an identifier: could be `t.col`, `col`, or
8539    /// `func(arg, ...)`. Detect each shape by looking at the next token.
8540    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
8541    /// [, ...])`. Caller has already consumed `OVER`. Either clause
8542    /// is optional; an empty `()` is also legal (PG semantics).
8543    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
8544    /// modifier between `name(args)` and `OVER (...)`. Default is
8545    /// `Respect`. Unrecognised idents leave the stream unchanged.
8546    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
8547        let Token::Ident(s) = self.peek().clone() else {
8548            return NullTreatment::Respect;
8549        };
8550        let is_ignore = s.eq_ignore_ascii_case("ignore");
8551        let is_respect = s.eq_ignore_ascii_case("respect");
8552        if !is_ignore && !is_respect {
8553            return NullTreatment::Respect;
8554        }
8555        // Lookahead for NULLS — only consume both tokens together.
8556        // pos+1 must hold a "nulls" ident.
8557        if self.pos + 1 < self.tokens.len()
8558            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
8559            && s2.eq_ignore_ascii_case("nulls")
8560        {
8561            self.advance();
8562            self.advance();
8563            return if is_ignore {
8564                NullTreatment::Ignore
8565            } else {
8566                NullTreatment::Respect
8567            };
8568        }
8569        NullTreatment::Respect
8570    }
8571
8572    /// No frame clause is supported.
8573    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
8574    fn parse_over_clause(
8575        &mut self,
8576    ) -> Result<
8577        (
8578            Vec<Expr>,
8579            Vec<(Expr, bool, Option<bool>)>,
8580            Option<WindowFrame>,
8581        ),
8582        ParseError,
8583    > {
8584        if !matches!(self.peek(), Token::LParen) {
8585            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
8586        }
8587        self.advance();
8588        let mut partition_by = Vec::new();
8589        let mut order_by = Vec::new();
8590        // PARTITION BY ?
8591        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8592            && s.eq_ignore_ascii_case("partition")
8593        {
8594            self.advance();
8595            if !matches!(self.peek(), Token::By) {
8596                return Err(self.err(format!(
8597                    "expected BY after PARTITION, got {:?}",
8598                    self.peek()
8599                )));
8600            }
8601            self.advance();
8602            loop {
8603                partition_by.push(self.parse_expr(0)?);
8604                if matches!(self.peek(), Token::Comma) {
8605                    self.advance();
8606                    continue;
8607                }
8608                break;
8609            }
8610        }
8611        // ORDER BY ?
8612        if matches!(self.peek(), Token::Order) {
8613            self.advance();
8614            if !matches!(self.peek(), Token::By) {
8615                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
8616            }
8617            self.advance();
8618            loop {
8619                let e = self.parse_expr(0)?;
8620                let desc = if matches!(self.peek(), Token::Desc) {
8621                    self.advance();
8622                    true
8623                } else if matches!(self.peek(), Token::Asc) {
8624                    self.advance();
8625                    false
8626                } else {
8627                    false
8628                };
8629                // v7.24.1 — NULLS FIRST/LAST inside OVER (…).
8630                let nulls_first = self.parse_optional_nulls_placement()?;
8631                order_by.push((e, desc, nulls_first));
8632                if matches!(self.peek(), Token::Comma) {
8633                    self.advance();
8634                    continue;
8635                }
8636                break;
8637            }
8638        }
8639        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
8640        // Both keywords come through the lexer as identifiers; match
8641        // case-insensitively.
8642        let mut frame: Option<WindowFrame> = None;
8643        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
8644            let kind = if s.eq_ignore_ascii_case("rows") {
8645                Some(FrameKind::Rows)
8646            } else if s.eq_ignore_ascii_case("range") {
8647                Some(FrameKind::Range)
8648            } else {
8649                None
8650            };
8651            if let Some(kind) = kind {
8652                self.advance();
8653                frame = Some(self.parse_frame_tail(kind)?);
8654            }
8655        }
8656        if !matches!(self.peek(), Token::RParen) {
8657            return Err(self.err(format!(
8658                "expected ')' to close OVER clause, got {:?}",
8659                self.peek()
8660            )));
8661        }
8662        self.advance();
8663        Ok((partition_by, order_by, frame))
8664    }
8665
8666    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
8667    /// or `RANGE` keyword was just consumed. Accepts both
8668    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
8669    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
8670    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
8671    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
8672        if matches!(self.peek(), Token::Between) {
8673            self.advance();
8674            let start = self.parse_frame_bound()?;
8675            if !matches!(self.peek(), Token::And) {
8676                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
8677            }
8678            self.advance();
8679            let end = self.parse_frame_bound()?;
8680            Ok(WindowFrame {
8681                kind,
8682                start,
8683                end: Some(end),
8684            })
8685        } else {
8686            let start = self.parse_frame_bound()?;
8687            Ok(WindowFrame {
8688                kind,
8689                start,
8690                end: None,
8691            })
8692        }
8693    }
8694
8695    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
8696    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
8697    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
8698        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
8699        if let Token::Integer(n) = *self.peek() {
8700            self.advance();
8701            let n: u64 = u64::try_from(n).map_err(|_| {
8702                self.err(format!(
8703                    "invalid frame offset {n} — expected non-negative integer"
8704                ))
8705            })?;
8706            let dir = self.expect_ident_like()?;
8707            return if dir.eq_ignore_ascii_case("preceding") {
8708                Ok(FrameBound::OffsetPreceding(n))
8709            } else if dir.eq_ignore_ascii_case("following") {
8710                Ok(FrameBound::OffsetFollowing(n))
8711            } else {
8712                Err(self.err(format!(
8713                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
8714                )))
8715            };
8716        }
8717        let first = self.expect_ident_like()?;
8718        if first.eq_ignore_ascii_case("unbounded") {
8719            let dir = self.expect_ident_like()?;
8720            return if dir.eq_ignore_ascii_case("preceding") {
8721                Ok(FrameBound::UnboundedPreceding)
8722            } else if dir.eq_ignore_ascii_case("following") {
8723                Ok(FrameBound::UnboundedFollowing)
8724            } else {
8725                Err(self.err(format!(
8726                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
8727                )))
8728            };
8729        }
8730        if first.eq_ignore_ascii_case("current") {
8731            let row = self.expect_ident_like()?;
8732            if !row.eq_ignore_ascii_case("row") {
8733                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
8734            }
8735            return Ok(FrameBound::CurrentRow);
8736        }
8737        Err(self.err(format!(
8738            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
8739        )))
8740    }
8741
8742    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
8743        if matches!(self.peek(), Token::Dot) {
8744            self.advance();
8745            let name = self.expect_ident_like()?;
8746            // v7.14.0 — schema-qualified function call
8747            // `<schema>.<fn>(args)`. PG dumps emit
8748            // `pg_catalog.set_config(...)` in the preamble. SPG
8749            // is single-namespace: drop the schema prefix and
8750            // route the dispatch on the bare function name.
8751            if matches!(self.peek(), Token::LParen) {
8752                return self.finish_ident_atom(name);
8753            }
8754            return Ok(Expr::Column(ColumnName {
8755                qualifier: Some(first),
8756                name,
8757            }));
8758        }
8759        if matches!(self.peek(), Token::LParen) {
8760            self.advance();
8761            // `COUNT(*)` — special-cased here because `*` isn't a normal
8762            // expression token. Lower-case match on `first` since the lexer
8763            // folds identifiers.
8764            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
8765                self.advance();
8766                if !matches!(self.peek(), Token::RParen) {
8767                    return Err(self.err(format!(
8768                        "expected ')' after COUNT(*), got {:?}",
8769                        self.peek()
8770                    )));
8771                }
8772                self.advance();
8773                // v4.12: COUNT(*) OVER (...) — same window tail.
8774                let null_treatment = self.parse_null_treatment_modifier();
8775                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8776                    && s.eq_ignore_ascii_case("over")
8777                {
8778                    self.advance();
8779                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
8780                    return Ok(Expr::WindowFunction {
8781                        name: "count_star".into(),
8782                        args: Vec::new(),
8783                        partition_by,
8784                        order_by,
8785                        frame,
8786                        null_treatment,
8787                    });
8788                }
8789                return Ok(Expr::FunctionCall {
8790                    name: "count_star".into(),
8791                    args: Vec::new(),
8792                });
8793            }
8794            // Function call. PG-style: zero-or-more comma-separated args.
8795            let mut args = Vec::new();
8796            let mut agg_order_by: Vec<OrderBy> = Vec::new();
8797            // v7.25 (round-17) — `COUNT(DISTINCT x)` and friends.
8798            let agg_distinct = if matches!(self.peek(), Token::Distinct) {
8799                self.advance();
8800                true
8801            } else {
8802                false
8803            };
8804            if !matches!(self.peek(), Token::RParen) {
8805                loop {
8806                    args.push(self.parse_expr(0)?);
8807                    // v7.25 (round-17) — standard `CAST(expr AS type)`.
8808                    // The `::` cast already worked; this lowers the
8809                    // function form onto the same Expr::Cast node.
8810                    if first.eq_ignore_ascii_case("cast")
8811                        && args.len() == 1
8812                        && matches!(self.peek(), Token::As)
8813                    {
8814                        self.advance();
8815                        let target = self.parse_cast_target()?;
8816                        if !matches!(self.peek(), Token::RParen) {
8817                            return Err(self.err(format!(
8818                                "expected ')' to close CAST, got {:?}",
8819                                self.peek()
8820                            )));
8821                        }
8822                        self.advance();
8823                        return Ok(Expr::Cast {
8824                            expr: Box::new(args.pop().expect("one arg")),
8825                            target,
8826                        });
8827                    }
8828                    // v7.24 (round-16 A) — aggregate-internal
8829                    // ordering: `array_agg(x ORDER BY y DESC NULLS
8830                    // LAST)`. Keys close the argument list.
8831                    if matches!(self.peek(), Token::Order) {
8832                        self.advance();
8833                        if !matches!(self.peek(), Token::By) {
8834                            return Err(self.err(format!(
8835                                "expected BY after ORDER in aggregate args, got {:?}",
8836                                self.peek()
8837                            )));
8838                        }
8839                        self.advance();
8840                        loop {
8841                            let expr = self.parse_expr(0)?;
8842                            let desc = if matches!(self.peek(), Token::Desc) {
8843                                self.advance();
8844                                true
8845                            } else if matches!(self.peek(), Token::Asc) {
8846                                self.advance();
8847                                false
8848                            } else {
8849                                false
8850                            };
8851                            let nulls_first = self.parse_optional_nulls_placement()?;
8852                            agg_order_by.push(OrderBy {
8853                                expr,
8854                                desc,
8855                                nulls_first,
8856                            });
8857                            if matches!(self.peek(), Token::Comma) {
8858                                self.advance();
8859                            } else {
8860                                break;
8861                            }
8862                        }
8863                        if !matches!(self.peek(), Token::RParen) {
8864                            return Err(self.err(format!(
8865                                "expected ')' after aggregate ORDER BY, got {:?}",
8866                                self.peek()
8867                            )));
8868                        }
8869                        break;
8870                    }
8871                    match self.peek() {
8872                        Token::Comma => {
8873                            self.advance();
8874                        }
8875                        Token::RParen => break,
8876                        other => {
8877                            return Err(self.err(format!(
8878                                "expected ',' or ')' in function args, got {other:?}"
8879                            )));
8880                        }
8881                    }
8882                }
8883            }
8884            self.advance(); // consume ')'
8885            // v4.12: window-function tail — `name(args) OVER (...)`.
8886            // Promotes the just-parsed FunctionCall into a
8887            // WindowFunction node carrying partition + order.
8888            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
8889            // / `RESPECT NULLS OVER (...)` between the closing paren
8890            // and `OVER`.
8891            let null_treatment = self.parse_null_treatment_modifier();
8892            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8893                && s.eq_ignore_ascii_case("over")
8894            {
8895                self.advance();
8896                let (partition_by, order_by, frame) = self.parse_over_clause()?;
8897                return Ok(Expr::WindowFunction {
8898                    name: first,
8899                    args,
8900                    partition_by,
8901                    order_by,
8902                    frame,
8903                    null_treatment,
8904                });
8905            }
8906            if !agg_order_by.is_empty() || agg_distinct {
8907                return Ok(Expr::AggregateOrdered {
8908                    call: Box::new(Expr::FunctionCall { name: first, args }),
8909                    order_by: agg_order_by,
8910                    distinct: agg_distinct,
8911                });
8912            }
8913            return Ok(Expr::FunctionCall { name: first, args });
8914        }
8915        // v7.9.20 — SQL-standard parenless keyword expressions
8916        // (PG treats these as functions called without parens).
8917        // Resolve to a synthetic FunctionCall so the engine's
8918        // eval path reuses the existing function-call routing.
8919        // mailrs G3.
8920        let lc = first.to_ascii_lowercase();
8921        if matches!(
8922            lc.as_str(),
8923            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
8924        ) {
8925            return Ok(Expr::FunctionCall {
8926                name: lc,
8927                args: Vec::new(),
8928            });
8929        }
8930        Ok(Expr::Column(ColumnName {
8931            qualifier: None,
8932            name: first,
8933        }))
8934    }
8935}
8936
8937/// v6.8.2 — walk an expression tree and return the first column
8938/// reference's bare name. Used by `parse_create_index_stmt_after_create`
8939/// to derive `CreateIndexStatement.column` from an expression
8940/// key (so downstream planner code resolving a primary column
8941/// position keeps working with expression indexes). Returns
8942/// `None` when the expression has no column ref at all — caller
8943/// surfaces that as a parse error.
8944fn extract_first_column(expr: &Expr) -> Option<String> {
8945    match expr {
8946        Expr::Column(cn) => Some(cn.name.clone()),
8947        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
8948        Expr::Binary { lhs, rhs, .. } => {
8949            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
8950        }
8951        Expr::Unary { expr: e, .. } => extract_first_column(e),
8952        _ => None,
8953    }
8954}
8955
8956fn maybe_not(expr: Expr, negated: bool) -> Expr {
8957    if negated {
8958        Expr::Unary {
8959            op: UnOp::Not,
8960            expr: Box::new(expr),
8961        }
8962    } else {
8963        expr
8964    }
8965}
8966
8967fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
8968    let pair = match tok {
8969        Token::Or => (BinOp::Or, 1),
8970        Token::And => (BinOp::And, 2),
8971        Token::Eq => (BinOp::Eq, 4),
8972        Token::NotEq => (BinOp::NotEq, 4),
8973        Token::Lt => (BinOp::Lt, 4),
8974        Token::LtEq => (BinOp::LtEq, 4),
8975        Token::Gt => (BinOp::Gt, 4),
8976        Token::GtEq => (BinOp::GtEq, 4),
8977        // pgvector distance ops all sit on the same rung — tighter than
8978        // comparisons (4) so `col <-> v < threshold` parses correctly.
8979        Token::L2Distance => (BinOp::L2Distance, 5),
8980        Token::InnerProduct => (BinOp::InnerProduct, 5),
8981        Token::CosineDistance => (BinOp::CosineDistance, 5),
8982        Token::Plus => (BinOp::Add, 6),
8983        Token::Minus => (BinOp::Sub, 6),
8984        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
8985        // by the same level as binary additive arithmetic).
8986        Token::Concat => (BinOp::Concat, 6),
8987        // Bitwise `|` / `&` ride the same rung as `||` — PG groups
8988        // all "other" operators between additive and comparison, so
8989        // `flags & $1 = 0` parses as `(flags & $1) = 0`.
8990        //
8991        // Known divergence (the same one `||` has carried since v1):
8992        // SPG's rung 6 TIES with `+ -`, while PG binds generic
8993        // operators LOOSER than additive — `a & b + 1` is
8994        // `(a & b) + 1` here vs `a & (b + 1)` in PG. Parenthesise
8995        // mixed bitwise/arithmetic. Keeping every generic operator
8996        // on one shared rung is deliberate: splitting bitwise off
8997        // would fix that case but skew `a || b & c`, which PG
8998        // left-folds at a single level.
8999        Token::Pipe => (BinOp::BitOr, 6),
9000        Token::Amp => (BinOp::BitAnd, 6),
9001        Token::Star => (BinOp::Mul, 7),
9002        Token::Slash => (BinOp::Div, 7),
9003        // v4.14: JSON path ops bind tighter than comparisons (4)
9004        // and additive (6) so `doc->'k' = 'v'` parses correctly.
9005        // Same rung as the multiplicative ops.
9006        Token::JsonGet => (BinOp::JsonGet, 7),
9007        Token::JsonGetText => (BinOp::JsonGetText, 7),
9008        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
9009        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
9010        Token::JsonContains => (BinOp::JsonContains, 7),
9011        // v7.12.2 — `@@` binds at the comparison rung (looser than
9012        // arithmetic, tighter than AND / OR). PG places `@@` at
9013        // the same precedence as `=` / `<`, so we follow.
9014        Token::TsMatch => (BinOp::TsMatch, 4),
9015        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
9016        // PG places these at the comparison rung (same level as `=`),
9017        // so we follow.
9018        Token::InetContainedBy => (BinOp::InetContainedBy, 4),
9019        Token::InetContainedByEq => (BinOp::InetContainedByEq, 4),
9020        Token::InetContains => (BinOp::InetContains, 4),
9021        Token::InetContainsEq => (BinOp::InetContainsEq, 4),
9022        Token::InetOverlap => (BinOp::InetOverlap, 4),
9023        _ => return None,
9024    };
9025    Some(pair)
9026}
9027
9028#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
9029// `as f32` here is intentional: vector elements widen / narrow into f32 on
9030// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
9031// past ~15 decimal digits — both are acceptable for a fixed-precision
9032// pgvector column.
9033/// v7.17.0 Phase 1.3 — words that would otherwise be eaten as an
9034/// implicit table alias and break trailing clauses. WITH lands
9035/// here so `… FROM t WITH NO DATA` doesn't consume WITH as the
9036/// alias for `t`; same for ON / WHERE / HAVING / GROUP / ORDER /
9037/// LIMIT / OFFSET / UNION / EXCEPT / INTERSECT / RETURNING / SET
9038/// / VALUES / FOR / LATERAL — all of which would otherwise be
9039/// silently swallowed by `parse_optional_alias`.
9040fn is_alias_stopword(s: &str) -> bool {
9041    matches!(
9042        s.to_ascii_lowercase().as_str(),
9043        "with"
9044            | "on"
9045            | "where"
9046            | "having"
9047            | "group"
9048            | "order"
9049            | "limit"
9050            | "offset"
9051            | "union"
9052            | "except"
9053            | "intersect"
9054            | "returning"
9055            | "set"
9056            | "values"
9057            | "for"
9058            | "lateral"
9059            | "left"
9060            | "right"
9061            | "inner"
9062            | "outer"
9063            | "full"
9064            | "cross"
9065            | "join"
9066            | "natural"
9067            | "using"
9068            | "fetch"
9069    )
9070}
9071
9072fn extract_numeric_literal(e: &Expr) -> Option<f32> {
9073    match e {
9074        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
9075        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
9076        Expr::Unary {
9077            op: UnOp::Neg,
9078            expr,
9079        } => extract_numeric_literal(expr).map(|x| -x),
9080        _ => None,
9081    }
9082}
9083
9084/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
9085/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
9086/// negative. Returns `None` if any pair fails to parse or no pair is found.
9087///
9088/// Recognised units (case-insensitive, optional trailing `s`):
9089/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
9090/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
9091pub fn parse_interval_text(s: &str) -> Option<(i32, i64)> {
9092    let parts: Vec<&str> = s.split_whitespace().collect();
9093    if parts.is_empty() || !parts.len().is_multiple_of(2) {
9094        return None;
9095    }
9096    let mut months: i32 = 0;
9097    let mut micros: i64 = 0;
9098    let mut i = 0;
9099    while i < parts.len() {
9100        let n: i64 = parts[i].parse().ok()?;
9101        let unit = parts[i + 1].to_ascii_lowercase();
9102        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
9103        match unit_stripped {
9104            "microsecond" => micros = micros.checked_add(n)?,
9105            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
9106            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
9107            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
9108            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
9109            "day" => micros = micros.checked_add(n.checked_mul(86_400_000_000)?)?,
9110            "week" => micros = micros.checked_add(n.checked_mul(604_800_000_000)?)?,
9111            "month" => {
9112                let n32 = i32::try_from(n).ok()?;
9113                months = months.checked_add(n32)?;
9114            }
9115            "year" => {
9116                let n32 = i32::try_from(n).ok()?;
9117                months = months.checked_add(n32.checked_mul(12)?)?;
9118            }
9119            _ => return None,
9120        }
9121        i += 2;
9122    }
9123    Some((months, micros))
9124}
9125
9126/// v7.12.4 — map a bare type-name identifier (the form that
9127/// appears in a function arg list or RETURNS clause) to a
9128/// [`ColumnTypeName`]. Returns `None` for unknown / extension
9129/// types so the caller can preserve them as
9130/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
9131///
9132/// Subset of the full column-type grammar — we deliberately
9133/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
9134/// here because function-arg types in v7.12.4 are mostly the
9135/// bare form (`text`, `int`, `bytea`, …).
9136fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
9137    Some(match ident.to_ascii_lowercase().as_str() {
9138        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
9139        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
9140        "bigint" => ColumnTypeName::BigInt,
9141        "float" | "double" | "real" => ColumnTypeName::Float,
9142        "text" => ColumnTypeName::Text,
9143        "bool" | "boolean" => ColumnTypeName::Bool,
9144        "date" => ColumnTypeName::Date,
9145        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
9146        "timestamptz" => ColumnTypeName::Timestamptz,
9147        "json" => ColumnTypeName::Json,
9148        "jsonb" => ColumnTypeName::Jsonb,
9149        "bytea" | "bytes" => ColumnTypeName::Bytes,
9150        "tsvector" => ColumnTypeName::TsVector,
9151        "tsquery" => ColumnTypeName::TsQuery,
9152        "uuid" => ColumnTypeName::Uuid,
9153        "time" => ColumnTypeName::Time,
9154        "year" => ColumnTypeName::Year,
9155        "timetz" => ColumnTypeName::TimeTz,
9156        "money" => ColumnTypeName::Money,
9157        _ => return None,
9158    })
9159}
9160
9161/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
9162/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
9163///
9164/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
9165/// / embedded SQL land in v7.12.5+):
9166///
9167/// ```text
9168///   body          := [ws] block [ws]
9169///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
9170///   stmt          := assign | return
9171///   assign        := assign_target := expr
9172///   assign_target := ( NEW | OLD ) . ident | ident
9173///   return        := RETURN ( NEW | OLD | NULL | expr )
9174/// ```
9175///
9176/// `expr` is parsed by recursing into the regular `Parser` — so a
9177/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
9178/// NEW.subject || ' ' || NEW.sender)` body shape works without
9179/// the body parser knowing what `to_tsvector` is.
9180///
9181/// Errors here cause the caller to fall back to
9182/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
9183/// successful, but the executor will refuse to invoke the
9184/// function with an "unparseable body" error.
9185/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
9186/// from the crate root as `spg_sql::parse_function_body`.
9187pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
9188    parse_plpgsql_body(body)
9189}
9190
9191fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
9192    // Use the regular lexer on the body text. The trailing
9193    // `END;` may or may not have a semicolon; the lexer treats
9194    // both forms identically.
9195    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
9196        message: alloc::format!("plpgsql body lex error: {e}"),
9197        token_pos: 0,
9198    })?;
9199    let mut parser = Parser::new(tokens);
9200    parser.parse_plpgsql_block()
9201}
9202
9203#[cfg(test)]
9204mod tests {
9205    use super::*;
9206    use alloc::string::ToString;
9207
9208    fn parse(s: &str) -> Statement {
9209        parse_statement(s).expect("parse ok")
9210    }
9211
9212    // v7.30.2 (mailrs round-25 ask 2) — nesting / chain budgets must
9213    // surface as parse errors, never stack overflows (embed hosts
9214    // abort on overflow).
9215    #[test]
9216    fn nesting_budget_errors_cleanly() {
9217        let depth = MAX_NEST_DEPTH + 50;
9218        let sql = format!("SELECT {}1{}", "(".repeat(depth), ")".repeat(depth));
9219        let err = parse_statement(&sql).expect_err("must reject");
9220        assert!(err.message.contains("nests deeper"), "{err:?}");
9221        // Within budget still parses.
9222        let sql = format!("SELECT {}1{}", "(".repeat(48), ")".repeat(48));
9223        parse(&sql);
9224    }
9225
9226    #[test]
9227    fn binary_chain_budget_errors_cleanly() {
9228        let sql = format!("SELECT 1{}", " + 1".repeat(MAX_BINARY_CHAIN + 50));
9229        let err = parse_statement(&sql).expect_err("must reject");
9230        assert!(err.message.contains("chained binary"), "{err:?}");
9231        // Within budget still parses (chain depth ≤ budget is safe
9232        // for recursive eval/drop on 2 MiB stacks).
9233        let sql = format!("SELECT 1{}", " + 1".repeat(200));
9234        parse(&sql);
9235    }
9236
9237    #[test]
9238    fn in_list_unaffected_by_chain_budget() {
9239        // Flat InList: 20k elements parse fine and stay flat.
9240        let items: alloc::vec::Vec<String> = (0..20_000).map(|k| k.to_string()).collect();
9241        let sql = format!("SELECT 1 WHERE 5 IN ({})", items.join(","));
9242        let Statement::Select(s) = parse(&sql) else {
9243            panic!("expected select")
9244        };
9245        let Some(Expr::InList { list, negated, .. }) = s.where_ else {
9246            panic!("expected flat InList, got {:?}", s.where_)
9247        };
9248        assert_eq!(list.len(), 20_000);
9249        assert!(!negated);
9250    }
9251
9252    fn lit_int(n: i64) -> Expr {
9253        Expr::Literal(Literal::Integer(n))
9254    }
9255
9256    fn col(name: &str) -> Expr {
9257        Expr::Column(ColumnName {
9258            qualifier: None,
9259            name: name.into(),
9260        })
9261    }
9262
9263    #[test]
9264    fn select_single_integer() {
9265        let s = parse("SELECT 1");
9266        let Statement::Select(s) = s else {
9267            panic!("expected SELECT")
9268        };
9269        assert_eq!(s.items.len(), 1);
9270        assert!(s.from.is_none());
9271        assert!(s.where_.is_none());
9272    }
9273
9274    #[test]
9275    fn select_multiple_literal_kinds() {
9276        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
9277        let Statement::Select(s) = s else {
9278            panic!("expected SELECT")
9279        };
9280        assert_eq!(s.items.len(), 5);
9281    }
9282
9283    #[test]
9284    fn select_wildcard_from_table() {
9285        let s = parse("SELECT * FROM users");
9286        let Statement::Select(s) = s else {
9287            panic!("expected SELECT")
9288        };
9289        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
9290        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
9291    }
9292
9293    #[test]
9294    fn select_with_table_alias() {
9295        let s = parse("SELECT * FROM users AS u");
9296        let Statement::Select(s) = s else {
9297            panic!("expected SELECT")
9298        };
9299        let t = &s.from.as_ref().unwrap().primary;
9300        assert_eq!(t.name, "users");
9301        assert_eq!(t.alias.as_deref(), Some("u"));
9302    }
9303
9304    #[test]
9305    fn select_with_where_eq() {
9306        let s = parse("SELECT a FROM t WHERE a = 1");
9307        let Statement::Select(s) = s else {
9308            panic!("expected SELECT")
9309        };
9310        let w = s.where_.unwrap();
9311        assert_eq!(
9312            w,
9313            Expr::Binary {
9314                lhs: Box::new(col("a")),
9315                op: BinOp::Eq,
9316                rhs: Box::new(lit_int(1)),
9317            }
9318        );
9319    }
9320
9321    #[test]
9322    fn arithmetic_precedence() {
9323        let s = parse("SELECT 1 + 2 * 3");
9324        let Statement::Select(s) = s else {
9325            panic!("expected SELECT")
9326        };
9327        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9328            panic!("wildcard?")
9329        };
9330        assert_eq!(
9331            expr,
9332            &Expr::Binary {
9333                lhs: Box::new(lit_int(1)),
9334                op: BinOp::Add,
9335                rhs: Box::new(Expr::Binary {
9336                    lhs: Box::new(lit_int(2)),
9337                    op: BinOp::Mul,
9338                    rhs: Box::new(lit_int(3)),
9339                }),
9340            }
9341        );
9342    }
9343
9344    #[test]
9345    fn parentheses_override_precedence() {
9346        let s = parse("SELECT (1 + 2) * 3");
9347        let Statement::Select(s) = s else {
9348            panic!("expected SELECT")
9349        };
9350        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9351            panic!()
9352        };
9353        assert_eq!(
9354            expr,
9355            &Expr::Binary {
9356                lhs: Box::new(Expr::Binary {
9357                    lhs: Box::new(lit_int(1)),
9358                    op: BinOp::Add,
9359                    rhs: Box::new(lit_int(2)),
9360                }),
9361                op: BinOp::Mul,
9362                rhs: Box::new(lit_int(3)),
9363            }
9364        );
9365    }
9366
9367    #[test]
9368    fn not_binds_below_comparison() {
9369        // `NOT a = 1` should parse as `NOT (a = 1)`.
9370        let s = parse("SELECT NOT a = 1 FROM t");
9371        let Statement::Select(s) = s else {
9372            panic!("expected SELECT")
9373        };
9374        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9375            panic!()
9376        };
9377        assert_eq!(
9378            expr,
9379            &Expr::Unary {
9380                op: UnOp::Not,
9381                expr: Box::new(Expr::Binary {
9382                    lhs: Box::new(col("a")),
9383                    op: BinOp::Eq,
9384                    rhs: Box::new(lit_int(1)),
9385                }),
9386            }
9387        );
9388    }
9389
9390    #[test]
9391    fn unary_minus_binds_above_multiplication() {
9392        // `-a * 2` should be `(-a) * 2`.
9393        let s = parse("SELECT -a * 2 FROM t");
9394        let Statement::Select(s) = s else {
9395            panic!("expected SELECT")
9396        };
9397        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9398            panic!()
9399        };
9400        assert_eq!(
9401            expr,
9402            &Expr::Binary {
9403                lhs: Box::new(Expr::Unary {
9404                    op: UnOp::Neg,
9405                    expr: Box::new(col("a")),
9406                }),
9407                op: BinOp::Mul,
9408                rhs: Box::new(lit_int(2)),
9409            }
9410        );
9411    }
9412
9413    #[test]
9414    fn qualified_column() {
9415        let s = parse("SELECT t.col FROM t");
9416        let Statement::Select(s) = s else {
9417            panic!("expected SELECT")
9418        };
9419        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9420            panic!()
9421        };
9422        assert_eq!(
9423            expr,
9424            &Expr::Column(ColumnName {
9425                qualifier: Some("t".into()),
9426                name: "col".into()
9427            })
9428        );
9429    }
9430
9431    #[test]
9432    fn select_item_alias_with_as() {
9433        let s = parse("SELECT a AS y FROM t");
9434        let Statement::Select(s) = s else {
9435            panic!("expected SELECT")
9436        };
9437        let SelectItem::Expr { alias, .. } = &s.items[0] else {
9438            panic!()
9439        };
9440        assert_eq!(alias.as_deref(), Some("y"));
9441    }
9442
9443    #[test]
9444    fn trailing_semicolon_accepted() {
9445        let s = parse("SELECT 1;");
9446        let Statement::Select(s) = s else {
9447            panic!("expected SELECT")
9448        };
9449        assert_eq!(s.items.len(), 1);
9450    }
9451
9452    #[test]
9453    fn boolean_chain_with_and_or_not() {
9454        // (NOT a) OR (b AND (NOT c))
9455        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
9456        let Statement::Select(s) = s else {
9457            panic!("expected SELECT")
9458        };
9459        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9460            panic!()
9461        };
9462        let expected = Expr::Binary {
9463            lhs: Box::new(Expr::Unary {
9464                op: UnOp::Not,
9465                expr: Box::new(col("a")),
9466            }),
9467            op: BinOp::Or,
9468            rhs: Box::new(Expr::Binary {
9469                lhs: Box::new(col("b")),
9470                op: BinOp::And,
9471                rhs: Box::new(Expr::Unary {
9472                    op: UnOp::Not,
9473                    expr: Box::new(col("c")),
9474                }),
9475            }),
9476        };
9477        assert_eq!(expr, &expected);
9478    }
9479
9480    #[test]
9481    fn empty_input_errors() {
9482        // v7.14.0 — pg_dump preambles emit several comment-only
9483        // / blank-line statements that collapse to Statement::
9484        // Empty rather than a parse error. The old "SELECT in
9485        // message" assertion is stale; verify the new contract:
9486        // empty / whitespace / comment-only input parses to
9487        // Statement::Empty.
9488        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
9489        assert!(matches!(
9490            parse_statement("  \n\t ").unwrap(),
9491            Statement::Empty
9492        ));
9493        // Sanity: malformed-but-non-empty still errors.
9494        assert!(parse_statement("SELECT FROM WHERE").is_err());
9495    }
9496
9497    #[test]
9498    fn unmatched_paren_errors() {
9499        assert!(parse_statement("SELECT (1 + 2").is_err());
9500    }
9501
9502    #[test]
9503    fn display_round_trip_simple_select() {
9504        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
9505        let text = original.to_string();
9506        let again = parse_statement(&text).expect("re-parse");
9507        assert_eq!(original, again);
9508    }
9509
9510    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
9511
9512    #[test]
9513    fn create_table_single_column() {
9514        let s = parse("CREATE TABLE foo (a INT)");
9515        let Statement::CreateTable(c) = s else {
9516            panic!("expected CreateTable")
9517        };
9518        assert_eq!(c.name, "foo");
9519        assert_eq!(c.columns.len(), 1);
9520        assert_eq!(c.columns[0].name, "a");
9521        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
9522        assert!(c.columns[0].nullable);
9523    }
9524
9525    #[test]
9526    fn create_table_multi_column_with_not_null_mix() {
9527        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
9528        let Statement::CreateTable(c) = s else {
9529            panic!()
9530        };
9531        assert_eq!(c.columns.len(), 4);
9532        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
9533        assert!(!c.columns[0].nullable);
9534        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
9535        assert!(c.columns[1].nullable);
9536        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
9537        assert!(!c.columns[2].nullable);
9538        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
9539    }
9540
9541    #[test]
9542    fn create_table_bigint_supported() {
9543        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
9544        let Statement::CreateTable(c) = s else {
9545            panic!()
9546        };
9547        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
9548    }
9549
9550    #[test]
9551    fn create_table_vector_default_is_f32() {
9552        let s = parse("CREATE TABLE t (v VECTOR(128))");
9553        let Statement::CreateTable(c) = s else {
9554            panic!()
9555        };
9556        assert_eq!(
9557            c.columns[0].ty,
9558            ColumnTypeName::Vector {
9559                dim: 128,
9560                encoding: VecEncoding::F32,
9561            },
9562        );
9563    }
9564
9565    #[test]
9566    fn create_table_vector_using_sq8() {
9567        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
9568        // Case-insensitive on both `USING` and the encoding name.
9569        for sql in [
9570            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
9571            "CREATE TABLE t (v VECTOR(128) using sq8)",
9572        ] {
9573            let s = parse(sql);
9574            let Statement::CreateTable(c) = s else {
9575                panic!()
9576            };
9577            assert_eq!(
9578                c.columns[0].ty,
9579                ColumnTypeName::Vector {
9580                    dim: 128,
9581                    encoding: VecEncoding::Sq8,
9582                },
9583                "{sql}",
9584            );
9585        }
9586    }
9587
9588    #[test]
9589    fn create_table_vector_using_unknown_errors() {
9590        // v7.16.1 — the inline `USING <encoding>` shape on
9591        // CREATE TABLE column defs was withdrawn before
9592        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
9593        // (col vector_<metric>_ops)`; the parser now rejects
9594        // USING at column-list position with a clearer
9595        // "expected ',' or ')'" message. Test asserts the
9596        // current rejection, not the old "unknown vector
9597        // encoding" string.
9598        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
9599        assert!(
9600            err.message.contains("USING")
9601                || err.message.contains("using")
9602                || err.message.contains("')'")
9603                || err.message.contains("','"),
9604            "expected USING/column-list rejection, got: {}",
9605            err.message
9606        );
9607    }
9608
9609    #[test]
9610    fn vector_using_sq8_display_roundtrips() {
9611        // The Display impl must produce text that re-parses to the
9612        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
9613        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
9614        let Statement::CreateTable(c) = s else {
9615            panic!()
9616        };
9617        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
9618    }
9619
9620    #[test]
9621    fn parser_recognises_placeholders() {
9622        use crate::ast::{Expr, SelectItem, Statement};
9623        // $N in expression position parses as Expr::Placeholder(N).
9624        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
9625        let Statement::Select(sel) = s else { panic!() };
9626        assert!(matches!(
9627            sel.items[0],
9628            SelectItem::Expr {
9629                expr: Expr::Placeholder(1),
9630                alias: None
9631            }
9632        ));
9633        // $2 + 1
9634        let SelectItem::Expr {
9635            expr: Expr::Binary { lhs, rhs, .. },
9636            ..
9637        } = &sel.items[1]
9638        else {
9639            panic!()
9640        };
9641        assert!(matches!(**lhs, Expr::Placeholder(2)));
9642        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
9643        // WHERE x = $3
9644        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
9645            panic!()
9646        };
9647        assert!(matches!(**rhs, Expr::Placeholder(3)));
9648    }
9649
9650    #[test]
9651    fn parser_rejects_dollar_zero() {
9652        // $0 is not valid in PG; the lexer rejects it.
9653        assert!(parse_statement("SELECT $0").is_err());
9654    }
9655
9656    #[test]
9657    fn placeholder_display_roundtrips() {
9658        // The Display impl must produce text that re-lexes to the
9659        // same Placeholder token.
9660        let s = parse("SELECT $42 FROM t");
9661        let printed = s.to_string();
9662        assert!(printed.contains("$42"));
9663        let again = parse(&printed);
9664        assert_eq!(s, again);
9665    }
9666
9667    #[test]
9668    fn alter_index_rebuild_bare() {
9669        use crate::ast::{AlterIndexTarget, Statement};
9670        let s = parse("ALTER INDEX my_idx REBUILD");
9671        let Statement::AlterIndex(a) = s else {
9672            panic!("expected AlterIndex, got {s:?}")
9673        };
9674        assert_eq!(a.name, "my_idx");
9675        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
9676    }
9677
9678    #[test]
9679    fn alter_index_rebuild_with_encoding() {
9680        use crate::ast::{AlterIndexTarget, Statement};
9681        for (sql, want) in [
9682            (
9683                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
9684                VecEncoding::F32,
9685            ),
9686            (
9687                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
9688                VecEncoding::Sq8,
9689            ),
9690            (
9691                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9692                VecEncoding::F16,
9693            ),
9694        ] {
9695            let s = parse(sql);
9696            let Statement::AlterIndex(a) = s else {
9697                panic!("{sql}: expected AlterIndex")
9698            };
9699            assert_eq!(a.name, "my_idx");
9700            assert_eq!(
9701                a.target,
9702                AlterIndexTarget::Rebuild {
9703                    encoding: Some(want)
9704                },
9705                "{sql}"
9706            );
9707        }
9708    }
9709
9710    #[test]
9711    fn alter_index_rebuild_unknown_encoding_errors() {
9712        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
9713        assert!(
9714            err.message.contains("unknown vector encoding"),
9715            "got: {}",
9716            err.message
9717        );
9718    }
9719
9720    #[test]
9721    fn alter_index_rebuild_display_roundtrips() {
9722        for (input, want) in [
9723            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
9724            (
9725                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9726                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9727            ),
9728            (
9729                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9730                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9731            ),
9732        ] {
9733            let s = parse(input);
9734            assert_eq!(s.to_string(), want);
9735        }
9736    }
9737
9738    #[test]
9739    fn create_table_unknown_type_defers_to_engine() {
9740        // v4.9 picked XML as a parse-time "unsupported column
9741        // type" probe. v7.17.0 Phase 1.4 changed the contract:
9742        // an unknown type ident parses as Text + `user_type_ref`
9743        // so CREATE TABLE can resolve user-defined enum / domain
9744        // types — rejection of truly-unknown types moved to the
9745        // engine's catalog lookup.
9746        let stmt = parse_statement("CREATE TABLE x (a xml)").unwrap();
9747        let Statement::CreateTable(t) = stmt else {
9748            panic!("expected CreateTable");
9749        };
9750        assert_eq!(t.columns[0].user_type_ref.as_deref(), Some("xml"));
9751    }
9752
9753    #[test]
9754    fn create_table_missing_table_keyword_errors() {
9755        assert!(parse_statement("CREATE x (a INT)").is_err());
9756    }
9757
9758    #[test]
9759    fn insert_single_value() {
9760        let s = parse("INSERT INTO foo VALUES (42)");
9761        let Statement::Insert(i) = s else {
9762            panic!("expected Insert")
9763        };
9764        assert_eq!(i.table, "foo");
9765        assert_eq!(i.rows.len(), 1);
9766        assert_eq!(i.rows[0].len(), 1);
9767        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
9768    }
9769
9770    #[test]
9771    fn insert_multi_value_with_mixed_literals() {
9772        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
9773        let Statement::Insert(i) = s else { panic!() };
9774        assert_eq!(i.rows.len(), 1);
9775        assert_eq!(i.rows[0].len(), 5);
9776    }
9777
9778    #[test]
9779    fn insert_missing_into_errors() {
9780        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
9781    }
9782
9783    #[test]
9784    fn create_table_round_trip() {
9785        let original =
9786            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
9787        let text = original.to_string();
9788        let again = parse_statement(&text).expect("re-parse");
9789        assert_eq!(original, again);
9790    }
9791
9792    #[test]
9793    fn insert_round_trip_with_negation_and_string() {
9794        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
9795        let text = original.to_string();
9796        let again = parse_statement(&text).expect("re-parse");
9797        assert_eq!(original, again);
9798    }
9799
9800    #[test]
9801    fn unknown_keyword_at_statement_start_errors() {
9802        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
9803        // the top-level dispatch still has no branch to take.
9804        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
9805        assert!(err.message.contains("expected SELECT"));
9806    }
9807
9808    // --- v0.8 CREATE INDEX --------------------------------------------------
9809
9810    #[test]
9811    fn create_index_basic() {
9812        let s = parse("CREATE INDEX idx_id ON users (id)");
9813        let Statement::CreateIndex(c) = s else {
9814            panic!("expected CreateIndex")
9815        };
9816        assert_eq!(c.name, "idx_id");
9817        assert_eq!(c.table, "users");
9818        assert_eq!(c.column, "id");
9819    }
9820
9821    #[test]
9822    fn create_index_missing_on_errors() {
9823        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
9824    }
9825
9826    #[test]
9827    fn create_index_missing_paren_errors() {
9828        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
9829    }
9830
9831    #[test]
9832    fn create_index_round_trip() {
9833        let original = parse("CREATE INDEX by_name ON users (name)");
9834        let again = parse_statement(&original.to_string()).unwrap();
9835        assert_eq!(original, again);
9836    }
9837
9838    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
9839
9840    #[test]
9841    fn create_unique_index_basic() {
9842        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
9843        let Statement::CreateIndex(c) = s else {
9844            panic!("expected CreateIndex");
9845        };
9846        assert!(c.is_unique);
9847        assert_eq!(c.column, "a");
9848        assert!(c.partial_predicate.is_none());
9849    }
9850
9851    #[test]
9852    fn create_unique_index_partial() {
9853        // mailrs's email_templates "one default per user" shape.
9854        let s = parse(
9855            "CREATE UNIQUE INDEX idx_email_templates_user_default \
9856             ON email_templates (user_address) WHERE is_default = true",
9857        );
9858        let Statement::CreateIndex(c) = s else {
9859            panic!("expected CreateIndex");
9860        };
9861        assert!(c.is_unique);
9862        assert_eq!(c.table, "email_templates");
9863        assert_eq!(c.column, "user_address");
9864        assert!(c.partial_predicate.is_some());
9865    }
9866
9867    #[test]
9868    fn create_unique_index_composite_with_predicate() {
9869        // mailrs's calendar_events instance: composite columns.
9870        let s = parse(
9871            "CREATE UNIQUE INDEX uq_calendar_events_instance \
9872             ON calendar_events (calendar_id, uid, recurrence_id) \
9873             WHERE recurrence_id IS NOT NULL",
9874        );
9875        let Statement::CreateIndex(c) = s else {
9876            panic!("expected CreateIndex");
9877        };
9878        assert!(c.is_unique);
9879        assert_eq!(c.column, "calendar_id");
9880        assert_eq!(
9881            c.extra_columns,
9882            vec!["uid".to_string(), "recurrence_id".to_string()]
9883        );
9884        assert!(c.partial_predicate.is_some());
9885    }
9886
9887    #[test]
9888    fn create_unique_index_using_btree_ok() {
9889        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
9890        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
9891    }
9892
9893    #[test]
9894    fn create_unique_index_using_hnsw_rejected() {
9895        let err =
9896            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
9897        assert!(err.message.contains("UNIQUE"), "{}", err.message);
9898    }
9899
9900    #[test]
9901    fn create_unique_index_round_trip() {
9902        let original = parse(
9903            "CREATE UNIQUE INDEX uq_calendar_events_master \
9904             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
9905        );
9906        let again = parse_statement(&original.to_string()).unwrap();
9907        assert_eq!(original, again);
9908    }
9909
9910    #[test]
9911    fn create_unique_without_index_errors() {
9912        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
9913        assert!(err.message.contains("INDEX"), "{}", err.message);
9914    }
9915
9916    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
9917
9918    #[test]
9919    fn create_table_bytea_column() {
9920        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
9921        let Statement::CreateTable(c) = s else {
9922            panic!("expected CreateTable");
9923        };
9924        assert_eq!(c.columns.len(), 2);
9925        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
9926        assert!(!c.columns[1].nullable);
9927    }
9928
9929    #[test]
9930    fn create_table_bytes_alias_column() {
9931        let s = parse("CREATE TABLE t (blob BYTES)");
9932        let Statement::CreateTable(c) = s else {
9933            panic!("expected CreateTable");
9934        };
9935        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
9936    }
9937
9938    #[test]
9939    fn bytea_round_trip_display() {
9940        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
9941        let again = parse_statement(&original.to_string()).unwrap();
9942        assert_eq!(original, again);
9943    }
9944
9945    // --- v0.9 transactions -------------------------------------------------
9946
9947    #[test]
9948    fn begin_commit_rollback_parse_as_unit_variants() {
9949        assert_eq!(parse("BEGIN"), Statement::Begin);
9950        assert_eq!(parse("COMMIT"), Statement::Commit);
9951        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
9952        // Trailing semicolons accepted too.
9953        assert_eq!(parse("BEGIN;"), Statement::Begin);
9954    }
9955
9956    // --- v1.2: pgvector distance ops + ::vector cast --------------------
9957
9958    #[test]
9959    fn inner_product_binop_parses() {
9960        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
9961        let Statement::Select(s) = s else { panic!() };
9962        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9963            panic!()
9964        };
9965        assert!(matches!(
9966            expr,
9967            Expr::Binary {
9968                op: BinOp::InnerProduct,
9969                ..
9970            }
9971        ));
9972    }
9973
9974    #[test]
9975    fn cosine_distance_binop_parses() {
9976        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
9977        let Statement::Select(s) = s else { panic!() };
9978        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9979            panic!()
9980        };
9981        assert!(matches!(
9982            expr,
9983            Expr::Binary {
9984                op: BinOp::CosineDistance,
9985                ..
9986            }
9987        ));
9988    }
9989
9990    #[test]
9991    fn vector_cast_postfix_wraps_string_literal() {
9992        let s = parse("SELECT '[1,2,3]'::vector FROM t");
9993        let Statement::Select(s) = s else { panic!() };
9994        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9995            panic!()
9996        };
9997        assert!(matches!(
9998            expr,
9999            Expr::Cast {
10000                target: CastTarget::Vector,
10001                ..
10002            }
10003        ));
10004    }
10005
10006    #[test]
10007    fn unsupported_cast_target_errors() {
10008        // `::numeric` isn't in the v1.3 cast target set.
10009        let err = parse_statement("SELECT 1::numeric FROM t").unwrap_err();
10010        assert!(err.message.contains("unsupported cast target"));
10011    }
10012
10013    #[test]
10014    fn tx_statements_round_trip() {
10015        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
10016            let original = parse(q);
10017            let again = parse_statement(&original.to_string()).unwrap();
10018            assert_eq!(original, again);
10019        }
10020    }
10021
10022    #[test]
10023    fn interval_text_parsing_units() {
10024        // Single unit.
10025        assert_eq!(parse_interval_text("1 day"), Some((0, 86_400_000_000)));
10026        assert_eq!(parse_interval_text("1 second"), Some((0, 1_000_000)));
10027        assert_eq!(parse_interval_text("1 month"), Some((1, 0)));
10028        assert_eq!(parse_interval_text("2 years"), Some((24, 0)));
10029        // Compound spans accumulate.
10030        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0)));
10031        assert_eq!(
10032            parse_interval_text("1 day 2 hours"),
10033            Some((0, 86_400_000_000 + 7_200_000_000))
10034        );
10035        // Negative numbers carry through.
10036        assert_eq!(parse_interval_text("-1 day"), Some((0, -86_400_000_000)));
10037        // Bad shapes return None.
10038        assert_eq!(parse_interval_text(""), None);
10039        assert_eq!(parse_interval_text("garbage"), None);
10040        assert_eq!(parse_interval_text("1 fortnight"), None);
10041        assert_eq!(parse_interval_text("1"), None);
10042    }
10043
10044    #[test]
10045    fn interval_literal_roundtrips_via_display() {
10046        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
10047        let s = parsed.to_string();
10048        // Display preserves the original text verbatim.
10049        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
10050        // And re-parsing yields a structurally equal statement.
10051        let again = parse_statement(&s).unwrap();
10052        assert_eq!(parsed, again);
10053    }
10054
10055    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
10056
10057    #[test]
10058    fn parser_recognises_create_publication_bare() {
10059        let s = parse("CREATE PUBLICATION pub_a");
10060        let Statement::CreatePublication(p) = s else {
10061            panic!("expected CreatePublication, got {s:?}")
10062        };
10063        assert_eq!(p.name, "pub_a");
10064        assert_eq!(p.scope, PublicationScope::AllTables);
10065    }
10066
10067    #[test]
10068    fn parser_recognises_create_publication_for_all_tables() {
10069        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
10070        let Statement::CreatePublication(p) = s else {
10071            panic!("expected CreatePublication, got {s:?}")
10072        };
10073        assert_eq!(p.name, "pub_a");
10074        assert_eq!(p.scope, PublicationScope::AllTables);
10075    }
10076
10077    #[test]
10078    fn parser_recognises_drop_publication() {
10079        let s = parse("DROP PUBLICATION pub_a");
10080        let Statement::DropPublication(name) = s else {
10081            panic!("expected DropPublication, got {s:?}")
10082        };
10083        assert_eq!(name, "pub_a");
10084    }
10085
10086    #[test]
10087    fn parser_recognises_for_table_list() {
10088        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
10089        let Statement::CreatePublication(p) = s else {
10090            panic!("expected CreatePublication, got {s:?}")
10091        };
10092        assert_eq!(p.name, "pub_a");
10093        let PublicationScope::ForTables(ts) = p.scope else {
10094            panic!("expected ForTables scope")
10095        };
10096        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
10097    }
10098
10099    #[test]
10100    fn parser_recognises_for_tables_plural() {
10101        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
10102        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
10103        let Statement::CreatePublication(p) = s else {
10104            panic!("expected CreatePublication, got {s:?}")
10105        };
10106        let PublicationScope::ForTables(ts) = p.scope else {
10107            panic!("expected ForTables")
10108        };
10109        assert_eq!(ts, alloc::vec!["t1", "t2"]);
10110    }
10111
10112    #[test]
10113    fn parser_recognises_for_all_tables_except_list() {
10114        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
10115        let Statement::CreatePublication(p) = s else {
10116            panic!()
10117        };
10118        let PublicationScope::AllTablesExcept(ts) = p.scope else {
10119            panic!("expected AllTablesExcept")
10120        };
10121        assert_eq!(ts, alloc::vec!["t1", "t2"]);
10122    }
10123
10124    #[test]
10125    fn parser_rejects_for_table_with_empty_list() {
10126        // `FOR TABLE` with nothing after is a parse error.
10127        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
10128            .expect_err("must error on empty list");
10129        // No specific message asserted — the call falls through to
10130        // expect_ident_like which yields "expected identifier, got …".
10131        assert!(!err.message.is_empty());
10132    }
10133
10134    #[test]
10135    fn parser_recognises_show_publications() {
10136        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
10137        // bare ident in this position, NOT a reserved keyword.
10138        let s = parse("SHOW PUBLICATIONS");
10139        assert!(matches!(s, Statement::ShowPublications));
10140    }
10141
10142    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
10143
10144    #[test]
10145    fn parser_recognises_create_subscription_single_publication() {
10146        let s = parse(
10147            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
10148        );
10149        let Statement::CreateSubscription(c) = s else {
10150            panic!("expected CreateSubscription, got {s:?}")
10151        };
10152        assert_eq!(c.name, "sub_a");
10153        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
10154        assert_eq!(c.publications, alloc::vec!["pub_a"]);
10155    }
10156
10157    #[test]
10158    fn parser_recognises_create_subscription_multi_publication() {
10159        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
10160        let Statement::CreateSubscription(c) = s else {
10161            panic!()
10162        };
10163        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
10164    }
10165
10166    #[test]
10167    fn parser_rejects_create_subscription_missing_connection() {
10168        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
10169            .expect_err("must error on missing CONNECTION");
10170        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
10171    }
10172
10173    #[test]
10174    fn parser_rejects_create_subscription_missing_publication() {
10175        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
10176            .expect_err("must error on missing PUBLICATION");
10177        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
10178    }
10179
10180    #[test]
10181    fn parser_recognises_drop_subscription() {
10182        let s = parse("DROP SUBSCRIPTION sub_a");
10183        let Statement::DropSubscription(name) = s else {
10184            panic!("expected DropSubscription, got {s:?}")
10185        };
10186        assert_eq!(name, "sub_a");
10187    }
10188
10189    #[test]
10190    fn parser_recognises_show_subscriptions() {
10191        let s = parse("SHOW SUBSCRIPTIONS");
10192        assert!(matches!(s, Statement::ShowSubscriptions));
10193    }
10194
10195    #[test]
10196    fn parser_recognises_wait_for_wal_position_no_timeout() {
10197        let s = parse("WAIT FOR WAL POSITION 12345");
10198        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
10199            panic!("expected WaitForWalPosition, got {s:?}")
10200        };
10201        assert_eq!(pos, 12345);
10202        assert!(timeout_ms.is_none());
10203    }
10204
10205    #[test]
10206    fn parser_recognises_wait_for_wal_position_with_timeout() {
10207        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
10208        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
10209            panic!()
10210        };
10211        assert_eq!(pos, 67890);
10212        assert_eq!(timeout_ms, Some(5000));
10213    }
10214
10215    #[test]
10216    fn parser_rejects_wait_with_negative_position() {
10217        // The lexer treats `-` as a token; `expect_u64_literal`
10218        // only sees the Integer that follows, so the negative
10219        // arrives as a unary-minus expression at higher levels.
10220        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
10221        // parse error one way or another.
10222        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
10223        assert!(!err.message.is_empty());
10224    }
10225
10226    #[test]
10227    fn parser_recognises_bare_analyze() {
10228        let s = parse("ANALYZE");
10229        assert!(matches!(s, Statement::Analyze(None)));
10230    }
10231
10232    #[test]
10233    fn parser_recognises_analyze_with_table() {
10234        let s = parse("ANALYZE users");
10235        let Statement::Analyze(Some(name)) = s else {
10236            panic!("expected Analyze, got {s:?}")
10237        };
10238        assert_eq!(name, "users");
10239    }
10240
10241    #[test]
10242    fn parser_recognises_analyze_with_quoted_table() {
10243        let s = parse("ANALYZE \"Mixed Case\"");
10244        let Statement::Analyze(Some(name)) = s else {
10245            panic!()
10246        };
10247        assert_eq!(name, "Mixed Case");
10248    }
10249
10250    #[test]
10251    fn parser_rejects_analyze_with_garbage_token() {
10252        let err = parse_statement("ANALYZE 42").expect_err("must error");
10253        assert!(!err.message.is_empty());
10254    }
10255
10256    #[test]
10257    fn analyze_display_roundtrips() {
10258        for sql in ["ANALYZE", "ANALYZE users"] {
10259            let s = parse(sql);
10260            let printed = s.to_string();
10261            let again = parse_statement(&printed)
10262                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10263            assert_eq!(s, again);
10264        }
10265    }
10266
10267    #[test]
10268    fn wait_for_display_roundtrips() {
10269        for sql in [
10270            "WAIT FOR WAL POSITION 12345",
10271            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
10272        ] {
10273            let s = parse(sql);
10274            let printed = s.to_string();
10275            let again = parse_statement(&printed)
10276                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10277            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10278        }
10279    }
10280
10281    #[test]
10282    fn subscription_ddl_display_roundtrips() {
10283        for sql in [
10284            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
10285            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
10286            "DROP SUBSCRIPTION sub_a",
10287            "SHOW SUBSCRIPTIONS",
10288        ] {
10289            let s = parse(sql);
10290            let printed = s.to_string();
10291            let again = parse_statement(&printed)
10292                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10293            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10294        }
10295    }
10296
10297    #[test]
10298    fn parser_drop_dispatches_user_vs_publication() {
10299        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
10300        // tokenises DROP. Both targets must still parse.
10301        let s = parse("DROP USER 'alice'");
10302        let Statement::DropUser(name) = s else {
10303            panic!("expected DropUser, got {s:?}")
10304        };
10305        assert_eq!(name, "alice");
10306        // And DROP PUBLICATION lands the new variant.
10307        let s = parse("DROP PUBLICATION p1");
10308        assert!(matches!(s, Statement::DropPublication(_)));
10309    }
10310
10311    #[test]
10312    fn publication_ddl_display_roundtrips() {
10313        // Every CREATE PUBLICATION variant must Display → parse →
10314        // same AST. v6.1.3 covers all three scope shapes.
10315        for sql in [
10316            "CREATE PUBLICATION pub_a",
10317            "CREATE PUBLICATION pub_a FOR ALL TABLES",
10318            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
10319            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
10320            "DROP PUBLICATION pub_a",
10321            "SHOW PUBLICATIONS",
10322        ] {
10323            let s = parse(sql);
10324            let printed = s.to_string();
10325            let again = parse_statement(&printed)
10326                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10327            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10328        }
10329    }
10330
10331    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
10332
10333    #[test]
10334    fn create_function_returns_trigger_plpgsql_minimal() {
10335        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
10336        let s = parse(sql);
10337        let Statement::CreateFunction(f) = s else {
10338            panic!("expected CreateFunction");
10339        };
10340        assert_eq!(f.name, "noop");
10341        assert!(!f.or_replace);
10342        assert!(f.args.is_empty());
10343        assert!(matches!(f.returns, FunctionReturn::Trigger));
10344        assert_eq!(f.language, "plpgsql");
10345        let FunctionBody::PlPgSql(block) = f.body else {
10346            panic!("expected PlPgSql body");
10347        };
10348        assert_eq!(block.statements.len(), 1);
10349        assert!(matches!(
10350            block.statements[0],
10351            PlPgSqlStmt::Return(ReturnTarget::New)
10352        ));
10353    }
10354
10355    #[test]
10356    fn create_function_or_replace_with_assignment() {
10357        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
10358        // RETURN NEW.
10359        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
10360BEGIN
10361  NEW.search_vector := to_tsvector('english', NEW.subject);
10362  RETURN NEW;
10363END;
10364$$";
10365        let s = parse(sql);
10366        let Statement::CreateFunction(f) = s else {
10367            panic!("expected CreateFunction");
10368        };
10369        assert!(f.or_replace);
10370        let FunctionBody::PlPgSql(block) = &f.body else {
10371            panic!("expected PlPgSql body");
10372        };
10373        assert_eq!(block.statements.len(), 2);
10374        // First statement: NEW.search_vector := to_tsvector(...)
10375        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
10376            panic!("expected Assign as first stmt");
10377        };
10378        match target {
10379            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
10380            other => panic!("expected NEW.col, got {other:?}"),
10381        }
10382        // Second statement: RETURN NEW
10383        assert!(matches!(
10384            block.statements[1],
10385            PlPgSqlStmt::Return(ReturnTarget::New)
10386        ));
10387    }
10388
10389    #[test]
10390    fn create_trigger_after_insert_or_update() {
10391        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
10392        let s = parse(sql);
10393        let Statement::CreateTrigger(t) = s else {
10394            panic!("expected CreateTrigger");
10395        };
10396        assert_eq!(t.name, "tg");
10397        assert_eq!(t.table, "messages");
10398        assert_eq!(t.timing, TriggerTiming::After);
10399        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
10400        assert_eq!(t.for_each, TriggerForEach::Row);
10401        assert_eq!(t.function, "update_sv");
10402    }
10403
10404    #[test]
10405    fn create_trigger_before_delete_execute_procedure_alias() {
10406        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
10407        let sql =
10408            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
10409        let s = parse(sql);
10410        let Statement::CreateTrigger(t) = s else {
10411            panic!("expected CreateTrigger");
10412        };
10413        assert_eq!(t.timing, TriggerTiming::Before);
10414        assert_eq!(t.events, vec![TriggerEvent::Delete]);
10415    }
10416
10417    #[test]
10418    fn drop_trigger_if_exists_round_trips() {
10419        // No parser support for DROP TRIGGER yet — added in v7.12.5
10420        // alongside the broader DROP …{IF EXISTS} cleanup. The
10421        // AST + Display impls are in place so we round-trip via
10422        // construction:
10423        let s = Statement::DropTrigger {
10424            name: "tg".into(),
10425            table: "messages".into(),
10426            if_exists: true,
10427        };
10428        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
10429    }
10430
10431    #[test]
10432    fn trigger_ddl_display_roundtrips_through_parser() {
10433        // CREATE TRIGGER + its referenced CREATE FUNCTION must
10434        // Display → parse → same AST (modulo PL/pgSQL body
10435        // formatting which is parser-canonicalised).
10436        for sql in [
10437            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
10438            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
10439        ] {
10440            let s = parse(sql);
10441            let printed = s.to_string();
10442            let again = parse_statement(&printed)
10443                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10444            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10445        }
10446    }
10447}